Updated on 2026-08-14
This commit is contained in:
parent
330ae73357
commit
e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions
|
|
@ -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
1
common/google/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
15
common/google/build.gradle.kts
Normal file
15
common/google/build.gradle.kts
Normal 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)
|
||||
|
||||
}
|
||||
|
|
@ -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
1
common/routing/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
30
common/routing/build.gradle.kts
Normal file
30
common/routing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.common.routing.bundle
|
||||
|
||||
import android.os.Bundle
|
||||
|
||||
interface RouteBundleParams {
|
||||
|
||||
fun getBundle(): Bundle
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.tangem.common
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface Converter<I, O> {
|
||||
fun convert(value: I): O
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.tangem.common
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface Filter<T> {
|
||||
fun filter(value: T): Boolean
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.tangem.common
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface Validator<Data, Error> {
|
||||
fun validate(data: Data? = null): Error?
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1
common/ui-charts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
26
common/ui-charts/build.gradle.kts
Normal file
26
common/ui-charts/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
1
common/ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
46
common/ui/build.gradle.kts
Normal file
46
common/ui/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)?
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue