Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

View file

@ -1,9 +1,35 @@
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
android {
namespace = "com.tangem.common"
}
dependencies {
implementation(projects.core.utils)
// region Firebase libraries
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.firebase.crashlytics)
implementation(deps.firebase.messaging)
// end
implementation(deps.timber)
implementation(deps.arrow.core)
implementation(deps.test.junit)
implementation(deps.test.truth)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// end
}

1
common/google/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.common.google"
}
dependencies {
implementation(deps.googlePlay.services.wallet)
}

View file

@ -0,0 +1,89 @@
package com.tangem.google
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.wallet.IsReadyToPayRequest
import com.google.android.gms.wallet.PaymentsClient
import com.google.android.gms.wallet.Wallet
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object GoogleServicesHelper {
private val allowedCardNetworks = JSONArray(
listOf(
"AMEX",
"DISCOVER",
"INTERAC",
"JCB",
"MASTERCARD",
"VISA",
),
)
private val allowedCardAuthMethods = JSONArray(
listOf(
"PAN_ONLY",
"CRYPTOGRAM_3DS",
),
)
private val baseCardPaymentMethod: JSONObject = JSONObject().apply {
val parameters = JSONObject().apply {
put("allowedAuthMethods", allowedCardAuthMethods)
put("allowedCardNetworks", allowedCardNetworks)
put("billingAddressRequired", true)
put(
"billingAddressParameters",
JSONObject().apply {
put("format", "FULL")
},
)
}
put("type", "CARD")
put("parameters", parameters)
}
private val baseRequest = JSONObject().apply {
put("apiVersion", 2)
put("apiVersionMinor", 0)
}
private val availabilityRequest = baseRequest.apply {
put("allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod))
}
fun createPaymentsClient(context: Context): PaymentsClient {
val walletOptions = Wallet.WalletOptions.Builder()
.build()
return Wallet.getPaymentsClient(context, walletOptions)
}
fun checkGoogleServicesAvailability(context: Context): Boolean {
val googleApiAvailability = GoogleApiAvailability.getInstance()
val status = googleApiAvailability.isGooglePlayServicesAvailable(context)
return status == ConnectionResult.SUCCESS
}
suspend fun checkGooglePayAvailability(paymentsClient: PaymentsClient): Boolean {
val request = IsReadyToPayRequest.fromJson(availabilityRequest.toString())
val task = paymentsClient.isReadyToPay(request)
return suspendCoroutine<Result<Boolean>> { continuation ->
task.addOnCompleteListener { completedTask ->
try {
val result = completedTask.getResult(ApiException::class.java)
continuation.resume(Result.success(result))
} catch (exception: ApiException) {
continuation.resume(Result.failure(exception))
}
}
}.getOrElse { false }
}
}

1
common/routing/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,30 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
android {
namespace = "com.tangem.common.routing"
}
dependencies {
/* Core */
implementation(projects.core.decompose)
/* Domain */
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.appCurrency.models)
/* Libs - Other */
api(deps.kotlin.serialization)
implementation(deps.androidx.core.ktx)
implementation(deps.timber)
}

View file

@ -0,0 +1,343 @@
package com.tangem.common.routing
import android.os.Bundle
import com.tangem.common.routing.bundle.RouteBundleParams
import com.tangem.common.routing.bundle.bundle
import com.tangem.common.routing.entity.SerializableIntent
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
sealed class AppRoute(val path: String) : Route {
@Serializable
data object Initial : AppRoute(path = "/initial")
@Serializable
data object Home : AppRoute(path = "/home")
@Serializable
data class Welcome(
val intent: SerializableIntent? = null,
) : AppRoute(path = "/welcome"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val INITIAL_INTENT_KEY = "intent"
}
}
@Serializable
data class Disclaimer(
val isTosAccepted: Boolean,
) : AppRoute(path = "/disclaimer${if (isTosAccepted) "/tos_accepted" else ""}")
@Serializable
data object OnboardingNote : AppRoute(path = "/onboarding/note")
@Serializable
data class OnboardingWallet(
val canSkipBackup: Boolean = true,
) : AppRoute(path = "/onboarding/wallet${if (canSkipBackup) "/skippable" else ""}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val CAN_SKIP_BACKUP_KEY = "canSkipBackup"
}
}
@Serializable
data object OnboardingTwins : AppRoute(path = "/onboarding/twins")
@Serializable
data object OnboardingOther : AppRoute(path = "/onboarding/other")
@Serializable
data object Wallet : AppRoute(path = "/wallet")
@Serializable
data class CurrencyDetails(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID_KEY = "userWalletId"
const val CRYPTO_CURRENCY_KEY = "currency"
}
}
@Serializable
data class Send(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val transactionId: String? = null,
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
) : AppRoute(
path = "/send/${userWalletId.stringValue}/${currency.id.value}?" +
"&$transactionId" +
"&$amount" +
"&$tag" +
"&$destinationAddress",
),
RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID_KEY = "userWalletId"
const val CRYPTO_CURRENCY_KEY = "currency"
const val TRANSACTION_ID_KEY = "transactionId"
const val AMOUNT_KEY = "amount"
const val TAG_KEY = "tag"
const val DESTINATION_ADDRESS_KEY = "destinationAddress"
}
}
@Serializable
data class Details(
val userWalletId: UserWalletId,
) : AppRoute(path = "/details/${userWalletId.stringValue}")
@Serializable
data class DetailsSecurity(
val userWalletId: UserWalletId,
) : AppRoute(path = "/details/security"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
@Serializable
data class CardSettings(
val userWalletId: UserWalletId,
) : AppRoute(path = "/card_settings/${userWalletId.stringValue}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID_KEY = "userWalletId"
}
}
@Serializable
data object AppSettings : AppRoute(path = "/app_settings")
/**
* Reset to factory
*
* @property userWalletId user wallet id
* @property cardId reset card id
* @property isActiveBackupStatus reset backup card status
* @property backupCardsCount backup cards count
*/
@Serializable
data class ResetToFactory(
val userWalletId: UserWalletId,
val cardId: String,
val isActiveBackupStatus: Boolean,
val backupCardsCount: Int,
) : AppRoute(
path = "/reset_to_factory" +
"/${userWalletId.stringValue}" +
"/$cardId" +
"/$isActiveBackupStatus" +
"/$backupCardsCount",
),
RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID = "userWalletId"
const val CARD_ID = "cardId"
const val IS_ACTIVE_BACKUP_STATUS = "isActiveBackupStatus"
const val BACKUP_CARDS_COUNT = "backupCardsCount"
}
}
@Serializable
data object AccessCodeRecovery : AppRoute(path = "/access_code_recovery"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
@Serializable
data class ManageTokens(
val source: Source,
val userWalletId: UserWalletId? = null,
) : AppRoute(path = "${source.name.lowercase()}/manage_tokens/$userWalletId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
enum class Source {
STORIES,
ONBOARDING,
SETTINGS,
}
}
@Serializable
data object WalletConnectSessions : AppRoute(path = "/wallet_connect_sessions")
@Serializable
data class QrScanning(
val source: SourceType,
val networkName: String? = null,
) : AppRoute(path = "/$source/qr_scanning${if (networkName != null) "/$networkName" else ""}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val SOURCE_KEY = "source"
const val NETWORK_KEY = "networkName"
}
}
@Serializable
data class ReferralProgram(
val userWalletId: UserWalletId,
) : AppRoute(path = "/referral_program"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID_KEY = "userWalletId"
}
}
@Serializable
data class Swap(
val currencyFrom: CryptoCurrency,
val currencyTo: CryptoCurrency? = null,
val userWalletId: UserWalletId,
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
) : AppRoute(
path = "/swap" +
"/${currencyFrom.id.value}" +
"/${currencyTo?.id?.value}" +
"/${userWalletId.stringValue}" +
"/$isInitialReverseOrder",
),
RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val CURRENCY_FROM_KEY = "currencyFrom"
const val CURRENCY_TO_KEY = "currencyTo"
const val USER_WALLET_ID_KEY = "userWalletId"
const val IS_INITIAL_REVERSE_ORDER = "isInitialReverseOrder"
}
}
@Serializable
data object TesterMenu : AppRoute(path = "/tester_menu")
@Serializable
data object SaveWallet : AppRoute(path = "/save_wallet")
@Serializable
data object AppCurrencySelector : AppRoute(path = "/app_currency_selector")
@Serializable
data class Staking(
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
val yieldId: String,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId")
@Serializable
data object PushNotification : AppRoute(path = "/push_notification")
@Serializable
data class WalletSettings(
val userWalletId: UserWalletId,
) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}")
@Serializable
data class MarketsTokenDetails(
val token: TokenMarketParams,
val appCurrency: AppCurrency,
val showPortfolio: Boolean,
val analyticsParams: AnalyticsParams? = null,
) : AppRoute(path = "/markets_token_details/${token.id}/$showPortfolio") {
@Serializable
data class AnalyticsParams(
val blockchain: String?,
val source: String,
)
}
@Serializable
data class Onramp(
val source: OnrampSource,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
@Serializable
data class OnrampSuccess(
val externalTxId: String,
) : AppRoute(path = "/onramp/success/$externalTxId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
@Serializable
data class BuyCrypto(
val userWalletId: UserWalletId,
) : AppRoute(path = "/buy_crypto/${userWalletId.stringValue}")
@Serializable
data class SellCrypto(
val userWalletId: UserWalletId,
) : AppRoute(path = "/sell_crypto/${userWalletId.stringValue}")
@Serializable
data class SwapCrypto(
val userWalletId: UserWalletId,
) : AppRoute(path = "/swap_crypto/${userWalletId.stringValue}")
/**
* Onboarding V2
* @property scanResponse scan response, determines onboarding route by the product type
* @property startFromBackup (MultiWallet param, doesn't affect other types) start onboarding from backup
* @property mode (MultiWallet param, doesn't affect other types) onboarding mode
*/
@Serializable
data class Onboarding(
val scanResponse: ScanResponse,
val startFromBackup: Boolean = false,
val mode: Mode = Mode.Onboarding,
) : AppRoute(path = "/onboarding_v2${if (startFromBackup) "/backup" else ""}") {
enum class Mode {
Onboarding, // general Mode
AddBackup, // continue backup process for existing wallet 1
}
}
@Serializable
data class Stories(
val storyId: String,
val nextScreen: AppRoute,
val screenSource: String,
) : AppRoute(path = "/stories$storyId"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.common.routing
import kotlin.reflect.KClass
/**
* Interface for a router in the application.
* It provides methods for navigating through the application.
*
* Same as [com.tangem.core.decompose.navigation.Router] but without Decompose dependency.
*
* ***Must be removed after Decompose migration.***
*/
interface AppRouter {
/**
* The current navigation stack.
*/
val stack: List<AppRoute>
/**
* Pushes a new route to the navigation stack.
*
* @param route The route to push.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun push(
route: AppRoute,
onComplete: (isSuccess: Boolean) -> Unit = { isSuccess ->
defaultCompletionHandler(isSuccess, errorMessage = "Unable to push $route")
},
)
/**
* Replaces ***all*** routes in the navigation stack with the specified [routes].
*
* @param routes The routes to replace the current stack with.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun replaceAll(
vararg routes: AppRoute,
onComplete: (isSuccess: Boolean) -> Unit = { isSuccess ->
defaultCompletionHandler(isSuccess, errorMessage = "Unable to replace routes with $routes")
},
)
/**
* Pops the top route from the navigation stack.
*
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun pop(
onComplete: (isSuccess: Boolean) -> Unit = { isSuccess ->
defaultCompletionHandler(isSuccess, errorMessage = "Unable to pop route")
},
)
/**
* Pops routes from the navigation stack until the specified [route] is found.
*
* @param route The route to pop to.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun popTo(
route: AppRoute,
onComplete: (isSuccess: Boolean) -> Unit = { isSuccess ->
defaultCompletionHandler(isSuccess, errorMessage = "Unable to pop to $route")
},
)
/**
* Pops routes from the navigation stack until the ***first*** specified [routeClass] is found.
*
* @param routeClass The route class to pop to.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun popTo(
routeClass: KClass<out AppRoute>,
onComplete: (isSuccess: Boolean) -> Unit = { isSuccess ->
defaultCompletionHandler(isSuccess, errorMessage = "Unable to pop to $routeClass")
},
)
fun defaultCompletionHandler(isSuccess: Boolean, errorMessage: String)
}

View file

@ -0,0 +1,108 @@
package com.tangem.common.routing.bundle
import android.os.Bundle
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind
import kotlinx.serialization.encoding.AbstractDecoder
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.modules.SerializersModule
@ExperimentalSerializationApi
internal class BundleDecoder(
private val bundle: Bundle,
private val elementsCount: Int = -1,
private val isInitializer: Boolean = true,
override val serializersModule: SerializersModule,
) : AbstractDecoder() {
private var index = -1
private var elementKey: String? = null
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
if (++index >= elementsCount) {
return CompositeDecoder.DECODE_DONE
}
elementKey = descriptor.getElementName(index)
return index
}
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
val b = if (isInitializer) {
bundle
} else {
requireNotNull(bundle.getBundle(elementKey)) {
"Bundle is missing for key $elementKey while decoding"
}
}
val count = when (descriptor.kind) {
StructureKind.MAP,
StructureKind.LIST,
-> b.getInt("\$size")
else -> descriptor.elementsCount
}
return BundleDecoder(
bundle = b,
elementsCount = count,
isInitializer = false,
serializersModule = serializersModule,
)
}
override fun endStructure(descriptor: SerialDescriptor) {
/* no-op */
}
override fun decodeBoolean(): Boolean {
return bundle.getBoolean(elementKey)
}
override fun decodeByte(): Byte {
return bundle.getByte(elementKey)
}
override fun decodeChar(): Char {
return bundle.getChar(elementKey)
}
override fun decodeDouble(): Double {
return bundle.getDouble(elementKey)
}
override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
return bundle.getInt(elementKey)
}
override fun decodeFloat(): Float {
return bundle.getFloat(elementKey)
}
override fun decodeInt(): Int {
return bundle.getInt(elementKey)
}
override fun decodeLong(): Long {
return bundle.getLong(elementKey)
}
override fun decodeNotNullMark(): Boolean {
return bundle.containsKey(elementKey)
}
override fun decodeNull(): Nothing? {
return null
}
override fun decodeShort(): Short {
return bundle.getShort(elementKey)
}
override fun decodeString(): String {
return requireNotNull(bundle.getString(elementKey)) {
"String is missing for key $elementKey while decoding"
}
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.common.routing.bundle
import android.os.Bundle
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.StructureKind
import kotlinx.serialization.encoding.AbstractEncoder
import kotlinx.serialization.encoding.CompositeEncoder
import kotlinx.serialization.modules.SerializersModule
@ExperimentalSerializationApi
internal class BundleEncoder(
private val bundle: Bundle,
private val parentBundle: Bundle? = null,
private val keyInParent: String? = null,
private val isInitializer: Boolean = true,
override val serializersModule: SerializersModule,
) : AbstractEncoder() {
private var elementKey: String? = null
override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean {
elementKey = descriptor.getElementName(index)
return super.encodeElement(descriptor, index)
}
override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder {
return if (isInitializer) {
BundleEncoder(
bundle = bundle,
parentBundle = null,
keyInParent = elementKey,
isInitializer = false,
serializersModule = serializersModule,
)
} else {
BundleEncoder(
bundle = Bundle(),
parentBundle = bundle,
keyInParent = elementKey,
isInitializer = false,
serializersModule = serializersModule,
)
}
}
override fun endStructure(descriptor: SerialDescriptor) {
if (descriptor.kind in arrayOf(StructureKind.LIST, StructureKind.MAP)) {
val size = elementKey?.toIntOrNull()?.let { it + 1 } ?: 0
bundle.putInt("\$size", size)
}
if (keyInParent.isNullOrBlank()) {
return
}
parentBundle?.putBundle(keyInParent, bundle)
}
override fun encodeBoolean(value: Boolean) {
bundle.putBoolean(elementKey, value)
}
override fun encodeByte(value: Byte) {
bundle.putByte(elementKey, value)
}
override fun encodeChar(value: Char) {
bundle.putChar(elementKey, value)
}
override fun encodeDouble(value: Double) {
bundle.putDouble(elementKey, value)
}
override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) {
bundle.putInt(elementKey, index)
}
override fun encodeFloat(value: Float) {
bundle.putFloat(elementKey, value)
}
override fun encodeInt(value: Int) {
bundle.putInt(elementKey, value)
}
override fun encodeLong(value: Long) {
bundle.putLong(elementKey, value)
}
override fun encodeNull() {
/* no-op */
}
override fun encodeShort(value: Short) {
bundle.putShort(elementKey, value)
}
override fun encodeString(value: String) {
bundle.putString(elementKey, value)
}
override fun encodeNotNullMark() {
/* no-op */
}
}

View file

@ -0,0 +1,60 @@
package com.tangem.common.routing.bundle
import android.os.Bundle
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.modules.SerializersModule
val defaultSerializersModule: SerializersModule = EmptySerializersModule()
/**
* Deserialize this bundle into an object of type [T].
*
* @receiver [Bundle] to deserialize.
* @param deserializer [DeserializationStrategy] of the [T] class.
*
* @return Object of type T deserialized from bundle.
*/
@OptIn(ExperimentalSerializationApi::class)
fun <T> Bundle.unbundle(
deserializer: DeserializationStrategy<T>,
serializersModule: SerializersModule = defaultSerializersModule,
): T {
val decoder = BundleDecoder(
bundle = this,
elementsCount = -1,
isInitializer = true,
serializersModule = serializersModule,
)
return deserializer.deserialize(decoder)
}
/**
* Serialize [T] into a bundle.
*
* @receiver Object to serialize.
* @param serializer [SerializationStrategy] of the [T] class.
*
* @return bundle serialized from value
*/
@OptIn(ExperimentalSerializationApi::class)
fun <T> T.bundle(
serializer: SerializationStrategy<T>,
serializersModule: SerializersModule = defaultSerializersModule,
): Bundle {
val bundle = Bundle(serializer.descriptor.elementsCount)
val encoder = BundleEncoder(
bundle = bundle,
parentBundle = null,
keyInParent = null,
isInitializer = true,
serializersModule = serializersModule,
)
serializer.serialize(encoder, value = this)
return bundle
}

View file

@ -0,0 +1,8 @@
package com.tangem.common.routing.bundle
import android.os.Bundle
interface RouteBundleParams {
fun getBundle(): Bundle
}

View file

@ -0,0 +1,24 @@
package com.tangem.common.routing.entity
import android.os.Bundle
import kotlinx.serialization.Serializable
@Serializable
data class SerializableBundle(
val map: Map<String, String>,
) {
constructor(bundle: Bundle) : this(
map = bundle.keySet().mapNotNull { key ->
bundle.getString(key)?.let { key to it }
}.toMap(),
)
fun toBundle(): Bundle {
return Bundle().apply {
map.forEach { (key, value) ->
putString(key, value)
}
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.common.routing.entity
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import kotlinx.serialization.Serializable
@Serializable
data class SerializableIntent(
val action: String?,
val dataString: String?,
val categories: Set<String>?,
val type: String?,
val packageValue: String?,
val component: String?,
val flags: Int,
val extras: SerializableBundle?,
) {
constructor(intent: Intent) : this(
action = intent.action,
dataString = intent.dataString,
categories = intent.categories,
type = intent.type,
packageValue = intent.`package`,
component = intent.component?.flattenToString(),
flags = intent.flags,
extras = intent.extras?.let(::SerializableBundle),
)
fun toIntent(): Intent {
val intent = Intent()
intent.action = action
intent.setDataAndType(
dataString?.let { Uri.parse(it) },
type,
)
categories?.let { categories ->
for (category in categories) {
intent.addCategory(category)
}
}
intent.`package` = packageValue
intent.component = component?.let { ComponentName.unflattenFromString(it) }
intent.flags = flags
extras?.let { intent.putExtras(it.toBundle()) }
return intent
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.common.routing.utils
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
/**
* Pops routes from the navigation stack until the specified route [R] is found.
*
* ***Must be removed after Decompose migration.***
*
* @param R The route to pop to.
* @param onComplete The callback to be invoked when the operation is complete.
*/
inline fun <reified R : AppRoute> AppRouter.popTo(noinline onComplete: (isSuccess: Boolean) -> Unit = {}) {
popTo(R::class, onComplete)
}

View file

@ -0,0 +1,51 @@
package com.tangem.common.routing.utils
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router
import kotlin.reflect.KClass
/**
* Temporary solution to convert [AppRouter] to [Router].
* (through manual ComponentContext creation).
*
* **Will be removed when all screens will be migrated to Decompose.**
*
* @return [Router] that wraps [AppRouter].
*/
fun AppRouter.asRouter(): Router {
return RouterProxy(appRouter = this)
}
private class RouterProxy(
private val appRouter: AppRouter,
) : Router {
override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) {
if (route is AppRoute) {
appRouter.push(route, onComplete)
}
}
override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) {
routes.filterIsInstance<AppRoute>().let {
appRouter.replaceAll(*it.toTypedArray(), onComplete = onComplete)
}
}
override fun pop(onComplete: (isSuccess: Boolean) -> Unit) {
appRouter.pop(onComplete)
}
override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) {
if (route is AppRoute) {
appRouter.popTo(route, onComplete)
}
}
@Suppress("UNCHECKED_CAST")
override fun popTo(routeClass: KClass<out Route>, onComplete: (isSuccess: Boolean) -> Unit) {
appRouter.popTo(routeClass as KClass<out AppRoute>, onComplete)
}
}

View file

@ -1,8 +0,0 @@
package com.tangem.common
/**
[REDACTED_AUTHOR]
*/
interface Converter<I, O> {
fun convert(value: I): O
}

View file

@ -1,8 +0,0 @@
package com.tangem.common
/**
[REDACTED_AUTHOR]
*/
interface Filter<T> {
fun filter(value: T): Boolean
}

View file

@ -1,8 +0,0 @@
package com.tangem.common
/**
[REDACTED_AUTHOR]
*/
interface Validator<Data, Error> {
fun validate(data: Data? = null): Error?
}

View file

@ -0,0 +1,32 @@
package com.tangem.common
import com.tangem.utils.SupportedLanguages
/**
[REDACTED_AUTHOR]
*/
object TangemBlogUrlBuilder {
fun build(post: Post): String {
val code = SupportedLanguages.getCurrentSupportedLanguageCode()
.takeIf { code ->
code == SupportedLanguages.RUSSIAN || code == SupportedLanguages.ENGLISH
}
?: SupportedLanguages.ENGLISH
return "https://tangem.com/$code/blog/post/${post.path}/"
}
sealed interface Post {
val path: String
data object SeedNotify : Post {
override val path: String = "seed-notify"
}
data object SeedNotifySecond : Post {
override val path: String = "tangem-resolves-log-issue"
}
}
}

View file

@ -0,0 +1,83 @@
package com.tangem.common.keyboard
import android.content.Context
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keyboard validator
*
* @property context application context
*/
@Singleton
class KeyboardValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Get keyboard identifier [KeyboardID] */
fun getKeyboardId(): KeyboardID? {
val id = Settings.Secure.getString(
this.context.contentResolver,
Settings.Secure.DEFAULT_INPUT_METHOD,
) ?: return null
return KeyboardID(value = id)
}
/** Check if [id] is trusted */
fun validate(id: KeyboardID): Boolean = trustedIdentifiers.contains(id.getPackageName())
/**
* Keyboard identifier
*
* @property value value
*/
@JvmInline
value class KeyboardID(val value: String) {
/** Get package name */
fun getPackageName(): String? = value.split("/").firstOrNull()
}
private companion object {
val trustedIdentifiers = listOf(
// Google
"com.android.inputmethod.latin",
"com.google.android.inputmethod.latin",
"com.google.android.tts",
// Samsung
"com.sec.android.inputmethod.latin",
"com.sec.android.inputmethod/.SamsungVoiceIME",
"com.samsung.android.honeyboard",
// Microsoft
"com.microsoft.SwiftKeyApp",
"com.touchtype.swiftkey",
"com.touchtype.swiftkey.beta",
// HtC
"com.htc.sense.ime.langpack.tger",
// LG
"com.lge.ime",
// Huawei
"com.huawei.ohos.inputmethod",
// Third party
"com.menny.android.anysoftkeyboard",
"ch.icoaching.wrio",
"rkr.simplekeyboard.inputmethod",
"keepass2android.keepass2android",
"keepass2android.keepass2android_nonet",
"com.softwarevalencia.openboard.inputmethod.latin",
"kl.ime.oh",
"com.syntellia.fleksy.keyboard",
"org.pocketworkstation.pckeyboard",
)
}
}

View file

@ -3,11 +3,10 @@ package com.tangem.common.module
/**
[REDACTED_AUTHOR]
*/
@Deprecated("Will be removed")
object ModuleErrorCode {
const val APP = 100000
const val COMMON = 10000
const val NETWORK = 20000
const val DOMAIN = 30000
const val SALT_PAY = 40000
const val ONBOARDING = 50000
}

View file

@ -4,6 +4,7 @@ package com.tangem.common.module
[REDACTED_AUTHOR]
* The base object for communication between modules
*/
@Deprecated("Will be removed")
interface ModuleMessage
/**
@ -11,17 +12,14 @@ interface ModuleMessage
* @property message the error description
* @property data any data that can help in the part where this error is being handled
*/
@Deprecated("Will be removed")
abstract class ModuleError : Throwable(), ModuleMessage {
abstract val code: Int
abstract override val message: String
abstract val data: Any?
}
/**
* An exception marked as FbConsumeException should be submitted to Firebase.Crashlytics as a non-fatal issue.
*/
interface FbConsumeException
@Deprecated("Will be removed")
interface ModuleMessageConverter<ModuleMessage, R> {
fun convert(message: ModuleMessage): R
}

View file

@ -0,0 +1,9 @@
package com.tangem.common.timemeasure
import android.os.SystemClock
import kotlin.time.AbstractLongTimeSource
import kotlin.time.DurationUnit
object RealtimeMonotonicTimeSource : AbstractLongTimeSource(DurationUnit.NANOSECONDS) {
override fun read(): Long = SystemClock.elapsedRealtimeNanos()
}

View file

@ -0,0 +1,31 @@
package com.tangem.common.uri
import com.google.firebase.crashlytics.FirebaseCrashlytics
import timber.log.Timber
import java.net.URI
/**
* External url validator
*
[REDACTED_AUTHOR]
*/
object ExternalUrlValidator {
private val trustedHost: List<String> = listOf("tangem.com")
/** Check if [externalUri] is trusted */
fun isUriTrusted(externalUri: String): Boolean {
return try {
val uri = URI.create(externalUri)
uri.scheme == "https" && uri.host in trustedHost
} catch (e: Exception) {
val exception = IllegalStateException("Failed to validate URI: $externalUri", e)
Timber.e(exception)
FirebaseCrashlytics.getInstance().recordException(exception)
false
}
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.common.uri
import com.google.common.truth.Truth
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
class ExternalUrlValidatorTest(private val model: Model) {
@Test
fun test() {
val actual = ExternalUrlValidator.isUriTrusted(externalUri = model.url)
Truth.assertThat(actual).isEqualTo(model.expected)
}
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> = listOf(
Model(url = "https://tangem.com", expected = true),
Model(url = "https://tange.com", expected = false),
Model(url = "https://fake.tangem.com", expected = false),
Model(url = "http://tangem.com", expected = false),
Model(url = "http://tandem.com", expected = false),
Model(url = "adawdawdassdw", expected = false),
)
data class Model(val url: String, val expected: Boolean)
}
}

1
common/ui-charts/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,26 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.common.ui.charts"
}
dependencies {
/** Project - Core */
implementation(projects.core.ui)
/** Compose */
implementation(tangemDeps.vico.core)
implementation(tangemDeps.vico.compose)
implementation(tangemDeps.vico.compose.m3)
implementation(deps.lifecycle.compose)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.kotlin.immutable.collections)
}

View file

@ -0,0 +1,405 @@
package com.tangem.common.ui.charts
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.resolveAsTypeface
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition
import com.patrykandpatrick.vico.core.cartesian.axis.BaseAxis
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener
import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.component.LineComponent
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.common.ui.charts.layer.TimeItemPlacer
import com.tangem.common.ui.charts.layer.rememberMarketChartLayer
import com.tangem.common.ui.charts.layer.rememberTangemChartMarker
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.common.ui.charts.state.*
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
private const val GUIDELINES_COUNT = 3
/**
* MarketChart ui component for representing coin prices.
*
* @param modifier The modifier to be applied to the chart.
* @param state The state of the Market Chart, which includes data and look of the chart.
* @param splitChartSegmentColor The color of the grayed by marker chart segment.
* @param backgroundSplitChartSegmentColorAlpha The alpha of the background the [splitChartSegmentColor]
* @param backgroundColorAlpha The alpha of the background color of the chart.
*/
@Composable
fun MarketChart(
modifier: Modifier = Modifier,
state: MarketChartState = rememberMarketChartState(),
splitChartSegmentColor: Color = TangemTheme.colors.icon.inactive,
@FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float = 0.24f,
@FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float = 0.24f,
) {
var canvasWidth by remember { mutableIntStateOf(0) }
var chartHeight by remember { mutableIntStateOf(0) }
val layer = rememberMarketChartLayer(
lineColor = state.chartColor,
backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha),
secondLineColor = splitChartSegmentColor,
backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha),
secondColorOnTheRightSide = state.markerHighlightRightSide.not(),
markerFraction = state.markerFraction,
axisValueOverrider = AxisValueOverrider.fixed(),
canvasHeight = chartHeight,
)
val marker = rememberTangemChartMarker(color = state.chartColor)
val chart = rememberCartesianChart(
layer,
startAxis = rememberMarketChartStartAxis(state.yValueFormatter),
bottomAxis = rememberMarketChartBottomAxis(state.xValueFormatter),
horizontalLayout = HorizontalLayout.FullWidth(),
markerVisibilityListener = rememberMarketVisibilityListener(canvasWidth, state),
marker = marker,
)
// we need to calculate what the overall height should be in order to get the correct height of the graph
val bottomAxisHeight = with(LocalDensity.current) {
getMarketChartBottomAxisHeight().toPx().toInt()
}
CartesianChartHost(
modifier = modifier
.onGloballyPositioned {
canvasWidth = it.size.width
chartHeight = if (it.size.height != 0) {
it.size.height - bottomAxisHeight
} else {
0
}
}
// Sometimes the chart is not drawn correctly (ex. in LazyLayout), so we need to force the redraw
.drawBehind {
state.markerFraction
state.chartColor
state.markerHighlightRightSide
},
chart = chart,
modelProducer = state.modelProducer,
scrollState = rememberVicoScrollState(scrollEnabled = false),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
animationSpec = null,
)
}
@Composable
fun getMarketChartBottomAxisHeight(): Dp {
return with(LocalDensity.current) {
TangemTheme.typography.caption2.fontSize.toDp() + TangemTheme.dimens.spacing26
}
}
@Composable
private fun rememberMarketVisibilityListener(
canvasWidth: Int,
state: MarketChartState,
): CartesianMarkerVisibilityListener {
val haptic = LocalHapticManager.current
return remember(state, canvasWidth) {
val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f }
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onShown(marker, targets)
haptic.perform(TangemHapticEffect.View.ContextClick)
}
override fun onHidden(marker: CartesianMarker) {
state.markerFraction = null
state.markerVisibilityListener.onHidden(marker)
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onUpdated(marker, targets)
haptic.perform(TangemHapticEffect.View.TextHandleMove)
}
}
}
}
@Composable
private fun rememberMarketChartStartAxis(
yValueFormatter: CartesianValueFormatter,
): VerticalAxis<AxisPosition.Vertical.Start> {
val textStyle = TangemTheme.typography.caption2
val resolver = LocalFontFamilyResolver.current
val typeface by remember(resolver, textStyle) {
resolver.resolveAsTypeface(
fontFamily = textStyle.fontFamily,
fontWeight = textStyle.fontWeight ?: FontWeight.Normal,
fontStyle = textStyle.fontStyle ?: FontStyle.Normal,
fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All,
)
}
return rememberCustomStartAxis(
line = null,
tick = null,
guideline = null,
labelGuideline = rememberChartAxisGuidelineComponent(
color = TangemTheme.colors.icon.inactive.copy(alpha = 0.12f),
),
label = rememberAxisLabelComponent(
color = TangemTheme.colors.text.tertiary,
background = null,
padding = Dimensions.of(
start = TangemTheme.dimens.spacing4,
end = TangemTheme.dimens.spacing4,
),
textSize = TangemTheme.typography.caption2.fontSize,
typeface = typeface,
),
horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside,
verticalLabelPosition = VerticalAxis.VerticalLabelPosition.Center,
itemPlacer = VerticalAxis.ItemPlacer.count({ GUIDELINES_COUNT }, false),
valueFormatter = yValueFormatter,
)
}
@Composable
private fun rememberMarketChartBottomAxis(
xValueFormatter: CartesianValueFormatter,
): HorizontalAxis<AxisPosition.Horizontal.Bottom> {
val textStyle = TangemTheme.typography.caption2
val resolver = LocalFontFamilyResolver.current
val typeface by remember(resolver, textStyle) {
resolver.resolveAsTypeface(
fontFamily = textStyle.fontFamily,
fontWeight = textStyle.fontWeight ?: FontWeight.Normal,
fontStyle = textStyle.fontStyle ?: FontStyle.Normal,
fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All,
)
}
return rememberBottomAxis(
label = rememberAxisLabelComponent(
color = TangemTheme.colors.text.tertiary,
textSize = TangemTheme.typography.caption2.fontSize,
padding = Dimensions.of(top = TangemTheme.dimens.spacing26),
typeface = typeface,
),
tick = null,
line = null,
guideline = null,
sizeConstraint = BaseAxis.SizeConstraint.Auto(),
itemPlacer = remember { TimeItemPlacer() },
valueFormatter = xValueFormatter,
)
}
@Composable
private fun rememberChartAxisGuidelineComponent(color: Color): LineComponent {
return rememberAxisGuidelineComponent(
color = color,
shape = Shape.Rectangle,
margins = Dimensions(
startDp = TangemTheme.dimens.spacing4.value,
endDp = TangemTheme.dimens.spacing4.value,
topDp = 0f,
bottomDp = 0f,
),
thickness = TangemTheme.dimens.size2,
)
}
// region Preview
@Suppress("LongMethod")
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun MarketChartPreview(
@PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair<List<BigDecimal>, List<BigDecimal>>,
) {
val y = previewData.second
val x = previewData.first
val dataProducer = remember {
MarketChartDataProducer.build {
chartLook = MarketChartLook(
type = MarketChartLook.Type.Growing,
markerHighlightRightSide = true,
)
}
}
LaunchedEffect(key1 = Unit) {
dataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
x = x.toImmutableList(),
y = y.toImmutableList(),
)
updateLook {
it.copy(
xAxisFormatter = { value ->
value.toLong().toTimeFormat(DateTimeFormatters.dateMMMdd)
},
yAxisFormatter = { value ->
value.setScale(3, RoundingMode.HALF_UP).toPlainString()
},
)
}
}
}
var markerPoint by remember {
mutableStateOf(Pair<BigDecimal?, BigDecimal?>(null, null))
}
val coroutineScope = rememberCoroutineScope()
val look by dataProducer.lookState.collectAsState()
TangemThemePreview {
val growingColor = TangemTheme.colors.icon.accent
val fallingColor = TangemTheme.colors.icon.warning
val neutralColor = TangemTheme.colors.icon.informative
val chartState = rememberMarketChartState(
dataProducer = dataProducer,
onMarkerShown = { x, y ->
markerPoint = Pair(x, y)
},
colorMapper = {
when (it) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
MarketChartLook.Type.Neutral -> neutralColor
}
},
)
Column(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.fillMaxWidth(),
) {
Text(text = "Point: ${markerPoint.first}, ${markerPoint.second}")
MarketChart(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.tertiary)
.height(173.dp),
state = chartState,
splitChartSegmentColor = TangemTheme.colors.icon.inactive,
backgroundSplitChartSegmentColorAlpha = 0.24f,
backgroundColorAlpha = 0.24f,
)
SpacerH16()
Button(
onClick = {
dataProducer.runTransaction {
updateLook {
it.copy(markerHighlightRightSide = !it.markerHighlightRightSide)
}
}
},
) {
Text(
text = "Change marker highlight side",
)
}
Button(
onClick = {
coroutineScope.launch {
dataProducer.runTransactionSuspend {
updateData {
MarketChartData.Data(
x = it.x,
y = it.y.reversed().toImmutableList(),
)
}
}
}
},
) {
Text("Change Data")
}
Button(
onClick = {
dataProducer.runTransaction {
updateLook {
it.copy(
type = when (it.type) {
MarketChartLook.Type.Growing -> MarketChartLook.Type.Falling
MarketChartLook.Type.Falling -> MarketChartLook.Type.Neutral
MarketChartLook.Type.Neutral -> MarketChartLook.Type.Growing
},
)
}
}
},
) {
Text("Change color type")
}
}
}
}
// endregion Preview

View file

@ -0,0 +1,117 @@
package com.tangem.common.ui.charts
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.*
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.ColorShader
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.toImmutableList
import kotlin.random.Random
@Composable
fun MarketChartMini(
rawData: MarketChartRawData,
modifier: Modifier = Modifier,
type: MarketChartLook.Type = MarketChartLook.Type.Growing,
growingColor: Color = TangemTheme.colors.icon.accent,
fallingColor: Color = TangemTheme.colors.icon.warning,
neutralColor: Color = TangemTheme.colors.icon.informative,
) {
val model = remember(rawData) {
CartesianChartModel(LineCartesianLayerModel.build { series(rawData.y) })
}
val lineColor = when (type) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
MarketChartLook.Type.Neutral -> neutralColor
}
val lineSpec = rememberLine(
shader = ColorShader(lineColor.toArgb()),
thickness = 1.dp,
backgroundShader = Brush.verticalGradient(
colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent),
).toDynamicShader(),
)
val layer = rememberLineCartesianLayer(LineCartesianLayer.LineProvider.series(lineSpec))
val chart = rememberCartesianChart(
layer,
horizontalLayout = HorizontalLayout.fullWidth(),
)
CartesianChartHost(
modifier = modifier,
chart = chart,
model = model,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
scrollState = rememberVicoScrollState(scrollEnabled = false),
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
val data = MarketChartRawData(
x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
)
TangemThemePreview {
Column {
MarketChartMini(rawData = data, type = MarketChartLook.Type.Growing)
SpacerH16()
MarketChartMini(rawData = data, type = MarketChartLook.Type.Falling)
SpacerH16()
MarketChartMini(rawData = data, type = MarketChartLook.Type.Neutral)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewColumn() {
val data = MarketChartRawData(
x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(),
)
TangemThemePreview {
LazyColumn {
items(100) {
MarketChartMini(
rawData = data,
type = if (it % 3 == 0) MarketChartLook.Type.Growing else MarketChartLook.Type.Falling,
)
}
}
}
}
// endregion Preview

View file

@ -0,0 +1,246 @@
package com.tangem.common.ui.charts.downsample
import kotlin.math.max
/**
* =========================================================
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =========================================================
*
* Downsamples the given data points to the desired number of buckets (points + 2).
*
[REDACTED_AUTHOR]
*/
object LTThreeBuckets {
fun downsample(x: List<Double>, y: List<Double>, desiredBuckets: Int): Result {
require(x.size == y.size) { "X and Y must have the same size" }
require(desiredBuckets > 0) { "Desired buckets must be greater than 0" }
val points = x.zip(y).mapIndexed { index, (x, y) -> Point(index, x, y) }
val results = mutableListOf<Point>()
points.onPassBucketize(desiredBuckets)
.sliding(size = 3, step = 1)
.map { buckets -> Triangle.of(buckets) }
.fastForEach { triangle ->
if (results.isEmpty()) {
results.add(triangle.getFirst())
}
results.add(triangle.getResult())
if (results.size == desiredBuckets + 1) {
results.add(triangle.getLast())
}
}
val xRes = ArrayList<Double>(points.size)
val yRes = ArrayList<Double>(points.size)
val indexesRes = ArrayList<Int>(points.size)
results.fastForEach {
xRes.add(it.x)
yRes.add(it.y)
indexesRes.add(it.originalIndex!!)
}
return Result(
originalIndexes = indexesRes,
x = xRes,
y = yRes,
)
}
data class Result(
val originalIndexes: List<Int>,
val x: List<Double>,
val y: List<Double>,
)
}
private fun List<Point>.onPassBucketize(desiredBucketsCount: Int): List<Bucket> {
val middleSize = size - 2
val bucketSize = middleSize / desiredBucketsCount
val remainingElements = middleSize % desiredBucketsCount
require(bucketSize != 0) {
"Can't produce $desiredBucketsCount buckets from an input series of ${middleSize + 2} elements"
}
val buckets = mutableListOf<Bucket>()
// Add first point as the only point in the first bucket
buckets.add(Bucket.of(this[0]))
var rest = this.subList(1, this.lastIndex)
// Add middle buckets.
// When inputSize is not a multiple of desiredBuckets,
// remaining elements are equally distributed on the first buckets.
while (buckets.size < desiredBucketsCount + 1) {
val size = if (buckets.size <= remainingElements) bucketSize + 1 else bucketSize
buckets.add(Bucket.of(rest.subList(0, size)))
rest = rest.subList(size, rest.size)
}
// Add last point as the only point in the last bucket
buckets.add(Bucket.of(this.last()))
return buckets
}
private fun List<Bucket>.sliding(size: Int, step: Int): List<List<Bucket>> {
val window = max(size, step)
val buffer = ArrayDeque<Bucket>()
var totalIn = 0
val lists = mutableListOf<List<Bucket>>()
fastForEach { p ->
buffer.add(p)
++totalIn
if (buffer.size == window) {
val batch = buffer.take(size)
lists.add(batch)
repeat(step) {
buffer.removeFirst()
}
}
}
if (buffer.isNotEmpty()) {
val totalOut = max(0, (totalIn + step - size - 1) / step) + 1
if (totalOut > lists.size) {
val batch = buffer.take(size)
lists.add(batch)
}
}
return lists
}
private data class Point(
val originalIndex: Int? = null,
val x: Double,
val y: Double,
)
private data class Bucket(
val data: List<Point>,
val center: Point,
val result: Point,
val first: Point,
val last: Point,
) {
companion object {
private fun centerBetweenPoints(a: Point, b: Point): Point {
val vector = Point(
x = b.x - a.x,
y = b.y - a.y,
)
val halfVector = Point(
x = vector.x / 2,
y = vector.y / 2,
)
return Point(
x = a.x + halfVector.x,
y = a.y + halfVector.y,
)
}
fun of(points: List<Point>): Bucket {
val first = points.first()
val last = points.last()
return Bucket(
data = points,
center = centerBetweenPoints(first, last),
result = first,
first = first,
last = last,
)
}
fun of(point: Point): Bucket {
return Bucket(
data = listOf(point),
center = point,
result = point,
first = point,
last = point,
)
}
}
}
private data class Triangle(
val left: Bucket,
val center: Bucket,
val right: Bucket,
) {
fun getResult(): Point {
return center.data.map { Area.ofTriangle(left.result, it, right.center) }
.maxByOrNull { it.value }
?.generator
?: error("Can't obtain max area triangle")
}
fun getFirst(): Point {
return left.first
}
fun getLast(): Point {
return right.last
}
companion object {
fun of(buckets: List<Bucket>): Triangle {
return Triangle(
left = buckets[0],
center = buckets[1],
right = buckets[2],
)
}
}
}
private data class Area(
val generator: Point,
val value: Double,
) {
companion object {
fun ofTriangle(a: Point, b: Point, c: Point): Area {
val addends = listOf(
a.x * (b.y - c.y),
b.x * (c.y - a.y),
c.x * (a.y - b.y),
)
val sum = addends.sum()
val value = kotlin.math.abs(sum / 2)
return Area(b, value)
}
}
}
private inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
for (index in indices) {
val item = get(index)
action(item)
}
}

View file

@ -0,0 +1,109 @@
package com.tangem.common.ui.charts.layer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent
import com.patrykandpatrick.vico.compose.common.component.shapeComponent
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.compose.common.shape.dashed
import com.patrykandpatrick.vico.core.cartesian.*
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerValueFormatter
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.LayeredComponent
import com.patrykandpatrick.vico.core.common.component.TextComponent
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.core.ui.res.TangemTheme
/**
* @param color The color of the indicator and guideline.
* @param innerCircleColor The color of the inner circle of the indicator.
*
* @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect.
*/
@Composable
internal fun rememberTangemChartMarker(color: Color): CartesianMarker {
val guideline = rememberUnboundedLineComponent(
color = color,
verticalAddDrawSpace = TangemTheme.dimens.spacing24,
shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) },
)
return remember(guideline) {
val outColor = guideline.color
object : DefaultCartesianMarker(
label = TextComponent(textSizeSp = 0f),
indicator = { color ->
val composeColor = Color(color)
LayeredComponent(
rear = shapeComponent(
color = composeColor.copy(alpha = INDICATOR_REAR_COLOR_ALPHA),
shape = Shape.Pill,
),
front = LayeredComponent(
rear = shapeComponent(
color = composeColor,
shape = Shape.Pill,
),
front = shapeComponent(
color = Color.White,
shape = Shape.Pill,
),
padding = indicatorPadding,
),
padding = indicatorPadding,
)
},
indicatorSizeDp = INDICATOR_SIZE_DP,
guideline = guideline,
valueFormatter = object : CartesianMarkerValueFormatter {
override fun format(
context: CartesianDrawContext,
targets: List<CartesianMarker.Target>,
): CharSequence = ""
},
) {
override fun updateInsets(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
model: CartesianChartModel,
insets: Insets,
) {
with(context) {
super.updateInsets(context, horizontalDimensions, model, insets)
val baseShadowInsetDp =
CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP
val topInset = (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels
val bottomInset = (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels
insets.ensureValuesAtLeast(top = topInset, bottom = bottomInset)
}
}
override fun CartesianDrawContext.drawIndicator(x: Float, y: Float, color: Int, halfIndicatorSize: Float) {
val indicator = indicator ?: return
cacheStore
.getOrSet(keyNamespace, indicator, outColor) { indicator.invoke(outColor) }
.draw(
this,
x - halfIndicatorSize,
y - halfIndicatorSize,
x + halfIndicatorSize,
y + halfIndicatorSize,
)
}
}
}
}
private val indicatorPadding = Dimensions.of(3.dp)
private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f
private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f
private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f
private const val INDICATOR_SIZE_DP = 16f
private const val INDICATOR_REAR_COLOR_ALPHA = .24f

View file

@ -0,0 +1,220 @@
package com.tangem.common.ui.charts.layer
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLine
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.DynamicShader
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Creates and remembers a LineCartesianLayer for a chart with specific characteristics.
*
* @param lineColor The color of the main line in the chart.
* @param backgroundLineColor The color of the line's background.
* @param secondLineColor The color of the line for the second part of the chart.
* @param backgroundSecondLineColor The color of the line's background for the second part of the chart.
* @param startDrawingAnimation A mutable state that triggers the start of the drawing animation when set to true.
* @param axisValueOverrider An AxisValueOverrider that provides custom values for the axis.
* @param secondColorOnTheRightSide A boolean that determines if the second color should be on the right side of the chart. Default is false.
* @param markerFraction A float between 0.0 and 1.0 that represents the fraction of the chart where the marker is located. Default is null.
*
* @return A LineCartesianLayer that represents a layer in a chart with the specified characteristics.
*/
@Suppress("LongParameterList")
@Composable
internal fun rememberMarketChartLayer(
lineColor: Color,
backgroundLineColor: Color,
secondLineColor: Color,
backgroundSecondLineColor: Color,
axisValueOverrider: AxisValueOverrider,
secondColorOnTheRightSide: Boolean,
@FloatRange(from = 0.0, to = 1.0) markerFraction: Float?,
canvasHeight: Int,
): LineCartesianLayer {
val backgroundColorLineGradient = persistentListOf(backgroundLineColor, Color.Transparent)
val backgroundSecondLineColorGradient = persistentListOf(backgroundSecondLineColor, Color.Transparent)
val markerSet = markerFraction != null
return rememberLayer(
fractionValue = markerFraction ?: 0f,
axisValueOverrider = axisValueOverrider,
canvasHeight = canvasHeight,
lineColor = if (markerFraction != null) {
secondLineColor
} else {
lineColor
},
backLineColor = if (markerSet && !secondColorOnTheRightSide) {
backgroundSecondLineColorGradient
} else {
backgroundColorLineGradient
},
lineColorRight = when {
markerSet && secondColorOnTheRightSide -> secondLineColor
else -> lineColor
},
backLineColorRight = when {
markerSet && secondColorOnTheRightSide -> backgroundSecondLineColorGradient
else -> backgroundColorLineGradient
},
)
}
@Suppress("LongParameterList")
@Composable
private fun rememberLayer(
fractionValue: Float,
axisValueOverrider: AxisValueOverrider,
lineColor: Color,
backLineColor: ImmutableList<Color>,
lineColorRight: Color,
backLineColorRight: ImmutableList<Color>,
canvasHeight: Int,
): LineCartesianLayer {
val endGradientColorPosition = if (canvasHeight != 0) {
canvasHeight * END_GRADIENT_COLOR_POSITION_PERCENTAGE
} else {
Float.POSITIVE_INFINITY
}
val alineColor = remember(lineColor) { lineColor.toArgb() }
val alineColorRight = remember(lineColorRight) { lineColorRight.toArgb() }
return rememberLineCartesianLayer(
LineCartesianLayer.LineProvider.series(
rememberSplitLine(
shader = DynamicShader.Companion.horizontalGradient(
colors = intArrayOf(alineColor, alineColorRight),
positions = floatArrayOf(fractionValue, fractionValue),
),
backgroundShaderFirst = Brush.verticalGradient(
colors = backLineColor,
endY = endGradientColorPosition,
).toDynamicShader(),
backgroundShaderSecond = Brush.verticalGradient(
colors = backLineColorRight,
endY = endGradientColorPosition,
).toDynamicShader(),
xSplitFraction = fractionValue,
thickness = 1.dp,
),
),
axisValueOverrider = axisValueOverrider,
)
}
private const val END_GRADIENT_COLOR_POSITION_PERCENTAGE = 0.9f
// region Preview
@Suppress("LongMethod")
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun LayerChartPreview(
@PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair<List<BigDecimal>, List<BigDecimal>>,
) {
val y = previewData.second.map { it.toFloat() }
val x = List(y.size) { it.toFloat() }
val model = CartesianChartModel(LineCartesianLayerModel.build { series(x, y) })
var lineColor by remember {
mutableStateOf(Color.Blue)
}
TangemThemePreview {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(48.dp),
) {
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
secondColorOnTheRightSide = true,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
markerFraction = 0.35f,
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
markerFraction = 0.35f,
secondColorOnTheRightSide = true,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = lineColor.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
markerFraction = 0.35f,
secondColorOnTheRightSide = false,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
canvasHeight = 495,
),
horizontalLayout = HorizontalLayout.fullWidth(),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
model = model,
)
}
}
}
// endregion Preview

View file

@ -0,0 +1,56 @@
package com.tangem.common.ui.charts.layer
import com.patrykandpatrick.vico.core.cartesian.CartesianDrawContext
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasureContext
import com.patrykandpatrick.vico.core.cartesian.HorizontalDimensions
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.data.ChartValues
@Suppress("MagicNumber")
class TimeItemPlacer : HorizontalAxis.ItemPlacer {
private val ChartValues.measuredLabelValues
get() = buildList {
// produce exactly 6 values distributed evenly
val xLength = maxX - minX
val xStep = xLength / 7
repeat(times = 6) {
add(minX + xStep * (it + 1))
}
}
override fun getEndHorizontalAxisInset(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
tickThickness: Float,
maxLabelWidth: Float,
): Float = 0f
override fun getStartHorizontalAxisInset(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
tickThickness: Float,
maxLabelWidth: Float,
): Float = 0f
override fun getHeightMeasurementLabelValues(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
fullXRange: ClosedFloatingPointRange<Double>,
maxLabelWidth: Float,
): List<Double> = context.chartValues.measuredLabelValues
override fun getLabelValues(
context: CartesianDrawContext,
visibleXRange: ClosedFloatingPointRange<Double>,
fullXRange: ClosedFloatingPointRange<Double>,
maxLabelWidth: Float,
): List<Double> = context.chartValues.measuredLabelValues
override fun getWidthMeasurementLabelValues(
context: CartesianMeasureContext,
horizontalDimensions: HorizontalDimensions,
fullXRange: ClosedFloatingPointRange<Double>,
): List<Double> = context.chartValues.measuredLabelValues
}

View file

@ -0,0 +1,152 @@
@file:Suppress("MagicNumber")
package com.tangem.common.ui.charts.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import java.math.BigDecimal
internal class MarketChartPreviewDataProvider : PreviewParameterProvider<Pair<List<BigDecimal>, List<BigDecimal>>> {
override val values: Sequence<Pair<List<BigDecimal>, List<BigDecimal>>>
get() {
val bitcoinPrice = listOf(
59270, 61748, 61941, 62889, 62740, 62989, 63858, 63608, 63951, 63917,
63253, 63765, 63862, 64502, 63876, 64028, 63875, 64531, 64249, 63680,
63228, 63159, 63249, 63664, 63469, 63711, 63003, 62333, 62882, 62183,
62231, 62244, 62200, 61188, 61683, 61199, 61036, 62116, 62436, 63065,
62908, 63067, 63294, 60904, 60677, 60790, 60705, 61041, 60667, 61158,
61112, 60795, 60900, 60771, 61124, 61343, 61371, 61484, 61133, 62372,
62704, 63007, 63092, 62905, 62470, 62019, 61756, 61776, 61557, 61540,
61904, 62151, 62419, 64663, 65955, 66229, 65991, 66176, 66517, 65838,
65210, 65216, 65611, 66467, 66209, 67247, 66913, 67059, 66978, 66845,
67240, 66874, 66993, 66940, 67185, 67330, 67340, 66845, 66062, 66273,
66645, 66865, 67005, 67382, 70049, 71464, 71293, 70875, 71137, 69720,
69323, 70139, 69964, 69716, 69855, 70442, 69774, 69125, 69404, 69714,
69967, 68042, 67077, 67938, 67833, 67182, 67306, 68327, 69093, 68517,
68726, 68759, 69061, 68880, 69148, 69305, 69044, 69313, 69122, 68821,
68854, 68506, 68823, 68613, 68420, 70356, 69241, 69401, 68004, 67630,
68311, 68311, 68291, 68302, 68717, 67926, 67690, 67314, 67285, 67567,
68026, 67552, 67740, 68519, 68611, 68363, 68503, 68171, 68326, 67121,
67626, 67481, 67657, 67567, 67608, 67607, 67683, 67729, 67733, 67709,
).map { it.toBigDecimal() }
val bitcoinTimestamps = listOf(
1714752000000, 1714766400000, 1714780800000, 1714795200000, 1714809600000,
1714824000000, 1714838400000, 1714852800000, 1714867200000, 1714881600000,
1714896000000, 1714910400000, 1714924800000, 1714939200000, 1714953600000,
1714968000000, 1714982400000, 1714996800000, 1715011200000, 1715025600000,
1715040000000, 1715054400000, 1715068800000, 1715083200000, 1715097600000,
1715112000000, 1715126400000, 1715140800000, 1715155200000, 1715169600000,
1715184000000, 1715198400000, 1715212800000, 1715227200000, 1715241600000,
1715256000000, 1715270400000, 1715284800000, 1715299200000, 1715313600000,
1715328000000, 1715342400000, 1715356800000, 1715371200000, 1715385600000,
1715400000000, 1715414400000, 1715428800000, 1715443200000, 1715457600000,
1715472000000, 1715486400000, 1715500800000, 1715515200000, 1715529600000,
1715544000000, 1715558400000, 1715572800000, 1715587200000, 1715601600000,
1715616000000, 1715630400000, 1715644800000, 1715659200000, 1715673600000,
1715688000000, 1715702400000, 1715716800000, 1715731200000, 1715745600000,
1715760000000, 1715774400000, 1715788800000, 1715803200000, 1715817600000,
1715832000000, 1715846400000, 1715860800000, 1715875200000, 1715889600000,
1715904000000, 1715918400000, 1715932800000, 1715947200000, 1715961600000,
1715976000000, 1715990400000, 1716004800000, 1716019200000, 1716033600000,
1716048000000, 1716062400000, 1716076800000, 1716091200000, 1716105600000,
1716120000000, 1716134400000, 1716148800000, 1716163200000, 1716177600000,
1716192000000, 1716206400000, 1716220800000, 1716235200000, 1716249600000,
1716264000000, 1716278400000, 1716292800000, 1716307200000, 1716321600000,
1716336000000, 1716350400000, 1716364800000, 1716379200000, 1716393600000,
1716408000000, 1716422400000, 1716436800000, 1716451200000, 1716465600000,
1716480000000, 1716494400000, 1716508800000, 1716523200000, 1716537600000,
1716552000000, 1716566400000, 1716580800000, 1716595200000, 1716609600000,
1716624000000, 1716638400000, 1716652800000, 1716667200000, 1716681600000,
1716696000000, 1716710400000, 1716724800000, 1716739200000, 1716753600000,
1716768000000, 1716782400000, 1716796800000, 1716811200000, 1716825600000,
1716840000000, 1716854400000, 1716868800000, 1716883200000, 1716897600000,
1716912000000, 1716926400000, 1716940800000, 1716955200000, 1716969600000,
1716984000000, 1716998400000, 1717012800000, 1717027200000, 1717041600000,
1717056000000, 1717070400000, 1717084800000, 1717099200000, 1717113600000,
1717128000000, 1717142400000, 1717156800000, 1717171200000, 1717185600000,
1717200000000, 1717214400000, 1717228800000, 1717243200000, 1717257600000,
1717272000000, 1717286400000, 1717300800000, 1717315200000, 1717329600000,
).map { it.toBigDecimal() }
val notcoinPrice = listOf(
"0.02026708", "0.02033274", "0.02112643", "0.02090579",
"0.02047231", "0.0215645", "0.0089055", "0.00674459",
"0.00733586", "0.00758165", "0.0068592", "0.00680505",
"0.00685493", "0.00702113", "0.00725545", "0.00696515",
"0.00681556", "0.00677416", "0.00669383", "0.00659057",
"0.00668081", "0.0066211", "0.0065232", "0.00612542",
"0.00600531", "0.00570974", "0.00559875", "0.00555247",
"0.005495", "0.00547841", "0.00545514", "0.00553865",
"0.00563302", "0.00568475", "0.00562512", "0.00551265",
"0.005398", "0.00560001", "0.00572701", "0.00563223",
"0.00555209", "0.00549261", "0.00524999", "0.00531381",
"0.00539702", "0.00530647", "0.00533963", "0.00527143",
"0.00525981", "0.00495323", "0.00481584", "0.0048854",
"0.00480298", "0.00471316", "0.00473191", "0.00476389",
"0.00476992", "0.00484649", "0.00471095", "0.00501396",
"0.00495481", "0.00545029", "0.0053947", "0.00533251",
"0.00518064", "0.00504481", "0.00507209", "0.00516952",
"0.00524886", "0.00542661", "0.00544231", "0.00579898",
"0.00681472", "0.00720634", "0.00824023", "0.00856427",
"0.00821144", "0.00818103", "0.00960254", "0.00911172",
"0.00888481", "0.00925911", "0.0091132", "0.009285",
"0.00886256", "0.00938211", "0.00936566", "0.0104387",
"0.01088452", "0.01200575", "0.01217514", "0.011921",
"0.01293908", "0.0126124", "0.01224851", "0.01191149",
"0.01179783", "0.01164533", "0.01175718", "0.01169137",
"0.01212768", "0.01215952", "0.01300256", "0.01589522",
"0.01588223", "0.01780629", "0.01919794", "0.01922187",
"0.02165268", "0.02400163", "0.02290975", "0.02383495",
"0.02088105", "0.02373489", "0.02269442", "0.02226249",
"0.02148038", "0.0232045", "0.02623129", "0.02378975",
"0.02438442", "0.02417507", "0.02269891", "0.02236198",
"0.02209164", "0.02169842", "0.02137774", "0.02188837",
"0.0216619", "0.02238334", "0.02186701", "0.02186521",
"0.0217595", "0.02099916", "0.02129634", "0.02143028",
"0.02192513", "0.02172005", "0.02184525", "0.01873974",
"0.01899164", "0.01957115", "0.0204723", "0.0199247",
"0.01935441", "0.01886976", "0.01856944", "0.0179975",
"0.0178625", "0.01796981",
).map { BigDecimal(it) }
val notcoinTimestamps = listOf<Long>(
1715428800000, 1715443200000, 1715457600000, 1715472000000,
1715486400000, 1715860800000, 1715875200000, 1715889600000,
1715904000000, 1715918400000, 1715932800000, 1715947200000,
1715961600000, 1715976000000, 1715990400000, 1716004800000,
1716019200000, 1716033600000, 1716048000000, 1716062400000,
1716076800000, 1716091200000, 1716105600000, 1716120000000,
1716134400000, 1716148800000, 1716163200000, 1716177600000,
1716192000000, 1716206400000, 1716220800000, 1716235200000,
1716249600000, 1716264000000, 1716278400000, 1716292800000,
1716307200000, 1716321600000, 1716336000000, 1716350400000,
1716364800000, 1716379200000, 1716393600000, 1716408000000,
1716422400000, 1716436800000, 1716451200000, 1716465600000,
1716480000000, 1716494400000, 1716508800000, 1716523200000,
1716537600000, 1716552000000, 1716566400000, 1716580800000,
1716595200000, 1716609600000, 1716624000000, 1716638400000,
1716652800000, 1716667200000, 1716681600000, 1716696000000,
1716710400000, 1716724800000, 1716739200000, 1716753600000,
1716768000000, 1716782400000, 1716796800000, 1716811200000,
1716825600000, 1716840000000, 1716854400000, 1716868800000,
1716883200000, 1716897600000, 1716912000000, 1716926400000,
1716940800000, 1716955200000, 1716969600000, 1716984000000,
1716998400000, 1717012800000, 1717027200000, 1717041600000,
1717056000000, 1717070400000, 1717084800000, 1717099200000,
1717113600000, 1717128000000, 1717142400000, 1717156800000,
1717171200000, 1717185600000, 1717200000000, 1717214400000,
1717228800000, 1717243200000, 1717257600000, 1717272000000,
1717286400000, 1717300800000, 1717315200000, 1717329600000,
1717344000000, 1717358400000, 1717372800000, 1717387200000,
1717401600000, 1717416000000, 1717430400000, 1717444800000,
1717459200000, 1717473600000, 1717488000000, 1717502400000,
1717516800000, 1717531200000, 1717545600000, 1717560000000,
1717574400000, 1717588800000, 1717603200000, 1717617600000,
1717632000000, 1717646400000, 1717660800000, 1717675200000,
1717689600000, 1717704000000, 1717718400000, 1717732800000,
1717747200000, 1717761600000, 1717776000000, 1717790400000,
1717804800000, 1717819200000, 1717833600000, 1717848000000,
1717862400000, 1717876800000, 1717891200000, 1717905600000,
1717920000000, 1717934400000,
).map { it.toBigDecimal() }
return sequenceOf(bitcoinTimestamps to bitcoinPrice, notcoinTimestamps to notcoinPrice)
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Immutable
sealed interface MarketChartData {
/**
* This interface represents the state when there is no data for the Market Chart.
*/
@Immutable
sealed interface NoData : MarketChartData {
@Immutable
data object Empty : NoData
@Immutable
data object Loading : NoData
@Immutable
data object ErrorAndRetry : NoData
}
/**
* This data class represents the data for the Market Chart.
* It includes properties for x and y values.
*
* @property x List of x values.
* @property y List of y values.
*/
@Immutable
data class Data(
val x: ImmutableList<BigDecimal> = persistentListOf(),
val y: ImmutableList<BigDecimal> = persistentListOf(),
) : MarketChartData
}

View file

@ -0,0 +1,208 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.tangem.common.ui.charts.state.converter.PointValuesConverter
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* This class represents a transaction for updating the state and look of a Market Chart.
*
* @property chartLook The updated look of the Market Chart.
* @property chartData The updated state of the Market Chart.
*/
class Transaction(
private val currentData: MarketChartData,
private val currentLook: MarketChartLook,
) {
var chartLook: MarketChartLook? = null
var chartData: MarketChartData.NoData? = null
fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) {
val newLook = block(currentLook)
chartLook = newLook.copy(
xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter),
yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter),
)
}
fun updateState(block: (prev: MarketChartData) -> MarketChartData.NoData) {
chartData = block(currentData)
}
}
/**
* This class represents a transaction for updating the state and look of a Market Chart.
* It extends the Transaction class and allows update state by data.
*
* @property chartData The updated state of the Market Chart.
* @property chartLook The updated look of the Market Chart.
*/
class TransactionSuspend(
private val currentData: MarketChartData,
private val currentLook: MarketChartLook,
) {
internal var nonSuspendTransaction: Transaction? = null
var chartData: MarketChartData? = null
var chartLook: MarketChartLook?
get() = nonSuspendTransaction?.chartLook
set(value) {
if (nonSuspendTransaction == null) {
nonSuspendTransaction = Transaction(currentData, currentLook)
}
nonSuspendTransaction?.chartLook = value
}
fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) {
val newLook = block(currentLook)
chartLook = newLook.copy(
xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter),
yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter),
)
}
internal fun updateState(block: (prev: MarketChartData) -> MarketChartData) {
chartData = block(currentData)
}
internal fun updateData(block: (prev: MarketChartData.Data) -> MarketChartData.Data) {
chartData = when (val currentState = currentData) {
is MarketChartData.Data -> block(currentState)
else -> currentState
}
}
}
@Stable
class MarketChartDataProducer private constructor(
initialData: MarketChartData,
initialLook: MarketChartLook,
val pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
internal val dataState = MutableStateFlow(initialData)
internal val lookState = MutableStateFlow(initialLook)
internal val entries = MutableStateFlow<List<LineCartesianLayerModel.Entry>>(emptyList())
internal val modelProducer = CartesianChartModelProducer(dispatcher = dispatcher)
internal val rawData = MutableStateFlow<MarketChartRawData?>(null)
private val mutex = Mutex()
/**
* This function runs a suspending transaction block to update the state and look of the Market Chart.
*/
suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = withContext(dispatcher) {
mutex.withLock {
handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block))
}
}
/**
* This function runs a non-suspending transaction block to update the state and look of the Market Chart.
*/
fun runTransaction(block: Transaction.() -> Unit) =
handleTransaction(transaction = Transaction(dataState.value, lookState.value).apply(block))
private suspend fun handleTransactionSuspend(transaction: TransactionSuspend) {
val nonSuspendTransaction = transaction.nonSuspendTransaction
val chartData = transaction.chartData
val oldData = dataState.value
if (chartData is MarketChartData.Data && (oldData !is MarketChartData.Data || oldData != chartData)) {
(lookState.value.xAxisFormatter as? FormatterWrapWithCache)?.clearCache()
(lookState.value.yAxisFormatter as? FormatterWrapWithCache)?.clearCache()
val rawData = pointsValuesConverter.convert(chartData)
val entriesLocal =
rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) }
currentCoroutineContext().ensureActive()
runCatching {
modelProducer.runTransaction {
add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal)))
}
}
entries.value = entriesLocal
dataState.value = chartData
this.rawData.value = rawData
delay(timeMillis = 200)
} else if (chartData != null) {
dataState.value = chartData
}
nonSuspendTransaction?.let { handleTransaction(it) }
}
private fun handleTransaction(transaction: Transaction) {
transaction.chartData?.let {
dataState.value = it
}
transaction.chartLook?.let {
lookState.value = it
}
}
companion object {
private val initialData: MarketChartData = MarketChartData.NoData.Empty
private val initialLook: MarketChartLook = MarketChartLook()
/**
* This function builds a MarketChartDataProducer with the given parameters.
* It runs a suspending transaction block to initialize the data and look of the Market Chart.
*
* @param dispatcher The dispatcher to be used for data updates.
* @param block The transaction block to be run.
* @return A MarketChartDataProducer.
*/
suspend fun buildSuspend(
pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: TransactionSuspend.() -> Unit,
): MarketChartDataProducer {
val transaction = TransactionSuspend(initialData, initialLook).apply(block)
return MarketChartDataProducer(
initialData = initialData,
initialLook = initialLook,
dispatcher = dispatcher,
pointsValuesConverter = pointsValuesConverter,
).apply {
handleTransactionSuspend(transaction)
}
}
/**
* This function builds a MarketChartDataProducer with the given parameters.
* It runs a non-suspending transaction block to initialize the data and look of the Market Chart.
*
* @param dispatcher The dispatcher to be used for data updates.
* @param block The transaction block to be run.
* @return A MarketChartDataProducer.
*/
fun build(
pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: Transaction.() -> Unit,
): MarketChartDataProducer {
val transaction = Transaction(initialData, initialLook).apply(block)
return MarketChartDataProducer(
initialData = transaction.chartData ?: initialData,
initialLook = transaction.chartLook ?: initialLook,
dispatcher = dispatcher,
pointsValuesConverter = pointsValuesConverter,
)
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.formatter.AxisLabelFormatter
/**
* This class represents the look and feel of a Market Chart.
* It includes properties for type, marker highlight, animation on data change, animate data appearance,
* and formatters for x and y axis.
*
* @property type The type of the chart, can be either Growing or Falling.
* @property markerHighlightRightSide A boolean indicating whether the marker highlights the right side of the chart.
* @property xAxisFormatter A formatter for the x-axis labels.
* @property yAxisFormatter A formatter for the y-axis labels.
*/
@Immutable
data class MarketChartLook(
val type: Type = Type.Growing,
val markerHighlightRightSide: Boolean = true,
val xAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() },
val yAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() },
) {
enum class Type {
Growing,
Falling,
Neutral,
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* This class represents raw data for a Market Chart. Used for drawing the chart.
*
* @property originalIndexes If the source data has the original representation (due to reduced sampling),
* this list contains the original indexes of the data points.
* @property y The list of y-values.
* @property x The list of x-values.
*/
@Immutable
data class MarketChartRawData(
val originalIndexes: ImmutableList<Int>? = null,
val y: ImmutableList<Double>,
val x: ImmutableList<Double> = List(y.size) { 1.0 }.toImmutableList(),
)

View file

@ -0,0 +1,127 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener
import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget
import java.math.BigDecimal
/**
* MarketChartState used for MarketChart ui component.
*
* @param dataProducer The producer of the data for the Market Chart.
* @param colorMapper A function that maps a MarketChartLook.Type to a Color.
* @param onMarkerShown A callback function that is called when the marker is shown, hidden, or updated.
* @return A MarketChartState.
*/
@Composable
fun rememberMarketChartState(
dataProducer: MarketChartDataProducer = remember { MarketChartDataProducer.build {} },
colorMapper: (MarketChartLook.Type) -> Color = remember {
{
when (it) {
MarketChartLook.Type.Growing -> Color.Green
MarketChartLook.Type.Falling -> Color.Red
MarketChartLook.Type.Neutral -> Color.Gray
}
}
},
onMarkerShown: (x: BigDecimal?, y: BigDecimal?) -> Unit = { _, _ -> },
): MarketChartState {
val lookState = dataProducer.lookState.collectAsState()
val state = remember(dataProducer, lookState, colorMapper, onMarkerShown) {
MarketChartState(dataProducer, lookState, colorMapper, onMarkerShown)
}
return state
}
/**
* Represents the state of a Market Chart.
*
* @property dataProducer The producer of the data for the Market Chart.
* @property lookState The look state of the Market Chart.
* @property colorMapper A function that maps a MarketChartLook.Type to a Color.
* @property markerCallback A callback function that is called when the marker is shown, hidden, or updated.
* @property isDrawingAnimationInProgress A boolean indicating whether the drawing animation is in progress.
*/
@Stable
class MarketChartState internal constructor(
private val dataProducer: MarketChartDataProducer,
private val lookState: State<MarketChartLook>,
private val colorMapper: (MarketChartLook.Type) -> Color,
private val markerCallback: (x: BigDecimal?, y: BigDecimal?) -> Unit,
) {
internal val modelProducer = dataProducer.modelProducer
internal val chartColor by derivedStateOf {
colorMapper(lookState.value.type)
}
internal val markerHighlightRightSide by derivedStateOf {
lookState.value.markerHighlightRightSide
}
internal val xValueFormatter = CartesianValueFormatter { value, _, _ ->
val formatter = dataProducer.lookState.value.xAxisFormatter
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
formatter.format(
value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state),
)
}
internal val yValueFormatter = CartesianValueFormatter { value, _, _ ->
val formatter = dataProducer.lookState.value.yAxisFormatter
val state = dataProducer.dataState.value as? MarketChartData.Data
?: return@CartesianValueFormatter value.toString()
formatter.format(
value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state),
)
}
internal var markerFraction by mutableStateOf<Float?>(null)
internal val markerVisibilityListener = object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
val point = getPoint(targets) ?: run {
markerCallback(null, null)
return
}
markerCallback(point.first, point.second)
}
override fun onHidden(marker: CartesianMarker) {
markerCallback(null, null)
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
val point = getPoint(targets) ?: run {
markerCallback(null, null)
return
}
markerCallback(point.first, point.second)
}
}
private fun getPoint(targets: List<CartesianMarker.Target>): Pair<BigDecimal, BigDecimal>? {
val entry = (targets[0] as LineCartesianLayerMarkerTarget).points[0].entry
val entryIndex = dataProducer.entries.value.indexOf(entry).takeIf { it != -1 } ?: return null
val state = dataProducer.dataState.value as? MarketChartData.Data ?: return null
val rawData = dataProducer.rawData.value ?: return null
val originalIndex = rawData.originalIndexes?.getOrNull(entryIndex)
val index = originalIndex ?: entryIndex
val x = state.x.getOrNull(index) ?: return null
val y = state.y.getOrNull(index) ?: return null
return x to y
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.common.ui.charts.state
import kotlinx.collections.immutable.toImmutableList
fun MarketChartData.Data.sorted(): MarketChartData.Data {
val points = this.x.zip(this.y).sortedBy { it.first }
val (x, y) = points.unzip()
return MarketChartData.Data(
x = x.toImmutableList(),
y = y.toImmutableList(),
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.common.ui.charts.state.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import java.math.BigDecimal
/**
* Interface to convert chart data values to Floats and backwards.
*
* We need to convert the values on the graph to floating point values in order to display them correctly on the canvas.
* We also need to determine exactly which floating point value on the graph corresponds to the decimal point,
* so that we can format the actual value and display on the x/y axis.
*
* **[prepareRawXForFormat] and [prepareRawYForFormat] must be very fast because they are called in the onDraw method**
*/
interface PointValuesConverter {
fun convert(data: MarketChartData.Data): MarketChartRawData
fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal
fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal
}

View file

@ -0,0 +1,108 @@
package com.tangem.common.ui.charts.state.converter
import com.tangem.common.ui.charts.downsample.LTThreeBuckets
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Suppress("MagicNumber")
class PriceAndTimePointValuesConverter(
private val needToFormatAxis: Boolean,
) : PointValuesConverter {
private data class MinMaxCache(
val minX: BigDecimal,
val maxX: BigDecimal,
val minY: BigDecimal,
val maxY: BigDecimal,
)
private var minMaxCache = MinMaxCache(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)
private val formatYValuesCache = mutableMapOf<Double, BigDecimal>()
private val formatXValuesCache = mutableMapOf<Double, BigDecimal>()
override fun convert(data: MarketChartData.Data): MarketChartRawData {
formatYValuesCache.clear()
formatXValuesCache.clear()
val cache = MinMaxCache(
minY = data.y.minOrNull() ?: BigDecimal.ZERO,
maxY = data.y.maxOrNull() ?: BigDecimal.ZERO,
minX = data.x.minOrNull() ?: BigDecimal.ZERO,
maxX = data.x.maxOrNull() ?: BigDecimal.ZERO,
)
minMaxCache = cache
val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY)
val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX)
return if (normX.size > MAX_POINTS) {
LTThreeBuckets
.downsample(normX, normY, MAX_POINTS - 2)
.let {
MarketChartRawData(
originalIndexes = it.originalIndexes.toImmutableList(),
x = it.x.toImmutableList(),
y = it.y.toImmutableList(),
)
}
} else {
MarketChartRawData(
x = normX.toImmutableList(),
y = normY.toImmutableList(),
)
}
}
override fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal {
if (!needToFormatAxis) return BigDecimal.ZERO
if (formatXValuesCache.containsKey(rawX)) return formatXValuesCache[rawX]!!
val result = (rawX * MINUTE).toBigDecimal()
formatXValuesCache[rawX] = result
return result
}
override fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal {
if (!needToFormatAxis) return BigDecimal.ZERO
if (formatYValuesCache.containsKey(rawY)) return formatYValuesCache[rawY]!!
val min = minMaxCache.minY
val max = minMaxCache.maxY
val length = max - min
val result = when {
rawY < 0.01f -> min
rawY < 0.55f && rawY > 0.45f -> min + length / 2.toBigDecimal()
rawY > 0.97f && rawY < 1.01f -> max
else -> length * rawY.toBigDecimal() + min
}
formatYValuesCache[rawY] = result
return result
}
private fun List<BigDecimal>.normalizeToDouble(min: BigDecimal, max: BigDecimal): List<Double> {
if (min == max) {
return List(size) { 0.5 }
}
return map { ((it - min) / (max - min)).toDouble() }
}
private fun List<BigDecimal>.normalizeTime(min: BigDecimal, max: BigDecimal): List<Double> {
if (min == max) {
return List(size) { 0.5 }
}
return map {
(it / MINUTE_BIG).toDouble()
}
}
private companion object {
private const val MAX_POINTS = 502
private const val MINUTE = 60000L
private val MINUTE_BIG = 60000L.toBigDecimal()
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.common.ui.charts.state.formatter
import androidx.compose.runtime.Stable
import java.math.BigDecimal
/**
* Used for formatting the axis labels in a chart.
* It takes a BigDecimal value and returns a CharSequence that represents the formatted label.
*
* [format] has to be very fast because it is called in the onDraw method.
*
* @param value The value to be formatted.
* @return The formatted label as a CharSequence.
*/
@Stable
fun interface AxisLabelFormatter {
fun format(value: BigDecimal): CharSequence
}

View file

@ -0,0 +1,15 @@
package com.tangem.common.ui.charts.state.formatter
import java.math.BigDecimal
internal class FormatterWrapWithCache(private val formatter: AxisLabelFormatter) : AxisLabelFormatter {
private val cache = mutableMapOf<BigDecimal, CharSequence>()
override fun format(value: BigDecimal): CharSequence {
return cache.getOrPut(value) { formatter.format(value) }
}
fun clearCache() {
cache.clear()
}
}

1
common/ui/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.common.ui"
}
dependencies {
/** Compose */
implementation(deps.compose.material3)
implementation(deps.compose.material)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.coil)
implementation(deps.compose.constraintLayout)
/** Deps */
implementation(deps.kotlin.immutable.collections)
/** Project - Common */
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Project - Domain */
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.models)
implementation(projects.domain.legacy)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo.models)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.common.ui.alerts
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.alerts.models.AlertTransactionErrorUM
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.utils.converter.Converter
class TransactionErrorAlertConverter(
private val popBackStack: () -> Unit,
private val onFailedTxEmailClick: (String) -> Unit,
) : Converter<SendTransactionError, AlertUM?> {
override fun convert(value: SendTransactionError): AlertUM? {
return when (value) {
is SendTransactionError.DemoCardError -> AlertDemoModeUM(
onConfirmClick = popBackStack,
)
is SendTransactionError.TangemSdkError -> AlertTransactionErrorUM(
code = value.code.toString(),
cause = null,
causeTextReference = resourceReference(value.messageRes, wrappedList(value.args)),
onConfirmClick = { onFailedTxEmailClick(value.code.toString()) },
)
is SendTransactionError.BlockchainSdkError -> AlertTransactionErrorUM(
code = value.code.toString(),
cause = value.message,
onConfirmClick = { onFailedTxEmailClick("${value.code}: ${value.message.orEmpty()}") },
)
is SendTransactionError.DataError -> AlertTransactionErrorUM(
code = "",
cause = value.message,
onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.NetworkError -> AlertTransactionErrorUM(
code = value.code.orEmpty(),
cause = value.message.orEmpty(),
onConfirmClick = { onFailedTxEmailClick(value.message.orEmpty()) },
)
is SendTransactionError.UnknownError -> AlertTransactionErrorUM(
code = "",
cause = value.ex?.localizedMessage,
onConfirmClick = { onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) },
)
else -> null
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.common.ui.alerts.models
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
data class AlertDemoModeUM(
override val onConfirmClick: () -> Unit,
) : AlertUM {
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
override val title: TextReference = resourceReference(id = R.string.warning_demo_mode_title)
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}

View file

@ -0,0 +1,21 @@
package com.tangem.common.ui.alerts.models
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
data class AlertTransactionErrorUM(
val code: String,
val cause: String?,
val causeTextReference: TextReference? = null,
override val onConfirmClick: () -> Unit,
) : AlertUM {
override val title: TextReference = resourceReference(id = R.string.send_alert_transaction_failed_title)
override val message: TextReference = resourceReference(
id = R.string.send_alert_transaction_failed_text,
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.common_support)
}

View file

@ -0,0 +1,12 @@
package com.tangem.common.ui.alerts.models
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
interface AlertUM {
val title: TextReference?
val message: TextReference
val confirmButtonText: TextReference
val onConfirmClick: (() -> Unit)?
}

View file

@ -0,0 +1,24 @@
package com.tangem.common.ui.amountScreen
/** Amount screen clicks */
interface AmountScreenClickIntents {
/** On amount [value] changed */
fun onAmountValueChange(value: String)
/** Click triggered on value paste */
fun onAmountPasteTriggerDismiss()
/** On max amount click */
fun onMaxValueClick()
/**
* On currency change from crypto currency to app currency clicked
*
* @param isFiat indicates currency to change
*/
fun onCurrencyChangeClick(isFiat: Boolean)
/** On next screen click */
fun onAmountNext()
}

View file

@ -0,0 +1,75 @@
package com.tangem.common.ui.amountScreen
import android.content.res.Configuration
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountScreenClickIntentsStub
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.common.ui.amountScreen.ui.amountField
import com.tangem.common.ui.amountScreen.ui.buttons
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Amount screen with field
* @param amountState amount state
* @param isBalanceHidden flag hidden balances
* @param clickIntents amount screen clicks
*/
@Composable
fun AmountScreenContent(
amountState: AmountState,
isBalanceHidden: Boolean,
clickIntents: AmountScreenClickIntents,
modifier: Modifier = Modifier,
) {
if (amountState !is AmountState.Data) return
// Do not put fillMaxSize() in here
LazyColumn(
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
amountField(amountState = amountState, isBalanceHidden = isBalanceHidden)
buttons(
segmentedButtonConfig = amountState.segmentedButtonConfig,
clickIntents = clickIntents,
isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled,
selectedButton = amountState.selectedButton,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendAmountContentPreview(
@PreviewParameter(SendAmountContentPreviewProvider::class) amountState: AmountState,
) {
TangemThemePreview {
AmountScreenContent(
amountState = amountState,
isBalanceHidden = false,
clickIntents = AmountScreenClickIntentsStub,
)
}
}
private class SendAmountContentPreviewProvider : PreviewParameterProvider<AmountState> {
override val values: Sequence<AmountState>
get() = sequenceOf(
AmountStatePreviewData.amountState,
)
}
// endregion

View file

@ -0,0 +1,45 @@
package com.tangem.common.ui.amountScreen.converters
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
/**
* Selected currency change from crypto currency to app currency and vice versa
*
* @property cryptoCurrencyStatus current cryptocurrency status
* @property value is crypto currency or app currency
*/
class AmountCurrencyTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val value: Boolean,
) : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
val amountTextField = prevState.amountTextField
val isValidFiatRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero()
val isDoneActionEnabled = prevState.isPrimaryButtonEnabled
return if (amountTextField.isFiatValue == value && !isValidFiatRate) {
prevState
} else {
return prevState.copy(
amountTextField = amountTextField.copy(
isFiatValue = value,
isValuePasted = true,
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
selectedButton = prevState.segmentedButtonConfig.indexOfFirst { it.isFiat == value },
)
}
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.utils.transformer.Transformer
/**
* Dismisses indication on pasted value
*/
class AmountPastedTriggerDismissTransformer : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
return prevState.copy(
amountTextField = prevState.amountTextField.copy(
isValuePasted = false,
),
)
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.common.ui.amountScreen.converters
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.amountScreen.utils.checkExceedBalance
import com.tangem.common.ui.amountScreen.utils.getFiatValue
import com.tangem.common.ui.amountScreen.utils.getKeyboardAction
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
/**
* Reduces amount by specific value
*
* @property cryptoCurrencyStatus current cryptocurrency status
* @property value reduced by value
*/
class AmountReduceByTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: ReduceByData,
) : Transformer<AmountState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
val amountTextField = prevState.amountTextField
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val amountValue = prevState.amountTextField.cryptoAmount.value ?: return prevState
val decimalCryptoValue = amountValue.minus(value.reduceAmountByDiff)
val cryptoValue = decimalCryptoValue.parseBigDecimal(cryptoDecimals)
val (fiatValue, decimalFiatValue) = cryptoValue.getFiatValue(
fiatRate = cryptoCurrencyStatus.value.fiatRate,
isFiatValue = false,
decimals = fiatDecimals,
)
val maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus)
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(maxEnterAmount, amountTextField)
val isLessThanMinimumIfProvided = minimumTransactionAmount?.amount?.let { decimalCryptoValue < it } ?: false
val isZero = if (amountTextField.isFiatValue) {
decimalFiatValue.isNullOrZero()
} else {
decimalCryptoValue.isNullOrZero()
}
val isCheckFailed = isExceedBalance || isLessThanMinimumIfProvided
return prevState.copy(
isPrimaryButtonEnabled = !isZero && !isCheckFailed,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isCheckFailed,
error = when {
isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance)
isLessThanMinimumIfProvided -> {
val minimumAmount = minimumTransactionAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),
)
}
else -> TextReference.EMPTY
},
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = getKeyboardAction(isCheckFailed, decimalCryptoValue),
keyboardType = KeyboardType.Number,
),
),
)
}
data class ReduceByData(
val reduceAmountBy: BigDecimal,
val reduceAmountByDiff: BigDecimal,
)
}

View file

@ -0,0 +1,87 @@
package com.tangem.common.ui.amountScreen.converters
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.amountScreen.utils.checkExceedBalance
import com.tangem.common.ui.amountScreen.utils.getFiatValue
import com.tangem.common.ui.amountScreen.utils.getKeyboardAction
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
/**
* Reduces amount to specific value
*
* @property cryptoCurrencyStatus current cryptocurrency status
* @property value reduced to value
*/
class AmountReduceToTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: BigDecimal,
) : Transformer<AmountState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
val amountTextField = prevState.amountTextField
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val cryptoValue = value.parseBigDecimal(cryptoDecimals)
val decimalCryptoValue = cryptoValue.parseToBigDecimal(cryptoDecimals)
val (fiatValue, decimalFiatValue) = cryptoValue.getFiatValue(
fiatRate = cryptoCurrencyStatus.value.fiatRate,
isFiatValue = false,
decimals = fiatDecimals,
)
val maxEnterAmount = maxEnterAmountConverter.convert(cryptoCurrencyStatus)
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(maxEnterAmount, amountTextField)
val isLessThanMinimumIfProvided = minimumTransactionAmount?.amount?.let { decimalCryptoValue < it } ?: false
val isZero = if (amountTextField.isFiatValue) decimalFiatValue.isNullOrZero() else value.isNullOrZero()
val isCheckFailed = isExceedBalance || isLessThanMinimumIfProvided
return prevState.copy(
isPrimaryButtonEnabled = !isZero && !isCheckFailed,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isCheckFailed,
error = when {
isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance)
isLessThanMinimumIfProvided -> {
val minimumAmount = minimumTransactionAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),
)
}
else -> TextReference.EMPTY
},
cryptoAmount = amountTextField.cryptoAmount.copy(value = value),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = getKeyboardAction(isCheckFailed, decimalCryptoValue),
keyboardType = KeyboardType.Number,
),
),
)
}
}

View file

@ -0,0 +1,83 @@
package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.persistentListOf
/**
* Converts initial [String] to [AmountState]
*
* @property clickIntents amount screen clicks
* @property appCurrencyProvider selected app currency provider
* @property maxEnterAmount max enter amount data
* @property cryptoCurrencyStatusProvider current cryptocurrency status provider
* @property iconStateConverter currency icon converter
*/
class AmountStateConverter(
private val clickIntents: AmountScreenClickIntents,
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val maxEnterAmount: EnterAmountBoundary,
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
) : Converter<AmountParameters, AmountState> {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
AmountFieldConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
)
}
override fun convert(value: AmountParameters): AmountState {
val appCurrency = appCurrencyProvider()
val status = cryptoCurrencyStatusProvider()
val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
val crypto = maxEnterAmount.amount.format { crypto(status.currency) }
val noFeeRate = status.value.fiatRate.isNullOrZero()
return AmountState.Data(
title = value.title,
availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)),
tokenIconState = iconStateConverter.convert(status),
amountTextField = amountFieldConverter.convert(value.value),
isPrimaryButtonEnabled = false,
appCurrencyCode = appCurrency.code,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convertCustom(
value = status,
forceGrayscale = noFeeRate,
showCustomTokenBadge = false,
),
isFiat = false,
),
AmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),
isSegmentedButtonsEnabled = !noFeeRate,
selectedButton = 0,
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.converter.Converter
/**
* Converts [CryptoCurrencyStatus] to [EnterAmountBoundary]
*/
class MaxEnterAmountConverter : Converter<CryptoCurrencyStatus, EnterAmountBoundary> {
override fun convert(value: CryptoCurrencyStatus): EnterAmountBoundary {
return EnterAmountBoundary(
amount = value.value.amount,
fiatAmount = value.value.fiatAmount,
fiatRate = value.value.fiatRate,
)
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.common.ui.amountScreen.converters.field
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.amountScreen.utils.checkExceedBalance
import com.tangem.common.ui.amountScreen.utils.getCryptoValue
import com.tangem.common.ui.amountScreen.utils.getFiatValue
import com.tangem.common.ui.amountScreen.utils.getKeyboardAction
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
/**
* Amount value change
*
* @property maxEnterAmount max amount to enter
* @property value amount value
*/
class AmountFieldChangeTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val maxEnterAmount: EnterAmountBoundary,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: String,
) : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
val amountTextField = prevState.amountTextField
if (value.isEmpty()) return prevState.emptyState()
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val trimmedValue = value.trim()
val cryptoValue = trimmedValue.getCryptoValue(
fiatRate = maxEnterAmount.fiatRate,
isFiatValue = amountTextField.isFiatValue,
decimals = cryptoDecimals,
)
val decimalCryptoValue = cryptoValue.parseToBigDecimal(cryptoDecimals)
val (fiatValue, decimalFiatValue) = trimmedValue.getFiatValue(
fiatRate = maxEnterAmount.fiatRate,
isFiatValue = amountTextField.isFiatValue,
decimals = fiatDecimals,
)
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(maxEnterAmount, amountTextField)
val isLessThanMinimumIfProvided = minimumTransactionAmount?.amount?.let { decimalCryptoValue < it } ?: false
val isZero = if (amountTextField.isFiatValue) {
decimalFiatValue.isNullOrZero()
} else {
decimalCryptoValue.isNullOrZero()
}
val isCheckFailed = isExceedBalance || isLessThanMinimumIfProvided
return prevState.copy(
isPrimaryButtonEnabled = !isZero && !isCheckFailed,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isCheckFailed,
error = when {
isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance)
isLessThanMinimumIfProvided -> {
val minimumAmount = minimumTransactionAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),
)
}
else -> TextReference.EMPTY
},
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = getKeyboardAction(isCheckFailed, decimalCryptoValue),
keyboardType = KeyboardType.Number,
),
),
)
}
private fun AmountState.Data.emptyState(): AmountState.Data {
return copy(
isPrimaryButtonEnabled = false,
amountTextField = amountTextField.copy(
value = "",
fiatValue = "",
cryptoAmount = amountTextField.cryptoAmount.copy(value = BigDecimal.ZERO),
fiatAmount = amountTextField.fiatAmount.copy(value = BigDecimal.ZERO),
isError = false,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
)
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.common.ui.amountScreen.converters.field
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.convertToAmount
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.isNullOrZero
import java.math.BigDecimal
/**
* Converts initial [String] to [AmountField]
*
* @property clickIntents amount screen clicks
* @property appCurrencyProvider selected app currency provider
* @property cryptoCurrencyStatusProvider current cryptocurrency status provider
*/
class AmountFieldConverter(
private val clickIntents: AmountScreenClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Converter<String, AmountFieldModel> {
override fun convert(value: String): AmountFieldModel {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoDecimal = value.toBigDecimalOrNull() ?: BigDecimal.ZERO
val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency)
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val (fiatValue, fiatDecimal) = when {
fiatRate.isNullOrZero() -> "" to null
value.isEmpty() -> "" to BigDecimal.ZERO
else -> {
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
val fiatValue = fiatDecimal?.parseBigDecimal(FIAT_DECIMALS).orEmpty()
fiatValue to fiatDecimal
}
}
val isDoneActionEnabled = !cryptoDecimal.isNullOrZero()
return AmountFieldModel(
value = value,
fiatValue = fiatValue,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(
onDone = { clickIntents.onAmountNext() },
),
isFiatValue = false,
cryptoAmount = cryptoAmount,
fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrencyProvider()),
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isFiatUnavailable = fiatRate == null,
isValuePasted = false,
onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss,
)
}
private fun getAppCurrencyAmount(fiatValue: BigDecimal?, appCurrency: AppCurrency) = Amount(
currencySymbol = appCurrency.symbol,
value = fiatValue,
decimals = FIAT_DECIMALS,
type = AmountType.FiatType(appCurrency.code),
)
private companion object {
private const val FIAT_DECIMALS = 2
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.common.ui.amountScreen.converters.field
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.amountScreen.utils.getKeyboardAction
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.utils.extensions.isZero
import com.tangem.utils.transformer.Transformer
import java.math.RoundingMode
/**
* Selects maximum amount value
*
* @property maxAmount maximum enter amount
*/
class AmountFieldSetMaxAmountTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val maxAmount: EnterAmountBoundary,
private val minAmount: EnterAmountBoundary?,
) : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
if (prevState !is AmountState.Data) return prevState
val amountTextField = prevState.amountTextField
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val decimalCryptoValue = maxAmount.amount
val decimalFiatValue = maxAmount.fiatAmount
if (decimalCryptoValue == null || decimalCryptoValue.isZero()) return prevState
val cryptoValue = decimalCryptoValue.parseBigDecimal(cryptoDecimals)
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty()
val isLessThanMinimumIfProvided = minAmount?.amount?.let { decimalCryptoValue < it } ?: false
return prevState.copy(
isPrimaryButtonEnabled = !isLessThanMinimumIfProvided,
amountTextField = amountTextField.copy(
isValuePasted = true,
value = cryptoValue,
fiatValue = fiatValue,
isError = isLessThanMinimumIfProvided,
error = when {
isLessThanMinimumIfProvided -> {
val minimumAmount = minAmount
?.amount
?.format { crypto(cryptoCurrencyStatus.currency) }
.orEmpty()
resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(minimumAmount, minimumAmount),
)
}
else -> TextReference.EMPTY
},
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = getKeyboardAction(isLessThanMinimumIfProvided, decimalCryptoValue),
keyboardType = KeyboardType.Number,
),
),
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.common.ui.amountScreen.models
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.tokens.model.Amount
/**
* Model for amount field
*
* @param value entered value
* @param onValueChange on value change
* @param keyboardOptions keyboard options
* @param keyboardActions keyboard actions
* @param cryptoAmount value as amount
* @param fiatAmount value in fiat as amount
* @param isFiatValue indicates if app currency or crypto currency is selected
* @param fiatValue value in fiat
* @param isFiatUnavailable indicates if fiat rates are unavailable
* @param isValuePasted indicated if value was pasted
* @param onValuePastedTriggerDismiss on value pasted action
* @param isError indicates is value invalid
* @param error error text
*/
data class AmountFieldModel(
val value: String,
val onValueChange: (String) -> Unit,
val keyboardOptions: KeyboardOptions,
val keyboardActions: KeyboardActions,
val cryptoAmount: Amount,
val fiatAmount: Amount,
val isFiatValue: Boolean,
val fiatValue: String,
val isFiatUnavailable: Boolean,
val isValuePasted: Boolean,
val onValuePastedTriggerDismiss: () -> Unit,
val isError: Boolean,
val isWarning: Boolean,
val error: TextReference,
)

View file

@ -0,0 +1,8 @@
package com.tangem.common.ui.amountScreen.models
import com.tangem.core.ui.extensions.TextReference
data class AmountParameters(
val title: TextReference,
val value: String,
)

View file

@ -0,0 +1,21 @@
package com.tangem.common.ui.amountScreen.models
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
/**
* Segmented buttons config
*
* @param title button title
* @param iconState currency icon state
* @param iconUrl currency icon url
* @param isFiat is fiat currency
*/
@Immutable
data class AmountSegmentedButtonsConfig(
val title: TextReference,
val iconState: CurrencyIconState? = null,
val iconUrl: String? = null,
val isFiat: Boolean,
)

View file

@ -0,0 +1,40 @@
package com.tangem.common.ui.amountScreen.models
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.PersistentList
/** Model for amount state */
@Stable
sealed class AmountState {
abstract val isPrimaryButtonEnabled: Boolean
/**
* @param isPrimaryButtonEnabled indicates if next state button enabled
* @param title title
* @param availableBalance user crypto currency balance
* @param tokenIconState crypto currency icon state
* @param segmentedButtonConfig currency switcher config
* @param selectedButton selected currency index
* @param isSegmentedButtonsEnabled indicates if currency switches is enabled
* @param amountTextField amount field state
* @param appCurrencyCode app currency code
*/
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val title: TextReference,
val availableBalance: TextReference,
val tokenIconState: CurrencyIconState,
val segmentedButtonConfig: PersistentList<AmountSegmentedButtonsConfig>,
val selectedButton: Int,
val isSegmentedButtonsEnabled: Boolean,
val amountTextField: AmountFieldModel,
val appCurrencyCode: String,
) : AmountState()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : AmountState()
}

View file

@ -0,0 +1,22 @@
package com.tangem.common.ui.amountScreen.models
import java.math.BigDecimal
data class EnterAmountBoundary(
val amount: BigDecimal? = null,
val fiatAmount: BigDecimal? = null,
val fiatRate: BigDecimal? = null,
) {
constructor(
amount: BigDecimal? = null,
fiatRate: BigDecimal? = null,
) : this(
amount = amount,
fiatAmount = if (amount != null && fiatRate != null) {
amount * fiatRate
} else {
null
},
fiatRate = fiatRate,
)
}

View file

@ -0,0 +1,16 @@
package com.tangem.common.ui.amountScreen.preview
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
object AmountScreenClickIntentsStub : AmountScreenClickIntents {
override fun onAmountValueChange(value: String) {}
override fun onCurrencyChangeClick(isFiat: Boolean) {}
override fun onMaxValueClick() {}
override fun onAmountPasteTriggerDismiss() {}
override fun onAmountNext() {}
}

View file

@ -0,0 +1,88 @@
package com.tangem.common.ui.amountScreen.preview
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
object AmountStatePreviewData {
val amountState = AmountState.Data(
isPrimaryButtonEnabled = false,
title = stringReference("Family Wallet"),
availableBalance = stringReference("2 130,88 USDT (2 129,92 \$)"),
tokenIconState = CurrencyIconState.Loading,
segmentedButtonConfig = persistentListOf(
AmountSegmentedButtonsConfig(
title = stringReference("USDT"),
iconState = CurrencyIconState.Locked,
isFiat = false,
),
AmountSegmentedButtonsConfig(
title = stringReference("USD"),
isFiat = true,
),
),
appCurrencyCode = "usd",
amountTextField = AmountFieldModel(
value = "",
onValueChange = {},
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
cryptoAmount = Amount(
currencySymbol = "USDT",
value = BigDecimal.ZERO,
decimals = 18,
type = AmountType.CoinType,
),
fiatAmount = Amount(
currencySymbol = "$",
value = BigDecimal.ZERO,
decimals = 2,
type = AmountType.CoinType,
),
isFiatValue = false,
fiatValue = "123.123",
isFiatUnavailable = false,
isError = false,
isWarning = false,
error = TextReference.EMPTY,
isValuePasted = false,
onValuePastedTriggerDismiss = {},
),
isSegmentedButtonsEnabled = true,
selectedButton = 0,
)
val amountWithValueState = amountState.copy(
amountTextField = amountState.amountTextField.copy(
value = "100.00",
cryptoAmount = amountState.amountTextField.cryptoAmount.copy(
value = BigDecimal("100.00"),
),
fiatValue = "99.98",
fiatAmount = amountState.amountTextField.fiatAmount.copy(
value = BigDecimal("99.98"),
),
),
)
val amountWithValueFiatState = amountWithValueState.copy(
amountTextField = amountWithValueState.amountTextField.copy(isFiatValue = false),
)
val amountErrorState = amountWithValueState.copy(
amountTextField = amountWithValueState.amountTextField.copy(
isError = true,
error = stringReference("Insufficient funds for transfer"),
),
)
}

View file

@ -0,0 +1,108 @@
package com.tangem.common.ui.amountScreen.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import java.math.BigDecimal
@Composable
fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit) {
if (amountState !is AmountState.Data) return
val amount = amountState.amountTextField
val cryptoAmount = formatWithSymbol(amount.value, amount.cryptoAmount.currencySymbol)
val fiatAmount = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amount.fiatAmount.value,
fiatCurrencySymbol = amount.fiatAmount.currencySymbol,
fiatCurrencyCode = amountState.appCurrencyCode,
)
val backgroundColor = if (isEditingDisabled) {
TangemTheme.colors.button.disabled
} else {
TangemTheme.colors.background.action
}
val (firstAmount, secondAmount) = if (amount.isFiatValue) {
fiatAmount to cryptoAmount
} else {
cryptoAmount to fiatAmount
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick)
.padding(TangemTheme.dimens.spacing16),
) {
CurrencyIcon(state = amountState.tokenIconState)
ResizableText(
text = firstAmount,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing24),
)
Text(
text = secondAmount,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8),
)
}
}
fun formatWithSymbol(amount: String, symbol: String) =
BigDecimal.ZERO.format { crypto(symbol, 0).anyDecimals() }.replace("0", amount)
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AmountBlockPreview(@PreviewParameter(AmountBlockPreviewProvider::class) value: AmountState) {
TangemThemePreview {
AmountBlock(
amountState = value,
isClickDisabled = false,
isEditingDisabled = false,
onClick = {},
)
}
}
private class AmountBlockPreviewProvider : PreviewParameterProvider<AmountState> {
override val values: Sequence<AmountState>
get() = sequenceOf(
AmountStatePreviewData.amountState,
)
}
// endregion

View file

@ -0,0 +1,124 @@
package com.tangem.common.ui.amountScreen.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.fiaticon.FiatIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.PersistentList
private const val AMOUNT_BUTTONS_KEY = "amountButtonsKey"
internal fun LazyListScope.buttons(
segmentedButtonConfig: PersistentList<AmountSegmentedButtonsConfig>,
clickIntents: AmountScreenClickIntents,
isSegmentedButtonsEnabled: Boolean,
selectedButton: Int,
) {
item(
key = AMOUNT_BUTTONS_KEY,
) {
val hapticFeedback = LocalHapticFeedback.current
Row(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
) {
if (segmentedButtonConfig.isNotEmpty()) {
SegmentedButtons(
modifier = Modifier
.weight(1f)
.height(TangemTheme.dimens.size40),
config = segmentedButtonConfig,
showIndication = false,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onCurrencyChangeClick(it.isFiat)
},
initialSelectedItem = segmentedButtonConfig.getOrNull(selectedButton),
isEnabled = isSegmentedButtonsEnabled,
) {
AmountCurrencyButton(
button = it,
isSegmentedButtonsEnabled = isSegmentedButtonsEnabled,
)
}
} else {
SpacerWMax()
}
Text(
text = stringResourceSafe(R.string.send_max_amount),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.height(TangemTheme.dimens.size40)
.clip(shape = RoundedCornerShape(TangemTheme.dimens.radius26))
.background(TangemTheme.colors.button.secondary)
.clickable {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onMaxValueClick()
}
.padding(
vertical = TangemTheme.dimens.spacing10,
horizontal = TangemTheme.dimens.spacing34,
),
)
}
}
}
@Composable
private fun AmountCurrencyButton(button: AmountSegmentedButtonsConfig, isSegmentedButtonsEnabled: Boolean) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing10,
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier
.size(TangemTheme.dimens.size18)
.padding(horizontal = TangemTheme.dimens.spacing1)
if (button.isFiat) {
FiatIcon(
url = button.iconUrl,
size = TangemTheme.dimens.size18,
isGrayscale = !isSegmentedButtonsEnabled,
modifier = iconModifier,
)
} else if (button.iconState != null) {
CurrencyIcon(
state = button.iconState,
shouldDisplayNetwork = false,
modifier = iconModifier,
)
}
Text(
text = button.title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing8,
),
)
}
}

View file

@ -0,0 +1,150 @@
package com.tangem.common.ui.amountScreen.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.BottomCenter
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.rememberDecimalFormat
import kotlinx.coroutines.delay
@Composable
internal fun AmountField(amountField: AmountFieldModel, appCurrencyCode: String) {
val decimalFormat = rememberDecimalFormat()
val isFiatValue = amountField.isFiatValue
val currencyCode = if (isFiatValue) appCurrencyCode else null
val (primaryAmount, primaryValue) = if (isFiatValue) {
amountField.fiatAmount to amountField.fiatValue
} else {
amountField.cryptoAmount to amountField.value
}
val requester = remember { FocusRequester() }
AmountTextField(
value = primaryValue,
decimals = primaryAmount.decimals,
visualTransformation = AmountVisualTransformation(
decimals = primaryAmount.decimals,
symbol = primaryAmount.currencySymbol,
currencyCode = currencyCode,
decimalFormat = decimalFormat,
),
onValueChange = amountField.onValueChange,
keyboardOptions = amountField.keyboardOptions,
keyboardActions = amountField.keyboardActions,
textStyle = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
),
isAutoResize = true,
isValuePasted = amountField.isValuePasted,
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
modifier = Modifier
.focusRequester(requester)
.padding(
top = TangemTheme.dimens.spacing24,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.requiredHeightIn(min = TangemTheme.dimens.size32),
)
LaunchedEffect(key1 = Unit) {
delay(timeMillis = 200)
requester.requestFocus()
}
AmountSecondary(amountField, appCurrencyCode)
}
@Composable
private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: String) {
val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount
Box(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
) {
val text = if (amountField.isFiatValue) {
secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) }
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = secondaryAmount.value,
fiatCurrencySymbol = secondaryAmount.currencySymbol,
fiatCurrencyCode = appCurrencyCode,
)
}
Text(
text = text,
style = TangemTheme.typography.caption2.copy(textDirection = TextDirection.ContentOrLtr),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.align(TopCenter)
.padding(bottom = TangemTheme.dimens.spacing32),
)
AmountFieldError(
isError = amountField.isError,
isWarning = amountField.isWarning,
error = amountField.error,
modifier = Modifier
.align(BottomCenter)
.padding(
top = TangemTheme.dimens.spacing20,
bottom = TangemTheme.dimens.spacing12,
),
)
}
}
@Composable
private fun AmountFieldError(
isError: Boolean,
isWarning: Boolean,
error: TextReference,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isError || isWarning,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
val errorText = remember(this, error) { error }
val color = if (isError) TangemTheme.colors.text.warning else TangemTheme.colors.text.attention
Text(
text = errorText.resolveReference(),
style = TangemTheme.typography.caption2,
color = color,
textAlign = TextAlign.Center,
)
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.common.ui.amountScreen.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
private const val AMOUNT_FIELD_KEY = "amountFieldKey"
internal fun LazyListScope.amountField(
amountState: AmountState.Data,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
item(key = AMOUNT_FIELD_KEY) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.action),
) {
Text(
text = amountState.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing14),
)
val balance = amountState.availableBalance.orMaskWithStars(isBalanceHidden).resolveReference()
AnimatedContent(
targetState = balance,
label = "Hide Balance Animation",
) {
Text(
text = it,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing2),
)
}
CurrencyIcon(
state = amountState.tokenIconState,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32),
)
AmountField(
amountField = amountState.amountTextField,
appCurrencyCode = amountState.appCurrencyCode,
)
}
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.common.ui.amountScreen.ui
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.R
import com.tangem.core.ui.components.SecondaryButtonIconStart
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun SendDoneButtons(
txUrl: String,
onExploreClick: () -> Unit,
onShareClick: (String) -> Unit,
isVisible: Boolean,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
AnimatedVisibility(
visible = isVisible && txUrl.isNotBlank(),
modifier = modifier,
enter = slideInVertically().plus(fadeIn()),
exit = slideOutVertically().plus(fadeOut()),
label = "Animate show sent state buttons",
) {
Row(modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12)) {
SecondaryButtonIconStart(
text = stringResourceSafe(id = R.string.common_explore),
iconResId = R.drawable.ic_web_24,
onClick = onExploreClick,
modifier = Modifier.weight(1f),
)
SpacerW12()
SecondaryButtonIconStart(
text = stringResourceSafe(id = R.string.common_share),
iconResId = R.drawable.ic_share_24,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onShareClick(txUrl)
},
modifier = Modifier.weight(1f),
)
}
}
}
@Preview(showBackground = true, widthDp = 328)
@Preview(showBackground = true, widthDp = 328, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SendDoneButtons_Preview() {
TangemThemePreview {
SendDoneButtons(
txUrl = "txUrl",
onShareClick = {},
onExploreClick = {},
isVisible = true,
)
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.common.ui.amountScreen.utils
import androidx.compose.ui.text.input.ImeAction
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
internal fun String.getCryptoValue(fiatRate: BigDecimal?, isFiatValue: Boolean, decimals: Int): String {
return if (isFiatValue && fiatRate != null) {
parseToBigDecimal(decimals).divide(fiatRate, decimals, RoundingMode.DOWN)
.parseBigDecimal(decimals)
} else {
this
}
}
internal fun String.getFiatValue(
fiatRate: BigDecimal?,
isFiatValue: Boolean,
decimals: Int,
): Pair<String, BigDecimal?> {
return if (fiatRate != null) {
val fiatValue = if (!isFiatValue) {
parseToBigDecimal(decimals).multiply(fiatRate).parseBigDecimal(decimals)
} else {
this
}
val decimalFiatValue = fiatValue.parseToBigDecimal(decimals)
fiatValue to decimalFiatValue
} else {
"" to null
}
}
internal fun String.checkExceedBalance(
maxEnterAmount: EnterAmountBoundary,
amountTextField: AmountFieldModel,
): Boolean {
val currencyCryptoAmount = maxEnterAmount.amount ?: BigDecimal.ZERO
val currencyFiatAmount = maxEnterAmount.fiatAmount ?: BigDecimal.ZERO
val fiatDecimal = parseToBigDecimal(amountTextField.fiatAmount.decimals)
val cryptoDecimal = parseToBigDecimal(amountTextField.cryptoAmount.decimals)
return if (amountTextField.isFiatValue) {
fiatDecimal > currencyFiatAmount
} else {
cryptoDecimal > currencyCryptoAmount
}
}
internal fun getKeyboardAction(isCheckFailed: Boolean, decimalCryptoValue: BigDecimal) =
if (!isCheckFailed && !decimalCryptoValue.isZero()) {
ImeAction.Done
} else {
ImeAction.None
}

View file

@ -0,0 +1,24 @@
package com.tangem.common.ui.amountScreen.utils
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.BigDecimalFormatter.EMPTY_BALANCE_SIGN
import com.tangem.domain.appcurrency.model.AppCurrency
import java.math.BigDecimal
fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): TextReference? {
if (value == null || rate == null) return null
val formattedFiat = getFiatString(value = value, rate = rate, appCurrency = appCurrency)
return stringReference(formattedFiat)
}
fun getFiatString(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): String {
if (value == null || rate == null) return EMPTY_BALANCE_SIGN
val feeValue = value.multiply(rate)
return BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}

View file

@ -0,0 +1,323 @@
package com.tangem.common.ui.bottomsheet.permission
import android.content.res.Configuration
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.RoundedCornerShape
import androidx.compose.material3.*
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.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.PopupProperties
import com.tangem.common.ui.R
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
@Composable
fun GiveTxPermissionBottomSheet(config: TangemBottomSheetConfig) {
var isPermissionAlertShow by remember { mutableStateOf(false) }
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.secondary,
titleText = resourceReference(R.string.give_permission_title),
titleAction = TopAppBarButtonUM(
iconRes = R.drawable.ic_information_24,
onIconClicked = { isPermissionAlertShow = true },
),
content = { content: GiveTxPermissionBottomSheetConfig ->
GiveTxPermissionBottomSheetContent(content = content)
if (isPermissionAlertShow) {
BasicDialog(
message = content.data.dialogText.resolveReference(),
title = stringResourceSafe(id = R.string.common_approve),
confirmButton = DialogButtonUM { isPermissionAlertShow = false },
onDismissDialog = {},
)
}
},
)
}
@Composable
private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSheetConfig) {
val data = content.data
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = content.data.subtitle.resolveReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing24),
)
SpacerH16()
ApprovalBottomSheetInfo(data)
SpacerH(height = TangemTheme.dimens.spacing20)
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.common_approve),
iconResId = R.drawable.ic_tangem_24,
showProgress = data.approveButton.loading,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
onClick = data.approveButton.onClick,
enabled = data.approveButton.enabled,
)
SpacerH12()
SecondaryButton(
text = stringResourceSafe(id = R.string.common_cancel),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
onClick = content.onCancel,
enabled = data.cancelButton.enabled,
)
SpacerH16()
}
}
@Composable
private fun ApprovalBottomSheetInfo(data: GiveTxPermissionState.ReadyForRequest) {
FooterContainer(
footer = resourceReference(R.string.give_permission_policy_type_footer),
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
AmountItem(
currency = data.currency,
approveType = data.approveType,
onChangeApproveType = data.onChangeApproveType,
approveItems = data.approveItems,
)
}
SpacerH16()
FooterContainer(
footer = data.footerText,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
FeeItem(fee = data.fee)
}
}
@Composable
private fun FeeItem(fee: TextReference) {
InputRowDefault(
title = resourceReference(R.string.common_network_fee_title),
text = fee,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
}
@Composable
private fun AmountItem(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: ((ApproveType) -> Unit)?,
) {
var isExpandSelector by remember { mutableStateOf(false) }
var amountSize by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = onChangeApproveType != null,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { isExpandSelector = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { amountSize = it }
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
SpacerWMax()
Text(
text = approveType.text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body1,
maxLines = 1,
)
if (onChangeApproveType != null) {
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
}
if (onChangeApproveType != null) {
DropdownSelector(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { approveType ->
onChangeApproveType.let {
isExpandSelector = false
onChangeApproveType.invoke(approveType)
}
},
items = approveItems,
selectedType = approveType,
amountSize = amountSize,
)
}
}
}
@Suppress("LongParameterList")
@Composable
private fun DropdownSelector(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
selectedType: ApproveType,
amountSize: IntSize,
) {
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
val offsetY = amountSize.height.times(-1)
val offsetX = amountSize.width - dropDownWidth.width
// Workaround to set color and shape of dropdown menu
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
properties = PopupProperties(clippingEnabled = false),
offset = with(LocalDensity.current) {
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
},
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.action)
.onSizeChanged { dropDownWidth = it },
) {
items.forEach { item ->
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
DropdownMenuItem(
modifier = Modifier.fillMaxWidth(),
text = {
Row {
Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = R.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(id = R.string.give_permission_unlimited)
},
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
)
}
},
onClick = {
onItemClick.invoke(item)
},
)
}
}
}
}
// region preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, locale = "ru")
private fun Preview_GiveTxPermissionBottomSheet() {
TangemThemePreview {
GiveTxPermissionBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = previewData,
),
)
}
}
private val previewData = GiveTxPermissionBottomSheetConfig(
data = GiveTxPermissionState.ReadyForRequest(
currency = "DAI",
amount = "1",
walletAddress = "",
spenderAddress = "",
fee = TextReference.Str("0.1233 BTC (2,14$)"),
approveType = ApproveType.LIMITED,
approveButton = ApprovePermissionButton(true) {},
cancelButton = CancelPermissionButton(true),
onChangeApproveType = { ApproveType.LIMITED },
subtitle = resourceReference(R.string.give_permission_staking_subtitle, wrappedList("1")),
dialogText = resourceReference(R.string.give_permission_staking_footer),
footerText = resourceReference(R.string.swap_give_permission_fee_footer),
),
onCancel = {},
)

View file

@ -0,0 +1,8 @@
package com.tangem.common.ui.bottomsheet.permission.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class GiveTxPermissionBottomSheetConfig(
val data: GiveTxPermissionState.ReadyForRequest,
val onCancel: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,49 @@
package com.tangem.common.ui.bottomsheet.permission.state
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
sealed class GiveTxPermissionState {
data object InProgress : GiveTxPermissionState()
data object Empty : GiveTxPermissionState()
data class ReadyForRequest(
val subtitle: TextReference,
val dialogText: TextReference,
val footerText: TextReference,
val currency: String,
val amount: String,
val walletAddress: String,
val spenderAddress: String,
val fee: TextReference,
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
val approveButton: ApprovePermissionButton,
val cancelButton: CancelPermissionButton,
val onChangeApproveType: ((ApproveType) -> Unit)? = null,
) : GiveTxPermissionState()
fun GiveTxPermissionState.getApproveTypeOrNull(): ApproveType? {
return (this as? ReadyForRequest)?.approveType
}
}
enum class ApproveType(val text: TextReference) {
LIMITED(resourceReference(R.string.give_permission_current_transaction)),
UNLIMITED(resourceReference(R.string.give_permission_unlimited)),
}
data class ApprovePermissionButton(
val enabled: Boolean,
val loading: Boolean = false,
val onClick: () -> Unit,
)
data class CancelPermissionButton(
val enabled: Boolean,
)

View file

@ -0,0 +1,71 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.common.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.inputrow.InputRowApprox
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Suppress("LongParameterList")
@Composable
fun ExpressEstimate(
timestamp: TextReference,
fromTokenIconState: CurrencyIconState,
toTokenIconState: CurrencyIconState,
fromCryptoAmount: TextReference,
fromCryptoSymbol: String,
toCryptoAmount: TextReference,
toCryptoSymbol: String,
fromFiatAmount: TextReference?,
toFiatAmount: TextReference?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing14,
bottom = TangemTheme.dimens.spacing2,
),
) {
Text(
text = stringResourceSafe(id = R.string.express_estimated_amount),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = timestamp.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
InputRowApprox(
leftIcon = fromTokenIconState,
leftTitle = fromCryptoAmount,
leftSubtitle = fromFiatAmount,
leftTitleEllipsisOffset = fromCryptoSymbol.length,
rightIcon = toTokenIconState,
rightTitle = toCryptoAmount,
rightSubtitle = toFiatAmount,
rightTitleEllipsisOffset = toCryptoSymbol.length,
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
fun ColumnScope.ExpressHideButton(isTerminal: Boolean, isAutoDisposable: Boolean, onClick: () -> Unit) {
AnimatedVisibility(
visible = isTerminal && !isAutoDisposable,
label = "Hide button visibility animation",
) {
Text(
text = stringResourceSafe(R.string.express_status_hide_button_text),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.button,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 14.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = onClick,
)
.padding(vertical = 10.dp),
)
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.common.ui.expressStatus
import android.content.res.Configuration
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.inputrow.InputRowBestRate
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun ExpressProvider(
providerName: TextReference,
providerType: TextReference,
providerTxId: String?,
imageUrl: String,
) {
val clipboardManager = LocalClipboardManager.current
val hapticFeedback = LocalHapticFeedback.current
val context = LocalContext.current
Column(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
Row(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.padding(horizontal = TangemTheme.dimens.spacing12)
.fillMaxWidth(),
) {
Text(
text = stringResourceSafe(id = R.string.express_provider),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerWMax()
if (!providerTxId.isNullOrEmpty()) {
Row(
modifier = Modifier
.padding(start = 8.dp)
.clickable {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clipboardManager.setText(AnnotatedString(providerTxId))
Toast
.makeText(context, R.string.express_transaction_id_copied, Toast.LENGTH_SHORT)
.show()
},
) {
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size20)
.padding(end = TangemTheme.dimens.spacing4)
.align(Alignment.CenterVertically),
painter = painterResource(id = R.drawable.ic_copy_24),
contentDescription = null,
tint = TangemTheme.colors.text.tertiary,
)
Text(
modifier = Modifier.align(Alignment.CenterVertically),
text = stringResourceSafe(R.string.express_transaction_id, providerTxId),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
InputRowBestRate(
imageUrl = imageUrl,
title = providerName,
titleExtra = providerType,
subtitle = TextReference.Res(R.string.express_floating_rate),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ExpressProvider_Preview() {
TangemThemePreview {
ExpressProvider(
providerName = TextReference.Str("Changelly"),
providerType = TextReference.Str("CEX"),
providerTxId = "hjsbajcqbhjsbajcqbhjsbajcqbhjsbajcqbhjsbajcqb",
imageUrl = "",
)
}
}

View file

@ -0,0 +1,230 @@
package com.tangem.common.ui.expressStatus
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
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.tooling.preview.Preview
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressLinkUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState
import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
/**
* Block with express statuses
*
* @param state ui holder
* @param modifier modifier
* @see [Figma](https://www.figma.com/design/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?node-id=18459-26521&t=4jox7bfqUiXnm2h1-4)
*/
@Composable
fun ExpressStatusBlock(state: ExpressStatusUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing12,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing16),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerWMax()
AnimatedVisibility(visible = state.link is ExpressLinkUM.Content) {
val link = remember(this) { state.link as ExpressLinkUM.Content }
Row(
modifier = Modifier.clickable { link.onClick() },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = link.icon),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.spacing16)
.padding(end = TangemTheme.dimens.spacing2),
)
Text(
text = link.text.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
Column {
state.statuses.forEachIndexed { index, item ->
ExpressStatusStep(item, index == state.statuses.lastIndex)
}
}
}
}
@Composable
private fun ExpressStatusStep(status: ExpressStatusItemUM, isLast: Boolean) {
AnimatedContent(
targetState = status,
label = "Exchange Step Change Success",
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
) { content ->
Row {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (content.state) {
ExpressStatusItemState.Active -> StepInProgress()
ExpressStatusItemState.Default -> StepDefault()
ExpressStatusItemState.Done -> Step(
iconRes = R.drawable.ic_check_24,
iconColor = TangemTheme.colors.icon.primary1,
borderColor = TangemTheme.colors.field.focused,
)
ExpressStatusItemState.Error -> Step(
iconRes = R.drawable.ic_close_24,
iconColor = TangemTheme.colors.icon.warning,
)
ExpressStatusItemState.Warning -> Step(
iconRes = R.drawable.ic_close_24,
iconColor = TangemTheme.colors.icon.attention,
)
}
if (!isLast) {
StepSeparator()
}
}
val textColor = when (status.state) {
ExpressStatusItemState.Active -> TangemTheme.colors.text.primary1
ExpressStatusItemState.Default -> TangemTheme.colors.text.disabled
ExpressStatusItemState.Done -> TangemTheme.colors.text.primary1
ExpressStatusItemState.Error -> TangemTheme.colors.text.warning
ExpressStatusItemState.Warning -> TangemTheme.colors.text.attention
}
Text(
text = content.text.resolveReference(),
style = TangemTheme.typography.body2,
color = textColor,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
private fun StepDefault() {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size20)
.border(
width = TangemTheme.dimens.size1_5,
color = TangemTheme.colors.field.focused,
shape = CircleShape,
)
.padding(TangemTheme.dimens.spacing2),
)
}
@Composable
private fun Step(iconColor: Color, @DrawableRes iconRes: Int, borderColor: Color = iconColor) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconColor,
modifier = Modifier
.size(TangemTheme.dimens.size20)
.border(
width = TangemTheme.dimens.size1_5,
color = borderColor,
shape = CircleShape,
)
.padding(TangemTheme.dimens.spacing2),
)
}
@Composable
private fun StepInProgress() {
CircularProgressIndicator(
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
modifier = Modifier
.padding(TangemTheme.dimens.spacing2)
.size(TangemTheme.dimens.size14),
)
}
@Composable
private fun StepSeparator() {
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(
width = TangemTheme.dimens.size1_5,
height = TangemTheme.dimens.size10,
)
.background(
color = TangemTheme.colors.field.focused,
shape = CircleShape,
),
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_ExchangeStatusBlock() {
val state = ExpressStatusUM(
title = resourceReference(R.string.express_exchange_status_title),
link = ExpressLinkUM.Content(
icon = R.drawable.ic_alert_24,
text = resourceReference(R.string.common_go_to_provider),
onClick = {},
),
statuses = persistentListOf(
ExpressStatusItemUM(text = stringReference("Done"), state = ExpressStatusItemState.Done),
ExpressStatusItemUM(text = stringReference("Active"), state = ExpressStatusItemState.Active),
ExpressStatusItemUM(text = stringReference("Warning"), state = ExpressStatusItemState.Warning),
ExpressStatusItemUM(text = stringReference("Error"), state = ExpressStatusItemState.Error),
ExpressStatusItemUM(text = stringReference("Default"), state = ExpressStatusItemState.Default),
),
)
TangemThemePreview {
ExpressStatusBlock(state = state)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.runtime.Composable
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.res.TangemTheme
data class ExpressStatusBottomSheetConfig(
val value: ExpressTransactionStateUM,
) : TangemBottomSheetConfigContent
@Composable
fun ExpressStatusBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
) { content: ExpressStatusBottomSheetConfig ->
when (val state = content.value) {
is ExpressTransactionStateUM.OnrampUM -> OnrampStatusBottomSheetContent(state)
}
}
}

View file

@ -0,0 +1,191 @@
package com.tangem.common.ui.expressStatus
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.constraintlayout.compose.*
import com.tangem.common.ui.R
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod", "LongParameterList")
@Composable
internal fun ExpressStatusItem(
title: TextReference,
fromTokenIconState: CurrencyIconState,
toTokenIconState: CurrencyIconState,
fromAmount: TextReference,
fromSymbol: String,
toSymbol: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
toAmount: TextReference = TextReference.EMPTY,
@DrawableRes infoIconRes: Int? = null,
infoIconTint: Color? = null,
) {
ConstraintLayout(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.clickable { onClick() }
.padding(TangemTheme.dimens.spacing12),
) {
val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs()
val padding6 = TangemTheme.dimens.spacing6
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.constrainAs(titleRef) {
start.linkTo(parent.start)
top.linkTo(parent.top)
},
)
CurrencyIcon(
state = fromTokenIconState,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size20)
.constrainAs(fromIconRef) {
start.linkTo(parent.start)
top.linkTo(titleRef.bottom, padding6)
bottom.linkTo(parent.bottom)
},
)
EllipsisText(
text = fromAmount.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length),
modifier = Modifier.constrainAs(fromRef) {
start.linkTo(fromIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(swapIconRef.start)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints.atMostWrapContent
},
)
Icon(
painter = painterResource(id = R.drawable.ic_forward_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size12)
.constrainAs(swapIconRef) {
start.linkTo(fromRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(toIconRef.start)
bottom.linkTo(parent.bottom)
},
)
CurrencyIcon(
state = toTokenIconState,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size20)
.constrainAs(toIconRef) {
start.linkTo(swapIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(toRef.start)
bottom.linkTo(parent.bottom)
},
)
EllipsisText(
text = toAmount.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
ellipsis = TextEllipsis.OffsetEnd(toSymbol.length),
modifier = Modifier.constrainAs(toRef) {
start.linkTo(toIconRef.end, padding6)
top.linkTo(titleRef.bottom, padding6)
end.linkTo(infoIconRef.start, padding6, padding6)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints.atLeastWrapContent
},
)
Icon(
painter = painterResource(id = infoIconRes ?: R.drawable.ic_alert_triangle_20),
contentDescription = null,
tint = infoIconTint ?: TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size20)
.constrainAs(infoIconRef) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(iconRef.start)
visibility = if (infoIconRes == null) {
Visibility.Gone
} else {
Visibility.Visible
}
},
)
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size24)
.constrainAs(iconRef) {
end.linkTo(parent.end)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
},
)
}
}
//region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ExpressStatusItemPreview(
@PreviewParameter(ExpressStatusItemPreviewParameterProvider::class) amount: String,
) {
TangemThemePreview {
ExpressStatusItem(
title = stringReference("ChangeNow"),
fromTokenIconState = CurrencyIconState.Loading,
toTokenIconState = CurrencyIconState.Loading,
fromAmount = stringReference(amount),
fromSymbol = "USDT",
toAmount = stringReference(amount),
toSymbol = "USDT",
onClick = {},
infoIconRes = null,
infoIconTint = null,
)
}
}
private class ExpressStatusItemPreviewParameterProvider : PreviewParameterProvider<String> {
override val values: Sequence<String>
get() = sequenceOf(
"1111111111111111111111111111 USDT",
"11111 USDT",
)
}
//endregion

View file

@ -0,0 +1,45 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.PersistentList
fun LazyListScope.expressTransactionsItems(
expressTxs: PersistentList<ExpressTransactionStateUM>,
modifier: Modifier = Modifier,
) {
items(
count = expressTxs.size,
key = { expressTxs[it].info.txId },
contentType = { expressTxs[it]::class.java },
) {
val itemInfo = expressTxs[it].info
val (iconRes, tint) = when (itemInfo.iconState) {
ExpressTransactionStateIconUM.Warning -> {
R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention
}
ExpressTransactionStateIconUM.Error -> {
R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning
}
ExpressTransactionStateIconUM.None -> null to null
}
ExpressStatusItem(
title = itemInfo.title,
fromTokenIconState = itemInfo.fromCurrencyIcon,
toTokenIconState = itemInfo.toCurrencyIcon,
fromAmount = itemInfo.fromAmount,
fromSymbol = itemInfo.fromAmountSymbol,
toAmount = itemInfo.toAmount,
toSymbol = itemInfo.toAmountSymbol,
onClick = itemInfo.onClick,
infoIconRes = iconRes,
infoIconTint = tint,
modifier = modifier.animateItem(),
)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.ExpressNotificationsUM
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
@Composable
fun ExpressStatusNotificationBlock(state: NotificationUM?) {
AnimatedVisibility(
visible = state?.config != null,
modifier = Modifier.padding(top = 12.dp),
label = "Express Status Notification Change",
) {
val wrappedNotification = remember(this) { requireNotNull(state?.config) }
Notification(
config = wrappedNotification,
iconTint = when (state) {
is ExpressNotificationsUM.NeedVerification -> TangemTheme.colors.icon.attention
is ExpressNotificationsUM.FailedByProvider -> TangemTheme.colors.icon.warning
else -> null
},
containerColor = TangemTheme.colors.background.action,
)
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH10
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
fun OnrampStatusBottomSheetContent(state: ExpressTransactionStateUM.OnrampUM) {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.verticalScroll(rememberScrollState()),
) {
SpacerH10()
Text(
text = stringResourceSafe(id = R.string.common_transaction_status),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.align(CenterHorizontally),
)
SpacerH10()
Text(
text = stringResourceSafe(id = R.string.express_exchange_status_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.align(CenterHorizontally),
)
SpacerH16()
ExpressEstimate(
timestamp = state.info.timestampFormatted,
fromTokenIconState = state.info.fromCurrencyIcon,
toTokenIconState = state.info.toCurrencyIcon,
fromCryptoAmount = state.info.fromAmount,
fromCryptoSymbol = state.info.fromAmountSymbol,
toCryptoAmount = state.info.toAmount,
toCryptoSymbol = state.info.toAmountSymbol,
fromFiatAmount = state.info.fromFiatAmount,
toFiatAmount = state.info.toFiatAmount,
)
SpacerH12()
ExpressProvider(
providerName = stringReference(state.providerName),
providerType = stringReference(state.providerType),
providerTxId = state.info.txExternalId,
imageUrl = state.providerImageUrl,
)
SpacerH12()
ExpressStatusBlock(state = state.info.status)
ExpressStatusNotificationBlock(state = state.info.notification)
ExpressHideButton(
isTerminal = state.activeStatus.isTerminal,
isAutoDisposable = state.activeStatus.isAutoDisposable,
onClick = state.info.onDisposeExpressStatus,
)
SpacerH24()
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.common.ui.expressStatus.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
/**
* UI data holder for express status block
*
* @property title block title
* @property link provider web link
* @property statuses list of possible and active statuses
*/
data class ExpressStatusUM(
val title: TextReference,
val link: ExpressLinkUM,
val statuses: ImmutableList<ExpressStatusItemUM>,
)
/**
* Provider web link for express status block.
* [Empty] if no link needed
* [Content] if link is provided and displayed
*/
@Stable
sealed class ExpressLinkUM {
data object Empty : ExpressLinkUM()
data class Content(
@DrawableRes val icon: Int,
val text: TextReference,
val onClick: () -> Unit,
) : ExpressLinkUM()
}
/**
* Single status item in express status block
*/
data class ExpressStatusItemUM(
val text: TextReference,
val state: ExpressStatusItemState,
)
/**
* Available status states for express status block
*/
enum class ExpressStatusItemState {
Active,
Default,
Done,
Warning,
Error,
;
}

View file

@ -0,0 +1,50 @@
package com.tangem.common.ui.expressStatus.state
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampStatus
interface ExpressTransactionStateUM {
val info: ExpressTransactionStateInfoUM
data class OnrampUM(
override val info: ExpressTransactionStateInfoUM,
val providerName: String, // todo onramp fix after SwapProvider moved to own module
val providerImageUrl: String, // todo onramp fix after SwapProvider moved to own module
val providerType: String, // todo onramp fix after SwapProvider moved to own module
val activeStatus: OnrampStatus.Status,
val fromCurrencyCode: String,
) : ExpressTransactionStateUM
}
data class ExpressTransactionStateInfoUM(
val title: TextReference,
val status: ExpressStatusUM,
val notification: NotificationUM?,
val txId: String,
val txExternalId: String?,
val txExternalUrl: String?,
val timestamp: Long,
val timestampFormatted: TextReference,
val onGoToProviderClick: (String) -> Unit,
val onClick: () -> Unit,
val onDisposeExpressStatus: () -> Unit,
val iconState: ExpressTransactionStateIconUM,
val toAmount: TextReference,
val toFiatAmount: TextReference?,
val toAmountSymbol: String,
val toCurrencyIcon: CurrencyIconState,
val fromAmount: TextReference,
val fromFiatAmount: TextReference?,
val fromAmountSymbol: String,
val fromCurrencyIcon: CurrencyIconState,
)
enum class ExpressTransactionStateIconUM {
Warning,
Error,
None,
}

View file

@ -0,0 +1,222 @@
package com.tangem.common.ui.navigationButtons
import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.navigationButtons.preview.NavigationButtonsPreview
import com.tangem.core.ui.components.Keyboard
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.keyboardAsState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
@Composable
fun NavigationButtonsBlock(
buttonState: NavigationButtonsState,
modifier: Modifier = Modifier,
footerText: TextReference? = null,
) {
val state = buttonState as? NavigationButtonsState.Data
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth(),
) {
InfoText(footerText)
ExtraButtons(state?.extraButtons, state?.txUrl)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
PreviousButton(state?.prevButton)
NavigationPrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f))
}
}
}
@Composable
fun NavigationPrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = Modifier) {
val wrappedButton by rememberNavigationButton(primaryButton)
AnimatedContent(
targetState = wrappedButton,
transitionSpec = { navigationButtonsTransition() },
contentAlignment = Alignment.Center,
label = "Animate show primary button",
modifier = modifier.fillMaxWidth(),
) { button ->
if (button != null && button.textReference != TextReference.EMPTY) {
val icon = if (button.iconRes != null && button.isIconVisible) {
TangemButtonIconPosition.End(iconResId = button.iconRes)
} else {
TangemButtonIconPosition.None
}
TangemButton(
text = button.textReference.resolveReference(),
enabled = button.isEnabled,
onClick = button.onClick,
showProgress = button.showProgress,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
icon = icon,
modifier = Modifier.fillMaxWidth(),
)
} else {
Spacer(modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun PreviousButton(prevButton: NavigationButton?) {
AnimatedVisibility(
visible = prevButton != null,
enter = expandHorizontally(expandFrom = Alignment.End),
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
label = "Animate show prev button",
) {
val button = remember(this) { requireNotNull(prevButton) }
if (button.iconRes != null && button.isIconVisible) {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(button.iconRes),
),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.button.secondary)
.clickable(onClick = button.onClick)
.padding(TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
private fun ExtraButtons(extraButtons: ImmutableList<NavigationButton>?, txUrl: String?) {
AnimatedVisibility(
visible = !txUrl.isNullOrBlank() && extraButtons != null,
enter = slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()),
exit = slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut()),
label = "Animate show sent state buttons",
modifier = Modifier.fillMaxWidth(),
) {
val buttons = remember(this) { requireNotNull(extraButtons) }
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
) {
buttons.forEach { button ->
val icon = button.iconRes?.let { TangemButtonIconPosition.Start(iconResId = it) }
?: TangemButtonIconPosition.None
TangemButton(
text = button.textReference.resolveReference(),
icon = icon,
textStyle = TangemTheme.typography.subtitle1,
onClick = rememberHapticFeedback(state = button, onAction = button.onClick),
modifier = Modifier.weight(1f),
enabled = button.isEnabled,
showProgress = false,
colors = TangemButtonsDefaults.secondaryButtonColors,
)
}
}
}
}
@Composable
private fun InfoText(footerText: TextReference?, modifier: Modifier = Modifier) {
var isVisibleProxy by remember { mutableStateOf(!footerText.isNullOrEmpty()) }
val keyboard by keyboardAsState()
// the text should appear when the keyboard is closed
LaunchedEffect(footerText, keyboard) {
if (footerText.isNullOrEmpty() && keyboard is Keyboard.Opened) {
return@LaunchedEffect
}
isVisibleProxy = !footerText.isNullOrEmpty()
}
AnimatedVisibility(
visible = isVisibleProxy,
modifier = modifier,
enter = slideInVertically() + fadeIn(),
exit = fadeOut(tween(durationMillis = 300)),
label = "Animate footer text appearance",
) {
val text = remember(this) { requireNotNull(footerText) }
Text(
text = text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun rememberNavigationButton(button: NavigationButton?): MutableState<NavigationButton?> {
return remember(
button?.iconRes,
button?.isIconVisible,
button?.isEnabled,
button?.showProgress,
button?.textReference,
) { mutableStateOf(button) }
}
private fun <T> AnimatedContentTransitionScope<T>.navigationButtonsTransition(): ContentTransform {
val isPrimaryToHide = targetState != null && initialState == null
val isPrimaryWasVisible = targetState == null && initialState != null
return if (isPrimaryToHide || isPrimaryWasVisible) {
slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn())
.togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut()))
} else {
fadeIn().togetherWith(fadeOut())
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NavigationButtonsBlock_Preview(
@PreviewParameter(NavigationButtonsBlockDataProvider::class) navigationButtonsState: NavigationButtonsState,
) {
TangemThemePreview {
NavigationButtonsBlock(navigationButtonsState)
}
}
private class NavigationButtonsBlockDataProvider : PreviewParameterProvider<NavigationButtonsState> {
override val values: Sequence<NavigationButtonsState>
get() = sequenceOf(NavigationButtonsPreview.allButtons)
}
// endregion

View file

@ -0,0 +1,27 @@
package com.tangem.common.ui.navigationButtons
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
sealed class NavigationButtonsState {
data object Empty : NavigationButtonsState()
data class Data(
val primaryButton: NavigationButton,
val prevButton: NavigationButton?,
val extraButtons: ImmutableList<NavigationButton>,
val txUrl: String? = null,
val onTextClick: (String) -> Unit,
) : NavigationButtonsState()
}
data class NavigationButton(
val textReference: TextReference,
@DrawableRes val iconRes: Int? = null,
val isSecondary: Boolean = false,
val isIconVisible: Boolean = false,
val showProgress: Boolean = false,
val isEnabled: Boolean = true,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,59 @@
package com.tangem.common.ui.navigationButtons.preview
import com.tangem.common.ui.R
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import kotlinx.collections.immutable.persistentListOf
internal object NavigationButtonsPreview {
private val extraButtons = persistentListOf(
NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_tangem_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = {},
),
NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_tangem_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = {},
),
)
private val prev = NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = {},
)
private val finished = NavigationButton(
textReference = resourceReference(R.string.common_close),
isSecondary = false,
isIconVisible = false,
showProgress = false,
isEnabled = true,
onClick = {},
)
val allButtons = NavigationButtonsState.Data(
primaryButton = finished,
prevButton = prev,
extraButtons = extraButtons,
txUrl = "https://tangem.com",
onTextClick = {},
)
}

View file

@ -0,0 +1,32 @@
package com.tangem.common.ui.notifications
import com.tangem.common.ui.R
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
object ExpressNotificationsUM {
data class NeedVerification(val onGoToProviderClick: (() -> Unit)?) : NotificationUM.Warning(
title = resourceReference(R.string.express_exchange_notification_verification_title),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = onGoToProviderClick?.let { onClick ->
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_go_to_provider),
onClick = onClick,
)
},
)
data class FailedByProvider(val onGoToProviderClick: (() -> Unit)?) : NotificationUM.Error(
title = resourceReference(R.string.express_exchange_notification_failed_title),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
iconResId = R.drawable.ic_alert_circle_24,
buttonState = onGoToProviderClick?.let { onClick ->
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_go_to_provider),
onClick = onClick,
)
},
)
}

View file

@ -0,0 +1,352 @@
package com.tangem.common.ui.notifications
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.ui.R
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.shorted
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import java.math.BigDecimal
sealed class NotificationUM(val config: NotificationConfig) {
open class Error(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.ic_alert_24,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonState,
onCloseClick = onCloseClick,
),
) {
data object TotalExceedsBalance : Error(
title = resourceReference(R.string.send_notification_exceed_balance_title),
subtitle = resourceReference(R.string.send_notification_exceed_balance_text),
)
data object InvalidAmount : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(R.string.send_notification_invalid_amount_text),
)
data class MinimumAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.send_notification_invalid_minimum_amount_text,
wrappedList(amount, amount),
),
)
data class MinimumSendAmountError(val amount: String) : Error(
title = resourceReference(R.string.send_notification_invalid_amount_title),
subtitle = resourceReference(
R.string.transfer_notification_invalid_minimum_transaction_amount_text,
wrappedList(amount, amount),
),
)
data class TransactionLimitError(
val cryptoCurrency: String,
val utxoLimit: String,
val amountLimit: String,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(R.string.send_notification_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notification_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_leave_button, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
data class TokenExceedsBalance(
val networkIconId: Int,
val currencyName: String,
val feeName: String,
val feeSymbol: String,
val networkName: String,
val mergeFeeNetworkName: Boolean = false,
val onClick: (() -> Unit)? = null,
) : Error(
title = resourceReference(
id = R.string.warning_send_blocked_funds_for_fee_title,
wrappedList(feeName),
),
subtitle = resourceReference(
id = R.string.warning_send_blocked_funds_for_fee_message,
formatArgs = wrappedList(currencyName, networkName, currencyName, feeName, feeSymbol),
),
iconResId = networkIconId,
buttonState = onClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(
R.string.common_buy_currency,
wrappedList(
if (mergeFeeNetworkName) {
"$currencyName ($feeSymbol)"
} else {
feeName
},
),
),
onClick = onClick,
)
},
)
data class ExceedsBalance(
val networkIconId: Int,
val currencyName: String,
val feeName: String,
val feeSymbol: String,
val networkName: String,
val mergeFeeNetworkName: Boolean = false,
val onClick: (() -> Unit)? = null,
) : Error(
title = resourceReference(
id = R.string.warning_blocked_funds_for_fee_title,
wrappedList(feeName),
),
subtitle = resourceReference(
id = R.string.warning_blocked_funds_for_fee_message,
formatArgs = wrappedList(currencyName),
),
iconResId = networkIconId,
buttonState = onClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(
R.string.common_buy_currency,
wrappedList(
if (mergeFeeNetworkName) {
"$currencyName ($feeSymbol)"
} else {
feeName
},
),
),
onClick = onClick,
)
},
)
data class ExistentialDeposit(val deposit: String, val onConfirmClick: () -> Unit) : Error(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_leave_button, wrappedList(deposit)),
onClick = onConfirmClick,
),
)
data class ReserveAmount(val amount: String) : Error(
title = resourceReference(
id = R.string.send_notification_invalid_reserve_amount_title,
wrappedList(amount),
),
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
)
}
open class Warning(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.img_attention_20,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
) {
data class HighFeeError(
val currencyName: String,
val amount: String,
val onConfirmClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(currencyName, amount)),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
),
onCloseClick = onCloseClick,
)
data object FeeTooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
data class TooHigh(
val value: String,
) : Warning(
title = resourceReference(id = R.string.send_notification_fee_too_high_title),
subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,
),
)
data class TronAccountNotActivated(val tokenName: String) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(
R.string.send_tron_account_activation_error,
wrappedList(tokenName),
),
)
data class FeeCoverageNotification(val cryptoAmount: String, val fiatAmount: String) : Warning(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(
R.string.common_network_fee_warning_content,
wrappedList(cryptoAmount, fiatAmount),
),
)
data class OnrampErrorNotification(val errorCode: String?, val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.common_error),
subtitle = if (errorCode != null) {
resourceReference(R.string.express_error_code, wrappedList(errorCode))
} else {
resourceReference(R.string.common_unknown_error)
},
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRefresh,
),
)
data object SwapNoAvailablePair : Warning(
title = resourceReference(id = R.string.action_buttons_swap_no_available_pair_notification_title),
subtitle = resourceReference(id = R.string.action_buttons_swap_no_available_pair_notification_message),
)
data object SellingRegionalRestriction : Warning(
title = resourceReference(id = R.string.selling_regional_restriction_alert_title),
subtitle = resourceReference(id = R.string.selling_regional_restriction_alert_message),
)
data object InsufficientBalanceForSelling : Warning(
title = resourceReference(id = R.string.selling_insufficient_balance_alert_title),
subtitle = resourceReference(id = R.string.selling_insufficient_balance_alert_message),
)
}
open class Info(
title: TextReference,
subtitle: TextReference,
iconResId: Int = R.drawable.ic_alert_circle_24,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = iconResId,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
)
sealed interface Cardano {
data class MinAdaValueCharged(val tokenName: String, val minAdaValue: String) : Warning(
title = resourceReference(id = R.string.cardano_coin_will_be_send_with_token_title),
subtitle = resourceReference(
id = R.string.cardano_coin_will_be_send_with_token_description,
formatArgs = wrappedList(minAdaValue, tokenName),
),
)
data object InsufficientBalanceToTransferCoin : Error(
title = resourceReference(id = R.string.cardano_max_amount_has_token_title),
subtitle = resourceReference(id = R.string.cardano_max_amount_has_token_description),
)
data class InsufficientBalanceToTransferToken(val tokenName: String) : Error(
title = resourceReference(id = R.string.cardano_insufficient_balance_to_send_token_title),
subtitle = resourceReference(
id = R.string.cardano_insufficient_balance_to_send_token_description,
formatArgs = wrappedList(tokenName),
),
)
}
sealed interface Koinos {
data class InsufficientRecoverableMana(
val mana: BigDecimal,
val maxMana: BigDecimal,
) : Error(
title = resourceReference(R.string.koinos_insufficient_mana_to_send_koin_title),
subtitle = resourceReference(
R.string.koinos_insufficient_mana_to_send_koin_description,
formatArgs = wrappedList(
mana.format { crypto("", Blockchain.Koinos.decimals()).shorted() },
maxMana.format { crypto("", Blockchain.Koinos.decimals()).shorted() },
),
),
)
data object InsufficientBalance : Error(
title = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_title),
subtitle = resourceReference(R.string.koinos_insufficient_balance_to_send_koin_description),
)
data class ManaExceedsBalance(
val availableKoinForTransfer: BigDecimal,
val onReduceClick: () -> Unit,
) : Error(
title = resourceReference(R.string.koinos_mana_exceeds_koin_balance_title),
subtitle = resourceReference(
R.string.koinos_mana_exceeds_koin_balance_description,
formatArgs = wrappedList(
availableKoinForTransfer.format {
crypto(Blockchain.Koinos.currency, Blockchain.Koinos.decimals())
},
),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(availableKoinForTransfer)),
onClick = onReduceClick,
),
)
}
sealed interface Solana {
data class RentInfo(
private val rentInfo: CryptoCurrencyWarning.Rent,
) : Error(
title = TextReference.Res(R.string.send_notification_invalid_amount_title),
subtitle = TextReference.Res(
id = R.string.send_notification_invalid_amount_rent_fee,
formatArgs = wrappedList(rentInfo.exemptionAmount),
),
)
}
}

View file

@ -0,0 +1,440 @@
package com.tangem.common.ui.notifications
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.uncapped
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
@Suppress("LargeClass")
object NotificationsFactory {
fun MutableList<NotificationUM>.addFeeUnreachableNotification(
feeError: GetFeeError?,
tokenName: String,
onReload: () -> Unit,
) {
when (feeError) {
is GetFeeError.BlockchainErrors.TronActivationError -> add(
NotificationUM.Warning.TronAccountNotActivated(tokenName),
)
is GetFeeError.DataError,
is GetFeeError.UnknownError,
-> add(
NotificationUM.Warning.NetworkFeeUnreachable(onReload),
)
else -> {
/* do nothing */
}
}
}
fun MutableList<NotificationUM>.addFeeUnreachableNotification(
tokenStatus: CryptoCurrencyStatus,
coinStatus: CryptoCurrencyStatus,
feeError: GetFeeError?,
onReload: () -> Unit,
onClick: (currency: CryptoCurrency) -> Unit,
) {
when (feeError) {
is GetFeeError.BlockchainErrors.TronActivationError -> add(
NotificationUM.Warning.TronAccountNotActivated(coinStatus.currency.name),
)
is GetFeeError.BlockchainErrors.KaspaZeroUtxo -> add(
NotificationUM.Error.TokenExceedsBalance(
networkIconId = coinStatus.currency.networkIconResId,
networkName = coinStatus.currency.name,
currencyName = tokenStatus.currency.name,
feeName = coinStatus.currency.name,
feeSymbol = coinStatus.currency.symbol,
mergeFeeNetworkName = false,
onClick = {
onClick(coinStatus.currency)
},
),
)
is GetFeeError.DataError,
is GetFeeError.UnknownError,
-> add(
NotificationUM.Warning.NetworkFeeUnreachable(onReload),
)
else -> {
/* do nothing */
}
}
}
fun MutableList<NotificationUM>.addExceedBalanceNotification(
feeAmount: BigDecimal,
sendingAmount: BigDecimal,
isSubtractionAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
minimumRequirement: BigDecimal? = null,
) {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (!isSubtractionAvailable) return
val showNotification = sendingAmount + feeAmount > balance - minimumRequirement.orZero()
if (showNotification) {
add(NotificationUM.Error.TotalExceedsBalance)
}
}
fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
reserveAmount: BigDecimal?,
sendingAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
isAccountFunded: Boolean,
) {
if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingAmount) {
add(
NotificationUM.Error.ReserveAmount(
reserveAmount.format {
crypto(cryptoCurrency)
},
),
)
}
}
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(
minimumSendAmount: BigDecimal?,
sendingAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
) {
if (minimumSendAmount != null && minimumSendAmount > sendingAmount) {
add(
NotificationUM.Error.MinimumSendAmountError(
amount = minimumSendAmount.format { crypto(cryptoCurrency) },
),
)
}
}
@Suppress("LongParameterList")
fun MutableList<NotificationUM>.addTransactionLimitErrorNotification(
currencyCheck: CryptoCurrencyCheck?,
sendingAmount: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
feeCurrencyStatus: CryptoCurrencyStatus?,
feeValue: BigDecimal,
onReduceClick: (
reduceAmountTo: BigDecimal,
notification: Class<out NotificationUM>,
) -> Unit,
) {
val cryptoCurrency = cryptoCurrencyStatus.currency
val utxoLimit = currencyCheck?.utxoAmountLimit
val availableToSend = utxoLimit?.availableToSend
val isDustLimit = checkDustLimits(
feeAmount = feeValue,
sendingAmount = sendingAmount,
dustValue = currencyCheck?.dustValue.orZero(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
)
if (availableToSend != null && !feeValue.isZero() && !isDustLimit) {
add(
NotificationUM.Error.TransactionLimitError(
cryptoCurrency = cryptoCurrency.name,
utxoLimit = utxoLimit.limit.toPlainString(),
amountLimit = availableToSend.format { crypto(cryptoCurrency) },
onConfirmClick = {
onReduceClick(
availableToSend,
NotificationUM.Error.TransactionLimitError::class.java,
)
},
),
)
}
}
/**
* Adds Existential Warning
*
* @param existentialDeposit existential deposit of blockchain
* @param feeAmount amount of fee spending for transaction
* @param sendingAmount amount sending by user (excluding fee for coins)
* @param cryptoCurrencyStatus blockchain currency status
* @param onReduceClick action to leave existential amount in balance after transaction
*/
fun MutableList<NotificationUM>.addExistentialWarningNotification(
existentialDeposit: BigDecimal?,
feeAmount: BigDecimal,
sendingAmount: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
onReduceClick: (
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) -> Unit,
) {
val cryptoCurrency = cryptoCurrencyStatus.currency
val balance = cryptoCurrencyStatus.value.amount ?: return
val spendingAmount = if (cryptoCurrency is CryptoCurrency.Token) {
feeAmount
} else {
sendingAmount + feeAmount
}
val diff = balance.minus(spendingAmount)
if (existentialDeposit != null && diff >= BigDecimal.ZERO && existentialDeposit > diff) {
add(
NotificationUM.Error.ExistentialDeposit(
deposit = existentialDeposit.format { crypto(cryptoCurrency).uncapped() },
onConfirmClick = {
onReduceClick(
existentialDeposit,
existentialDeposit.minus(diff),
NotificationUM.Error.ExistentialDeposit::class.java,
)
},
),
)
}
}
fun MutableList<NotificationUM>.addFeeCoverageNotification(
isFeeCoverage: Boolean,
amountField: AmountFieldModel,
sendingValue: BigDecimal,
appCurrency: AppCurrency,
cryptoCurrencyStatus: CryptoCurrencyStatus,
) {
val cryptoCurrency = cryptoCurrencyStatus.currency
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val amountValue = amountField.cryptoAmount.value ?: return
val cryptoDiff = amountValue.minus(sendingValue)
if (isFeeCoverage) {
add(
NotificationUM.Warning.FeeCoverageNotification(
cryptoAmount = cryptoDiff.format { crypto(cryptoCurrency).uncapped() },
fiatAmount = getFiatString(
value = cryptoDiff,
rate = fiatRate,
appCurrency = appCurrency,
),
),
)
}
}
fun MutableList<NotificationUM>.addDustWarningNotification(
dustValue: BigDecimal?,
feeValue: BigDecimal,
sendingAmount: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
feeCurrencyStatus: CryptoCurrencyStatus?,
) {
if (dustValue == null) return
val isExceedsLimit = checkDustLimits(
feeAmount = feeValue,
sendingAmount = sendingAmount,
dustValue = dustValue,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
)
if (isExceedsLimit) {
add(
NotificationUM.Error.MinimumAmountError(
amount = dustValue.format { crypto(cryptoCurrencyStatus.currency) },
),
)
}
}
fun MutableList<NotificationUM>.addExceedsBalanceNotification(
cryptoCurrencyWarning: CryptoCurrencyWarning?,
cryptoCurrencyStatus: CryptoCurrencyStatus,
shouldMergeFeeNetworkName: Boolean,
onClick: (CryptoCurrency) -> Unit,
onAnalyticsEvent: (CryptoCurrency) -> Unit,
) {
when (cryptoCurrencyWarning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
add(
NotificationUM.Error.TokenExceedsBalance(
networkIconId = cryptoCurrencyWarning.coinCurrency.networkIconResId,
networkName = cryptoCurrencyWarning.coinCurrency.name,
currencyName = cryptoCurrencyStatus.currency.name,
feeName = cryptoCurrencyWarning.coinCurrency.name,
feeSymbol = cryptoCurrencyWarning.coinCurrency.symbol,
mergeFeeNetworkName = shouldMergeFeeNetworkName,
onClick = {
onClick(cryptoCurrencyWarning.coinCurrency)
},
),
)
onAnalyticsEvent(cryptoCurrencyStatus.currency)
}
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
val currency = cryptoCurrencyWarning.feeCurrency
add(
NotificationUM.Error.TokenExceedsBalance(
networkIconId = currency?.networkIconResId ?: R.drawable.ic_alert_24,
currencyName = cryptoCurrencyWarning.currency.name,
feeName = cryptoCurrencyWarning.feeCurrencyName,
feeSymbol = cryptoCurrencyWarning.feeCurrencySymbol,
networkName = cryptoCurrencyWarning.networkName,
mergeFeeNetworkName = shouldMergeFeeNetworkName,
onClick = {
currency?.let {
onClick(currency)
}
},
),
)
onAnalyticsEvent(cryptoCurrencyWarning.currency)
}
else -> Unit
}
}
fun MutableList<NotificationUM>.addValidateTransactionNotifications(
dustValue: BigDecimal,
validationError: Throwable?,
cryptoCurrency: CryptoCurrency,
minAdaValue: BigDecimal?, // TODO revert to Fee, after swap TxFee refactored
onReduceClick: (
reduceAmountTo: BigDecimal,
notification: Class<out NotificationUM>,
) -> Unit,
) {
when (validationError) {
is BlockchainSdkError.Cardano -> addCardanoTransactionValidationError(
error = validationError,
sendingCurrency = cryptoCurrency,
dustValue = dustValue,
)
is BlockchainSdkError.Koinos -> addKoinosTransactionValidationError(
error = validationError,
onReduceClick = onReduceClick,
)
null -> minAdaValue?.let {
add(
NotificationUM.Cardano.MinAdaValueCharged(
tokenName = cryptoCurrency.name,
minAdaValue = minAdaValue.parseBigDecimal(cryptoCurrency.decimals),
),
)
}
else -> return
}
}
private fun MutableList<NotificationUM>.addCardanoTransactionValidationError(
error: BlockchainSdkError.Cardano,
sendingCurrency: CryptoCurrency,
dustValue: BigDecimal?,
) {
when (error) {
BlockchainSdkError.Cardano.InsufficientMinAdaBalanceToSendToken -> {
add(NotificationUM.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name))
}
BlockchainSdkError.Cardano.InsufficientRemainingBalanceToWithdrawTokens -> {
when (sendingCurrency) {
is CryptoCurrency.Coin -> NotificationUM.Cardano.InsufficientBalanceToTransferCoin
is CryptoCurrency.Token -> {
NotificationUM.Cardano.InsufficientBalanceToTransferToken(sendingCurrency.name)
}
}.let(::add)
}
BlockchainSdkError.Cardano.InsufficientRemainingBalance,
BlockchainSdkError.Cardano.InsufficientSendingAdaAmount,
-> {
dustValue?.let {
add(
NotificationUM.Error.MinimumAmountError(
amount = it.format { crypto(sendingCurrency) },
),
)
}
}
}
}
private fun MutableList<NotificationUM>.addKoinosTransactionValidationError(
error: BlockchainSdkError.Koinos,
onReduceClick: (
reduceAmountTo: BigDecimal,
notification: Class<out NotificationUM>,
) -> Unit,
) {
when (error) {
is BlockchainSdkError.Koinos.InsufficientBalance -> {
add(NotificationUM.Koinos.InsufficientBalance)
}
is BlockchainSdkError.Koinos.InsufficientMana -> {
add(
NotificationUM.Koinos.InsufficientRecoverableMana(
mana = error.manaBalance ?: BigDecimal.ZERO,
maxMana = error.maxMana ?: BigDecimal.ZERO,
),
)
}
is BlockchainSdkError.Koinos.ManaFeeExceedsBalance -> {
add(
NotificationUM.Koinos.ManaExceedsBalance(
availableKoinForTransfer = error.availableKoinForTransfer,
onReduceClick = {
onReduceClick(
error.availableKoinForTransfer,
NotificationUM.Koinos.InsufficientRecoverableMana::class.java,
)
},
),
)
}
else -> {}
}
}
fun MutableList<NotificationUM>.addRentExemptionNotification(rentWarning: CryptoCurrencyWarning.Rent?) {
if (rentWarning == null) return
add(NotificationUM.Solana.RentInfo(rentWarning))
}
private fun checkDustLimits(
feeAmount: BigDecimal,
sendingAmount: BigDecimal,
dustValue: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
feeCurrencyStatus: CryptoCurrencyStatus?,
): Boolean {
val change = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> {
val balance = cryptoCurrencyStatus.value.amount.orZero()
balance - (feeAmount + sendingAmount)
}
is CryptoCurrency.Token -> {
val balance = feeCurrencyStatus?.value?.amount.orZero()
balance - feeAmount
}
}
val dust = when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> dustValue
is CryptoCurrency.Token -> BigDecimal.ZERO
}
val isChangeLowerThanDust = change < dust && change > BigDecimal.ZERO
return when (cryptoCurrencyStatus.currency) {
is CryptoCurrency.Coin -> sendingAmount < dust || isChangeLowerThanDust
is CryptoCurrency.Token -> isChangeLowerThanDust
}
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.common.ui.swapStoriesScreen
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.promo.models.StoryContent
import kotlinx.collections.immutable.persistentListOf
object SwapStoriesFactory {
// WARNING! Be careful with indices. Temporary solution.
// Use all data from v1/stories api (image url, title, subtitle)
@Suppress("MagicNumber")
fun createStoriesState(swapStory: StoryContent, onStoriesClose: (Int) -> Unit): SwapStoriesUM {
val storyOrderedImageUrls = swapStory.getImageUrls()
if (storyOrderedImageUrls.size != 5) return SwapStoriesUM.Empty
return SwapStoriesUM.Content(
stories = persistentListOf(
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[0],
title = resourceReference(R.string.swap_story_first_title),
subtitle = resourceReference(R.string.swap_story_first_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[1],
title = resourceReference(R.string.swap_story_second_title),
subtitle = resourceReference(R.string.swap_story_second_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[2],
title = resourceReference(R.string.swap_story_third_title),
subtitle = resourceReference(R.string.swap_story_third_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[3],
title = resourceReference(R.string.swap_story_forth_title),
subtitle = resourceReference(R.string.swap_story_forth_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[4],
title = resourceReference(R.string.swap_story_fifth_title),
subtitle = resourceReference(R.string.swap_story_fifth_subtitle),
),
),
onClose = onStoriesClose,
)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.common.ui.swapStoriesScreen
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.core.ui.components.stories.StoriesContainer
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
private val SubtitleColor = Color(0xFFB0B0B0)
private const val STORIES_RELATIVE_PADDING = 0.7
@Composable
fun SwapStoriesScreen(config: SwapStoriesUM) {
if (config !is SwapStoriesUM.Content) return
SystemBarsIconsDisposable(darkIcons = false)
StoriesContainer(
config = config,
) { current, _ ->
Box(
modifier = Modifier
.fillMaxSize()
.background(TangemColorPalette.Black),
) {
SubcomposeAsyncImage(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
model = ImageRequest.Builder(context = LocalContext.current)
.data(current.imageUrl)
.crossfade(enable = false)
.allowHardware(true)
.memoryCacheKey(current.imageUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
loading = { },
error = { },
contentDescription = null,
)
SwapStoriesText(current)
}
}
}
@Composable
private fun SwapStoriesText(current: SwapStoriesUM.Content.Config) {
val height = LocalWindowSize.current.height.value
val textAlign = (height.dp.value * STORIES_RELATIVE_PADDING).dp
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(
top = textAlign,
start = 44.dp,
end = 44.dp,
),
) {
Text(
text = current.title.resolveReference(),
style = TextStyle(
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 34f, type = TextUnitType.Sp),
),
color = TangemTheme.colors.text.constantWhite,
textAlign = TextAlign.Center,
)
Text(
text = current.subtitle.resolveReference(),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
letterSpacing = TextUnit(value = 0.1f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp),
),
color = SubtitleColor,
textAlign = TextAlign.Center,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SwapStoriesScreen_Preview() {
TangemThemePreview {
SwapStoriesScreen(
SwapStoriesUM.Content(
stories = persistentListOf(
SwapStoriesUM.Content.Config(
imageUrl = "https://devweb.tangem.com/images/stories/swap/image1.png",
title = stringReference("Exchange With Us"),
subtitle = stringReference(
"Trusted exchange providers let you swap assets effortlessly",
),
),
),
onClose = {},
),
)
}
}
// endregion

View file

@ -0,0 +1,27 @@
package com.tangem.common.ui.swapStoriesScreen
import com.tangem.core.ui.components.stories.inner.STORY_DURATION
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
import com.tangem.core.ui.components.stories.model.StoryConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
sealed class SwapStoriesUM {
data object Empty : SwapStoriesUM()
data class Content(
override val stories: ImmutableList<Config>,
override val onClose: (Int) -> Unit,
) : SwapStoriesUM(), StoriesContentConfig<Content.Config> {
override val isRestartable: Boolean = false
data class Config(
val imageUrl: String,
val title: TextReference,
val subtitle: TextReference,
) : StoryConfig {
override val duration: Int = STORY_DURATION
}
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.common.ui.tokens
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
fun ScenarioUnavailabilityReason.getUnavailabilityReasonText(): TextReference {
return when (val unavailabilityReason = this) {
is ScenarioUnavailabilityReason.StakingUnavailable -> {
resourceReference(
id = R.string.token_button_unavailability_reason_staking_unavailable,
formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName),
)
}
is ScenarioUnavailabilityReason.PendingTransaction -> unavailabilityReason.getDescription()
is ScenarioUnavailabilityReason.EmptyBalance -> unavailabilityReason.getDescription()
is ScenarioUnavailabilityReason.BuyUnavailable -> {
resourceReference(
id = R.string.token_button_unavailability_reason_buy_unavailable,
formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName),
)
}
is ScenarioUnavailabilityReason.NotExchangeable -> {
resourceReference(
id = R.string.token_button_unavailability_reason_not_exchangeable,
formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName),
)
}
is ScenarioUnavailabilityReason.NotSupportedBySellService -> {
resourceReference(
id = R.string.token_button_unavailability_reason_sell_unavailable,
formatArgs = wrappedList(unavailabilityReason.cryptoCurrencyName),
)
}
ScenarioUnavailabilityReason.Unreachable -> {
resourceReference(
id = R.string.token_button_unavailability_generic_description,
)
}
ScenarioUnavailabilityReason.UnassociatedAsset -> resourceReference(
id = R.string.warning_receive_blocked_hedera_token_association_required_message,
)
ScenarioUnavailabilityReason.UsedOutdatedData -> {
resourceReference(id = R.string.token_button_unavailability_reason_out_of_date_balance)
}
ScenarioUnavailabilityReason.None -> {
throw IllegalArgumentException("The unavailability reason must be other than None")
}
}
}
private fun ScenarioUnavailabilityReason.PendingTransaction.getDescription(): TextReference {
return resourceReference(
id = when (withdrawalScenario) {
ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> {
R.string.token_button_unavailability_reason_pending_transaction_send
}
ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> {
R.string.token_button_unavailability_reason_pending_transaction_sell
}
},
formatArgs = wrappedList(networkName),
)
}
private fun ScenarioUnavailabilityReason.EmptyBalance.getDescription(): TextReference {
return resourceReference(
id = when (withdrawalScenario) {
ScenarioUnavailabilityReason.WithdrawalScenario.SEND -> {
R.string.token_button_unavailability_reason_empty_balance_send
}
ScenarioUnavailabilityReason.WithdrawalScenario.SELL -> {
R.string.token_button_unavailability_reason_empty_balance_sell
}
},
)
}

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