Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+105
View File
@@ -0,0 +1,105 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: '../scripts/secrets.gradle'
android {
defaultConfig {
buildConfigField "String", "PEZKUWI_CARD_WIDGET_ID", "\"4ce98182-ed76-4933-ba1b-b85e4a51d75a\""
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
namespace 'io.novafoundation.nova.feature_assets'
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-wallet-api')
implementation project(':feature-account-api')
implementation project(':feature-nft-api')
implementation project(':feature-currency-api')
implementation project(':feature-crowdloan-api')
implementation project(':feature-wallet-connect-api')
implementation project(':feature-staking-api')
implementation project(':feature-swap-api')
implementation project(':web3names')
implementation project(':runtime')
implementation project(':feature-buy-api')
implementation project(':feature-xcm:api')
implementation project(':feature-banners-api')
implementation project(':feature-deep-linking')
implementation project(':feature-ahm-api')
implementation project(':feature-gift-api')
implementation kotlinDep
implementation androidDep
implementation swipeRefershLayout
implementation materialDep
implementation cardViewDep
implementation constraintDep
implementation permissionsDep
implementation coroutinesDep
implementation coroutinesAndroidDep
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation daggerDep
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
ksp daggerCompiler
implementation roomDep
ksp roomCompiler
implementation lifecycleDep
ksp lifecycleCompiler
implementation androidxWebKit
implementation bouncyCastleDep
testImplementation jUnitDep
testImplementation mockitoDep
implementation substrateSdkDep
implementation gsonDep
implementation retrofitDep
implementation wsDep
implementation zXingCoreDep
implementation zXingEmbeddedDep
implementation insetterDep
implementation shimmerDep
implementation flexBoxDep
implementation chartsDep
androidTestImplementation androidTestRunnerDep
androidTestImplementation androidTestRulesDep
androidTestImplementation androidJunitDep
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,264 @@
package io.novafoundation.nova.feature_assets.data.mappers
import io.novafoundation.nova.core_db.model.AssetAndChainId
import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeJoin
import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeLocal
import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeJoin
import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeLocal
import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal
import io.novafoundation.nova.core_db.model.operation.OperationJoin
import io.novafoundation.nova.core_db.model.operation.OperationLocal
import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey
import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeJoin
import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeLocal
import io.novafoundation.nova.core_db.model.operation.RewardTypeLocal
import io.novafoundation.nova.core_db.model.operation.SwapTypeJoin
import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal
import io.novafoundation.nova.core_db.model.operation.TransferTypeJoin
import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetWithAmountToLocal
import io.novafoundation.nova.feature_wallet_api.data.mappers.mapOperationStatusToOperationLocalStatus
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Extrinsic.Content
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Reward.RewardKind
import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
private fun mapOperationStatusLocalToOperationStatus(status: OperationBaseLocal.Status) = when (status) {
OperationBaseLocal.Status.PENDING -> Operation.Status.PENDING
OperationBaseLocal.Status.COMPLETED -> Operation.Status.COMPLETED
OperationBaseLocal.Status.FAILED -> Operation.Status.FAILED
}
fun mapOperationToOperationLocalDb(
operation: Operation,
source: OperationBaseLocal.Source,
): OperationLocal = with(operation) {
val localAssetId = AssetAndChainId(chainAsset.chainId, chainAsset.id)
val foreignKey = OperationForeignKey(id, address, localAssetId)
val typeLocal = when (val operationType = operation.type) {
is Type.Extrinsic -> mapExtrinsicToLocal(operationType, foreignKey)
is Type.Reward -> mapRewardToLocal(operationType, foreignKey)
is Type.Swap -> mapSwapToLocal(operationType, foreignKey)
is Type.Transfer -> mapTransferToLocal(operationType, foreignKey)
}
val base = OperationBaseLocal(
id = id,
address = address,
time = time,
assetId = localAssetId,
hash = extrinsicHash,
status = mapOperationStatusToOperationLocalStatus(operation.status),
source = source
)
OperationLocal(
base = base,
type = typeLocal
)
}
fun mapOperationLocalToOperation(
operationLocal: OperationJoin,
chainAsset: Chain.Asset,
chain: Chain,
coinRate: CoinRate?,
): Operation? = with(operationLocal) {
val operationType = when {
operationLocal.transfer != null -> mapTransferFromLocal(operationLocal.transfer!!, chainAsset, coinRate, operationLocal.base.address)
operationLocal.directReward != null -> mapDirectRewardFromLocal(operationLocal.directReward!!, chainAsset, coinRate)
operationLocal.poolReward != null -> mapPoolRewardFromLocal(operationLocal.poolReward!!, chainAsset, coinRate)
operationLocal.extrinsic != null -> mapExtrinsicFromLocal(operationLocal.extrinsic!!, chainAsset, coinRate)
operationLocal.swap != null -> mapSwapFromLocal(operationLocal.swap!!, chainAsset, chain, coinRate)
else -> null
} ?: return@with null
return Operation(
id = base.id,
address = base.address,
type = operationType,
time = base.time,
chainAsset = chainAsset,
extrinsicHash = base.hash,
status = mapOperationStatusLocalToOperationStatus(base.status)
)
}
private fun mapExtrinsicToLocal(
extrinsic: Type.Extrinsic,
foreignKey: OperationForeignKey
): ExtrinsicTypeLocal {
return when (val content = extrinsic.content) {
is Content.ContractCall -> ExtrinsicTypeLocal(
foreignKey = foreignKey,
contentType = ExtrinsicTypeLocal.ContentType.SMART_CONTRACT_CALL,
module = content.contractAddress,
call = content.function,
fee = extrinsic.fee
)
is Content.SubstrateCall -> ExtrinsicTypeLocal(
foreignKey = foreignKey,
contentType = ExtrinsicTypeLocal.ContentType.SUBSTRATE_CALL,
module = content.module,
call = content.call,
fee = extrinsic.fee
)
}
}
private fun mapTransferToLocal(
transfer: Type.Transfer,
foreignKey: OperationForeignKey
): TransferTypeLocal = with(transfer) {
TransferTypeLocal(
foreignKey = foreignKey,
amount = amount,
sender = sender,
receiver = receiver,
fee = fee
)
}
private fun mapRewardToLocal(
reward: Type.Reward,
foreignKey: OperationForeignKey
): RewardTypeLocal = with(reward) {
when (val kind = reward.kind) {
is RewardKind.Direct -> DirectRewardTypeLocal(
foreignKey = foreignKey,
isReward = isReward,
amount = amount,
eventId = eventId,
era = kind.era,
validator = kind.validator
)
is RewardKind.Pool -> PoolRewardTypeLocal(
foreignKey = foreignKey,
isReward = isReward,
amount = amount,
eventId = eventId,
poolId = kind.poolId
)
}
}
private fun mapSwapToLocal(
swap: Type.Swap,
foreignKey: OperationForeignKey
): SwapTypeLocal = with(swap) {
SwapTypeLocal(
foreignKey = foreignKey,
fee = mapAssetWithAmountToLocal(fee),
assetIn = mapAssetWithAmountToLocal(amountIn),
assetOut = mapAssetWithAmountToLocal(amountOut),
)
}
private fun mapExtrinsicFromLocal(
local: ExtrinsicTypeJoin,
chainAsset: Chain.Asset,
coinRate: CoinRate?,
): Type.Extrinsic {
val content = when (local.contentType) {
ExtrinsicTypeLocal.ContentType.SUBSTRATE_CALL -> Content.SubstrateCall(
module = local.module,
call = local.call.orEmpty()
)
ExtrinsicTypeLocal.ContentType.SMART_CONTRACT_CALL -> Content.ContractCall(
contractAddress = local.module,
function = local.call
)
}
return Type.Extrinsic(
content = content,
fee = local.fee,
fiatFee = coinRate?.convertPlanks(chainAsset, local.fee)
)
}
private fun mapDirectRewardFromLocal(
local: DirectRewardTypeJoin,
chainAsset: Chain.Asset,
coinRate: CoinRate?,
): Type.Reward {
return Type.Reward(
amount = local.amount,
isReward = local.isReward,
eventId = local.eventId,
kind = RewardKind.Direct(
// For a null value of Int? field, Room inserts zero when this Int? is used in Join
era = local.era.takeIf { it != 0 },
validator = local.validator
),
fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount)
)
}
private fun mapPoolRewardFromLocal(
local: PoolRewardTypeJoin,
chainAsset: Chain.Asset,
coinRate: CoinRate?,
): Type.Reward {
return Type.Reward(
amount = local.amount,
isReward = local.isReward,
eventId = local.eventId,
kind = RewardKind.Pool(poolId = local.poolId),
fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount)
)
}
private fun mapTransferFromLocal(
local: TransferTypeJoin,
chainAsset: Chain.Asset,
coinRate: CoinRate?,
myAddress: String,
): Type.Transfer {
return Type.Transfer(
amount = local.amount,
myAddress = myAddress,
receiver = local.receiver,
sender = local.sender,
fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount),
fee = local.fee
)
}
private fun mapSwapFromLocal(
local: SwapTypeJoin,
chainAsset: Chain.Asset,
chain: Chain,
coinRate: CoinRate?,
): Type.Swap? {
val amountIn = mapAssetWithAmountFromLocal(chain, local.assetIn) ?: return null
val amountOut = mapAssetWithAmountFromLocal(chain, local.assetOut) ?: return null
val amount = if (amountIn.chainAsset.fullId == chainAsset.fullId) amountIn.amount else amountOut.amount
return Type.Swap(
fee = mapAssetWithAmountFromLocal(chain, local.fee) ?: return null,
amountIn = amountIn,
amountOut = amountOut,
fiatAmount = coinRate?.convertPlanks(chainAsset, amount),
)
}
private fun mapAssetWithAmountFromLocal(
chain: Chain,
local: SwapTypeLocal.AssetWithAmount
): ChainAssetWithAmount? {
val asset = chain.assetsById[local.assetId.assetId] ?: return null
return ChainAssetWithAmount(
chainAsset = asset,
amount = local.amount
)
}
@@ -0,0 +1,124 @@
package io.novafoundation.nova.feature_assets.data.network
import android.util.Log
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.mergeIfMultiple
import io.novafoundation.nova.common.utils.transformLatestDiffed
import io.novafoundation.nova.core.updater.UpdateSystem
import io.novafoundation.nova.core.updater.Updater
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ethereum.subscribe
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.ext.isFullSync
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlin.coroutines.coroutineContext
class BalancesUpdateSystem(
private val chainRegistry: ChainRegistry,
private val paymentUpdaterFactory: PaymentUpdaterFactory,
private val balanceLocksUpdater: BalanceLocksUpdaterFactory,
private val pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory,
private val accountUpdateScope: AccountUpdateScope,
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
) : UpdateSystem {
override fun start(): Flow<Updater.SideEffect> {
return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount ->
chainRegistry.currentChains.transformLatestDiffed { chain ->
emitAll(balancesSync(chain, metaAccount))
}
}.flowOn(Dispatchers.Default)
}
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
return when {
!metaAccount.hasAccountIn(chain) -> emptyFlow()
chain.connectionState.isDisabled -> emptyFlow()
chain.canPerformFullSync() -> fullBalancesSync(chain, metaAccount)
else -> lightBalancesSync(chain, metaAccount)
}
}
private suspend fun fullBalancesSync(
chain: Chain,
metaAccount: MetaAccount,
): Flow<Updater.SideEffect> {
return launchChainUpdaters(
chain = chain,
metaAccount = metaAccount,
createUpdaters = { createFullSyncUpdaters(chain) }
)
}
private suspend fun lightBalancesSync(
chain: Chain,
metaAccount: MetaAccount,
): Flow<Updater.SideEffect> {
return launchChainUpdaters(
chain = chain,
metaAccount = metaAccount,
createUpdaters = { createLightSyncUpdaters(chain) }
)
}
private suspend fun launchChainUpdaters(
chain: Chain,
metaAccount: MetaAccount,
createUpdaters: suspend () -> List<Updater<MetaAccount>>
): Flow<Updater.SideEffect> {
return flow {
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
val updaters = createUpdaters()
val sideEffectFlows = updaters.map { updater ->
try {
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
} catch (e: Exception) {
emptyFlow()
}
}
subscriptionBuilder.subscribe(coroutineContext)
val resultFlow = sideEffectFlows.mergeIfMultiple()
emitAll(resultFlow)
}.catch { logError(chain, it) }
}
private fun Chain.canPerformFullSync(): Boolean {
return connectionState.isFullSync || !hasSubstrateRuntime
}
private fun createFullSyncUpdaters(chain: Chain): List<Updater<MetaAccount>> {
return listOf(
paymentUpdaterFactory.createFullSync(chain),
balanceLocksUpdater.create(chain),
pooledBalanceUpdaterFactory.create(chain)
)
}
private fun createLightSyncUpdaters(chain: Chain): List<Updater<MetaAccount>> {
return listOf(
paymentUpdaterFactory.createLightSync(chain),
)
}
private fun logError(chain: Chain, error: Throwable) {
Log.e(LOG_TAG, "Failed to subscribe to balances in ${chain.name}: ${error.message}", error)
}
}
@@ -0,0 +1,64 @@
package io.novafoundation.nova.feature_assets.data.repository
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
interface NovaCardStateRepository {
fun getNovaCardCreationState(): NovaCardState
fun setNovaCardCreationState(state: NovaCardState)
fun observeNovaCardCreationState(): Flow<NovaCardState>
fun setLastTopUpTime(time: Long)
fun getLastTopUpTime(): Long
suspend fun setTopUpFinishedEvent()
fun observeTopUpFinishedEvent(): Flow<Unit>
}
private const val PREFS_NOVA_CARD_STATE = "PREFS_NOVA_CARD_STATE"
private const val PREFS_TIME_CARD_BEING_ISSUED = "PREFS_TIME_CARD_BEING_ISSUED"
class RealNovaCardStateRepository(
private val preferences: Preferences
) : NovaCardStateRepository {
private val topUpFinishedEvent = MutableSharedFlow<Unit>()
override fun getNovaCardCreationState(): NovaCardState {
val novaCardState = preferences.getString(PREFS_NOVA_CARD_STATE, NovaCardState.NONE.toString())
return NovaCardState.valueOf(novaCardState)
}
override fun setNovaCardCreationState(state: NovaCardState) {
preferences.putString(PREFS_NOVA_CARD_STATE, state.toString())
}
override fun observeNovaCardCreationState(): Flow<NovaCardState> {
return preferences.keyFlow(PREFS_NOVA_CARD_STATE)
.map { getNovaCardCreationState() }
}
override fun setLastTopUpTime(time: Long) {
preferences.putLong(PREFS_TIME_CARD_BEING_ISSUED, time)
}
override fun getLastTopUpTime(): Long {
return preferences.getLong(PREFS_TIME_CARD_BEING_ISSUED, 0)
}
override suspend fun setTopUpFinishedEvent() {
topUpFinishedEvent.emit(Unit)
}
override fun observeTopUpFinishedEvent(): Flow<Unit> {
return topUpFinishedEvent
}
}
@@ -0,0 +1,212 @@
package io.novafoundation.nova.feature_assets.data.repository
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.common.utils.Filter
import io.novafoundation.nova.common.utils.applyFilters
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal
import io.novafoundation.nova.core_db.model.operation.OperationJoin
import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher
import io.novafoundation.nova.feature_assets.data.mappers.mapOperationLocalToOperation
import io.novafoundation.nova.feature_assets.data.mappers.mapOperationToOperationLocalDb
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory
import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation
import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.poolRewardAccountMatcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.getAllCoinPriceHistory
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate
import io.novafoundation.nova.runtime.ext.accountIdOrNull
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
interface TransactionHistoryRepository {
suspend fun syncOperationsFirstPage(
pageSize: Int,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
)
suspend fun getOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): DataPage<Operation>
suspend fun operationsFirstPageFlow(
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): Flow<DataPage<Operation>>
}
class RealTransactionHistoryRepository(
private val assetSourceRegistry: AssetSourceRegistry,
private val operationDao: OperationDao,
private val poolAccountDerivation: PoolAccountDerivation,
private val mythosMainPotMatcherFactory: MythosMainPotMatcherFactory,
private val coinPriceRepository: CoinPriceRepository
) : TransactionHistoryRepository {
override suspend fun syncOperationsFirstPage(
pageSize: Int,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
) = withContext(Dispatchers.Default) {
val historySource = historySourceFor(chainAsset)
val accountAddress = chain.addressOf(accountId)
val dataPageResult = runCatching {
historySource.getFilteredOperations(
pageSize,
PageOffset.Loadable.FirstPage,
filters,
accountId,
chain,
chainAsset,
currency
)
}
historySource.additionalFirstPageSync(chain, chainAsset, accountId, dataPageResult)
val dataPage = dataPageResult.getOrThrow()
val localOperations = dataPage.map { mapOperationToOperationLocalDb(it, OperationBaseLocal.Source.REMOTE) }
operationDao.insertFromRemote(accountAddress, chain.id, chainAsset.id, localOperations)
}
override suspend fun getOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): DataPage<Operation> = withContext(Dispatchers.Default) {
val historySource = historySourceFor(chainAsset)
historySource.getFilteredOperations(
pageSize = pageSize,
pageOffset = pageOffset,
filters = filters,
accountId = accountId,
chain = chain,
chainAsset = chainAsset,
currency = currency
)
}
override suspend fun operationsFirstPageFlow(
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): Flow<DataPage<Operation>> {
val accountAddress = chain.addressOf(accountId)
val historySource = historySourceFor(chainAsset)
return operationDao.observe(accountAddress, chain.id, chainAsset.id)
.transform { operations ->
emit(mapOperations(operations, chainAsset, chain, emptyList()))
runCatching { coinPriceRepository.getAllCoinPriceHistory(chainAsset.priceId!!, currency) }
.onSuccess { emit(mapOperations(operations, chainAsset, chain, it)) }
}
.mapLatest { operations ->
val pageOffset = historySource.getSyncedPageOffset(accountId, chain, chainAsset)
DataPage(pageOffset, operations)
}
}
private fun mapOperations(
operations: List<OperationJoin>,
chainAsset: Chain.Asset,
chain: Chain,
coinPrices: List<HistoricalCoinRate>,
): List<Operation> {
return operations.mapNotNull { operation ->
val operationTimestamp = operation.base.time.milliseconds.inWholeSeconds
val coinPrice = coinPrices.findNearestCoinRate(operationTimestamp)
mapOperationLocalToOperation(operation, chainAsset, chain, coinPrice)
}
}
private fun historySourceFor(chainAsset: Chain.Asset): AssetHistory = assetSourceRegistry.sourceFor(chainAsset).history
private suspend fun AssetHistory.getFilteredOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): DataPage<Operation> {
val nonFiltered = getOperations(pageSize, pageOffset, filters, accountId, chain, chainAsset, currency)
val pageFilters = createTransactionFilters(chain, chainAsset)
val filtered = nonFiltered.applyFilters(pageFilters)
return DataPage(nonFiltered.nextOffset, items = filtered)
}
private suspend fun AssetHistory.createTransactionFilters(chain: Chain, chainAsset: Chain.Asset): List<Filter<Operation>> {
val systemAccountFilterCreator = { matcher: SystemAccountMatcher? ->
matcher?.let { IgnoreTransfersFromSystemAccount(it, chain) }
}
return listOfNotNull(
IgnoreUnsafeOperations(this),
systemAccountFilterCreator(poolAccountDerivation.poolRewardAccountMatcher(chain.id)),
systemAccountFilterCreator(mythosMainPotMatcherFactory.create(chainAsset))
)
}
private class IgnoreTransfersFromSystemAccount(
private val systemAccountMatcher: SystemAccountMatcher,
private val chain: Chain
) : Filter<Operation> {
override fun shouldInclude(model: Operation): Boolean {
val operationType = model.type as? Operation.Type.Transfer ?: return true
val accountId = chain.accountIdOrNull(operationType.sender) ?: return true
return !systemAccountMatcher.isSystemAccount(accountId)
}
}
private class IgnoreUnsafeOperations(private val assetsHistory: AssetHistory) : Filter<Operation> {
override fun shouldInclude(model: Operation): Boolean {
return assetsHistory.isOperationSafe(model)
}
}
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_assets.data.repository.assetFilters
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.feature_assets.domain.assets.filters.AssetFilter
import io.novafoundation.nova.feature_assets.domain.assets.filters.NonZeroBalanceFilter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface AssetFiltersRepository {
val allFilters: List<AssetFilter>
fun assetFiltersFlow(): Flow<List<AssetFilter>>
fun updateAssetFilters(filters: List<AssetFilter>)
}
private const val PREF_ASSET_FILTERS = "ASSET_FILTERS"
class PreferencesAssetFiltersRepository(
private val preferences: Preferences
) : AssetFiltersRepository {
override val allFilters: List<AssetFilter> = listOf(
NonZeroBalanceFilter
)
private val filterFactory = allFilters.associateBy(AssetFilter::name)
override fun assetFiltersFlow(): Flow<List<AssetFilter>> {
return preferences.stringFlow(PREF_ASSET_FILTERS).map { encoded ->
encoded?.let {
encoded.split(",").mapNotNull(filterFactory::get)
} ?: emptyList()
}
}
override fun updateAssetFilters(filters: List<AssetFilter>) {
val encoded = filters.joinToString(separator = ",") { it.name }
preferences.putString(PREF_ASSET_FILTERS, encoded)
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_assets.di
import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem
import io.novafoundation.nova.feature_assets.di.modules.deeplinks.AssetDeepLinks
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator
interface AssetsFeatureApi {
val updateSystem: BalancesUpdateSystem
val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
val assetDeepLinks: AssetDeepLinks
val assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator
}
@@ -0,0 +1,176 @@
package io.novafoundation.nova.feature_assets.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator
import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.detail.di.BalanceDetailComponent
import io.novafoundation.nova.feature_assets.presentation.balance.list.di.BalanceListComponent
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.GoToNftsView
import io.novafoundation.nova.feature_assets.presentation.balance.search.di.AssetSearchComponent
import io.novafoundation.nova.feature_assets.presentation.gifts.assets.di.AssetGiftsFlowComponent
import io.novafoundation.nova.feature_assets.presentation.gifts.networks.di.NetworkGiftsFlowComponent
import io.novafoundation.nova.feature_assets.presentation.novacard.overview.di.NovaCardComponent
import io.novafoundation.nova.feature_assets.presentation.topup.di.TopUpAddressComponent
import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.di.WaitingNovaCardTopUpComponent
import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.di.AssetBuyFlowComponent
import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.di.NetworkBuyFlowComponent
import io.novafoundation.nova.feature_assets.presentation.receive.di.ReceiveComponent
import io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.di.AssetReceiveFlowComponent
import io.novafoundation.nova.feature_assets.presentation.receive.flow.network.di.NetworkReceiveFlowComponent
import io.novafoundation.nova.feature_assets.presentation.send.amount.di.SelectSendComponent
import io.novafoundation.nova.feature_assets.presentation.send.confirm.di.ConfirmSendComponent
import io.novafoundation.nova.feature_assets.presentation.send.flow.asset.di.AssetSendFlowComponent
import io.novafoundation.nova.feature_assets.presentation.send.flow.network.di.NetworkSendFlowComponent
import io.novafoundation.nova.feature_assets.presentation.swap.asset.di.AssetSwapFlowComponent
import io.novafoundation.nova.feature_assets.presentation.swap.network.di.NetworkSwapFlowComponent
import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.di.AddTokenEnterInfoComponent
import io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.di.AddTokenSelectChainComponent
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.di.ManageChainTokensComponent
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.di.ManageTokensComponent
import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator
import io.novafoundation.nova.feature_assets.presentation.bridge.di.BridgeComponent
import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.di.AssetSellFlowComponent
import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.di.NetworkSellFlowComponent
import io.novafoundation.nova.feature_assets.presentation.trade.provider.di.TradeProviderListComponent
import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.di.TradeWebComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.ExtrinsicDetailComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.PoolRewardDetailComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.RewardDetailComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.TransactionDetailComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.di.SwapDetailComponent
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.di.TransactionHistoryFilterComponent
import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi
import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi
import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import io.novafoundation.nova.web3names.di.Web3NamesApi
@Component(
dependencies = [
AssetsFeatureDependencies::class
],
modules = [
AssetsFeatureModule::class,
]
)
@FeatureScope
interface AssetsFeatureComponent : AssetsFeatureApi {
fun balanceListComponentFactory(): BalanceListComponent.Factory
fun balanceDetailComponentFactory(): BalanceDetailComponent.Factory
fun chooseAmountComponentFactory(): SelectSendComponent.Factory
fun confirmTransferComponentFactory(): ConfirmSendComponent.Factory
fun transactionDetailComponentFactory(): TransactionDetailComponent.Factory
fun swapDetailComponentFactory(): SwapDetailComponent.Factory
fun transactionHistoryComponentFactory(): TransactionHistoryFilterComponent.Factory
fun rewardDetailComponentFactory(): RewardDetailComponent.Factory
fun poolRewardDetailComponentFactory(): PoolRewardDetailComponent.Factory
fun extrinsicDetailComponentFactory(): ExtrinsicDetailComponent.Factory
fun receiveComponentFactory(): ReceiveComponent.Factory
fun assetSearchComponentFactory(): AssetSearchComponent.Factory
fun manageTokensComponentFactory(): ManageTokensComponent.Factory
fun manageChainTokensComponentFactory(): ManageChainTokensComponent.Factory
fun addTokenSelectChainComponentFactory(): AddTokenSelectChainComponent.Factory
fun addTokenEnterInfoComponentFactory(): AddTokenEnterInfoComponent.Factory
fun sendFlowComponent(): AssetSendFlowComponent.Factory
fun swapFlowComponent(): AssetSwapFlowComponent.Factory
fun receiveFlowComponent(): AssetReceiveFlowComponent.Factory
fun buyFlowComponent(): AssetBuyFlowComponent.Factory
fun sellFlowComponent(): AssetSellFlowComponent.Factory
fun bridgeComponentFactory(): BridgeComponent.Factory
fun giftsFlowComponent(): AssetGiftsFlowComponent.Factory
fun tradeProviderListComponent(): TradeProviderListComponent.Factory
fun tradeWebComponent(): TradeWebComponent.Factory
fun networkBuyFlowComponent(): NetworkBuyFlowComponent.Factory
fun networkSellFlowComponent(): NetworkSellFlowComponent.Factory
fun networkReceiveFlowComponent(): NetworkReceiveFlowComponent.Factory
fun networkSendFlowComponent(): NetworkSendFlowComponent.Factory
fun networkSwapFlowComponent(): NetworkSwapFlowComponent.Factory
fun topUpCardComponentFactory(): TopUpAddressComponent.Factory
fun networkGiftsFlowComponent(): NetworkGiftsFlowComponent.Factory
fun novaCardComponentFactory(): NovaCardComponent.Factory
fun waitingNovaCardTopUpComponentFactory(): WaitingNovaCardTopUpComponent.Factory
fun inject(view: GoToNftsView)
@Component.Factory
interface Factory {
fun create(
@BindsInstance accountRouter: AssetsRouter,
@BindsInstance selectAddressCommunicator: SelectAddressCommunicator,
@BindsInstance topUpAddressCommunicator: TopUpAddressCommunicator,
deps: AssetsFeatureDependencies
): AssetsFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
RuntimeApi::class,
NftFeatureApi::class,
WalletFeatureApi::class,
AccountFeatureApi::class,
CurrencyFeatureApi::class,
CrowdloanFeatureApi::class,
StakingFeatureApi::class,
Web3NamesApi::class,
WalletConnectFeatureApi::class,
SwapFeatureApi::class,
BuyFeatureApi::class,
BannersFeatureApi::class,
DeepLinkingFeatureApi::class,
ChainMigrationFeatureApi::class,
GiftFeatureApi::class
]
)
interface AssetsFeatureDependenciesComponent : AssetsFeatureDependencies
}
@@ -0,0 +1,366 @@
package io.novafoundation.nova.feature_assets.di
import android.content.ContentResolver
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.format.EthereumAddressFormat
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.data.network.HttpExceptionHandler
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ClipboardManager
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.QrCodeGenerator
import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory
import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory
import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor
import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.core_db.dao.HoldsDao
import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory
import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository
import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory
import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory
import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation
import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher
import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi
import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.ProxyPriceApi
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor
import io.novasama.substrate_sdk_android.encrypt.Signer
import io.novasama.substrate_sdk_android.icon.IconGenerator
import io.novasama.substrate_sdk_android.wsrpc.logging.Logger
import okhttp3.OkHttpClient
import javax.inject.Named
interface AssetsFeatureDependencies {
val maskingModeUseCase: MaskingModeUseCase
val maskableValueFormatterFactory: MaskableValueFormatterFactory
val amountFormatterProvider: MaskableValueFormatterProvider
val fiatFormatter: FiatFormatter
val amountFormatter: AmountFormatter
val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory
val assetsSourceRegistry: AssetSourceRegistry
val addressInputMixinFactory: AddressInputMixinFactory
val multiChainQrSharingFactory: MultiChainQrSharingFactory
val walletUiUseCase: WalletUiUseCase
val computationalCache: ComputationalCache
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val crossChainTraRepository: CrossChainTransfersRepository
val crossChainWeigher: CrossChainWeigher
val crossChainTransactor: CrossChainTransactor
val crossChainValidationSystemProvider: CrossChainValidationSystemProvider
val resourcesHintsMixinFactory: ResourcesHintsMixinFactory
val parachainInfoRepository: ParachainInfoRepository
val watchOnlyMissingKeysPresenter: WatchOnlyMissingKeysPresenter
val balanceLocksRepository: BalanceLocksRepository
val chainAssetRepository: ChainAssetRepository
val erc20Standard: Erc20Standard
val externalBalanceRepository: ExternalBalanceRepository
val pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory
val paymentUpdaterFactory: PaymentUpdaterFactory
val locksUpdaterFactory: BalanceLocksUpdaterFactory
val accountUpdateScope: AccountUpdateScope
val storageSharedRequestBuilderFactory: StorageSharedRequestsBuilderFactory
val poolDisplayUseCase: PoolDisplayUseCase
val poolAccountDerivation: PoolAccountDerivation
val operationDao: OperationDao
val coinPriceRepository: CoinPriceRepository
val swapSettingsStateProvider: SwapSettingsStateProvider
val swapService: SwapService
val swapAvailabilityInteractor: SwapAvailabilityInteractor
val bannerVisibilityRepository: BannerVisibilityRepository
val tradeMixinFactory: TradeMixin.Factory
val crossChainTransfersUseCase: CrossChainTransfersUseCase
val arbitraryTokenUseCase: ArbitraryTokenUseCase
val swapRateFormatter: SwapRateFormatter
val bottomSheetLauncher: DescriptionBottomSheetLauncher
val selectAddressMixinFactory: SelectAddressMixin.Factory
val chainStateRepository: ChainStateRepository
val holdsRepository: BalanceHoldsRepository
val holdsDao: HoldsDao
val coinGeckoLinkParser: CoinGeckoLinkParser
val assetIconProvider: AssetIconProvider
val swapFlowScopeAggregator: SwapFlowScopeAggregator
val okHttpClient: OkHttpClient
val mythosMainPotMatcherFactory: MythosMainPotMatcherFactory
val bannerSourceFactory: BannersSourceFactory
val bannersMixinFactory: PromotionBannersMixinFactory
val webViewPermissionAskerFactory: WebViewPermissionAskerFactory
val webViewFileChooserFactory: WebViewFileChooserFactory
val tradeTokenRegistry: TradeTokenRegistry
val interceptingWebViewClientFactory: InterceptingWebViewClientFactory
val mercuryoSellRequestInterceptorFactory: MercuryoSellRequestInterceptorFactory
val multisigPendingOperationsService: MultisigPendingOperationsService
val automaticInteractionGate: AutomaticInteractionGate
val linkBuilderFactory: LinkBuilderFactory
val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
val actionBottomSheetLauncher: ActionBottomSheetLauncher
val chainMigrationInfoUseCase: ChainMigrationInfoUseCase
val sendUseCase: SendUseCase
val feePaymentProviderRegistry: FeePaymentProviderRegistry
val customFeeCapabilityFacade: CustomFeeCapabilityFacade
val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase
val giftsAccountSupportedUseCase: GiftsAccountSupportedUseCase
fun web3NamesInteractor(): Web3NamesInteractor
fun contributionsInteractor(): ContributionsInteractor
fun contributionsRepository(): ContributionsRepository
fun locksDao(): LockDao
fun currencyInteractor(): CurrencyInteractor
fun currencyRepository(): CurrencyRepository
fun metaAccountGroupingInteractor(): MetaAccountGroupingInteractor
fun accountInteractor(): AccountInteractor
fun preferences(): Preferences
fun encryptedPreferences(): EncryptedPreferences
fun resourceManager(): ResourceManager
fun iconGenerator(): IconGenerator
fun clipboardManager(): ClipboardManager
fun contentResolver(): ContentResolver
fun accountRepository(): AccountRepository
fun networkCreator(): NetworkApiCreator
fun signer(): Signer
fun logger(): Logger
fun jsonMapper(): Gson
fun addressIconGenerator(): AddressIconGenerator
fun appLinksProvider(): AppLinksProvider
fun qrCodeGenerator(): QrCodeGenerator
fun fileProvider(): FileProvider
fun externalAccountActions(): ExternalActions.Presentation
fun httpExceptionHandler(): HttpExceptionHandler
fun addressDisplayUseCase(): AddressDisplayUseCase
fun chainRegistry(): ChainRegistry
@Named(REMOTE_STORAGE_SOURCE)
fun remoteStorageSource(): StorageDataSource
@Named(LOCAL_STORAGE_SOURCE)
fun localStorageSource(): StorageDataSource
fun extrinsicService(): ExtrinsicService
fun imageLoader(): ImageLoader
fun selectedAccountUseCase(): SelectedAccountUseCase
fun validationExecutor(): ValidationExecutor
fun eventsRepository(): EventsRepository
fun walletRepository(): WalletRepository
fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory
fun amountChooserFactory(): AmountChooserMixin.Factory
fun walletConstants(): WalletConstants
fun ethereumAddressFormat(): EthereumAddressFormat
fun proxyPriceApi(): ProxyPriceApi
fun coingeckoApi(): CoingeckoApi
fun assetsViewModeRepository(): AssetsViewModeRepository
fun walletConnectSessionsUseCase(): WalletConnectSessionsUseCase
fun assetsIconModeRepository(): AssetsIconModeRepository
fun nftRepository(): NftRepository
fun systemCallExecutor(): SystemCallExecutor
fun permissionsAskerFactory(): PermissionsAskerFactory
fun assetViewModeInteractor(): AssetViewModeInteractor
fun maxActionProviderFactory(): MaxActionProviderFactory
fun copyAddressMixin(): CopyAddressMixin
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_assets.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator
import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator
import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi
import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi
import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import io.novafoundation.nova.web3names.di.Web3NamesApi
import javax.inject.Inject
@ApplicationScope
class AssetsFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val selectAddressCommunicator: SelectAddressCommunicator,
private val topUpAddressCommunicator: TopUpAddressCommunicator,
private val router: AssetsRouter
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dependencies = DaggerAssetsFeatureComponent_AssetsFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.nftFeatureApi(getFeature(NftFeatureApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java))
.crowdloanFeatureApi(getFeature(CrowdloanFeatureApi::class.java))
.web3NamesApi(getFeature(Web3NamesApi::class.java))
.walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java))
.stakingFeatureApi(getFeature(StakingFeatureApi::class.java))
.swapFeatureApi(getFeature(SwapFeatureApi::class.java))
.buyFeatureApi(getFeature(BuyFeatureApi::class.java))
.bannersFeatureApi(getFeature(BannersFeatureApi::class.java))
.deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java))
.chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java))
.giftFeatureApi(getFeature(GiftFeatureApi::class.java))
.build()
return DaggerAssetsFeatureComponent.factory()
.create(router, selectAddressCommunicator, topUpAddressCommunicator, dependencies)
}
}
@@ -0,0 +1,376 @@
package io.novafoundation.nova.feature_assets.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.model.MaskingMode
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter
import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem
import io.novafoundation.nova.feature_assets.data.repository.NovaCardStateRepository
import io.novafoundation.nova.feature_assets.data.repository.RealNovaCardStateRepository
import io.novafoundation.nova.feature_assets.data.repository.RealTransactionHistoryRepository
import io.novafoundation.nova.feature_assets.data.repository.TransactionHistoryRepository
import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository
import io.novafoundation.nova.feature_assets.data.repository.assetFilters.PreferencesAssetFiltersRepository
import io.novafoundation.nova.feature_assets.di.modules.AddTokenModule
import io.novafoundation.nova.feature_assets.di.modules.ManageTokensCommonModule
import io.novafoundation.nova.feature_assets.di.modules.SendModule
import io.novafoundation.nova.feature_assets.di.modules.deeplinks.DeepLinkModule
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.WalletInteractorImpl
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.assets.RealExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory
import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchUseCase
import io.novafoundation.nova.feature_assets.domain.assets.search.AssetViewModeAssetSearchInteractorFactory
import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor
import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor
import io.novafoundation.nova.feature_assets.domain.novaCard.RealNovaCardInteractor
import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor
import io.novafoundation.nova.feature_assets.domain.price.RealChartsInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatterFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatterFactory
import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.swap.executor.InitialSwapFlowExecutor
import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutorFactory
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory
import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory
import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(
includes = [
SendModule::class,
ManageTokensCommonModule::class,
AddTokenModule::class,
DeepLinkModule::class
]
)
class AssetsFeatureModule {
@Provides
@FeatureScope
fun provideExternalBalancesInteractor(
accountRepository: AccountRepository,
externalBalanceRepository: ExternalBalanceRepository
): ExternalBalancesInteractor = RealExternalBalancesInteractor(accountRepository, externalBalanceRepository)
@Provides
@FeatureScope
fun provideAssetSearchUseCase(
walletRepository: WalletRepository,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
swapService: SwapService
) = AssetSearchUseCase(
walletRepository = walletRepository,
accountRepository = accountRepository,
chainRegistry = chainRegistry,
swapService = swapService
)
@Provides
@FeatureScope
fun provideSearchInteractorFactory(
assetViewModeRepository: AssetsViewModeRepository,
assetSearchUseCase: AssetSearchUseCase,
chainRegistry: ChainRegistry,
tradeTokenRegistry: TradeTokenRegistry,
availableGiftAssetsUseCase: AvailableGiftAssetsUseCase
): AssetSearchInteractorFactory = AssetViewModeAssetSearchInteractorFactory(
assetViewModeRepository,
assetSearchUseCase,
chainRegistry,
tradeTokenRegistry,
availableGiftAssetsUseCase
)
@Provides
@FeatureScope
fun provideAssetNetworksInteractor(
chainRegistry: ChainRegistry,
assetSearchUseCase: AssetSearchUseCase,
tradeTokenRegistry: TradeTokenRegistry,
giftAssetsUseCase: AvailableGiftAssetsUseCase
) = AssetNetworksInteractor(chainRegistry, assetSearchUseCase, tradeTokenRegistry, giftAssetsUseCase)
@Provides
@FeatureScope
fun provideAssetFiltersRepository(preferences: Preferences): AssetFiltersRepository {
return PreferencesAssetFiltersRepository(preferences)
}
@Provides
@FeatureScope
fun provideWalletInteractor(
walletRepository: WalletRepository,
accountRepository: AccountRepository,
assetFiltersRepository: AssetFiltersRepository,
chainRegistry: ChainRegistry,
nftRepository: NftRepository,
transactionHistoryRepository: TransactionHistoryRepository,
currencyRepository: CurrencyRepository
): WalletInteractor = WalletInteractorImpl(
walletRepository = walletRepository,
accountRepository = accountRepository,
assetFiltersRepository = assetFiltersRepository,
chainRegistry = chainRegistry,
nftRepository = nftRepository,
transactionHistoryRepository = transactionHistoryRepository,
currencyRepository = currencyRepository
)
@Provides
@FeatureScope
fun provideHistoryFiltersProviderFactory(
computationalCache: ComputationalCache,
assetSourceRegistry: AssetSourceRegistry,
chainRegistry: ChainRegistry,
) = HistoryFiltersProviderFactory(computationalCache, assetSourceRegistry, chainRegistry)
@Provides
@FeatureScope
fun provideControllableAssetCheckMixin(
missingKeysPresenter: WatchOnlyMissingKeysPresenter,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
resourceManager: ResourceManager
): ControllableAssetCheckMixin {
return ControllableAssetCheckMixin(
missingKeysPresenter,
actionAwaitableMixinFactory,
resourceManager
)
}
@Provides
@FeatureScope
fun provideBalancesUpdateSystem(
chainRegistry: ChainRegistry,
paymentUpdaterFactory: PaymentUpdaterFactory,
balanceLocksUpdater: BalanceLocksUpdaterFactory,
pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory,
accountUpdateScope: AccountUpdateScope,
storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
): BalancesUpdateSystem {
return BalancesUpdateSystem(
chainRegistry = chainRegistry,
paymentUpdaterFactory = paymentUpdaterFactory,
balanceLocksUpdater = balanceLocksUpdater,
pooledBalanceUpdaterFactory = pooledBalanceUpdaterFactory,
accountUpdateScope = accountUpdateScope,
storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory
)
}
@Provides
@FeatureScope
fun provideTransactionHistoryRepository(
assetSourceRegistry: AssetSourceRegistry,
operationsDao: OperationDao,
coinPriceRepository: CoinPriceRepository,
poolAccountDerivation: PoolAccountDerivation,
mythosMainPotMatcherFactory: MythosMainPotMatcherFactory,
): TransactionHistoryRepository = RealTransactionHistoryRepository(
assetSourceRegistry = assetSourceRegistry,
operationDao = operationsDao,
coinPriceRepository = coinPriceRepository,
poolAccountDerivation = poolAccountDerivation,
mythosMainPotMatcherFactory = mythosMainPotMatcherFactory
)
@Provides
@FeatureScope
fun provideNovaCardRepository(preferences: Preferences): NovaCardStateRepository {
return RealNovaCardStateRepository(preferences)
}
@Provides
@FeatureScope
fun provideNovaCardInteractor(repository: NovaCardStateRepository): NovaCardInteractor {
return RealNovaCardInteractor(repository)
}
@Provides
@FeatureScope
fun provideInitialSwapFlowExecutor(
assetsRouter: AssetsRouter
): InitialSwapFlowExecutor {
return InitialSwapFlowExecutor(assetsRouter)
}
@Provides
@FeatureScope
fun provideSwapExecutor(
initialSwapFlowExecutor: InitialSwapFlowExecutor,
assetsRouter: AssetsRouter,
swapSettingsStateProvider: SwapSettingsStateProvider
): SwapFlowExecutorFactory {
return SwapFlowExecutorFactory(initialSwapFlowExecutor, assetsRouter, swapSettingsStateProvider)
}
@Provides
@FeatureScope
fun provideNetworkAssetMapperFactory(
fiatFormatter: FiatFormatter,
amountFormatter: AmountFormatter
): NetworkAssetFormatterFactory {
return NetworkAssetFormatterFactory(
fiatFormatter,
amountFormatter
)
}
@Provides
@FeatureScope
fun provideTokenAssetMapperFactory(amountFormatter: AmountFormatter): TokenAssetFormatterFactory {
return TokenAssetFormatterFactory(amountFormatter)
}
@Provides
@FeatureScope
fun provideNotMaskingNetworkAssetMapper(
networkAssetFormatterFactory: NetworkAssetFormatterFactory,
maskableValueFormatterFactory: MaskableValueFormatterFactory
): NetworkAssetFormatter {
return networkAssetFormatterFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED))
}
@Provides
@FeatureScope
fun provideNotMaskingTokenAssetMapper(
tokenAssetFormatterFactory: TokenAssetFormatterFactory,
maskableValueFormatterFactory: MaskableValueFormatterFactory
): TokenAssetFormatter {
return tokenAssetFormatterFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED))
}
@Provides
@FeatureScope
fun provideExpandableAssetsMixinFactory(
assetIconProvider: AssetIconProvider,
currencyInteractor: CurrencyInteractor,
assetsViewModeRepository: AssetsViewModeRepository,
amountFormatterProvider: MaskableValueFormatterProvider,
networkAssetFormatterFactory: NetworkAssetFormatterFactory,
tokenAssetFormatterFactory: TokenAssetFormatterFactory,
): ExpandableAssetsMixinFactory {
return ExpandableAssetsMixinFactory(
assetIconProvider,
currencyInteractor,
assetsViewModeRepository,
amountFormatterProvider,
networkAssetFormatterFactory,
tokenAssetFormatterFactory
)
}
@Provides
@FeatureScope
fun provideChartsInteractor(
coinPriceRepository: CoinPriceRepository,
currencyRepository: CurrencyRepository
): ChartsInteractor {
return RealChartsInteractor(coinPriceRepository, currencyRepository)
}
@Provides
@FeatureScope
fun provideBuySellRestrictionCheckMixin(
accountUseCase: SelectedAccountUseCase,
actionLauncher: ActionBottomSheetLauncher,
resourceManager: ResourceManager
): BuySellRestrictionCheckMixin {
return BuySellRestrictionCheckMixin(
accountUseCase,
resourceManager,
actionLauncher
)
}
@Provides
@FeatureScope
fun provideNovaCardRestrictionCheckMixin(
accountUseCase: SelectedAccountUseCase,
actionLauncher: ActionBottomSheetLauncher,
resourceManager: ResourceManager,
chainRegistry: ChainRegistry
): NovaCardRestrictionCheckMixin {
return NovaCardRestrictionCheckMixin(
accountUseCase,
resourceManager,
actionLauncher,
chainRegistry
)
}
@Provides
@FeatureScope
fun provideBuySellMixinFactory(
router: AssetsRouter,
tradeTokenRegistry: TradeTokenRegistry,
chainRegistry: ChainRegistry,
resourceManager: ResourceManager,
buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin
): BuySellSelectorMixinFactory {
return BuySellSelectorMixinFactory(
router,
tradeTokenRegistry,
chainRegistry,
resourceManager,
buySellRestrictionCheckMixin
)
}
@Provides
@FeatureScope
fun provideGiftsRestrictionCheckMixin(
accountSupportedUseCase: GiftsAccountSupportedUseCase,
resourceManager: ResourceManager,
actionLauncher: ActionBottomSheetLauncher,
): GiftsRestrictionCheckMixin {
return GiftsRestrictionCheckMixin(
accountSupportedUseCase,
resourceManager,
actionLauncher
)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_assets.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.address.format.EthereumAddressFormat
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.feature_assets.domain.tokens.add.RealAddTokensInteractor
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class AddTokenModule {
@Provides
fun coinGeckoLinkValidationFactory(
coingeckoApi: CoingeckoApi,
coinGeckoLinkParser: CoinGeckoLinkParser
): CoinGeckoLinkValidationFactory {
return CoinGeckoLinkValidationFactory(coingeckoApi, coinGeckoLinkParser)
}
@Provides
@FeatureScope
fun provideInteractor(
chainRegistry: ChainRegistry,
erc20Standard: Erc20Standard,
chainAssetRepository: ChainAssetRepository,
coinGeckoLinkParser: CoinGeckoLinkParser,
ethereumAddressFormat: EthereumAddressFormat,
currencyRepository: CurrencyRepository,
walletRepository: WalletRepository,
coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
): AddTokensInteractor {
return RealAddTokensInteractor(
chainRegistry,
erc20Standard,
chainAssetRepository,
coinGeckoLinkParser,
ethereumAddressFormat,
currencyRepository,
walletRepository,
coinGeckoLinkValidationFactory
)
}
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_assets.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_assets.domain.tokens.AssetsDataCleaner
import io.novafoundation.nova.feature_assets.domain.tokens.RealAssetsDataCleaner
import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor
import io.novafoundation.nova.feature_assets.domain.tokens.manage.RealManageTokenInteractor
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper
import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class ManageTokensCommonModule {
@Provides
@FeatureScope
fun provideMultiChainTokenUiMapper(
assetIconProvider: AssetIconProvider,
resourceManager: ResourceManager
) = MultiChainTokenMapper(assetIconProvider, resourceManager)
@Provides
@FeatureScope
fun provideAssetDataCleaner(
externalBalanceRepository: ExternalBalanceRepository,
contributionsRepository: ContributionsRepository,
walletRepository: WalletRepository,
): AssetsDataCleaner {
return RealAssetsDataCleaner(externalBalanceRepository, contributionsRepository, walletRepository)
}
@Provides
@FeatureScope
fun provideInteractor(
chainRegistry: ChainRegistry,
chainAssetRepository: ChainAssetRepository,
assetsDataCleaner: AssetsDataCleaner
): ManageTokenInteractor = RealManageTokenInteractor(
chainRegistry = chainRegistry,
chainAssetRepository = chainAssetRepository,
assetsDataCleaner = assetsDataCleaner
)
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_assets.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_assets.domain.send.SendInteractor
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
@Module
class SendModule {
@Provides
@FeatureScope
fun provideSendInteractor(
assetSourceRegistry: AssetSourceRegistry,
crossChainTransfersRepository: CrossChainTransfersRepository,
crossChainTransactor: CrossChainTransactor,
parachainInfoRepository: ParachainInfoRepository,
extrinsicService: ExtrinsicService,
sendUseCase: SendUseCase,
crossChainTransfersUseCase: CrossChainTransfersUseCase,
crossChainValidationProvider: CrossChainValidationSystemProvider
) = SendInteractor(
assetSourceRegistry,
crossChainTransactor,
crossChainTransfersRepository,
parachainInfoRepository,
crossChainTransfersUseCase,
extrinsicService,
sendUseCase,
crossChainValidationProvider
)
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_assets.di.modules.deeplinks
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
class AssetDeepLinks(val deepLinkHandlers: List<DeepLinkHandler>)
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_assets.di.modules.deeplinks
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator
import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkHandler
import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.novacard.overview.deeplink.NovaCardDeepLinkHandler
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideDeepLinkConfigurator(
linkBuilderFactory: LinkBuilderFactory
): AssetDetailsDeepLinkConfigurator {
return AssetDetailsDeepLinkConfigurator(linkBuilderFactory)
}
@Provides
@FeatureScope
fun provideAssetDetailsDeepLinkHandler(
router: AssetsRouter,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
automaticInteractionGate: AutomaticInteractionGate,
assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator
): AssetDetailsDeepLinkHandler {
return AssetDetailsDeepLinkHandler(
router,
accountRepository,
chainRegistry,
automaticInteractionGate,
assetDetailsDeepLinkConfigurator
)
}
@Provides
@FeatureScope
fun provideNovaCardDeepLinkHandler(
router: AssetsRouter,
automaticInteractionGate: AutomaticInteractionGate,
novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin
): NovaCardDeepLinkHandler {
return NovaCardDeepLinkHandler(
router,
automaticInteractionGate,
novaCardRestrictionCheckMixin
)
}
@Provides
@FeatureScope
fun provideDeepLinks(
assetDetails: AssetDetailsDeepLinkHandler,
novaCardDeepLink: NovaCardDeepLinkHandler
): AssetDeepLinks {
return AssetDeepLinks(listOf(assetDetails, novaCardDeepLink))
}
}
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_assets.domain
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.OperationsPageChange
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import kotlinx.coroutines.flow.Flow
interface WalletInteractor {
fun isFiltersEnabledFlow(): Flow<Boolean>
fun filterAssets(assetsFlow: Flow<List<Asset>>): Flow<List<Asset>>
fun assetsFlow(): Flow<List<Asset>>
suspend fun syncAssetsRates(currency: Currency)
fun nftSyncTrigger(): Flow<NftSyncTrigger>
suspend fun syncAllNfts(metaAccount: MetaAccount)
suspend fun syncChainNfts(metaAccount: MetaAccount, chain: Chain)
fun chainFlow(chainId: ChainId): Flow<Chain>
fun assetFlow(chainId: ChainId, chainAssetId: Int): Flow<Asset>
fun assetFlow(chainAsset: Chain.Asset): Flow<Asset>
fun commissionAssetFlow(chainId: ChainId): Flow<Asset>
fun commissionAssetFlow(chain: Chain): Flow<Asset>
fun operationsFirstPageFlow(chainId: ChainId, chainAssetId: Int): Flow<OperationsPageChange>
suspend fun syncOperationsFirstPage(
chainId: ChainId,
chainAssetId: Int,
pageSize: Int,
filters: Set<TransactionFilter>,
): Result<*>
suspend fun getOperations(
chainId: ChainId,
chainAssetId: Int,
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>
): Result<DataPage<Operation>>
suspend fun groupAssetsByNetwork(
assets: List<Asset>,
externalBalances: List<ExternalBalance>
): Map<NetworkAssetGroup, List<AssetWithOffChainBalance>>
suspend fun groupAssetsByToken(
assets: List<Asset>,
externalBalances: List<ExternalBalance>
): Map<TokenAssetGroup, List<AssetWithNetwork>>
}
@@ -0,0 +1,196 @@
package io.novafoundation.nova.feature_assets.domain
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.common.utils.applyFilters
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_assets.data.repository.TransactionHistoryRepository
import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByNetwork
import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.OperationsPageChange
import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.enabledChainByIdFlow
import io.novafoundation.nova.runtime.multiNetwork.enabledChains
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.withContext
class WalletInteractorImpl(
private val walletRepository: WalletRepository,
private val accountRepository: AccountRepository,
private val assetFiltersRepository: AssetFiltersRepository,
private val chainRegistry: ChainRegistry,
private val nftRepository: NftRepository,
private val transactionHistoryRepository: TransactionHistoryRepository,
private val currencyRepository: CurrencyRepository
) : WalletInteractor {
override fun isFiltersEnabledFlow(): Flow<Boolean> {
return assetFiltersRepository.assetFiltersFlow()
.map { it.isNotEmpty() }
}
override fun filterAssets(assetsFlow: Flow<List<Asset>>): Flow<List<Asset>> {
return combine(assetsFlow, assetFiltersRepository.assetFiltersFlow()) { assets, filters ->
assets.applyFilters(filters)
}
}
override fun assetsFlow(): Flow<List<Asset>> {
val assetsFlow = accountRepository.selectedMetaAccountFlow()
.flatMapLatest { walletRepository.syncedAssetsFlow(it.id) }
val enabledChains = chainRegistry.enabledChainByIdFlow()
return combine(assetsFlow, enabledChains) { assets, chainsById ->
assets.filter { chainsById.containsKey(it.token.configuration.chainId) }
}
}
override suspend fun syncAssetsRates(currency: Currency) {
runCatching {
walletRepository.syncAssetsRates(currency)
}
}
override fun nftSyncTrigger(): Flow<NftSyncTrigger> {
return nftRepository.initialNftSyncTrigger()
}
override suspend fun syncAllNfts(metaAccount: MetaAccount) {
nftRepository.initialNftSync(metaAccount, forceOverwrite = false)
}
override suspend fun syncChainNfts(metaAccount: MetaAccount, chain: Chain) {
nftRepository.initialNftSync(metaAccount, chain)
}
override fun chainFlow(chainId: ChainId): Flow<Chain> {
return chainRegistry.enabledChainByIdFlow()
.map { it.getValue(chainId) }
}
override fun assetFlow(chainId: ChainId, chainAssetId: Int): Flow<Asset> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
val (_, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId)
walletRepository.assetFlow(metaAccount.id, chainAsset)
}
}
override fun assetFlow(chainAsset: Chain.Asset): Flow<Asset> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
walletRepository.assetFlow(metaAccount.id, chainAsset)
}
}
override fun commissionAssetFlow(chainId: ChainId): Flow<Asset> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
val chain = chainRegistry.getChain(chainId)
walletRepository.assetFlow(metaAccount.id, chain.commissionAsset)
}
}
override fun commissionAssetFlow(chain: Chain): Flow<Asset> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
walletRepository.assetFlow(metaAccount.id, chain.commissionAsset)
}
}
override fun operationsFirstPageFlow(chainId: ChainId, chainAssetId: Int): Flow<OperationsPageChange> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId)
val accountId = metaAccount.accountIdIn(chain)!!
val currency = currencyRepository.getSelectedCurrency()
transactionHistoryRepository.operationsFirstPageFlow(accountId, chain, chainAsset, currency)
.withIndex()
.map { (index, cursorPage) -> OperationsPageChange(cursorPage, accountChanged = index == 0) }
}
}
override suspend fun syncOperationsFirstPage(
chainId: ChainId,
chainAssetId: Int,
pageSize: Int,
filters: Set<TransactionFilter>,
) = withContext(Dispatchers.Default) {
runCatching {
val metaAccount = accountRepository.getSelectedMetaAccount()
val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId)
val accountId = metaAccount.accountIdIn(chain)!!
val currency = currencyRepository.getSelectedCurrency()
transactionHistoryRepository.syncOperationsFirstPage(pageSize, filters, accountId, chain, chainAsset, currency)
}
}
override suspend fun getOperations(
chainId: ChainId,
chainAssetId: Int,
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
): Result<DataPage<Operation>> {
return runCatching {
val metaAccount = accountRepository.getSelectedMetaAccount()
val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId)
val accountId = metaAccount.requireAccountIdIn(chain)
val currency = currencyRepository.getSelectedCurrency()
transactionHistoryRepository.getOperations(
pageSize = pageSize,
pageOffset = pageOffset,
filters = filters,
accountId = accountId,
chain = chain,
chainAsset = chainAsset,
currency = currency
)
}
}
override suspend fun groupAssetsByNetwork(
assets: List<Asset>,
externalBalances: List<ExternalBalance>
): Map<NetworkAssetGroup, List<AssetWithOffChainBalance>> {
val chains = chainRegistry.enabledChainByIdFlow().first()
return groupAndSortAssetsByNetwork(assets, externalBalances.aggregatedBalanceByAsset(), chains)
}
override suspend fun groupAssetsByToken(
assets: List<Asset>,
externalBalances: List<ExternalBalance>
): Map<TokenAssetGroup, List<AssetWithNetwork>> {
val chains = chainRegistry.enabledChainByIdFlow().first()
return groupAndSortAssetsByToken(assets, externalBalances.aggregatedBalanceByAsset(), chains)
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_assets.domain.assets
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
interface ExternalBalancesInteractor {
fun observeExternalBalances(): Flow<List<ExternalBalance>>
fun observeExternalBalances(assetId: FullChainAssetId): Flow<List<ExternalBalance>>
}
class RealExternalBalancesInteractor(
private val accountRepository: AccountRepository,
private val externalBalanceRepository: ExternalBalanceRepository,
) : ExternalBalancesInteractor {
override fun observeExternalBalances(): Flow<List<ExternalBalance>> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
externalBalanceRepository.observeAccountExternalBalances(metaAccount.id)
}
}
override fun observeExternalBalances(assetId: FullChainAssetId): Flow<List<ExternalBalance>> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
externalBalanceRepository.observeAccountChainExternalBalances(metaAccount.id, assetId)
}
}
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.domain.assets.filters
import io.novafoundation.nova.common.utils.NamedFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
typealias AssetFilter = NamedFilter<Asset>
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_assets.domain.assets.filters
import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository
import kotlinx.coroutines.flow.first
class AssetFiltersInteractor(
private val assetFiltersRepository: AssetFiltersRepository
) {
val allFilters = assetFiltersRepository.allFilters
fun updateFilters(filters: List<AssetFilter>) {
assetFiltersRepository.updateAssetFilters(filters)
}
suspend fun currentFilters(): Set<AssetFilter> = assetFiltersRepository.assetFiltersFlow().first().toSet()
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_assets.domain.assets.filters
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import java.math.BigDecimal
object NonZeroBalanceFilter : AssetFilter {
override val name: String = "NonZeroBalance"
override fun shouldInclude(model: Asset) = model.total > BigDecimal.ZERO
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_assets.domain.assets.list
import io.novafoundation.nova.common.data.model.AssetViewMode
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_nft_api.data.model.isFullySynced
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
private const val PREVIEW_COUNT = 3
class AssetsListInteractor(
private val accountRepository: AccountRepository,
private val nftRepository: NftRepository,
private val assetsViewModeRepository: AssetsViewModeRepository
) {
fun assetsViewModeFlow() = assetsViewModeRepository.assetsViewModeFlow()
suspend fun setAssetViewMode(assetViewModel: AssetViewMode) {
assetsViewModeRepository.setAssetsViewMode(assetViewModel)
}
suspend fun fullSyncNft(nft: Nft) = nftRepository.fullNftSync(nft)
fun observeNftPreviews(): Flow<NftPreviews> {
return accountRepository.selectedMetaAccountFlow()
.flatMapLatest(nftRepository::allNftFlow)
.map { nfts ->
NftPreviews(
totalNftsCount = nfts.size,
nftPreviews = nfts.sortedBy { it.isFullySynced }.take(PREVIEW_COUNT)
)
}
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_assets.domain.assets.list
import io.novafoundation.nova.feature_nft_api.data.model.Nft
class NftPreviews(
val totalNftsCount: Int,
val nftPreviews: List<Nft>
)
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_assets.domain.assets.models
import io.novafoundation.nova.common.utils.MultiMapList
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance
import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
sealed interface AssetsByViewModeResult {
class ByNetworks(val assets: MultiMapList<NetworkAssetGroup, AssetWithOffChainBalance>) : AssetsByViewModeResult
class ByTokens(val tokens: MultiMapList<TokenAssetGroup, AssetWithNetwork>) : AssetsByViewModeResult
}
fun AssetsByViewModeResult.groupList(): List<Any> {
return when (this) {
is AssetsByViewModeResult.ByNetworks -> assets.keys.toList()
is AssetsByViewModeResult.ByTokens -> tokens.keys.toList()
}
}
fun MultiMapList<NetworkAssetGroup, AssetWithOffChainBalance>.byNetworks(): AssetsByViewModeResult {
return AssetsByViewModeResult.ByNetworks(this)
}
fun MultiMapList<TokenAssetGroup, AssetWithNetwork>.byTokens(): AssetsByViewModeResult {
return AssetsByViewModeResult.ByTokens(this)
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_assets.domain.assets.search
import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface AssetSearchInteractorFactory {
fun createByAssetViewMode(): AssetSearchInteractor
}
typealias AssetSearchFilter = suspend (Asset) -> Boolean
interface AssetSearchInteractor {
fun tradeAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
tradeType: TradeTokenRegistry.TradeType
): Flow<AssetsByViewModeResult>
fun sendAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult>
fun searchSwapAssetsFlow(
forAsset: FullChainAssetId?,
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult>
fun searchReceiveAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult>
fun giftAssetsSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult>
fun searchAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult>
}
fun Flow<Set<FullChainAssetId>>.mapToAssetSearchFilter(): Flow<AssetSearchFilter> {
return map { assetsSet ->
{ asset ->
val chainAsset = asset.token.configuration
chainAsset.fullId in assetsSet
}
}
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_assets.domain.assets.search
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_assets.domain.common.searchTokens
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainsById
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
class AssetSearchUseCase(
private val walletRepository: WalletRepository,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val swapService: SwapService
) {
fun filteredAssetFlow(filterFlow: Flow<AssetSearchFilter?>): Flow<List<Asset>> {
val assetsFlow = accountRepository.selectedMetaAccountFlow()
.flatMapLatest { walletRepository.syncedAssetsFlow(it.id) }
return combine(assetsFlow, filterFlow) { assets, filter ->
if (filter == null) {
assets
} else {
assets.filter { filter(it) }
}
}
}
fun filterAssetsByQuery(query: String, assets: List<Asset>, chainsById: ChainsById): List<Asset> {
return assets.searchTokens(
query = query,
chainsById = chainsById,
tokenSymbol = { it.token.configuration.symbol.value },
relevantToChains = { asset, chainIds -> asset.token.configuration.chainId in chainIds }
)
}
fun getAvailableSwapAssets(asset: FullChainAssetId?, coroutineScope: CoroutineScope): Flow<Set<FullChainAssetId>> {
return flowOfAll {
val chainAsset = asset?.let { chainRegistry.asset(it) }
if (chainAsset == null) {
swapService.assetsAvailableForSwap(coroutineScope)
} else {
swapService.availableSwapDirectionsFor(chainAsset, coroutineScope)
}
}
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_assets.domain.assets.search
import io.novafoundation.nova.common.data.model.AssetViewMode
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
class AssetViewModeAssetSearchInteractorFactory(
private val assetViewModeRepository: AssetsViewModeRepository,
private val assetSearchUseCase: AssetSearchUseCase,
private val chainRegistry: ChainRegistry,
private val tradeTokenRegistry: TradeTokenRegistry,
private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase
) : AssetSearchInteractorFactory {
override fun createByAssetViewMode(): AssetSearchInteractor {
return when (assetViewModeRepository.getAssetViewMode()) {
AssetViewMode.TOKENS -> ByTokensAssetSearchInteractor(assetSearchUseCase, chainRegistry, tradeTokenRegistry, availableGiftAssetsUseCase)
AssetViewMode.NETWORKS -> ByNetworkAssetSearchInteractor(assetSearchUseCase, chainRegistry, tradeTokenRegistry, availableGiftAssetsUseCase)
}
}
}
@@ -0,0 +1,120 @@
package io.novafoundation.nova.feature_assets.domain.assets.search
import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult
import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance
import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.getAssetBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.getAssetGroupBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByNetwork
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.enabledChainById
import io.novasama.substrate_sdk_android.hash.isPositive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
class ByNetworkAssetSearchInteractor(
private val assetSearchUseCase: AssetSearchUseCase,
private val chainRegistry: ChainRegistry,
private val tradeTokenRegistry: TradeTokenRegistry,
private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase
) : AssetSearchInteractor {
override fun tradeAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
tradeType: TradeTokenRegistry.TradeType
): Flow<AssetsByViewModeResult> {
val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) }
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = filter)
}
override fun sendAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult> {
val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() }
return searchAssetsByNetworksInternalFlow(
queryFlow,
externalBalancesFlow,
assetGroupComparator = getAssetGroupBaseComparator { it.groupTransferableBalanceFiat },
assetsComparator = getAssetBaseComparator { it.balanceWithOffchain.transferable.fiat },
filter = filter
)
}
override fun searchSwapAssetsFlow(
forAsset: FullChainAssetId?,
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult> {
val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAsset, coroutineScope).mapToAssetSearchFilter()
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow)
}
override fun searchReceiveAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult> {
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = null)
}
override fun giftAssetsSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult> {
val filterFlow = availableGiftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter()
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow)
}
override fun searchAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult> {
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = null)
}
private fun ByNetworkAssetSearchInteractor.searchAssetsByNetworksInternalFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<NetworkAssetGroup> = getAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithOffChainBalance> = getAssetBaseComparator(),
filter: AssetSearchFilter?,
): Flow<AssetsByViewModeResult.ByNetworks> {
val filterFlow = flowOf(filter)
return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow)
}
private fun searchAssetsByNetworksInternalFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<NetworkAssetGroup> = getAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithOffChainBalance> = getAssetBaseComparator(),
filterFlow: Flow<AssetSearchFilter?>,
): Flow<AssetsByViewModeResult.ByNetworks> {
val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow)
val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() }
return combine(assetsFlow, aggregatedExternalBalances, queryFlow) { assets, externalBalances, query ->
val chainsById = chainRegistry.enabledChainById()
val filtered = assetSearchUseCase.filterAssetsByQuery(query, assets, chainsById)
val assetGroups = groupAndSortAssetsByNetwork(filtered, externalBalances, chainsById, assetGroupComparator, assetsComparator)
AssetsByViewModeResult.ByNetworks(assetGroups)
}
}
}
@@ -0,0 +1,121 @@
package io.novafoundation.nova.feature_assets.domain.assets.search
import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetGroupBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.enabledChainById
import io.novasama.substrate_sdk_android.hash.isPositive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
class ByTokensAssetSearchInteractor(
private val assetSearchUseCase: AssetSearchUseCase,
private val chainRegistry: ChainRegistry,
private val tradeTokenRegistry: TradeTokenRegistry,
private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase
) : AssetSearchInteractor {
override fun tradeAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
tradeType: TradeTokenRegistry.TradeType
): Flow<AssetsByViewModeResult> {
val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) }
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = filter)
}
override fun sendAssetSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult> {
val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() }
return searchAssetsByTokensInternalFlow(
queryFlow,
externalBalancesFlow,
assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat },
assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat },
filter = filter
)
}
override fun searchSwapAssetsFlow(
forAsset: FullChainAssetId?,
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult> {
val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAsset, coroutineScope).mapToAssetSearchFilter()
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow)
}
override fun searchReceiveAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult> {
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = null)
}
override fun giftAssetsSearch(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<AssetsByViewModeResult> {
val filterFlow = availableGiftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter()
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow)
}
override fun searchAssetsFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<AssetsByViewModeResult.ByTokens> {
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = null)
}
private fun searchAssetsByTokensInternalFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<TokenAssetGroup> = getTokenAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithNetwork> = getTokenAssetBaseComparator(),
filter: AssetSearchFilter?,
): Flow<AssetsByViewModeResult.ByTokens> {
val filterFlow = flowOf(filter)
return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow)
}
private fun searchAssetsByTokensInternalFlow(
queryFlow: Flow<String>,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<TokenAssetGroup> = getTokenAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithNetwork> = getTokenAssetBaseComparator(),
filterFlow: Flow<AssetSearchFilter?>,
): Flow<AssetsByViewModeResult.ByTokens> {
val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow)
val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() }
return combine(assetsFlow, aggregatedExternalBalances, queryFlow) { assets, externalBalances, query ->
val chainsById = chainRegistry.enabledChainById()
val filtered = assetSearchUseCase.filterAssetsByQuery(query, assets, chainsById)
val assetGroups = groupAndSortAssetsByToken(filtered, externalBalances, chainsById, assetGroupComparator, assetsComparator)
AssetsByViewModeResult.ByTokens(assetGroups)
}
}
}
@@ -0,0 +1,188 @@
package io.novafoundation.nova.feature_assets.domain.breakdown
import io.novafoundation.nova.common.utils.formatting.ABBREVIATED_SCALE
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.percentage
import io.novafoundation.nova.common.utils.sumByBigInteger
import io.novafoundation.nova.common.utils.unite
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdown.PercentageAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceBreakdownIds
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.unlabeledReserves
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.balanceId
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import java.math.BigDecimal
import java.math.BigInteger
class BalanceBreakdown(
val total: BigDecimal,
val transferableTotal: PercentageAmount,
val locksTotal: PercentageAmount,
val breakdown: List<BreakdownItem>
) {
companion object {
fun empty(): BalanceBreakdown {
return BalanceBreakdown(
total = BigDecimal.ZERO,
transferableTotal = PercentageAmount(amount = BigDecimal.ZERO, percentage = BigDecimal.ZERO),
locksTotal = PercentageAmount(amount = BigDecimal.ZERO, percentage = BigDecimal.ZERO),
breakdown = emptyList()
)
}
}
class PercentageAmount(val amount: BigDecimal, val percentage: BigDecimal)
class BreakdownItem(val id: String, val token: Token, val amountInPlanks: BigInteger) {
val tokenAmount by lazy { token.amountFromPlanks(amountInPlanks) }
val fiatAmount by lazy { token.amountToFiat(tokenAmount) }
}
}
class BalanceBreakdownInteractor(
private val accountRepository: AccountRepository,
private val balanceLocksRepository: BalanceLocksRepository,
private val balanceHoldsRepository: BalanceHoldsRepository,
) {
private class TotalAmount(
val totalFiat: BigDecimal,
val transferableFiat: BigDecimal,
val locksFiat: BigDecimal,
)
fun balanceBreakdownFlow(
assetsFlow: Flow<List<Asset>>,
externalBalancesFlow: Flow<List<ExternalBalance>>
): Flow<BalanceBreakdown> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
unite(
assetsFlow,
balanceLocksRepository.observeLocksForMetaAccount(metaAccount),
balanceHoldsRepository.observeHoldsForMetaAccount(metaAccount.id),
externalBalancesFlow
) { assets, locks, holds, externalBalances ->
if (assets == null) {
BalanceBreakdown.empty()
} else {
val assetsByChainId = assets.associateBy { it.token.configuration.fullId }
val locksItems = mapLocks(assetsByChainId, locks.orEmpty())
val holdsItems = mapHolds(assetsByChainId, holds.orEmpty())
val externalBalancesItems = mapExternalBalances(assetsByChainId, externalBalances.orEmpty())
val holdsByAsset = holds.orEmpty()
.groupBy { it.chainAsset.fullId }
.mapValues { (_, holds) -> holds.sumByBigInteger { it.amountInPlanks } }
val reserved = getReservedBreakdown(assets, holdsByAsset)
val breakdown = locksItems + holdsItems + externalBalancesItems + reserved
val totalAmount = calculateTotalBalance(assets, externalBalancesItems)
val (transferablePercentage, locksPercentage) = percentage(
scale = ABBREVIATED_SCALE,
totalAmount.transferableFiat,
totalAmount.locksFiat
)
BalanceBreakdown(
total = totalAmount.totalFiat,
transferableTotal = PercentageAmount(totalAmount.transferableFiat, transferablePercentage),
locksTotal = PercentageAmount(totalAmount.locksFiat, locksPercentage),
breakdown = breakdown.sortedByDescending { it.fiatAmount }
)
}
}
}
}
private fun mapLocks(
assetsByChainId: Map<FullChainAssetId, Asset>,
locks: List<BalanceLock>
): List<BalanceBreakdown.BreakdownItem> {
return locks.mapNotNull { lock ->
assetsByChainId[lock.chainAsset.fullId]?.let { asset ->
BalanceBreakdown.BreakdownItem(
id = lock.id.value,
token = asset.token,
amountInPlanks = lock.amountInPlanks,
)
}
}
}
private fun mapHolds(
assetsByChainId: Map<FullChainAssetId, Asset>,
holds: List<BalanceHold>
): List<BalanceBreakdown.BreakdownItem> {
return holds.mapNotNull { hold ->
assetsByChainId[hold.chainAsset.fullId]?.let { asset ->
BalanceBreakdown.BreakdownItem(
id = hold.identifier,
token = asset.token,
amountInPlanks = hold.amountInPlanks,
)
}
}
}
private fun mapExternalBalances(
assetsByChainId: Map<FullChainAssetId, Asset>,
externalBalances: List<ExternalBalance>
): List<BalanceBreakdown.BreakdownItem> {
return externalBalances.mapNotNull { externalBalance ->
assetsByChainId[externalBalance.chainAssetId]?.let { asset ->
BalanceBreakdown.BreakdownItem(
id = externalBalance.type.balanceId,
token = asset.token,
amountInPlanks = externalBalance.amount,
)
}
}
}
private fun calculateTotalBalance(
assets: List<Asset>,
externalBalancesItems: List<BalanceBreakdown.BreakdownItem>
): TotalAmount {
val externalBalancesTotal = externalBalancesItems.sumOf { it.fiatAmount }
var total = externalBalancesTotal
var transferable = BigDecimal.ZERO
var locks = externalBalancesTotal
assets.forEach { asset ->
total += asset.token.amountToFiat(asset.total)
transferable += asset.token.amountToFiat(asset.transferable)
locks += asset.token.amountToFiat(asset.locked)
}
return TotalAmount(total, transferable, locks)
}
private fun getReservedBreakdown(assets: List<Asset>, holds: Map<FullChainAssetId, Balance>): List<BalanceBreakdown.BreakdownItem> {
return assets
.filter { it.reservedInPlanks > BigInteger.ZERO }
.mapNotNull {
val labeledReserves = holds[it.token.configuration.fullId].orZero()
val unlabeledReserves = it.unlabeledReserves(labeledReserves)
if (unlabeledReserves <= BigInteger.ZERO) return@mapNotNull null
BalanceBreakdown.BreakdownItem(
id = BalanceBreakdownIds.RESERVED,
token = it.token,
amountInPlanks = unlabeledReserves
)
}
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_assets.domain.common
import java.math.BigDecimal
class AssetBalance(
val total: Amount,
val transferable: Amount
) {
class Amount(
val amount: BigDecimal,
val fiat: BigDecimal
) {
operator fun plus(other: Amount): Amount {
return Amount(
amount + other.amount,
fiat + other.fiat
)
}
}
companion object {
val ZERO = AssetBalance(Amount(BigDecimal.ZERO, BigDecimal.ZERO), Amount(BigDecimal.ZERO, BigDecimal.ZERO))
}
operator fun plus(other: AssetBalance): AssetBalance {
return AssetBalance(
total + other.total,
transferable + other.transferable
)
}
}
@@ -0,0 +1,79 @@
package io.novafoundation.nova.feature_assets.domain.common
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.sumByBigDecimal
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.runtime.ext.defaultComparatorFrom
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import java.math.BigDecimal
class NetworkAssetGroup(
val chain: Chain,
val groupTotalBalanceFiat: BigDecimal,
val groupTransferableBalanceFiat: BigDecimal,
val zeroBalance: Boolean
)
class AssetWithOffChainBalance(
val asset: Asset,
val balanceWithOffchain: AssetBalance,
)
fun groupAndSortAssetsByNetwork(
assets: List<Asset>,
externalBalances: Map<FullChainAssetId, Balance>,
chainsById: Map<String, Chain>,
assetGroupComparator: Comparator<NetworkAssetGroup> = getAssetGroupBaseComparator(),
assetComparator: Comparator<AssetWithOffChainBalance> = getAssetBaseComparator()
): Map<NetworkAssetGroup, List<AssetWithOffChainBalance>> {
return assets
.map { asset -> AssetWithOffChainBalance(asset, asset.totalWithOffChain(externalBalances)) }
.filter { chainsById.containsKey(it.asset.token.configuration.chainId) }
.groupBy { chainsById.getValue(it.asset.token.configuration.chainId) }
.mapValues { (_, assets) -> assets.sortedWith(assetComparator) }
.mapKeys { (chain, assets) ->
NetworkAssetGroup(
chain = chain,
groupTotalBalanceFiat = assets.sumByBigDecimal { it.balanceWithOffchain.total.fiat },
groupTransferableBalanceFiat = assets.sumByBigDecimal { it.balanceWithOffchain.transferable.fiat },
zeroBalance = assets.any { it.balanceWithOffchain.total.amount > BigDecimal.ZERO }
)
}.toSortedMap(assetGroupComparator)
}
fun getAssetBaseComparator(
comparing: (AssetWithOffChainBalance) -> Comparable<*> = { it.balanceWithOffchain.total.fiat }
): Comparator<AssetWithOffChainBalance> {
return compareByDescending(comparing)
.thenByDescending { it.balanceWithOffchain.total.amount }
.thenByDescending { it.asset.token.configuration.isUtilityAsset } // utility assets first
.thenBy { it.asset.token.configuration.symbol.value }
}
fun getAssetGroupBaseComparator(
comparing: (NetworkAssetGroup) -> Comparable<*> = NetworkAssetGroup::groupTotalBalanceFiat
): Comparator<NetworkAssetGroup> {
return compareByDescending(comparing)
.thenByDescending { it.zeroBalance } // non-zero balances first
.then(Chain.defaultComparatorFrom(NetworkAssetGroup::chain))
}
fun Asset.totalWithOffChain(externalBalances: Map<FullChainAssetId, Balance>): AssetBalance {
val onChainTotal = total
val offChainTotal = externalBalances[token.configuration.fullId]
?.let(token::amountFromPlanks)
.orZero()
val overallTotal = onChainTotal + offChainTotal
val overallFiat = token.amountToFiat(overallTotal)
return AssetBalance(
AssetBalance.Amount(overallTotal, overallFiat),
AssetBalance.Amount(transferable, token.amountToFiat(transferable))
)
}
@@ -0,0 +1,93 @@
package io.novafoundation.nova.feature_assets.domain.common
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.runtime.ext.defaultComparatorFrom
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.ext.normalize
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import java.math.BigDecimal
class TokenAssetGroup(
val tokenInfo: TokenInfo,
val groupBalance: AssetBalance,
val itemsCount: Int
) {
val groupId: String = tokenInfo.symbol.value
data class TokenInfo(
val icon: String?,
val token: Token
) {
val symbol = token.configuration.symbol.normalize()
val currency = token.currency
val coinRate = token.coinRate
}
}
class AssetWithNetwork(
val chain: Chain,
val asset: Asset,
val balanceWithOffChain: AssetBalance,
)
fun groupAndSortAssetsByToken(
assets: List<Asset>,
externalBalances: Map<FullChainAssetId, Balance>,
chainsById: Map<String, Chain>,
assetGroupComparator: Comparator<TokenAssetGroup> = getTokenAssetGroupBaseComparator(),
assetComparator: Comparator<AssetWithNetwork> = getTokenAssetBaseComparator()
): Map<TokenAssetGroup, List<AssetWithNetwork>> {
return assets
.filter { chainsById.containsKey(it.token.configuration.chainId) }
.map { asset -> AssetWithNetwork(chainsById.getValue(asset.token.configuration.chainId), asset, asset.totalWithOffChain(externalBalances)) }
.groupBy { mapToTokenGroup(it) }
.mapValues { (_, assets) -> assets.sortedWith(assetComparator) }
.mapKeys { (tokenWrapper, assets) ->
TokenAssetGroup(
tokenInfo = tokenWrapper.tokenInfo,
groupBalance = assets.fold(AssetBalance.ZERO) { acc, element -> acc + element.balanceWithOffChain },
itemsCount = assets.size
)
}.toSortedMap(assetGroupComparator)
}
fun getTokenAssetBaseComparator(
comparing: (AssetWithNetwork) -> Comparable<*> = { it.balanceWithOffChain.total.amount }
): Comparator<AssetWithNetwork> {
return compareByDescending(comparing)
.thenByDescending { it.asset.token.configuration.isUtilityAsset } // utility assets first
.thenBy { it.asset.token.configuration.symbol.value }
.then(Chain.defaultComparatorFrom(AssetWithNetwork::chain))
}
fun getTokenAssetGroupBaseComparator(
comparing: (TokenAssetGroup) -> Comparable<*> = { it.groupBalance.total.fiat }
): Comparator<TokenAssetGroup> {
return compareByDescending(comparing)
.thenByDescending { it.groupBalance.total.amount > BigDecimal.ZERO } // non-zero balances first
.then(TokenSymbol.defaultComparatorFrom { it.tokenInfo.symbol })
}
private fun mapToTokenGroup(it: AssetWithNetwork) = TokenGroupWrapper(
TokenAssetGroup.TokenInfo(
it.asset.token.configuration.icon,
it.asset.token
)
)
// Helper class to group items by symbol only
private class TokenGroupWrapper(val tokenInfo: TokenAssetGroup.TokenInfo) {
override fun equals(other: Any?): Boolean {
return other is TokenGroupWrapper && tokenInfo.symbol == other.tokenInfo.symbol
}
override fun hashCode(): Int {
return tokenInfo.symbol.hashCode()
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_assets.domain.common
import io.novafoundation.nova.common.utils.mapNotNullToSet
import io.novafoundation.nova.runtime.multiNetwork.ChainsById
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
private class SearchResult<T>(
val item: T,
val match: Match
)
private enum class Match {
NONE, INCLUSION, PREFIX, FULL;
}
private val Match.matchFound
get() = this != Match.NONE
private val Match.isFullMatch
get() = this == Match.FULL
// O(N * logN)
fun <T> List<T>.searchTokens(
query: String,
chainsById: ChainsById,
tokenSymbol: (T) -> String,
relevantToChains: (T, Set<ChainId>) -> Boolean,
): List<T> {
if (query.isEmpty()) return this
val searchResultsFromTokens = map {
SearchResult(
item = it,
match = tokenSymbol(it) match query
)
}
val anyMatchFromTokens = searchResultsFromTokens.mapNotNull { searchResult ->
searchResult.item.takeIf { searchResult.match.matchFound }
}
val allFullMatchesFromTokens = searchResultsFromTokens.filter { it.match.isFullMatch }
if (allFullMatchesFromTokens.isNotEmpty()) {
return anyMatchFromTokens
}
val foundChainIds = chainsById.values.mapNotNullToSet { chain ->
chain.id.takeIf { chain.name inclusionMatch query }
}
val fromChainSearch = filter { relevantToChains(it, foundChainIds) }
return (anyMatchFromTokens + fromChainSearch).distinct()
}
private infix fun String.match(query: String): Match = when {
fullMatch(query) -> Match.FULL
prefixMatch(prefix = query) -> Match.PREFIX
inclusionMatch(inclusion = query) -> Match.INCLUSION
else -> Match.NONE
}
private infix fun String.fullMatch(other: String) = lowercase() == other.lowercase()
private infix fun String.prefixMatch(prefix: String) = lowercase().startsWith(prefix.lowercase())
private infix fun String.inclusionMatch(inclusion: String) = inclusion.lowercase() in lowercase()
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_assets.domain.locks
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import kotlinx.coroutines.flow.Flow
interface BalanceLocksInteractor {
fun balanceLocksFlow(chainId: ChainId, chainAssetId: Int): Flow<List<BalanceLock>>
fun balanceHoldsFlow(chainId: ChainId, chainAssetId: Int): Flow<List<BalanceHold>>
}
@@ -0,0 +1,37 @@
package io.novafoundation.nova.feature_assets.domain.locks
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import kotlinx.coroutines.flow.Flow
class BalanceLocksInteractorImpl(
private val chainRegistry: ChainRegistry,
private val balanceLocksRepository: BalanceLocksRepository,
private val balanceHoldsRepository: BalanceHoldsRepository,
private val accountRepository: AccountRepository,
) : BalanceLocksInteractor {
override fun balanceLocksFlow(chainId: ChainId, chainAssetId: Int): Flow<List<BalanceLock>> {
return flowOfAll {
val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId)
val selectedAccount = accountRepository.getSelectedMetaAccount()
balanceLocksRepository.observeBalanceLocks(selectedAccount.id, chain, chainAsset)
}
}
override fun balanceHoldsFlow(chainId: ChainId, chainAssetId: Int): Flow<List<BalanceHold>> {
return flowOfAll {
val chainAsset = chainRegistry.asset(chainId, chainAssetId)
val selectedAccount = accountRepository.getSelectedMetaAccount()
balanceHoldsRepository.observeBalanceHolds(selectedAccount.id, chainAsset)
}
}
}
@@ -0,0 +1,128 @@
package io.novafoundation.nova.feature_assets.domain.networks
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.filterList
import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchFilter
import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchUseCase
import io.novafoundation.nova.feature_assets.domain.assets.search.mapToAssetSearchFilter
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetGroupBaseComparator
import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset
import io.novafoundation.nova.runtime.ext.normalize
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.enabledChainById
import io.novasama.substrate_sdk_android.hash.isPositive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
class AssetNetworksInteractor(
private val chainRegistry: ChainRegistry,
private val assetSearchUseCase: AssetSearchUseCase,
private val tradeTokenRegistry: TradeTokenRegistry,
private val giftAssetsUseCase: AvailableGiftAssetsUseCase
) {
fun tradeAssetFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
tradeType: TradeTokenRegistry.TradeType
): Flow<List<AssetWithNetwork>> {
val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) }
return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filter = filter)
}
fun sendAssetFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<List<AssetWithNetwork>> {
val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() }
return searchAssetsByTokenSymbolInternalFlow(
tokenSymbol,
externalBalancesFlow,
assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat },
assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat },
filter = filter
)
}
fun swapAssetsFlow(
forAssetId: FullChainAssetId?,
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<List<AssetWithNetwork>> {
val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAssetId, coroutineScope).mapToAssetSearchFilter()
return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filterFlow = filterFlow)
}
fun receiveAssetFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
): Flow<List<AssetWithNetwork>> {
return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filter = null)
}
fun giftsAssetFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
coroutineScope: CoroutineScope
): Flow<List<AssetWithNetwork>> {
val filterFlow = giftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter()
return searchAssetsByTokenSymbolInternalFlow(
tokenSymbol,
externalBalancesFlow,
assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat },
assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat },
filterFlow = filterFlow
)
}
fun searchAssetsByTokenSymbolInternalFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<TokenAssetGroup> = getTokenAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithNetwork> = getTokenAssetBaseComparator(),
filterFlow: Flow<AssetSearchFilter?>,
): Flow<List<AssetWithNetwork>> {
val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow)
.filterList { it.token.configuration.symbol.normalize() == tokenSymbol }
val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() }
return combine(assetsFlow, aggregatedExternalBalances) { assets, externalBalances ->
val chainsById = chainRegistry.enabledChainById()
groupAndSortAssetsByToken(assets, externalBalances, chainsById, assetGroupComparator, assetsComparator)
.flatMap { it.value }
}
}
private fun getSwapAssetsFilter(sourceAsset: FullChainAssetId?, coroutineScope: CoroutineScope): Flow<AssetSearchFilter> {
return assetSearchUseCase.getAvailableSwapAssets(sourceAsset, coroutineScope).mapToAssetSearchFilter()
}
}
private fun AssetNetworksInteractor.searchAssetsByTokenSymbolInternalFlow(
tokenSymbol: TokenSymbol,
externalBalancesFlow: Flow<List<ExternalBalance>>,
assetGroupComparator: Comparator<TokenAssetGroup> = getTokenAssetGroupBaseComparator(),
assetsComparator: Comparator<AssetWithNetwork> = getTokenAssetBaseComparator(),
filter: AssetSearchFilter?,
): Flow<List<AssetWithNetwork>> {
val filterFlow = flowOf(filter)
return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow)
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_assets.domain.novaCard
import io.novafoundation.nova.feature_assets.data.repository.NovaCardStateRepository
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration.Companion.minutes
interface NovaCardInteractor {
fun isNovaCardCreated(): Boolean
fun getNovaCardState(): NovaCardState
fun setNovaCardState(state: NovaCardState)
suspend fun setTopUpFinishedEvent()
fun observeTopUpFinishedEvent(): Flow<Unit>
fun observeNovaCardState(): Flow<NovaCardState>
fun setLastTopUpTime(time: Long)
fun getEstimatedTopUpDuration(): Long
}
const val TIMER_MINUTES = 5
class RealNovaCardInteractor(
private val novaCardStateRepository: NovaCardStateRepository
) : NovaCardInteractor {
override fun isNovaCardCreated(): Boolean {
return novaCardStateRepository.getNovaCardCreationState() == NovaCardState.CREATED
}
override fun getNovaCardState(): NovaCardState {
return novaCardStateRepository.getNovaCardCreationState()
}
override fun setNovaCardState(state: NovaCardState) {
return novaCardStateRepository.setNovaCardCreationState(state)
}
override suspend fun setTopUpFinishedEvent() {
novaCardStateRepository.setTopUpFinishedEvent()
}
override fun observeTopUpFinishedEvent(): Flow<Unit> {
return novaCardStateRepository.observeTopUpFinishedEvent()
}
override fun observeNovaCardState(): Flow<NovaCardState> {
return novaCardStateRepository.observeNovaCardCreationState()
}
override fun setLastTopUpTime(time: Long) {
novaCardStateRepository.setLastTopUpTime(time)
}
override fun getEstimatedTopUpDuration(): Long {
val lastTopUpTime = novaCardStateRepository.getLastTopUpTime()
val onTopUpFinishTime = lastTopUpTime + TIMER_MINUTES.minutes.inWholeMilliseconds
val currentTime = System.currentTimeMillis()
val estimatedDurationToFinishTopUp = onTopUpFinishTime - currentTime
return estimatedDurationToFinishTopUp.coerceAtLeast(0)
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_assets.domain.novaCard
enum class NovaCardState {
NONE,
CREATION,
CREATED
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_assets.domain.price
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod
import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate
class AssetPriceChart(val range: PricePeriod, val chart: ExtendedLoadingState<List<HistoricalCoinRate>>)
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_assets.domain.price
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.map
import io.novafoundation.nova.common.utils.combine
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod
import io.novafoundation.nova.feature_wallet_api.data.repository.duration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
interface ChartsInteractor {
fun chartsFlow(priceId: String): Flow<List<AssetPriceChart>>
}
class RealChartsInteractor(
private val coinPriceRepository: CoinPriceRepository,
private val currencyRepository: CurrencyRepository
) : ChartsInteractor {
override fun chartsFlow(priceId: String): Flow<List<AssetPriceChart>> {
val dayChart = getChartFor(priceId, PricePeriod.DAY)
val monthChart = getChartFor(priceId, PricePeriod.MONTH)
val maxChart = getChartFor(priceId, PricePeriod.MAX)
val weekChart = monthChart.map { it.subChartForPeriod(PricePeriod.WEEK) }
val yearChart = maxChart.map { it.subChartForPeriod(PricePeriod.YEAR) }
return listOf(dayChart, weekChart, monthChart, yearChart, maxChart).combine()
}
private fun getChartFor(priceId: String, range: PricePeriod): Flow<AssetPriceChart> {
return flow {
emit(AssetPriceChart(range, ExtendedLoadingState.Loading))
val currency = currencyRepository.getSelectedCurrency()
runCatching { coinPriceRepository.getLastHistoryForPeriod(priceId, currency, range) }
.onSuccess { emit(AssetPriceChart(range, ExtendedLoadingState.Loaded(it))) }
.onFailure { emit(AssetPriceChart(range, ExtendedLoadingState.Error(it))) }
}
}
private fun AssetPriceChart.subChartForPeriod(period: PricePeriod): AssetPriceChart {
val subChartDuration = period.duration()
val chart = chart.map { historicalPoints ->
val lastPoint = historicalPoints.lastOrNull() ?: return@map emptyList()
val fromDate = lastPoint.timestamp - subChartDuration.inWholeSeconds
historicalPoints.filter { it.timestamp > fromDate }
}
return AssetPriceChart(period, chart)
}
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_assets.domain.receive
import android.graphics.Bitmap
import android.net.Uri
import io.novafoundation.nova.common.data.model.AssetIconMode
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.utils.write
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val QR_FILE_NAME = "share-qr-address.png"
class ReceiveInteractor(
private val fileProvider: FileProvider,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val assetsIconModeRepository: AssetsIconModeRepository
) {
suspend fun getQrCodeSharingString(chainId: ChainId): String = withContext(Dispatchers.Default) {
val chain = chainRegistry.getChain(chainId)
val account = accountRepository.getSelectedMetaAccount()
accountRepository.createQrAccountContent(chain, account)
}
fun getAssetIconMode(): AssetIconMode = assetsIconModeRepository.getIconMode()
suspend fun generateTempQrFile(qrCode: Bitmap): Result<Uri> = withContext(Dispatchers.IO) {
runCatching {
val file = fileProvider.generateTempFile(fixedName = QR_FILE_NAME)
file.write(qrCode)
fileProvider.uriOf(file)
}
}
}
@@ -0,0 +1,98 @@
package io.novafoundation.nova.feature_assets.domain.send
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.isCrossChain
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider
import io.novafoundation.nova.feature_wallet_api.data.repository.getXcmChain
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.transferConfiguration
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SendInteractor(
private val assetSourceRegistry: AssetSourceRegistry,
private val crossChainTransactor: CrossChainTransactor,
private val crossChainTransfersRepository: CrossChainTransfersRepository,
private val parachainInfoRepository: ParachainInfoRepository,
private val crossChainTransfersUseCase: CrossChainTransfersUseCase,
private val extrinsicService: ExtrinsicService,
private val sendUseCase: SendUseCase,
private val crossChainValidationProvider: CrossChainValidationSystemProvider
) {
suspend fun getFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): TransferFee = withContext(Dispatchers.Default) {
if (transfer.isCrossChain) {
val fees = with(crossChainTransfersUseCase) {
extrinsicService.estimateFee(transfer, cachingScope = null)
}
val originFee = OriginFee(
submissionFee = fees.submissionFee,
deliveryFee = fees.postSubmissionByAccount,
)
TransferFee(originFee, fees.postSubmissionFromAmount)
} else {
TransferFee(
originFee = getOriginFee(transfer, coroutineScope),
crossChainFee = null
)
}
}
suspend fun getOriginFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): OriginFee = withContext(Dispatchers.Default) {
OriginFee(getSubmissionFee(transfer, coroutineScope), null)
}
suspend fun getSubmissionFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): SubmissionFee = withContext(Dispatchers.Default) {
getAssetTransfers(transfer).calculateFee(transfer, coroutineScope = coroutineScope)
}
suspend fun performTransfer(
transfer: WeightedAssetTransfer,
originFee: OriginFee,
crossChainFee: FeeBase?,
coroutineScope: CoroutineScope
): Result<ExtrinsicSubmission> = withContext(Dispatchers.Default) {
if (transfer.isCrossChain) {
val config = crossChainTransfersRepository.getConfiguration().configurationFor(transfer)!!
with(extrinsicService) {
crossChainTransactor.performTransfer(config, transfer, crossChainFee!!.amount)
}
} else {
sendUseCase.performOnChainTransfer(transfer, originFee.submissionFee, coroutineScope)
}
}
fun validationSystemFor(transfer: AssetTransfer, coroutineScope: CoroutineScope) = if (transfer.isCrossChain) {
crossChainValidationProvider.createValidationSystem()
} else {
assetSourceRegistry.sourceFor(transfer.originChainAsset).transfers.getValidationSystem(coroutineScope)
}
suspend fun areTransfersEnabled(asset: Chain.Asset) = assetSourceRegistry.sourceFor(asset).transfers.areTransfersEnabled(asset)
private fun getAssetTransfers(transfer: AssetTransfer) = assetSourceRegistry.sourceFor(transfer.originChainAsset).transfers
private suspend fun CrossChainTransfersConfiguration.configurationFor(transfer: AssetTransfer) = transferConfiguration(
originChain = parachainInfoRepository.getXcmChain(transfer.originChain),
originAsset = transfer.originChainAsset,
destinationChain = parachainInfoRepository.getXcmChain(transfer.destinationChain),
)
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_assets.domain.send.model
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import io.novafoundation.nova.feature_account_api.data.model.getAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee
import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
data class TransferFee(
val originFee: OriginFee,
val crossChainFee: FeeBase?
) : MaxAvailableDeduction {
fun totalFeeByExecutingAccount(chainAsset: Chain.Asset): Balance {
val accountThatPaysFees = originFee.submissionFee.submissionOrigin.executingAccount
val submission = originFee.submissionFee.getAmount(chainAsset, accountThatPaysFees)
val delivery = originFee.deliveryFee?.getAmount(chainAsset, accountThatPaysFees).orZero()
return submission + delivery
}
fun replaceSubmission(newSubmissionFee: SubmissionFee): TransferFee {
return copy(originFee = originFee.copy(newSubmissionFee))
}
override fun maxAmountDeductionFor(amountAsset: Chain.Asset): Balance {
// Delegate submission calculation to submission fee itself
val submission = originFee.submissionFee.maxAmountDeductionFor(amountAsset)
// Delivery is always paid from executing account
val delivery = originFee.deliveryFee?.getAmount(amountAsset).orZero()
// Execution is paid from the sending amount itself, so we subtract it as well since we later add it on top of sending amount
val execution = crossChainFee?.getAmount(amountAsset).orZero()
return submission + delivery + execution
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_assets.domain.tokens
import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
interface AssetsDataCleaner {
suspend fun clearAssetsData(assetIds: List<FullChainAssetId>)
}
class RealAssetsDataCleaner(
private val externalBalanceRepository: ExternalBalanceRepository,
private val contributionsRepository: ContributionsRepository,
private val walletRepository: WalletRepository,
) : AssetsDataCleaner {
override suspend fun clearAssetsData(assetIds: List<FullChainAssetId>) {
contributionsRepository.deleteContributions(assetIds)
externalBalanceRepository.deleteExternalBalances(assetIds)
walletRepository.clearAssets(assetIds)
}
}
@@ -0,0 +1,126 @@
package io.novafoundation.nova.feature_assets.domain.tokens.add
import io.novafoundation.nova.common.address.format.EthereumAddressFormat
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.utils.asPrecision
import io.novafoundation.nova.common.utils.asTokenSymbol
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.AddEvmTokenValidationSystem
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.evmAssetNotExists
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validCoinGeckoLink
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validErc20Contract
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validTokenDecimals
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.ext.defaultComparator
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.chainAssetIdOfErc20Token
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface AddTokensInteractor {
fun availableChainsToAddTokenFlow(): Flow<List<Chain>>
suspend fun retrieveContractMetadata(
chainId: ChainId,
contractAddress: String
): Erc20ContractMetadata?
suspend fun addCustomTokenAndSync(customErc20Token: CustomErc20Token): Result<*>
fun getValidationSystem(): AddEvmTokenValidationSystem
}
class RealAddTokensInteractor(
private val chainRegistry: ChainRegistry,
private val erc20Standard: Erc20Standard,
private val chainAssetRepository: ChainAssetRepository,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
private val ethereumAddressFormat: EthereumAddressFormat,
private val currencyRepository: CurrencyRepository,
private val walletRepository: WalletRepository,
private val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
) : AddTokensInteractor {
override fun availableChainsToAddTokenFlow(): Flow<List<Chain>> {
return chainRegistry.enabledChainsFlow().map { chains ->
chains.filter { it.isEthereumBased }
.sortedWith(Chain.defaultComparator())
}
}
override suspend fun retrieveContractMetadata(
chainId: ChainId,
contractAddress: String,
): Erc20ContractMetadata? {
return runCatching {
queryErc20Contract(chainId, contractAddress) {
Erc20ContractMetadata(
decimals = executeOrNull { decimals().toInt() },
symbol = executeOrNull { symbol() }
)
}
}.getOrNull()
}
override suspend fun addCustomTokenAndSync(customErc20Token: CustomErc20Token): Result<*> = runCatching {
val priceId = coinGeckoLinkParser.parse(customErc20Token.priceLink).getOrNull()?.priceId
val asset = Chain.Asset(
icon = null,
id = chainAssetIdOfErc20Token(customErc20Token.contract),
priceId = priceId,
chainId = customErc20Token.chainId,
symbol = customErc20Token.symbol.asTokenSymbol(),
precision = customErc20Token.decimals.asPrecision(),
buyProviders = emptyMap(),
sellProviders = emptyMap(),
staking = emptyList(),
type = Chain.Asset.Type.EvmErc20(customErc20Token.contract),
source = Chain.Asset.Source.MANUAL,
name = customErc20Token.symbol,
enabled = true
)
chainAssetRepository.insertCustomAsset(asset)
syncTokenPrice(asset)
}
override fun getValidationSystem(): AddEvmTokenValidationSystem {
return ValidationSystem {
evmAssetNotExists(chainRegistry)
validErc20Contract(ethereumAddressFormat, erc20Standard, chainRegistry)
validTokenDecimals()
validCoinGeckoLink(coinGeckoLinkValidationFactory)
}
}
private suspend fun <R> queryErc20Contract(
chainId: ChainId,
contractAddress: String,
query: suspend Erc20Queries.() -> R
): R {
val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chainId)
val erc20Queries = erc20Standard.querySingle(contractAddress, ethereumApi)
return query(erc20Queries)
}
private suspend fun <R> executeOrNull(action: suspend () -> R): R? = runCatching { action() }.getOrNull()
private suspend fun syncTokenPrice(asset: Chain.Asset) {
val currency = currencyRepository.getSelectedCurrency()
walletRepository.syncAssetRates(asset, currency)
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_assets.domain.tokens.add
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
class CustomErc20Token(
val contract: String,
val decimals: Int,
val symbol: String,
val priceLink: String,
val chainId: ChainId,
)
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.domain.tokens.add
class Erc20ContractMetadata(
val decimals: Int?,
val symbol: String?,
)
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_assets.domain.tokens.add.validations
import io.novafoundation.nova.common.address.format.EthereumAddressFormat
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.feature_assets.domain.tokens.add.CustomErc20Token
import io.novafoundation.nova.feature_wallet_api.domain.validation.evmAssetNotExists
import io.novafoundation.nova.feature_wallet_api.domain.validation.validErc20Contract
import io.novafoundation.nova.feature_wallet_api.domain.validation.validTokenDecimals
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
typealias AddEvmTokenValidationSystem = ValidationSystem<AddEvmTokenPayload, AddEvmTokensValidationFailure>
typealias AddEvmTokenValidationSystemBuilder = ValidationSystemBuilder<AddEvmTokenPayload, AddEvmTokensValidationFailure>
sealed interface AddEvmTokensValidationFailure {
class InvalidTokenContractAddress(val chainName: String) : AddEvmTokensValidationFailure
class AssetExist(val alreadyExistingSymbol: String, val canModify: Boolean) : AddEvmTokensValidationFailure
object InvalidDecimals : AddEvmTokensValidationFailure
object InvalidCoinGeckoLink : AddEvmTokensValidationFailure
}
fun AddEvmTokenValidationSystemBuilder.validErc20Contract(
ethereumAddressFormat: EthereumAddressFormat,
erc20Standard: Erc20Standard,
chainRegistry: ChainRegistry,
) = validErc20Contract(
ethereumAddressFormat = ethereumAddressFormat,
erc20Standard = erc20Standard,
chainRegistry = chainRegistry,
chain = { it.chain },
address = { it.customErc20Token.contract },
error = { AddEvmTokensValidationFailure.InvalidTokenContractAddress(it.chain.name) }
)
fun AddEvmTokenValidationSystemBuilder.evmAssetNotExists(chainRegistry: ChainRegistry) = evmAssetNotExists(
chainRegistry = chainRegistry,
chain = { it.chain },
address = { it.customErc20Token.contract },
assetNotExistError = AddEvmTokensValidationFailure::AssetExist,
addressMappingError = { AddEvmTokensValidationFailure.InvalidTokenContractAddress(it.chain.name) }
)
fun AddEvmTokenValidationSystemBuilder.validTokenDecimals() = validTokenDecimals(
decimals = { it.customErc20Token.decimals },
error = { AddEvmTokensValidationFailure.InvalidDecimals }
)
fun AddEvmTokenValidationSystemBuilder.validCoinGeckoLink(
coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
) = validCoinGeckoLink(
coinGeckoLinkValidationFactory = coinGeckoLinkValidationFactory,
optional = true,
link = { it.customErc20Token.priceLink },
error = { AddEvmTokensValidationFailure.InvalidCoinGeckoLink }
)
class AddEvmTokenPayload(
val customErc20Token: CustomErc20Token,
val chain: Chain
)
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_assets.domain.tokens.add.validations
import android.text.TextUtils
import io.novafoundation.nova.common.utils.asQueryParam
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.isTrueOrError
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi
class CoinGeckoLinkValidationFactory(
private val coingeckoApi: CoingeckoApi,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
) {
fun <P, E> create(
optional: Boolean,
link: (P) -> String?,
error: (P) -> E,
): CoinGeckoLinkValidation<P, E> {
return CoinGeckoLinkValidation(
coingeckoApi,
coinGeckoLinkParser,
optional,
link,
error,
)
}
}
class CoinGeckoLinkValidation<P, E>(
private val coinGeckoApi: CoingeckoApi,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
private val optional: Boolean,
private val link: (P) -> String?,
private val error: (P) -> E,
) : Validation<P, E> {
override suspend fun validate(value: P): ValidationStatus<E> {
if (optional && TextUtils.isEmpty(link(value))) {
return valid()
}
return try {
val link = link(value)!!
val coinGeckoContent = coinGeckoLinkParser.parse(link).getOrThrow()
val priceId = coinGeckoContent.priceId
val result = coinGeckoApi.getAssetPrice(setOf(priceId).asQueryParam(), "usd", false)
result.isNotEmpty().isTrueOrError { error(value) }
} catch (e: Exception) {
validationError(error(value))
}
}
}
fun <P, E> ValidationSystemBuilder<P, E>.validCoinGeckoLink(
coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory,
optional: Boolean,
link: (P) -> String?,
error: (P) -> E,
) = validate(
coinGeckoLinkValidationFactory.create(
optional,
link,
error,
)
)
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_assets.domain.tokens.manage
import io.novafoundation.nova.common.utils.isSubsetOf
import io.novafoundation.nova.feature_assets.domain.common.searchTokens
import io.novafoundation.nova.feature_assets.domain.tokens.AssetsDataCleaner
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.runtime.ext.defaultComparator
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.normalizeSymbol
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chainsById
import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface ManageTokenInteractor {
fun multiChainTokensFlow(queryFlow: Flow<String>): Flow<List<MultiChainToken>>
fun multiChainTokenFlow(id: String): Flow<MultiChainToken>
suspend fun updateEnabledState(enabled: Boolean, assetIds: List<FullChainAssetId>)
}
class RealManageTokenInteractor(
private val chainRegistry: ChainRegistry,
private val chainAssetRepository: ChainAssetRepository,
private val assetsDataCleaner: AssetsDataCleaner,
) : ManageTokenInteractor {
private val changeTokensMutex = Mutex(false)
override fun multiChainTokensFlow(
queryFlow: Flow<String>
): Flow<List<MultiChainToken>> {
return combine(multiChainTokensFlow(), queryFlow) { tokens, query ->
tokens.searchTokens(
query = query,
chainsById = chainRegistry.chainsById(),
tokenSymbol = MultiChainToken::symbol,
relevantToChains = { multiChainToken, chainIds ->
multiChainToken.instances.any { it.chain.id in chainIds }
}
)
}
}
private fun multiChainTokensFlow() = chainRegistry.enabledChainsFlow().map { chains ->
constructMultiChainTokens(chains)
}
override fun multiChainTokenFlow(id: String): Flow<MultiChainToken> {
return multiChainTokensFlow().map { multiChainTokens ->
multiChainTokens.first { it.id == id }
}
}
override suspend fun updateEnabledState(enabled: Boolean, assetIds: List<FullChainAssetId>) = withContext(Dispatchers.IO) {
changeTokensMutex.withLock {
if (!enabled && canNotDisableAssets(assetIds)) {
return@withLock
}
chainAssetRepository.setAssetsEnabled(enabled, assetIds)
if (!enabled) {
assetsDataCleaner.clearAssetsData(assetIds)
}
}
}
private suspend fun canNotDisableAssets(assetIds: List<FullChainAssetId>): Boolean {
val enabledAssets = chainAssetRepository.getEnabledAssets()
.map { it.fullId }
return assetIds.containsAll(enabledAssets)
}
private fun constructMultiChainTokens(chains: List<Chain>): List<MultiChainToken> {
val chainComparator = Chain.defaultComparator()
val assetsWithChains = chains.sortedWith(chainComparator).flatMap { chain ->
chain.assets.map { asset -> ChainWithAsset(chain, asset) }
}
val enabledAssets = assetsWithChains.filter { it.asset.enabled }
.map { it.asset.fullId }
return assetsWithChains.groupBy { (_, asset) -> asset.normalizeSymbol() }
.map { (symbol, chainsWithAssets) ->
val (_, firstAsset) = chainsWithAssets.first()
val tokenAssets = chainsWithAssets.filter { it.asset.enabled }
.map { it.asset.fullId }
val isLastTokenEnabled = enabledAssets.isSubsetOf(tokenAssets)
val isLastAssetEnabled = isLastTokenEnabled && tokenAssets.size == 1
MultiChainToken(
id = symbol,
symbol = symbol,
icon = firstAsset.icon,
isSwitchable = !isLastTokenEnabled,
instances = chainsWithAssets.map { (chain, asset) ->
MultiChainToken.ChainTokenInstance(
chain = chain,
chainAssetId = asset.id,
isEnabled = asset.enabled,
isSwitchable = !asset.enabled || !isLastAssetEnabled
)
}
)
}
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_assets.domain.tokens.manage
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
class MultiChainToken(
val id: String,
val symbol: String,
val icon: String?,
val isSwitchable: Boolean,
val instances: List<ChainTokenInstance>
) {
class ChainTokenInstance(
val chain: Chain,
val chainAssetId: ChainAssetId,
val isEnabled: Boolean,
val isSwitchable: Boolean
)
}
fun MultiChainToken.isEnabled(): Boolean {
return instances.any { it.isEnabled }
}
fun MultiChainToken.allChainAssetIds(): List<FullChainAssetId> {
return instances.map {
FullChainAssetId(it.chain.id, it.chainAssetId)
}
}
@@ -0,0 +1,127 @@
package io.novafoundation.nova.feature_assets.presentation
import android.os.Bundle
import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload
import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel
import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload
import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload
import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
interface AssetsRouter {
fun openAssetDetails(assetPayload: AssetPayload)
fun finishTradeOperation()
fun back()
fun openFilter(payload: TransactionHistoryFilterPayload)
fun openSend(payload: SendPayload, initialRecipientAddress: String? = null, initialAmount: Double? = null)
fun openConfirmTransfer(transferDraft: TransferDraft)
fun openTransferDetail(transaction: OperationParcelizeModel.Transfer)
fun openExtrinsicDetail(extrinsic: OperationParcelizeModel.Extrinsic)
fun openRewardDetail(reward: OperationParcelizeModel.Reward)
fun openPoolRewardDetail(reward: OperationParcelizeModel.PoolReward)
fun openSwapDetail(swap: OperationParcelizeModel.Swap)
fun openSwitchWallet()
fun openSelectAddress(arguments: Bundle)
fun openSelectSingleWallet(arguments: Bundle)
fun openSelectMultipleWallets(arguments: Bundle)
fun openReceive(assetPayload: AssetPayload)
fun openAssetSearch()
fun openManageTokens()
fun openManageChainTokens(payload: ManageChainTokensPayload)
fun openAddTokenEnterInfo(payload: AddTokenEnterInfoPayload)
fun openAddTokenSelectChain()
fun openSendFlow()
fun openReceiveFlow()
fun openBuyFlow()
fun openSellFlow()
fun openBridgeFlow()
fun openBuyFlowFromSendFlow()
fun openNfts()
fun finishAddTokenFlow()
fun openWalletConnectSessions(metaId: Long)
fun openWalletConnectScan()
fun openSwapFlow()
fun openSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload)
fun returnToMainScreen()
fun finishSelectAndOpenSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload)
fun closeSendFlow()
fun openNovaCard()
fun openAwaitingCardCreation()
fun closeNovaCard()
fun openSendNetworks(payload: NetworkFlowPayload)
fun openReceiveNetworks(payload: NetworkFlowPayload)
fun openSwapNetworks(payload: NetworkSwapFlowPayload)
fun returnToMainSwapScreen()
fun openBuyNetworks(payload: NetworkFlowPayload)
fun openSellNetworks(payload: NetworkFlowPayload)
fun openGiftsNetworks(payload: NetworkFlowPayload)
fun openBuyProviders(chainId: String, chainAssetId: Int)
fun openSellProviders(chainId: String, chainAssetId: Int)
fun openTradeWebInterface(payload: TradeWebPayload)
fun finishTopUp()
fun openPendingMultisigOperations()
fun openAssetDetailsFromDeepLink(payload: AssetPayload)
fun openGifts()
fun openGiftsByAsset(assetPayload: AssetPayload)
fun openSelectGiftAmount(assetPayload: AssetPayload)
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_assets.presentation.balance.assetActions
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import android.widget.TextView
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.updatePadding
import io.novafoundation.nova.common.view.shape.getBlockDrawable
import io.novafoundation.nova.feature_assets.databinding.ViewAssetActionsBinding
class AssetActionsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val binder = ViewAssetActionsBinding.inflate(inflater(), this)
init {
orientation = HORIZONTAL
background = context.getBlockDrawable()
updatePadding(top = 4.dp(context), bottom = 4.dp(context))
}
val send: TextView
get() = binder.assetActionsSend
val receive: TextView
get() = binder.assetActionsReceive
val swap: TextView
get() = binder.assetActionsSwap
val buySell: TextView
get() = binder.assetActionsBuy
val gift: TextView
get() = binder.assetActionsGift
}
@@ -0,0 +1,78 @@
package io.novafoundation.nova.feature_assets.presentation.balance.breakdown
import android.view.ViewGroup
import io.novafoundation.nova.common.list.BaseGroupedDiffCallback
import io.novafoundation.nova.common.list.GroupedListAdapter
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_assets.databinding.ItemBalanceBreakdownAmountBinding
import io.novafoundation.nova.feature_assets.databinding.ItemBalanceBreakdownTotalBinding
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownAmount
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownTotal
import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount
class BalanceBreakdownAdapter : GroupedListAdapter<BalanceBreakdownTotal, BalanceBreakdownAmount>(DiffCallback) {
override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder {
return BalanceTotalHolder(ItemBalanceBreakdownTotalBinding.inflate(parent.inflater(), parent, false))
}
override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder {
return BalanceAmountHolder(ItemBalanceBreakdownAmountBinding.inflate(parent.inflater(), parent, false))
}
override fun bindGroup(holder: GroupedListHolder, group: BalanceBreakdownTotal) {
require(holder is BalanceTotalHolder)
holder.bind(group)
}
override fun bindChild(holder: GroupedListHolder, child: BalanceBreakdownAmount) {
require(holder is BalanceAmountHolder)
holder.bind(child)
}
}
class BalanceTotalHolder(
private val binder: ItemBalanceBreakdownTotalBinding,
) : GroupedListHolder(binder.root) {
fun bind(item: BalanceBreakdownTotal) {
binder.itemBreakdownTotalIcon.setImageResource(item.iconRes)
binder.itemBreakdownTotalName.text = item.name
binder.itemBreakdownTotalPercentage.text = item.percentage
binder.itemBreakdownTotal.text = item.fiatAmount
}
}
class BalanceAmountHolder(
private val binder: ItemBalanceBreakdownAmountBinding,
) : GroupedListHolder(binder.root) {
fun bind(item: BalanceBreakdownAmount) {
binder.balanceBreakdownItemDetail.setTitle(item.name)
binder.balanceBreakdownItemDetail.showAmount(item.amount)
}
}
private object DiffCallback : BaseGroupedDiffCallback<BalanceBreakdownTotal, BalanceBreakdownAmount>(BalanceBreakdownTotal::class.java) {
override fun areGroupItemsTheSame(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Boolean {
return oldItem.name == newItem.name
}
override fun areGroupContentsTheSame(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Boolean {
return oldItem == newItem
}
override fun areChildItemsTheSame(oldItem: BalanceBreakdownAmount, newItem: BalanceBreakdownAmount): Boolean {
return oldItem == newItem
}
override fun areChildContentsTheSame(oldItem: BalanceBreakdownAmount, newItem: BalanceBreakdownAmount): Boolean {
return true
}
override fun getGroupChangePayload(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Any? {
return true
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_assets.presentation.balance.breakdown
import android.content.Context
import android.view.LayoutInflater
import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet
import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceBreakdownBinding
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.TotalBalanceBreakdownModel
class BalanceBreakdownBottomSheet(context: Context) : BaseBottomSheet<FragmentBalanceBreakdownBinding>(context) {
override val binder: FragmentBalanceBreakdownBinding = FragmentBalanceBreakdownBinding.inflate(LayoutInflater.from(context))
private var totalBreakdown: TotalBalanceBreakdownModel? = null
private val adapter = BalanceBreakdownAdapter()
init {
binder.balanceBreakdownList.adapter = adapter
}
fun setBalanceBreakdown(totalBreakdown: TotalBalanceBreakdownModel) {
this.totalBreakdown = totalBreakdown
binder.balanceBreakdownTotal.text = totalBreakdown.totalFiat
adapter.submitList(totalBreakdown.breakdown)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model
import androidx.annotation.DrawableRes
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel
interface BalanceBreakdownItem {
val name: String
}
data class BalanceBreakdownAmount(
override val name: String,
val amount: AmountModel
) : BalanceBreakdownItem
data class BalanceBreakdownTotal(
override val name: String,
val fiatAmount: String,
@DrawableRes val iconRes: Int,
val percentage: String
) : BalanceBreakdownItem
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model
class TotalBalanceBreakdownModel(
val totalFiat: String,
val breakdown: List<BalanceBreakdownItem>
)
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import android.view.animation.AccelerateDecelerateInterpolator
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings
fun ExpandableAnimationSettings.Companion.createForAssets() = ExpandableAnimationSettings(400, AccelerateDecelerateInterpolator())
@@ -0,0 +1,98 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import io.novafoundation.nova.common.data.model.AssetViewMode
import io.novafoundation.nova.common.utils.combineToPair
import io.novafoundation.nova.common.utils.shareInBackground
import io.novafoundation.nova.common.utils.throttleLast
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor
import io.novafoundation.nova.feature_assets.domain.assets.models.byNetworks
import io.novafoundation.nova.feature_assets.domain.assets.models.byTokens
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlin.time.Duration.Companion.milliseconds
class AssetListMixinFactory(
private val walletInteractor: WalletInteractor,
private val assetsListInteractor: AssetsListInteractor,
private val externalBalancesInteractor: ExternalBalancesInteractor,
private val expandableAssetsMixinFactory: ExpandableAssetsMixinFactory
) {
fun create(coroutineScope: CoroutineScope): AssetListMixin = RealAssetListMixin(
walletInteractor,
assetsListInteractor,
externalBalancesInteractor,
expandableAssetsMixinFactory,
coroutineScope
)
}
interface AssetListMixin {
val assetsViewModeFlow: Flow<AssetViewMode>
val externalBalancesFlow: SharedFlow<List<ExternalBalance>>
val assetsFlow: Flow<List<Asset>>
val assetModelsFlow: Flow<List<BalanceListRvItem>>
fun expandToken(tokenGroupUi: TokenGroupUi)
suspend fun switchViewMode()
}
class RealAssetListMixin(
private val walletInteractor: WalletInteractor,
private val assetsListInteractor: AssetsListInteractor,
private val externalBalancesInteractor: ExternalBalancesInteractor,
private val expandableAssetsMixinFactory: ExpandableAssetsMixinFactory,
private val coroutineScope: CoroutineScope
) : AssetListMixin, CoroutineScope by coroutineScope {
override val assetsFlow = walletInteractor.assetsFlow()
.shareInBackground()
private val filteredAssetsFlow = walletInteractor.filterAssets(assetsFlow)
.shareInBackground()
override val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances()
.shareInBackground()
override val assetsViewModeFlow = assetsListInteractor.assetsViewModeFlow()
.shareInBackground()
private val throttledBalance = combineToPair(filteredAssetsFlow, externalBalancesFlow)
.throttleLast(300.milliseconds)
private val assetsByViewMode = combine(
throttledBalance,
assetsViewModeFlow
) { (assets, externalBalances), viewMode ->
when (viewMode) {
AssetViewMode.NETWORKS -> walletInteractor.groupAssetsByNetwork(assets, externalBalances).byNetworks()
AssetViewMode.TOKENS -> walletInteractor.groupAssetsByToken(assets, externalBalances).byTokens()
}
}.shareInBackground()
private val expandableAssetsMixin = expandableAssetsMixinFactory.create(assetsByViewMode)
override val assetModelsFlow = expandableAssetsMixin.assetModelsFlow
.shareInBackground()
override fun expandToken(tokenGroupUi: TokenGroupUi) {
expandableAssetsMixin.expandToken(tokenGroupUi)
}
override suspend fun switchViewMode() {
expandableAssetsMixin.switchViewMode()
}
}
@@ -0,0 +1,200 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import android.animation.ArgbEvaluator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.core.graphics.toRect
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAdapter
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableItemDecoration
import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimationItemState
import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator
import io.novafoundation.nova.common.utils.recyclerView.expandable.expandingFraction
import io.novafoundation.nova.common.utils.recyclerView.expandable.flippedFraction
import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import kotlin.math.roundToInt
class AssetTokensDecoration(
private val context: Context,
private val adapter: ExpandableAdapter,
animator: ExpandableAnimator
) : ExpandableItemDecoration(
adapter,
animator
) {
private val argbEvaluator = ArgbEvaluator()
private val childrenBlockCollapsedHorizontalMargin = 16.dp(context)
private val childrenBlockCollapsedHeight = 4.dp(context)
private val blockRadiusCollapsed = 4.dpF(context)
private val blockRadiusExpanded = 12.dpF(context)
private val blockRadiusDelta = blockRadiusExpanded - blockRadiusCollapsed
private val blockColor = context.getColor(R.color.block_background)
private val hidedBlockColor = context.getColor(R.color.hided_networks_block_background)
private val transparentColor = Color.TRANSPARENT
private val dividerColor = context.getColor(R.color.divider)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
}
private var drawingPath = Path()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val viewHolder = parent.getChildViewHolder(view)
if (viewHolder.bindingAdapterPosition == 0) return
if (viewHolder is TokenAssetGroupViewHolder) {
if (viewHolder.bindingAdapterPosition == adapter.getItems().size - 1) {
outRect.set(0, 12.dp(context), 0, 12.dp(context))
} else {
outRect.set(0, 12.dp(context), 0, 0)
}
}
}
override fun onDrawGroup(
canvas: Canvas,
animationState: ExpandableAnimationItemState,
recyclerView: RecyclerView,
parentItem: ExpandableParentItem,
parent: RecyclerView.ViewHolder?,
children: List<RecyclerView.ViewHolder>
) {
val expandingFraction = animationState.expandingFraction()
val parentBounds = parentBounds(parent)
if (parentBounds != null) {
drawParentBlock(parentBounds, canvas, expandingFraction)
}
// Don't draw children background if it's a single item
if (parentItem is TokenGroupUi && parentItem.singleItemGroup) return
val childrenBlockBounds = getChildrenBlockBounds(animationState, recyclerView, parent, children)
drawChildrenBlock(expandingFraction, childrenBlockBounds, canvas)
clipChildren(children, childrenBlockBounds)
}
private fun clipChildren(children: List<RecyclerView.ViewHolder>, childrenBlockBounds: RectF) {
val childrenBlock = childrenBlockBounds.toRect()
children.forEach {
val childrenBottomClipInset = (it.itemView.bottom + it.itemView.translationY.roundToInt()) - childrenBlock.bottom
val childrenTopClipInset = childrenBlock.top - (it.itemView.top + it.itemView.translationY.roundToInt())
if (childrenTopClipInset > 0 || childrenBottomClipInset > 0) {
it.itemView.clipBounds = Rect(
0,
childrenTopClipInset,
it.itemView.width,
it.itemView.height - childrenBottomClipInset
)
} else {
it.itemView.clipBounds = null
}
}
}
private fun drawChildrenBlock(expandingFraction: Float, childrenBlockBounds: RectF, canvas: Canvas) {
val animatedBlockRadius = blockRadiusDelta * expandingFraction
childrenBlockBounds.toPath(drawingPath, topRadius = 0f, bottomRadius = blockRadiusCollapsed + animatedBlockRadius * expandingFraction)
paint.color = argbEvaluator.evaluate(expandingFraction, hidedBlockColor, blockColor) as Int
canvas.drawPath(drawingPath, paint)
}
private fun drawParentBlock(
parentBounds: RectF,
canvas: Canvas,
expandingFraction: Float
) {
val path = Path()
val bottomRadius = blockRadiusExpanded * expandingFraction.flippedFraction()
parentBounds.toPath(path, topRadius = blockRadiusExpanded, bottomRadius = bottomRadius)
paint.color = blockColor
canvas.drawPath(path, paint)
drawParentDivider(expandingFraction, bottomRadius, canvas, parentBounds)
}
private fun drawParentDivider(
expandingFraction: Float,
dividerHorizontalMargin: Float,
canvas: Canvas,
parentBounds: RectF
) {
linePaint.color = argbEvaluator.evaluate(expandingFraction, transparentColor, dividerColor) as Int
canvas.drawLine(
parentBounds.left + dividerHorizontalMargin,
parentBounds.bottom,
parentBounds.right - dividerHorizontalMargin,
parentBounds.bottom,
linePaint
)
}
private fun parentBounds(parent: RecyclerView.ViewHolder?): RectF? {
if (parent == null) return null
return parent.itemView.let {
RectF(
it.left.toFloat(),
it.top.toFloat() + it.translationY,
it.right.toFloat(),
it.bottom.toFloat() + it.translationY
)
}
}
private fun getChildrenBlockBounds(
animationState: ExpandableAnimationItemState,
recyclerView: RecyclerView,
parent: RecyclerView.ViewHolder?,
children: List<RecyclerView.ViewHolder>
): RectF {
val lastChild = children.maxByOrNull { it.itemView.bottom }
val parentTranslationY = parent?.itemView?.translationY ?: 0f
val childTranslationY = lastChild?.itemView?.translationY ?: 0f
val top = (parent?.itemView?.bottom ?: 0) + parentTranslationY
val bottom = (lastChild?.itemView?.bottom?.toFloat() ?: top).coerceAtLeast(top)
val left = parent?.itemView?.left ?: lastChild?.itemView?.left ?: recyclerView.left
val right = parent?.itemView?.right ?: lastChild?.itemView?.right ?: recyclerView.right
val expandingFraction = animationState.expandingFraction()
val flippedExpandingFraction = expandingFraction.flippedFraction()
val heightDelta = (bottom - top)
return RectF(
left + childrenBlockCollapsedHorizontalMargin * flippedExpandingFraction,
top,
right - childrenBlockCollapsedHorizontalMargin * flippedExpandingFraction,
top + childrenBlockCollapsedHeight + heightDelta * expandingFraction + childTranslationY
)
}
private fun RectF.toPath(path: Path, topRadius: Float, bottomRadius: Float) {
path.reset()
path.addRoundRect(
this,
floatArrayOf(topRadius, topRadius, topRadius, topRadius, bottomRadius, bottomRadius, bottomRadius, bottomRadius),
Path.Direction.CW
)
}
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import android.view.ViewPropertyAnimator
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableItemAnimator
import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator
private const val REMOVE_SCALE = 0.9f
class AssetTokensItemAnimator(
settings: ExpandableAnimationSettings,
expandableAnimator: ExpandableAnimator
) : ExpandableItemAnimator(
settings,
expandableAnimator
) {
override fun preAddImpl(holder: RecyclerView.ViewHolder) {
holder.itemView.alpha = 0f
holder.itemView.scaleX = REMOVE_SCALE
holder.itemView.scaleY = REMOVE_SCALE
}
override fun getAddAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
return holder.itemView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
}
override fun preRemoveImpl(holder: RecyclerView.ViewHolder) {
resetAddState(holder)
}
override fun getRemoveAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
return holder.itemView.animate()
.alpha(0f)
.scaleX(REMOVE_SCALE)
.scaleY(REMOVE_SCALE)
}
override fun preMoveImpl(holder: RecyclerView.ViewHolder, fromY: Int, toY: Int) {
val yDelta = toY - fromY
holder.itemView.translationY += -yDelta
}
override fun getMoveAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator {
return holder.itemView.animate()
.translationY(0f)
}
override fun endAnimation(viewHolder: RecyclerView.ViewHolder) {
super.endAnimation(viewHolder)
viewHolder.itemView.translationY = 0f
viewHolder.itemView.alpha = 1f
viewHolder.itemView.scaleX = 1f
viewHolder.itemView.scaleY = 1f
}
override fun resetAddState(holder: RecyclerView.ViewHolder) {
holder.itemView.alpha = 1f
holder.itemView.scaleX = 1f
holder.itemView.scaleY = 1f
}
override fun resetRemoveState(holder: RecyclerView.ViewHolder) {
holder.itemView.alpha = 1f
holder.itemView.scaleX = 1f
holder.itemView.scaleY = 1f
}
override fun resetMoveState(holder: RecyclerView.ViewHolder) {
holder.itemView.translationY = 0f
}
}
@@ -0,0 +1,171 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.ImageLoader
import io.novafoundation.nova.common.list.PayloadGenerator
import io.novafoundation.nova.common.list.resolvePayload
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAdapter
import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem
import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetBinding
import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetGroupBinding
import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetBinding
import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetGroupBinding
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetGroupViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
private val priceRateExtractor = { asset: AssetModel -> asset.token.rate }
private val recentChangeExtractor = { asset: AssetModel -> asset.token.recentRateChange }
private val amountExtractor = { asset: AssetModel -> asset.amount }
private val tokenGroupPriceRateExtractor = { group: TokenGroupUi -> group.rate }
private val tokenGroupRecentChangeExtractor = { group: TokenGroupUi -> group.recentRateChange }
private val tokenGroupAmountExtractor = { group: TokenGroupUi -> group.balance }
private val tokenGroupTypeExtractor = { group: TokenGroupUi -> group.groupType }
const val TYPE_NETWORK_GROUP = 0
const val TYPE_NETWORK_ASSET = 1
const val TYPE_TOKEN_GROUP = 2
const val TYPE_TOKEN_ASSET = 3
class BalanceListAdapter(
private val imageLoader: ImageLoader,
private val itemHandler: ItemAssetHandler,
) : ListAdapter<BalanceListRvItem, ViewHolder>(DiffCallback), ExpandableAdapter {
interface ItemAssetHandler {
fun assetClicked(asset: Chain.Asset)
fun tokenGroupClicked(tokenGroup: TokenGroupUi)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
TYPE_NETWORK_GROUP -> NetworkAssetGroupViewHolder(ItemNetworkAssetGroupBinding.inflate(parent.inflater(), parent, false))
TYPE_NETWORK_ASSET -> NetworkAssetViewHolder(ItemNetworkAssetBinding.inflate(parent.inflater(), parent, false), imageLoader)
TYPE_TOKEN_GROUP -> TokenAssetGroupViewHolder(ItemTokenAssetGroupBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler)
TYPE_TOKEN_ASSET -> TokenAssetViewHolder(ItemTokenAssetBinding.inflate(parent.inflater(), parent, false), imageLoader)
else -> error("Unknown view type")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
return when (holder) {
is NetworkAssetGroupViewHolder -> holder.bind(getItem(position) as NetworkGroupUi)
is NetworkAssetViewHolder -> holder.bind(getItem(position) as NetworkAssetUi, itemHandler)
is TokenAssetGroupViewHolder -> holder.bind(getItem(position) as TokenGroupUi)
is TokenAssetViewHolder -> holder.bind(getItem(position) as TokenAssetUi, itemHandler)
else -> error("Unknown holder")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
when (holder) {
is NetworkAssetViewHolder -> {
val item = getItem(position) as NetworkAssetUi
resolvePayload(holder, position, payloads) {
when (it) {
priceRateExtractor -> holder.bindPriceInfo(item.asset)
recentChangeExtractor -> holder.bindRecentChange(item.asset)
amountExtractor -> holder.bindTotal(item.asset)
}
}
}
is TokenAssetViewHolder -> {
val item = getItem(position) as TokenAssetUi
holder.updateExpandableItem(item)
resolvePayload(holder, position, payloads) {
when (it) {
amountExtractor -> holder.bindTotal(item.asset)
}
}
}
is TokenAssetGroupViewHolder -> {
val item = getItem(position) as TokenGroupUi
holder.updateExpandableItem(item)
resolvePayload(holder, position, payloads) {
when (it) {
tokenGroupPriceRateExtractor -> holder.bindPriceRate(item)
tokenGroupRecentChangeExtractor -> holder.bindRecentChange(item)
tokenGroupAmountExtractor -> holder.bindTotal(item)
tokenGroupTypeExtractor -> holder.bindGroupType(item)
}
}
}
else -> super.onBindViewHolder(holder, position, payloads)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is NetworkGroupUi -> TYPE_NETWORK_GROUP
is NetworkAssetUi -> TYPE_NETWORK_ASSET
is TokenGroupUi -> TYPE_TOKEN_GROUP
is TokenAssetUi -> TYPE_TOKEN_ASSET
else -> error("Unknown item type")
}
}
override fun getItems(): List<ExpandableBaseItem> {
return currentList
}
}
private object DiffCallback : DiffUtil.ItemCallback<BalanceListRvItem>() {
override fun areItemsTheSame(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Boolean {
return oldItem.itemId == newItem.itemId
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Any? {
return when {
oldItem is NetworkAssetUi && newItem is NetworkAssetUi -> NetworkAssetPayloadGenerator.diff(oldItem.asset, newItem.asset)
oldItem is TokenAssetUi && newItem is TokenAssetUi -> TokenAssetPayloadGenerator.diff(oldItem.asset, newItem.asset)
oldItem is TokenGroupUi && newItem is TokenGroupUi -> TokenGroupAssetPayloadGenerator.diff(oldItem, newItem)
else -> null
}
}
}
private object NetworkAssetPayloadGenerator : PayloadGenerator<AssetModel>(
priceRateExtractor,
recentChangeExtractor,
amountExtractor
)
private object TokenAssetPayloadGenerator : PayloadGenerator<AssetModel>(
amountExtractor
)
private object TokenGroupAssetPayloadGenerator : PayloadGenerator<TokenGroupUi>(
tokenGroupPriceRateExtractor,
tokenGroupRecentChangeExtractor,
tokenGroupAmountExtractor,
tokenGroupTypeExtractor
)
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class ControllableAssetCheckMixin(
private val missingKeysPresenter: WatchOnlyMissingKeysPresenter,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val resourceManager: ResourceManager
) {
val acknowledgeLedgerWarning = actionAwaitableMixinFactory.confirmingAction<String>()
suspend fun check(metaAccount: MetaAccount, chainAsset: Chain.Asset, action: () -> Unit) {
when {
metaAccount.type == LightMetaAccount.Type.LEDGER_LEGACY && chainAsset.type is Chain.Asset.Type.Orml -> showLedgerAssetNotSupportedWarning(
chainAsset
)
metaAccount.type == LightMetaAccount.Type.WATCH_ONLY -> missingKeysPresenter.presentNoKeysFound()
else -> action()
}
}
private suspend fun showLedgerAssetNotSupportedWarning(chainAsset: Chain.Asset) {
val assetSymbol = chainAsset.symbol
val warningMessage = resourceManager.getString(R.string.assets_receive_ledger_not_supported_message, assetSymbol, assetSymbol)
acknowledgeLedgerWarning.awaitAction(warningMessage)
}
}
@@ -0,0 +1,131 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common
import io.novafoundation.nova.common.data.model.switch
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.utils.combineToTuple4
import io.novafoundation.nova.common.utils.toggle
import io.novafoundation.nova.common.utils.updateValue
import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatterFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatterFactory
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
class ExpandableAssetsMixinFactory(
private val assetIconProvider: AssetIconProvider,
private val currencyInteractor: CurrencyInteractor,
private val assetsViewModeRepository: AssetsViewModeRepository,
private val amountFormatterProvider: MaskableValueFormatterProvider,
private val networkAssetFormatterFactory: NetworkAssetFormatterFactory,
private val tokenAssetFormatterFactory: TokenAssetFormatterFactory,
) {
fun create(assetsFlow: Flow<AssetsByViewModeResult>): ExpandableAssetsMixin {
return RealExpandableAssetsMixin(
assetsFlow,
currencyInteractor,
amountFormatterProvider,
networkAssetFormatterFactory,
tokenAssetFormatterFactory,
assetIconProvider,
assetsViewModeRepository
)
}
}
interface ExpandableAssetsMixin {
val assetModelsFlow: Flow<List<BalanceListRvItem>>
fun expandToken(tokenGroupUi: TokenGroupUi)
suspend fun switchViewMode()
}
class RealExpandableAssetsMixin(
assetsFlow: Flow<AssetsByViewModeResult>,
currencyInteractor: CurrencyInteractor,
amountFormatterProvider: MaskableValueFormatterProvider,
networkAssetFormatterFactory: NetworkAssetFormatterFactory,
tokenAssetFormatterFactory: TokenAssetFormatterFactory,
private val assetIconProvider: AssetIconProvider,
private val assetsViewModeRepository: AssetsViewModeRepository
) : ExpandableAssetsMixin {
private val assetsFormatters = amountFormatterProvider.provideFormatter()
.map {
AssetMappers(
networkAssetFormatterFactory.create(it),
tokenAssetFormatterFactory.create(it)
)
}
private val selectedCurrency = currencyInteractor.observeSelectCurrency()
private val expandedTokenIdsFlow = MutableStateFlow(setOf<String>())
override val assetModelsFlow: Flow<List<BalanceListRvItem>> = combineToTuple4(
assetsFlow,
expandedTokenIdsFlow,
selectedCurrency,
assetsFormatters
).mapLatest { (assetsByViewMode, expandedTokens, currency, assetMappers) ->
when (assetsByViewMode) {
is AssetsByViewModeResult.ByNetworks -> assetMappers.networkAssetMapper.mapGroupedAssetsToUi(
groupedAssets = assetsByViewMode.assets,
assetIconProvider = assetIconProvider,
currency = currency
)
is AssetsByViewModeResult.ByTokens -> assetMappers.tokenAssetFormatter.mapGroupedAssetsToUi(
groupedTokens = assetsByViewMode.tokens,
assetIconProvider = assetIconProvider,
assetFilter = { groupId, assetsInGroup -> filterTokens(groupId, assetsInGroup, expandedTokens) }
)
}
}
.distinctUntilChanged()
override fun expandToken(tokenGroupUi: TokenGroupUi) {
expandedTokenIdsFlow.updateValue { it.toggle(tokenGroupUi.itemId) }
}
override suspend fun switchViewMode() {
expandedTokenIdsFlow.value = emptySet()
val assetViewMode = assetsViewModeRepository.getAssetViewMode()
assetsViewModeRepository.setAssetsViewMode(assetViewMode.switch())
}
private fun filterTokens(groupId: String, assets: List<TokenAssetUi>, expandedGroups: Set<String>): List<TokenAssetUi> {
if (groupId in expandedGroups) {
return filterIfSingleItem(assets)
}
return emptyList()
}
private fun filterIfSingleItem(assets: List<TokenAssetUi>): List<TokenAssetUi> {
return if (assets.size <= 1) {
emptyList()
} else {
assets
}
}
}
private class AssetMappers(
val networkAssetMapper: NetworkAssetFormatter,
val tokenAssetFormatter: TokenAssetFormatter
)
@@ -0,0 +1,141 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.common.view.shape.addRipple
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_assets.R
import kotlin.math.roundToInt
/**
* Note - clients are required to call [RecyclerView.invalidateItemDecorations] in [ListAdapter.submitList] callback due to issues with DiffUtil.
* The issue is that this decoration does not currently support partial list updates and assumes it will be iterated over whole list
* TODO update decoration to not require this invalidation
*/
class AssetBaseDecoration(
private val background: Drawable,
private val assetsAdapter: ListAdapter<*, *>,
context: Context,
private val preferences: AssetDecorationPreferences
) : RecyclerView.ItemDecoration() {
companion object;
private val bounds = Rect()
// used to hide rounded corners for the last group to simulate effect of not-closed group
private val finalGroupExtraPadding = 20.dp(context)
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (assetsAdapter.itemCount == 0) return
var groupTop: Int? = null
parent.children.forEachIndexed { index, view ->
val viewHolder = parent.getChildViewHolder(view)
if (shouldSkip(viewHolder)) return@forEachIndexed
val bindingPosition = viewHolder.bindingAdapterPosition
val nextType = assetsAdapter.getItemViewTypeOrNull(bindingPosition + 1)
if (groupTop == null) {
parent.getDecoratedBoundsWithMargins(view, bounds)
groupTop = bounds.top + view.translationY.roundToInt()
}
when {
// if group is finished
isFinalItemInGroup(nextType) -> {
parent.getDecoratedBoundsWithMargins(view, bounds)
bounds.set(view.left, bounds.top, view.right, bounds.bottom)
val groupBottom = bounds.bottom + view.translationY.roundToInt() - preferences.outerGroupPadding(viewHolder)
background.setBounds(bounds.left, groupTop!!, bounds.right, groupBottom)
background.draw(c)
if (index + 1 < parent.childCount) {
val nextView = parent.getChildAt(index + 1)
parent.getDecoratedBoundsWithMargins(nextView, bounds)
groupTop = bounds.top + view.translationY.roundToInt()
}
}
// draw last group
index == parent.childCount - 1 -> {
parent.getDecoratedBoundsWithMargins(view, bounds)
bounds.set(view.left, bounds.top, view.right, bounds.bottom)
val groupBottom = bounds.bottom + view.translationY.roundToInt() + finalGroupExtraPadding
background.setBounds(bounds.left, groupTop!!, bounds.right, groupBottom)
background.draw(c)
}
}
}
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val viewHolder = parent.getChildViewHolder(view)
if (shouldSkip(viewHolder)) {
outRect.set(0, 0, 0, 0)
return
}
val adapterPosition = viewHolder.bindingAdapterPosition
val nextType = assetsAdapter.getItemViewTypeOrNull(adapterPosition + 1)
val bottom = if (isFinalItemInGroup(nextType)) {
preferences.outerGroupPadding(viewHolder) + preferences.innerGroupPadding(viewHolder)
} else {
0
}
outRect.set(0, 0, 0, bottom)
}
private fun RecyclerView.Adapter<*>.getItemViewTypeOrNull(position: Int): Int? {
if (position < 0 || position >= itemCount) return null
return getItemViewType(position)
}
private fun isFinalItemInGroup(nextType: Int?): Boolean {
return nextType == null || preferences.isGroupItem(nextType)
}
private fun shouldSkip(viewHolder: RecyclerView.ViewHolder): Boolean {
val noPosition = viewHolder.bindingAdapterPosition == RecyclerView.NO_POSITION
val unsupportedViewHolder = !preferences.shouldUseViewHolder(viewHolder)
return noPosition || unsupportedViewHolder
}
}
fun AssetBaseDecoration.Companion.applyDefaultTo(
recyclerView: RecyclerView,
adapter: ListAdapter<*, *>,
preferences: AssetDecorationPreferences = NetworkAssetDecorationPreferences()
) {
val groupBackground = with(recyclerView.context) {
addRipple(getRoundedCornerDrawable(R.color.block_background))
}
val decoration = AssetBaseDecoration(
background = groupBackground,
assetsAdapter = adapter,
context = recyclerView.context,
preferences = preferences
)
recyclerView.addItemDecoration(decoration)
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.feature_assets.presentation.balance.common.TYPE_NETWORK_GROUP
import io.novafoundation.nova.feature_assets.presentation.balance.common.TYPE_TOKEN_GROUP
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetGroupViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetViewHolder
import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder
interface AssetDecorationPreferences {
fun innerGroupPadding(viewHolder: ViewHolder): Int
fun outerGroupPadding(viewHolder: ViewHolder): Int
fun isGroupItem(viewType: Int): Boolean
fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean
}
class NetworkAssetDecorationPreferences : AssetDecorationPreferences {
override fun innerGroupPadding(viewHolder: ViewHolder): Int {
return 8.dp(viewHolder.itemView.context)
}
override fun outerGroupPadding(viewHolder: ViewHolder): Int {
return 8.dp(viewHolder.itemView.context)
}
override fun isGroupItem(viewType: Int): Boolean {
return viewType == TYPE_NETWORK_GROUP
}
override fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean {
return viewHolder is NetworkAssetViewHolder ||
viewHolder is NetworkAssetGroupViewHolder
}
}
class TokenAssetGroupDecorationPreferences : AssetDecorationPreferences {
override fun innerGroupPadding(viewHolder: ViewHolder): Int {
return 0
}
override fun outerGroupPadding(viewHolder: ViewHolder): Int {
return 8.dp(viewHolder.itemView.context)
}
override fun isGroupItem(viewType: Int): Boolean {
return viewType == TYPE_TOKEN_GROUP
}
override fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean {
return viewHolder is TokenAssetGroupViewHolder
}
}
class CompoundAssetDecorationPreferences(private vararg val preferences: AssetDecorationPreferences) : AssetDecorationPreferences {
override fun innerGroupPadding(viewHolder: ViewHolder): Int {
val firstPreferences = preferences.firstOrNull { it.shouldUseViewHolder(viewHolder) }
return firstPreferences?.innerGroupPadding(viewHolder) ?: 0
}
override fun outerGroupPadding(viewHolder: ViewHolder): Int {
val firstPreferences = preferences.firstOrNull { it.shouldUseViewHolder(viewHolder) }
return firstPreferences?.outerGroupPadding(viewHolder) ?: 0
}
override fun isGroupItem(viewType: Int): Boolean {
return preferences.any { it.isGroupItem(viewType) }
}
override fun shouldUseViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean {
return preferences.any { it.shouldUseViewHolder(viewHolder) }
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell
import io.novafoundation.nova.common.mixin.restrictions.RestrictionCheckMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.primary
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.isMultisig
import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1
import io.novafoundation.nova.feature_assets.R
class BuySellRestrictionCheckMixin(
private val accountUseCase: SelectedAccountUseCase,
private val resourceManager: ResourceManager,
private val actionLauncher: ActionBottomSheetLauncher,
) : RestrictionCheckMixin {
override suspend fun isRestricted(): Boolean {
val selectedAccount = accountUseCase.getSelectedMetaAccount()
return selectedAccount.isMultisig() && !selectedAccount.isThreshold1()
}
override suspend fun checkRestrictionAndDo(action: () -> Unit) {
when {
isRestricted() -> showMultisigWarning()
else -> action()
}
}
private fun showMultisigWarning() {
actionLauncher.launchBottomSheet(
imageRes = R.drawable.ic_multisig,
title = resourceManager.getString(R.string.multisig_sell_not_supported_title),
subtitle = resourceManager.getString(R.string.multisig_sell_not_supported_message),
actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)),
neutralButtonPreferences = null
)
}
}
@@ -0,0 +1,141 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.mixin.restrictions.isAllowed
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin.SelectorType
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
interface BuySellSelectorMixin {
sealed interface SelectorType {
object AllAssets : SelectorType
class Asset(val chaiId: String, val assetId: Int) : SelectorType
}
class SelectorPayload(vararg val items: ListSelectorMixin.Item)
val tradingEnabledFlow: Flow<Boolean>
val actionLiveData: LiveData<Event<SelectorPayload>>
val errorLiveData: MutableLiveData<Event<Pair<String, String>>>
fun openSelector()
}
class RealBuySellSelectorMixin(
private val buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin,
private val router: AssetsRouter,
private val tradeTokenRegistry: TradeTokenRegistry,
private val chainRegistry: ChainRegistry,
private val resourceManager: ResourceManager,
private val selectorType: SelectorType,
private val coroutineScope: CoroutineScope
) : BuySellSelectorMixin {
override val tradingEnabledFlow: Flow<Boolean> = flowOf {
when (selectorType) {
SelectorType.AllAssets -> true
is SelectorType.Asset -> {
val chainAsset = chainRegistry.asset(selectorType.chaiId, selectorType.assetId)
tradeTokenRegistry.hasProvider(chainAsset)
}
}
}
override val actionLiveData: MutableLiveData<Event<BuySellSelectorMixin.SelectorPayload>> = MutableLiveData()
override val errorLiveData: MutableLiveData<Event<Pair<String, String>>> = MutableLiveData()
override fun openSelector() = coroutineScope.launchUnit {
val payload = when (selectorType) {
SelectorType.AllAssets -> openAllAssetsSelector()
is SelectorType.Asset -> openSpecifiedAssetSelector(selectorType)
}
if (payload != null) {
actionLiveData.value = Event(payload)
}
}
private suspend fun openAllAssetsSelector() = BuySellSelectorMixin.SelectorPayload(
buyItem(enabled = true) { router.openBuyFlow() },
sellItem(enabled = buySellRestrictionCheckMixin.isAllowed()) { router.openSellFlow() },
bridgeItem(enabled = true) { router.openBridgeFlow() }
)
private suspend fun openSpecifiedAssetSelector(selectorType: SelectorType.Asset): BuySellSelectorMixin.SelectorPayload? {
val chainAsset = chainRegistry.asset(selectorType.chaiId, selectorType.assetId)
val buyAvailable = tradeTokenRegistry.hasProvider(chainAsset, TradeTokenRegistry.TradeType.BUY)
val sellAvailable = tradeTokenRegistry.hasProvider(chainAsset, TradeTokenRegistry.TradeType.SELL) &&
buySellRestrictionCheckMixin.isAllowed()
if (!buyAvailable && !sellAvailable) {
showErrorMessage(R.string.trade_token_not_supported_title, R.string.trade_token_not_supported_message)
return null
}
return BuySellSelectorMixin.SelectorPayload(
buyItem(enabled = buyAvailable) { router.openBuyProviders(selectorType.chaiId, selectorType.assetId) },
sellItem(enabled = sellAvailable) { router.openSellProviders(selectorType.chaiId, selectorType.assetId) }
)
}
private fun buyItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_add_circle_outline,
if (enabled) R.color.icon_primary else R.color.icon_inactive,
R.string.wallet_asset_buy_tokens,
if (enabled) R.color.text_primary else R.color.button_text_inactive,
if (enabled) action else errorAction(R.string.buy_token_not_supported_title, R.string.buy_token_not_supported_message)
)
}
private fun sellItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_sell_tokens,
if (enabled) R.color.icon_primary else R.color.icon_inactive,
R.string.wallet_asset_sell_tokens,
if (enabled) R.color.text_primary else R.color.button_text_inactive,
if (enabled) action else sellErrorAction()
)
}
private fun bridgeItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_bridge,
if (enabled) R.color.icon_primary else R.color.icon_inactive,
R.string.wallet_asset_bridge,
if (enabled) R.color.text_primary else R.color.button_text_inactive,
action
)
}
private fun sellErrorAction(): () -> Unit = {
coroutineScope.launch {
buySellRestrictionCheckMixin.checkRestrictionAndDo {
showErrorMessage(R.string.sell_token_not_supported_title, R.string.sell_token_not_supported_message)
}
}
}
private fun errorAction(titleRes: Int, messageRes: Int): () -> Unit = { showErrorMessage(titleRes, messageRes) }
private fun showErrorMessage(titleRes: Int, messageRes: Int) {
errorLiveData.value = Event(Pair(resourceManager.getString(titleRes), resourceManager.getString(messageRes)))
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import kotlinx.coroutines.CoroutineScope
class BuySellSelectorMixinFactory(
private val router: AssetsRouter,
private val tradeTokenRegistry: TradeTokenRegistry,
private val chainRegistry: ChainRegistry,
private val resourceManager: ResourceManager,
private val buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin
) {
fun create(selectorType: BuySellSelectorMixin.SelectorType, coroutineScope: CoroutineScope): BuySellSelectorMixin {
return RealBuySellSelectorMixin(
buySellRestrictionCheckMixin,
router,
tradeTokenRegistry,
chainRegistry,
resourceManager,
selectorType,
coroutineScope
)
}
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell
import android.annotation.SuppressLint
import android.widget.TextView
import io.novafoundation.nova.common.R
import io.novafoundation.nova.common.base.BaseFragmentMixin
import io.novafoundation.nova.common.utils.ViewClickGestureDetector
import io.novafoundation.nova.common.utils.setCompoundDrawableTint
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.dialog.dialog
import io.novafoundation.nova.common.view.input.selector.DynamicSelectorBottomSheet
fun BaseFragmentMixin<*>.setupBuySellSelectorMixin(
buySellSelectorMixin: BuySellSelectorMixin
) {
buySellSelectorMixin.actionLiveData.observeEvent { action ->
DynamicSelectorBottomSheet(
context = fragment.requireContext(),
payload = DynamicSelectorBottomSheet.Payload(
titleRes = null,
subtitle = null,
data = action.items.toList()
),
onClicked = { _, item -> item.onClick() },
).show()
}
buySellSelectorMixin.errorLiveData.observeEvent {
dialog(providedContext) {
setTitle(it.first)
setMessage(it.second)
setPositiveButton(R.string.common_got_it) { _, _ -> }
}
}
}
@SuppressLint("ClickableViewAccessibility")
fun BaseFragmentMixin<*>.setupButSellActionButton(
buySellSelectorMixin: BuySellSelectorMixin,
actionButton: TextView
) {
val clickDetector = ViewClickGestureDetector(actionButton)
actionButton.setOnTouchListener { v, event ->
clickDetector.onTouchEvent(event)
}
actionButton.setOnClickListener { buySellSelectorMixin.openSelector() }
buySellSelectorMixin.tradingEnabledFlow.observe {
if (it) {
actionButton.setTextColorRes(R.color.actions_color)
actionButton.setCompoundDrawableTint(actionButton.context.getColor(R.color.actions_color))
} else {
actionButton.setTextColorRes(R.color.icon_inactive)
actionButton.setCompoundDrawableTint(actionButton.context.getColor(R.color.icon_inactive))
}
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.gifts
import io.novafoundation.nova.common.mixin.restrictions.RestrictionCheckMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.primary
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsSupportedState
class GiftsRestrictionCheckMixin(
private val accountSupportedUseCase: GiftsAccountSupportedUseCase,
private val resourceManager: ResourceManager,
private val actionLauncher: ActionBottomSheetLauncher,
) : RestrictionCheckMixin {
override suspend fun isRestricted(): Boolean {
return accountSupportedUseCase.supportedState() != GiftsSupportedState.SUPPORTED
}
override suspend fun checkRestrictionAndDo(action: () -> Unit) {
when {
isRestricted() -> showMultisigWarning()
else -> action()
}
}
private fun showMultisigWarning() {
actionLauncher.launchBottomSheet(
imageRes = R.drawable.ic_multisig,
title = resourceManager.getString(R.string.multisig_gifts_not_supported_title),
subtitle = resourceManager.getString(R.string.multisig_gifts_not_supported_message),
actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)),
neutralButtonPreferences = null
)
}
}
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.holders
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.presentation.masking.setMaskableText
import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetGroupBinding
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi
class NetworkAssetGroupViewHolder(
private val binder: ItemNetworkAssetGroupBinding,
) : GroupedListHolder(binder.root) {
fun bind(assetGroup: NetworkGroupUi) = with(binder) {
itemAssetGroupChain.setChain(assetGroup.chainUi)
itemAssetGroupBalance.setMaskableText(assetGroup.groupBalanceFiat)
}
}
@@ -0,0 +1,48 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.holders
import coil.ImageLoader
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.presentation.masking.setMaskableText
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetBinding
import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken
class NetworkAssetViewHolder(
private val binder: ItemNetworkAssetBinding,
private val imageLoader: ImageLoader,
) : GroupedListHolder(binder.root) {
fun bind(networkAsset: NetworkAssetUi, itemHandler: BalanceListAdapter.ItemAssetHandler) = with(containerView) {
val asset = networkAsset.asset
binder.itemAssetImage.setTokenIcon(networkAsset.icon, imageLoader)
bindPriceInfo(asset)
bindRecentChange(asset)
bindTotal(asset)
binder.itemAssetToken.text = asset.token.configuration.symbol.value
setOnClickListener { itemHandler.assetClicked(asset.token.configuration) }
}
fun bindTotal(asset: AssetModel) {
binder.itemAssetBalance.setMaskableText(asset.amount.maskableToken())
binder.itemAssetPriceAmount.setMaskableText(asset.amount.maskableFiat())
}
fun bindRecentChange(asset: AssetModel) = with(containerView) {
binder.itemAssetRateChange.setTextColorRes(asset.token.rateChangeColorRes)
binder.itemAssetRateChange.text = asset.token.recentRateChange
}
fun bindPriceInfo(asset: AssetModel) = with(containerView) {
binder.itemAssetRate.text = asset.token.rate
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.holders
import coil.ImageLoader
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.presentation.masking.setMaskableText
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableParentViewHolder
import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetGroupBinding
import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken
class TokenAssetGroupViewHolder(
private val binder: ItemTokenAssetGroupBinding,
private val imageLoader: ImageLoader,
private val itemHandler: BalanceListAdapter.ItemAssetHandler,
) : GroupedListHolder(binder.root), ExpandableParentViewHolder {
override var expandableItem: ExpandableParentItem? = null
fun bind(tokenGroup: TokenGroupUi) = with(binder) {
updateExpandableItem(tokenGroup)
itemTokenGroupAssetImage.setTokenIcon(tokenGroup.tokenIcon, imageLoader)
bindPriceRateInternal(tokenGroup)
bindRecentChangeInternal(tokenGroup)
bindTotalInternal(tokenGroup)
updateListener(tokenGroup)
itemAssetTokenGroupToken.text = tokenGroup.tokenSymbol
}
fun bindTotal(networkAsset: TokenGroupUi) {
updateListener(networkAsset)
bindTotalInternal(networkAsset)
}
fun bindRecentChange(networkAsset: TokenGroupUi) {
updateListener(networkAsset)
bindRecentChangeInternal(networkAsset)
}
fun bindPriceRate(networkAsset: TokenGroupUi) {
updateListener(networkAsset)
bindPriceRateInternal(networkAsset)
}
fun bindGroupType(networkAsset: TokenGroupUi) {
updateListener(networkAsset)
}
private fun bindTotalInternal(networkAsset: TokenGroupUi) {
val balance = networkAsset.balance
binder.itemAssetTokenGroupBalance.setMaskableText(balance.maskableToken())
binder.itemAssetTokenGroupPriceAmount.setMaskableText(balance.maskableFiat())
}
private fun bindRecentChangeInternal(networkAsset: TokenGroupUi) {
with(binder) {
itemAssetTokenGroupRateChange.setTextColorRes(networkAsset.rateChangeColorRes)
itemAssetTokenGroupRateChange.text = networkAsset.recentRateChange
}
}
private fun bindPriceRateInternal(networkAsset: TokenGroupUi) {
with(binder) {
itemAssetTokenGroupRate.text = networkAsset.rate
}
}
private fun updateListener(tokenGroupUi: TokenGroupUi) {
containerView.setOnClickListener { itemHandler.tokenGroupClicked(tokenGroupUi) }
}
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.holders
import coil.ImageLoader
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.presentation.masking.setMaskableText
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableChildViewHolder
import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableChildItem
import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetBinding
import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat
import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken
class TokenAssetViewHolder(
private val binder: ItemTokenAssetBinding,
private val imageLoader: ImageLoader,
) : GroupedListHolder(binder.root), ExpandableChildViewHolder {
override var expandableItem: ExpandableChildItem? = null
fun bind(tokenAsset: TokenAssetUi, itemHandler: BalanceListAdapter.ItemAssetHandler) = with(containerView) {
updateExpandableItem(tokenAsset)
val asset = tokenAsset.asset
binder.itemTokenAssetImage.setTokenIcon(tokenAsset.assetIcon, imageLoader)
binder.itemTokenAssetChainIcon.loadChainIcon(tokenAsset.chain.icon, imageLoader)
binder.itemTokenAssetChainName.text = tokenAsset.chain.name
bindTotal(asset)
binder.itemTokenAssetToken.text = asset.token.configuration.symbol.value
setOnClickListener { itemHandler.assetClicked(asset.token.configuration) }
}
fun bindTotal(asset: AssetModel) {
binder.itemTokenAssetBalance.setMaskableText(asset.amount.maskableToken())
binder.itemTokenAssetPriceAmount.setMaskableText(asset.amount.maskableFiat())
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter
import io.novafoundation.nova.feature_assets.domain.common.AssetBalance
import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
abstract class CommonAssetFormatter(
private val maskableValueFormatter: MaskableValueFormatter,
private val amountFormatter: AmountFormatter
) {
protected fun mapAssetToAssetModel(
asset: Asset,
balance: AssetBalance.Amount
): AssetModel {
return AssetModel(
token = mapTokenToTokenModel(asset.token),
amount = maskableValueFormatter.format {
amountFormatter.formatAmountToAmountModel(
amount = balance.amount,
token = asset.token,
config = AmountConfig(
includeAssetTicker = false,
tokenFractionPartStyling = FractionPartStyling.Styled(R.dimen.asset_balance_fraction_size)
)
)
}
)
}
}
@@ -0,0 +1,71 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers
import io.novafoundation.nova.common.list.GroupedList
import io.novafoundation.nova.common.list.toListWithHeaders
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance
import io.novafoundation.nova.feature_assets.domain.common.AssetBalance
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter
import java.math.BigDecimal
class NetworkAssetFormatterFactory(
private val fiatFormatter: FiatFormatter,
private val amountFormatter: AmountFormatter
) {
fun create(maskableFormatter: MaskableValueFormatter): NetworkAssetFormatter {
return NetworkAssetFormatter(maskableFormatter, fiatFormatter, amountFormatter)
}
}
class NetworkAssetFormatter(
private val maskableFormatter: MaskableValueFormatter,
private val fiatFormatter: FiatFormatter,
private val amountFormatter: AmountFormatter
) : CommonAssetFormatter(maskableFormatter, amountFormatter) {
fun mapGroupedAssetsToUi(
groupedAssets: GroupedList<NetworkAssetGroup, AssetWithOffChainBalance>,
assetIconProvider: AssetIconProvider,
currency: Currency,
groupBalance: (NetworkAssetGroup) -> BigDecimal = NetworkAssetGroup::groupTotalBalanceFiat,
balance: (AssetBalance) -> AssetBalance.Amount = AssetBalance::total,
): List<BalanceListRvItem> {
return groupedAssets.mapKeys { (assetGroup, _) -> mapAssetGroupToUi(assetGroup, currency, groupBalance) }
.mapValues { (_, assets) -> mapAssetsToAssetModels(assetIconProvider, assets, balance) }
.toListWithHeaders()
.filterIsInstance<BalanceListRvItem>()
}
private fun mapAssetsToAssetModels(
assetIconProvider: AssetIconProvider,
assets: List<AssetWithOffChainBalance>,
balance: (AssetBalance) -> AssetBalance.Amount
): List<BalanceListRvItem> {
return assets.map {
NetworkAssetUi(
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffchain)),
assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration)
)
}
}
private fun mapAssetGroupToUi(
assetGroup: NetworkAssetGroup,
currency: Currency,
groupBalance: (NetworkAssetGroup) -> BigDecimal
): NetworkGroupUi {
return NetworkGroupUi(
chainUi = mapChainToUi(assetGroup.chain),
groupBalanceFiat = maskableFormatter.format { fiatFormatter.formatFiat(groupBalance(assetGroup), currency) }
)
}
}
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers
import io.novafoundation.nova.common.list.GroupedList
import io.novafoundation.nova.common.list.toListWithHeaders
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.presentation.getAssetIconOrFallback
import io.novafoundation.nova.common.utils.formatting.formatAsChange
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup
import io.novafoundation.nova.feature_assets.domain.common.AssetBalance
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling
class TokenAssetFormatterFactory(
private val amountFormatter: AmountFormatter
) {
fun create(maskableFormatter: MaskableValueFormatter): TokenAssetFormatter {
return TokenAssetFormatter(maskableFormatter, amountFormatter)
}
}
class TokenAssetFormatter(
private val maskableFormatter: MaskableValueFormatter,
private val amountFormatter: AmountFormatter
) : CommonAssetFormatter(maskableFormatter, amountFormatter) {
fun mapGroupedAssetsToUi(
groupedTokens: GroupedList<TokenAssetGroup, AssetWithNetwork>,
assetIconProvider: AssetIconProvider,
assetFilter: (groupId: String, List<TokenAssetUi>) -> List<TokenAssetUi> = { _, assets -> assets },
groupBalance: (TokenAssetGroup) -> AssetBalance.Amount = { it.groupBalance.total },
balance: (AssetBalance) -> AssetBalance.Amount = AssetBalance::total,
): List<BalanceListRvItem> {
return groupedTokens.mapKeys { (group, assets) -> mapTokenAssetGroupToUi(assetIconProvider, group, assets, groupBalance) }
.mapValues { (group, assets) ->
val assetModels = mapAssetsToAssetModels(assetIconProvider, group, assets, balance)
assetFilter(group.itemId, assetModels)
}
.toListWithHeaders()
.filterIsInstance<BalanceListRvItem>()
}
fun mapTokenAssetGroupToUi(
assetIconProvider: AssetIconProvider,
assetGroup: TokenAssetGroup,
assets: List<AssetWithNetwork>,
groupBalance: (TokenAssetGroup) -> AssetBalance.Amount = { it.groupBalance.total }
): TokenGroupUi {
val balance = groupBalance(assetGroup)
return TokenGroupUi(
itemId = assetGroup.groupId,
tokenIcon = assetIconProvider.getAssetIconOrFallback(assetGroup.tokenInfo.icon),
rate = mapCoinRateChange(assetGroup.tokenInfo.coinRate, assetGroup.tokenInfo.currency),
recentRateChange = assetGroup.tokenInfo.coinRate?.recentRateChange.orZero().formatAsChange(),
rateChangeColorRes = mapCoinRateChangeColorRes(assetGroup.tokenInfo.coinRate),
tokenSymbol = assetGroup.tokenInfo.symbol.value,
singleItemGroup = assetGroup.itemsCount <= 1,
balance = maskableFormatter.format {
amountFormatter.formatAmountToAmountModel(
amount = balance.amount,
token = assetGroup.tokenInfo.token,
config = AmountConfig(
includeAssetTicker = false,
tokenFractionPartStyling = FractionPartStyling.Styled(R.dimen.asset_balance_fraction_size)
)
)
},
groupType = mapType(assets)
)
}
private fun mapAssetsToAssetModels(
assetIconProvider: AssetIconProvider,
group: TokenGroupUi,
assets: List<AssetWithNetwork>,
balance: (AssetBalance) -> AssetBalance.Amount
): List<TokenAssetUi> {
return assets.map {
TokenAssetUi(
group.getId(),
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)),
assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration),
mapChainToUi(it.chain)
)
}
}
private fun mapType(
assets: List<AssetWithNetwork>,
): TokenGroupUi.GroupType {
return if (assets.size == 1) {
TokenGroupUi.GroupType.SingleItem(assets.first().asset.token.configuration)
} else {
TokenGroupUi.GroupType.Group
}
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers
import androidx.annotation.ColorRes
import io.novafoundation.nova.common.utils.formatting.formatAsChange
import io.novafoundation.nova.common.utils.isNonNegative
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.presentation.model.TokenModel
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import java.math.BigDecimal
fun mapCoinRateChange(coinRateChange: CoinRateChange?, currency: Currency): String {
val rateChange = coinRateChange?.rate
return mapCoinRateChange(rateChange.orZero(), currency)
}
fun mapCoinRateChange(rate: BigDecimal, currency: Currency): String {
return rate.formatAsCurrency(currency)
}
@ColorRes
fun mapCoinRateChangeColorRes(coinRateChange: CoinRateChange?): Int {
val rateChange = coinRateChange?.recentRateChange
return when {
rateChange == null || rateChange.isZero -> R.color.text_secondary
rateChange.isNonNegative -> R.color.text_positive
else -> R.color.text_negative
}
}
fun mapTokenToTokenModel(token: Token): TokenModel {
return with(token) {
TokenModel(
configuration = configuration,
rate = mapCoinRateChange(token.coinRate, token.currency),
recentRateChange = (coinRate?.recentRateChange ?: BigDecimal.ZERO).formatAsChange(),
rateChangeColorRes = mapCoinRateChangeColorRes(coinRate)
)
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import android.content.Context
import android.util.AttributeSet
import io.novafoundation.nova.common.utils.setDrawableEnd
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_wallet_api.presentation.view.BalancesView
class AssetDetailBalancesView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : BalancesView(context, attrs, defStyle) {
val transferable = item(R.string.wallet_balance_transferable)
val locked = item(R.string.wallet_balance_locked).apply {
setOwnDividerVisible(false)
title.setDrawableEnd(R.drawable.ic_info, paddingInDp = 4)
}
fun showBalanceDetails(show: Boolean) {
expandableView.setExpandable(show)
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.feature_assets.presentation.model.TokenModel
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel
class AssetDetailsModel(
val token: TokenModel,
val assetIcon: Icon,
val total: AmountModel,
val transferable: AmountModel,
val locked: AmountModel
)
@@ -0,0 +1,187 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import android.os.Bundle
import android.view.View
import androidx.core.view.isGone
import coil.ImageLoader
import com.google.android.material.bottomsheet.BottomSheetBehavior
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.utils.hideKeyboard
import io.novafoundation.nova.common.utils.insets.applyBarMargin
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.view.setModelOrHide
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceDetailBinding
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupButSellActionButton
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupBuySellSelectorMixin
import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel
import io.novafoundation.nova.feature_assets.presentation.receive.view.LedgerNotSupportedWarningBottomSheet
import io.novafoundation.nova.feature_assets.presentation.transaction.history.setBannerModelOrHide
import io.novafoundation.nova.feature_assets.presentation.transaction.history.showState
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.feature_wallet_api.presentation.view.setTotalAmount
import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount
import javax.inject.Inject
private const val KEY_TOKEN = "KEY_TOKEN"
class BalanceDetailFragment : BaseFragment<BalanceDetailViewModel, FragmentBalanceDetailBinding>() {
companion object {
fun getBundle(assetPayload: AssetPayload): Bundle {
return Bundle().apply {
putParcelable(KEY_TOKEN, assetPayload)
}
}
}
override fun createBinding() = FragmentBalanceDetailBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun applyInsets(rootView: View) {
binder.root.applyNavigationBarInsets(consume = false)
binder.balanceDetailBack.applyBarMargin()
}
override fun initViews() {
hideKeyboard()
binder.transfersContainer.initializeBehavior(anchorView = binder.balanceDetailContent)
binder.transfersContainer.setScrollingListener(viewModel::transactionsScrolled)
binder.transfersContainer.setSlidingStateListener(::setRefreshEnabled)
binder.transfersContainer.setTransactionClickListener(viewModel::transactionClicked)
binder.transfersContainer.setFilterClickListener { viewModel.filterClicked() }
binder.transfersContainer.setBannerClickListener() { viewModel.filterClicked() }
binder.balanceDetailContainer.setOnRefreshListener {
viewModel.sync()
}
binder.balanceDetailBack.setOnClickListener { viewModel.backClicked() }
binder.balanceDetailActions.send.setOnClickListener {
viewModel.sendClicked()
}
binder.balanceDetailActions.swap.setOnClickListener {
viewModel.swapClicked()
}
binder.balanceDetailActions.receive.setOnClickListener {
viewModel.receiveClicked()
}
binder.balanceDetailActions.gift.setOnClickListener {
viewModel.giftClicked()
}
binder.balanceDetailsBalances.locked.setOnClickListener {
viewModel.lockedInfoClicked()
}
binder.balanceDetailsMigrationAlert.setOnCloseClickListener { viewModel.closeMigrationAlert() }
}
override fun inject() {
val token = argument<AssetPayload>(KEY_TOKEN)
FeatureUtils.getFeature<AssetsFeatureComponent>(
requireContext(),
AssetsFeatureApi::class.java
)
.balanceDetailComponentFactory()
.create(this, token)
.inject(this)
}
override fun subscribe(viewModel: BalanceDetailViewModel) {
observeBrowserEvents(viewModel)
setupBuySellSelectorMixin(viewModel.buySellSelectorMixin)
setupButSellActionButton(viewModel.buySellSelectorMixin, binder.balanceDetailActions.buySell)
viewModel.state.observe(binder.transfersContainer::showState)
viewModel.destinationMigrationBannerFlow.observe {
binder.transfersContainer.setBannerModelOrHide(it)
}
viewModel.assetDetailsModel.observe { asset ->
binder.balanceDetailTokenIcon.setTokenIcon(asset.assetIcon, imageLoader)
binder.balanceDetailTokenName.text = asset.token.configuration.symbol.value
binder.balanceDetailsBalances.setTotalAmount(asset.total)
binder.balanceDetailsBalances.transferable.showAmount(asset.transferable)
binder.balanceDetailsBalances.locked.showAmount(asset.locked)
}
viewModel.supportExpandableBalanceDetails.observe {
binder.balanceDetailsBalances.showBalanceDetails(it)
}
viewModel.priceChartFormatters.observe {
binder.priceChartView.setTextInjectors(it.price, it.priceChange, it.date)
}
viewModel.priceChartTitle.observe {
binder.priceChartView.setTitle(it)
}
viewModel.priceChartModels.observe {
if (it == null) {
binder.priceChartView.isGone = true
return@observe
}
binder.priceChartView.setCharts(it)
}
viewModel.hideRefreshEvent.observeEvent {
binder.balanceDetailContainer.isRefreshing = false
}
viewModel.showLockedDetailsEvent.observeEvent(::showLockedDetails)
viewModel.sendEnabled.observe(binder.balanceDetailActions.send::setEnabled)
viewModel.swapButtonEnabled.observe(binder.balanceDetailActions.swap::setEnabled)
viewModel.giftsButtonEnabled.observe(binder.balanceDetailActions.gift::setEnabled)
viewModel.acknowledgeLedgerWarning.awaitableActionLiveData.observeEvent {
LedgerNotSupportedWarningBottomSheet(
context = requireContext(),
onSuccess = { it.onSuccess(Unit) },
message = it.payload
).show()
}
viewModel.chainUI.observe {
binder.balanceDetailsBalances.setChain(it)
}
viewModel.originMigrationAlertFlow.observe {
binder.balanceDetailsMigrationAlert.setModelOrHide(it)
}
}
private fun setRefreshEnabled(bottomSheetState: Int) {
val bottomSheetCollapsed = BottomSheetBehavior.STATE_COLLAPSED == bottomSheetState
binder.balanceDetailContainer.isEnabled = bottomSheetCollapsed
}
private fun showLockedDetails(model: BalanceLocksModel) {
LockedTokensBottomSheet(requireContext(), model).show()
}
}
@@ -0,0 +1,483 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.sumByBigInteger
import io.novafoundation.nova.common.view.AlertModel
import io.novafoundation.nova.common.view.AlertView
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase
import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig
import io.novafoundation.nova.feature_ahm_api.presentation.getChainMigrationDateFormat
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractor
import io.novafoundation.nova.feature_assets.domain.price.AssetPriceChart
import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor
import io.novafoundation.nova.feature_assets.domain.send.SendInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.mapTokenToTokenModel
import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload
import io.novafoundation.nova.feature_assets.presentation.transaction.history.TransactionHistoryBannerModel
import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryMixin
import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryUi
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.PriceChartModel
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealDateChartTextInjector
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealPriceChangeTextInjector
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealPricePriceTextInjector
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.unlabeledReserves
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.balanceId
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId
import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.hash.isPositive
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import android.util.Log
import io.novafoundation.nova.common.utils.LOG_TAG
private const val ORIGIN_MIGRATION_ALERT = "ORIGIN_MIGRATION_ALERT"
class BalanceDetailViewModel(
private val walletInteractor: WalletInteractor,
private val balanceLocksInteractor: BalanceLocksInteractor,
private val sendInteractor: SendInteractor,
private val router: AssetsRouter,
private val assetPayload: AssetPayload,
private val transactionHistoryMixin: TransactionHistoryMixin,
private val accountUseCase: SelectedAccountUseCase,
private val resourceManager: ResourceManager,
private val currencyInteractor: CurrencyInteractor,
private val controllableAssetCheck: ControllableAssetCheckMixin,
private val externalBalancesInteractor: ExternalBalancesInteractor,
private val swapAvailabilityInteractor: SwapAvailabilityInteractor,
private val assetIconProvider: AssetIconProvider,
private val chartsInteractor: ChartsInteractor,
private val buySellSelectorMixinFactory: BuySellSelectorMixinFactory,
private val amountFormatter: AmountFormatter,
private val chainMigrationInfoUseCase: ChainMigrationInfoUseCase,
private val giftsAvailableGiftAssetsUseCase: AvailableGiftAssetsUseCase,
private val giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin
) : BaseViewModel(), TransactionHistoryUi by transactionHistoryMixin, Browserable {
override val openBrowserEvent = MutableLiveData<Event<String>>()
val acknowledgeLedgerWarning = controllableAssetCheck.acknowledgeLedgerWarning
private val _hideRefreshEvent = MutableLiveData<Event<Unit>>()
val hideRefreshEvent: LiveData<Event<Unit>> = _hideRefreshEvent
private val _showLockedDetailsEvent = MutableLiveData<Event<BalanceLocksModel>>()
val showLockedDetailsEvent: LiveData<Event<BalanceLocksModel>> = _showLockedDetailsEvent
private val chainFlow = walletInteractor.chainFlow(assetPayload.chainId)
.shareInBackground()
private val assetFlow = walletInteractor.assetFlow(assetPayload.chainId, assetPayload.chainAssetId)
.inBackground()
.share()
private val chainAssetFlow = assetFlow.map { it.token.configuration }
.distinctUntilChangedBy { it.fullId }
private val balanceLocksFlow = balanceLocksInteractor.balanceLocksFlow(assetPayload.chainId, assetPayload.chainAssetId)
.catch { error ->
Log.e(LOG_TAG, "Failed to load balance locks: ${error.message}")
emit(emptyList())
}
.shareInBackground()
private val balanceHoldsFlow = balanceLocksInteractor.balanceHoldsFlow(assetPayload.chainId, assetPayload.chainAssetId)
.catch { error ->
Log.e(LOG_TAG, "Failed to load balance holds: ${error.message}")
emit(emptyList())
}
.shareInBackground()
private val selectedAccountFlow = accountUseCase.selectedMetaAccountFlow()
.share()
private val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances(assetPayload.fullChainAssetId)
.onStart { emit(emptyList()) }
.shareInBackground()
private val migrationConfigFlow = chainMigrationInfoUseCase.observeMigrationConfigOrNull(assetPayload.chainId, assetPayload.chainAssetId)
.shareInBackground()
val assetDetailsModel = combine(assetFlow, externalBalancesFlow) { asset, externalBalances ->
mapAssetToUi(asset, externalBalances)
}
.inBackground()
.share()
val supportExpandableBalanceDetails = assetFlow.map { it.totalInPlanks.isPositive() }
.shareInBackground()
private val lockedBalanceModel = combine(balanceLocksFlow, balanceHoldsFlow, externalBalancesFlow, assetFlow) { locks, holds, externalBalances, asset ->
mapBalanceLocksToUi(locks, holds, externalBalances, asset)
}
.inBackground()
.share()
val buySellSelectorMixin = buySellSelectorMixinFactory.create(
BuySellSelectorMixin.SelectorType.Asset(assetPayload.chainId, assetPayload.chainAssetId),
viewModelScope
)
val chainUI = chainFlow.map { mapChainToUi(it) }
val swapButtonEnabled = chainAssetFlow.flatMapLatest {
swapAvailabilityInteractor.swapAvailableFlow(it, viewModelScope)
}
.onStart { emit(false) }
.catch { error ->
Log.e(LOG_TAG, "Failed to check swap availability: ${error.message}")
emit(false)
}
.shareInBackground()
val giftsButtonEnabled = chainAssetFlow.map {
giftsAvailableGiftAssetsUseCase.isGiftsAvailable(it)
}
.onStart { emit(false) }
.catch { error ->
Log.e(LOG_TAG, "Failed to check gifts availability: ${error.message}")
emit(false)
}
.shareInBackground()
val sendEnabled = assetFlow.map {
sendInteractor.areTransfersEnabled(it.token.configuration)
}
.inBackground()
.share()
val priceChartTitle = assetFlow.map {
val tokenName = it.token.configuration.symbol.value
resourceManager.getString(R.string.price_chart_title, tokenName)
}.shareInBackground()
val priceChartFormatters: Flow<PriceChartTextInjectors> = assetFlow.map { asset ->
val lastCoinRate = asset.token.coinRate?.rate
val currency = currencyInteractor.getSelectedCurrency()
PriceChartTextInjectors(
RealPricePriceTextInjector(currency, lastCoinRate),
RealPriceChangeTextInjector(resourceManager, currency),
RealDateChartTextInjector(resourceManager)
)
}.shareInBackground()
private val dateFormatter = getChainMigrationDateFormat()
val originMigrationAlertFlow = combine(
migrationConfigFlow,
chainFlow,
selectedAccountFlow,
chainMigrationInfoUseCase.observeInfoShouldBeHidden(ORIGIN_MIGRATION_ALERT, assetPayload.chainId, assetPayload.chainAssetId)
) { configWithChains, chain, metaAccount, shouldBeHidden ->
if (shouldBeHidden) return@combine null
if (configWithChains == null) return@combine null
if (configWithChains.originAsset.notMatchWithBalanceAsset()) return@combine null
if (!metaAccount.hasAccountIn(configWithChains.destinationChain)) return@combine null
val config = configWithChains.config
val sourceAsset = configWithChains.originAsset
val destinationChain = configWithChains.destinationChain
val formattedDate = dateFormatter.format(config.timeStartAt)
AlertModel(
style = AlertView.Style.fromPreset(AlertView.StylePreset.INFO),
message = resourceManager.getString(R.string.asset_details_source_asset_alert_title, sourceAsset.symbol.value, destinationChain.name),
subMessage = resourceManager.getString(
R.string.asset_details_source_asset_alert_message,
formattedDate,
sourceAsset.symbol.value,
destinationChain.name
),
linkAction = AlertModel.ActionModel(resourceManager.getString(R.string.common_learn_more)) { learnMoreMigrationClicked(config) },
buttonAction = AlertModel.ActionModel(
resourceManager.getString(R.string.asset_details_source_asset_alert_button, destinationChain.name),
{ openAssetDetails(chainData = configWithChains.config.destinationData) }
)
)
}.shareInBackground()
val destinationMigrationBannerFlow = combine(
migrationConfigFlow,
chainFlow,
selectedAccountFlow,
) { configWithChains, chain, metaAccount ->
if (configWithChains == null) return@combine null
if (configWithChains.destinationAsset.notMatchWithBalanceAsset()) return@combine null
if (!metaAccount.hasAccountIn(configWithChains.originChain)) return@combine null
val sourceAsset = configWithChains.originAsset
val sourceChain = configWithChains.originChain
TransactionHistoryBannerModel(
resourceManager.getString(R.string.transaction_history_migration_source_message, sourceAsset.symbol.value, sourceChain.name),
{ openAssetDetails(chainData = configWithChains.config.originData) }
)
}.shareInBackground()
private val priceCharts: Flow<List<AssetPriceChart>?> = assetFlow.map { it.token.configuration.priceId }
.distinctUntilChanged()
.flatMapLatest {
val priceId = it ?: return@flatMapLatest flowOf { null }
chartsInteractor.chartsFlow(priceId)
}.shareInBackground()
val priceChartModels = priceCharts.map { charts ->
charts?.map { mapChartsToUi(it) }
}.shareInBackground()
init {
sync()
}
override fun onCleared() {
super.onCleared()
transactionHistoryMixin.cancel()
}
fun transactionsScrolled(index: Int) {
transactionHistoryMixin.scrolled(index)
}
fun filterClicked() {
val payload = TransactionHistoryFilterPayload(assetPayload)
router.openFilter(payload)
}
fun sync() {
launch {
runCatching {
swapAvailabilityInteractor.sync(viewModelScope)
val currency = currencyInteractor.getSelectedCurrency()
val deferredAssetSync = async { walletInteractor.syncAssetsRates(currency) }
val deferredTransactionsSync = async { transactionHistoryMixin.syncFirstOperationsPage() }
awaitAll(deferredAssetSync, deferredTransactionsSync)
}.onFailure { error ->
Log.e(LOG_TAG, "Sync failed: ${error.message}")
}
_hideRefreshEvent.value = Event(Unit)
}
}
fun backClicked() {
router.back()
}
fun sendClicked() {
router.openSend(SendPayload.SpecifiedOrigin(assetPayload))
}
fun receiveClicked() = checkControllableAsset {
router.openReceive(assetPayload)
}
fun swapClicked() {
launch {
val chainAsset = assetFlow.first().token.configuration
val payload = SwapSettingsPayload.DefaultFlow(chainAsset.fullId.toAssetPayload())
router.openSwapSetupAmount(payload)
}
}
fun giftClicked() = launchUnit {
giftsRestrictionCheckMixin.checkRestrictionAndDo {
router.openGiftsByAsset(assetPayload)
}
}
fun lockedInfoClicked() = launch {
val balanceLocks = lockedBalanceModel.first()
_showLockedDetailsEvent.value = Event(balanceLocks)
}
fun closeMigrationAlert() {
chainMigrationInfoUseCase.markMigrationInfoAsHidden(ORIGIN_MIGRATION_ALERT, assetPayload.chainId, assetPayload.chainAssetId)
}
private fun checkControllableAsset(action: () -> Unit) {
launch {
val metaAccount = selectedAccountFlow.first()
val chainAsset = assetFlow.first().token.configuration
controllableAssetCheck.check(metaAccount, chainAsset) { action() }
}
}
private fun mapAssetToUi(asset: Asset, externalBalances: List<ExternalBalance>): AssetDetailsModel {
val totalContributedPlanks = externalBalances.sumByBigInteger { it.amount }
val totalContributed = asset.token.amountFromPlanks(totalContributedPlanks)
return AssetDetailsModel(
token = mapTokenToTokenModel(asset.token),
total = amountFormatter.formatAmountToAmountModel(
asset.total + totalContributed,
asset,
AmountConfig(useTokenAbbreviation = false, fiatAbbreviation = FiatConfig.AbbreviationStyle.NO_ABBREVIATION)
),
transferable = amountFormatter.formatAmountToAmountModel(asset.transferable, asset),
locked = amountFormatter.formatAmountToAmountModel(asset.locked + totalContributed, asset),
assetIcon = assetIconProvider.getAssetIconOrFallback(asset.token.configuration)
)
}
private fun openAssetDetails(chainData: ChainMigrationConfig.ChainData) {
router.openAssetDetails(
AssetPayload(
chainId = chainData.chainId,
chainAssetId = chainData.assetId
)
)
}
private fun mapBalanceLocksToUi(
balanceLocks: List<BalanceLock>,
holds: List<BalanceHold>,
externalBalances: List<ExternalBalance>,
asset: Asset
): BalanceLocksModel {
val mappedLocks = balanceLocks.map {
BalanceLocksModel.Lock(
mapBalanceIdToUi(resourceManager, it.id.value),
amountFormatter.formatAmountToAmountModel(it.amountInPlanks, asset)
)
}
val mappedHolds = holds.map {
BalanceLocksModel.Lock(
mapBalanceIdToUi(resourceManager, it.identifier),
amountFormatter.formatAmountToAmountModel(it.amountInPlanks, asset)
)
}
val unlabeledReserves = asset.unlabeledReserves(holds)
val reservedBalance = BalanceLocksModel.Lock(
resourceManager.getString(R.string.wallet_balance_reserved),
amountFormatter.formatAmountToAmountModel(unlabeledReserves, asset)
)
val external = externalBalances.map { externalBalance ->
BalanceLocksModel.Lock(
name = mapBalanceIdToUi(resourceManager, externalBalance.type.balanceId),
amount = amountFormatter.formatAmountToAmountModel(externalBalance.amount, asset)
)
}
val locks = buildList {
addAll(mappedLocks)
addAll(mappedHolds)
add(reservedBalance)
addAll(external)
}
return BalanceLocksModel(locks)
}
private fun mapChartsToUi(assetPriceChart: AssetPriceChart): PriceChartModel {
val buttonText = mapButtonText(assetPriceChart.range)
return if (assetPriceChart.chart is ExtendedLoadingState.Loaded) {
val periodName = mapPeriodName(assetPriceChart.range)
val supportTimeShowing = supportTimeShowing(assetPriceChart.range)
val mappedChart = assetPriceChart.chart.data.map { PriceChartModel.Chart.Price(it.timestamp, it.rate) }
PriceChartModel.Chart(buttonText, periodName, supportTimeShowing, mappedChart)
} else {
PriceChartModel.Loading(buttonText)
}
}
private fun mapButtonText(pricePeriod: PricePeriod): String {
val buttonTextRes = when (pricePeriod) {
PricePeriod.DAY -> R.string.price_chart_day
PricePeriod.WEEK -> R.string.price_chart_week
PricePeriod.MONTH -> R.string.price_chart_month
PricePeriod.YEAR -> R.string.price_chart_year
PricePeriod.MAX -> R.string.price_chart_max
}
return resourceManager.getString(buttonTextRes)
}
private fun mapPeriodName(pricePeriod: PricePeriod): String {
val periodNameRes = when (pricePeriod) {
PricePeriod.DAY -> R.string.price_charts_period_today
PricePeriod.WEEK -> R.string.price_charts_period_week
PricePeriod.MONTH -> R.string.price_charts_period_month
PricePeriod.YEAR -> R.string.price_charts_period_year
PricePeriod.MAX -> R.string.price_charts_period_all
}
return resourceManager.getString(periodNameRes)
}
private fun supportTimeShowing(pricePeriod: PricePeriod): Boolean {
return when (pricePeriod) {
PricePeriod.DAY, PricePeriod.WEEK, PricePeriod.MONTH -> true
PricePeriod.YEAR, PricePeriod.MAX -> false
}
}
private fun learnMoreMigrationClicked(config: ChainMigrationConfig) {
launch {
openBrowserEvent.value = Event(config.wikiURL)
}
}
private fun Chain.Asset.notMatchWithBalanceAsset(): Boolean {
return assetPayload.chainId != chainId || assetPayload.chainAssetId != id
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding
import io.novafoundation.nova.common.view.TableCellView
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet
import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel
import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount
class LockedTokensBottomSheet(
context: Context,
private val balanceLocks: BalanceLocksModel
) : FixedListBottomSheet<BottomSheeetFixedListBinding>(context, viewConfiguration = ViewConfiguration.default(context)) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.wallet_balance_locked)
val viewItems = createViewItems(balanceLocks.locks)
viewItems.forEach { addItem(it) }
}
private fun createViewItems(locks: List<BalanceLocksModel.Lock>): List<TableCellView> {
return locks.map(::createViewItem)
}
private fun createViewItem(lock: BalanceLocksModel.Lock): TableCellView {
return TableCellView.createTableCellView(context).apply {
setOwnDividerVisible(false)
setTitle(lock.name)
showAmount(lock.amount)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
updateMargins(
left = getCommonPadding(),
right = getCommonPadding()
)
}
}
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.DateChartTextInjector
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceChangeTextInjector
import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceTextInjector
class PriceChartTextInjectors(
val price: PriceTextInjector,
val priceChange: PriceChangeTextInjector,
val date: DateChartTextInjector
)
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink
import android.net.Uri
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull
class AssetDetailsDeepLinkData(
val accountAddress: String?,
val chainId: String,
val assetId: Int
)
class AssetDetailsDeepLinkConfigurator(
private val linkBuilderFactory: LinkBuilderFactory
) : DeepLinkConfigurator<AssetDetailsDeepLinkData> {
val action = "open"
val screen = "asset"
val deepLinkPrefix = "/$action/$screen"
val addressParam = "address"
val chainIdParam = "chainId"
val assetIdParam = "assetId"
override fun configure(payload: AssetDetailsDeepLinkData, type: DeepLinkConfigurator.Type): Uri {
return linkBuilderFactory.newLink(type)
.setAction(action)
.setScreen(screen)
.addParamIfNotNull(addressParam, payload.accountAddress)
.addParam(chainIdParam, payload.chainId)
.addParam(assetIdParam, payload.assetId.toString())
.build()
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink
import android.net.Uri
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.findMetaAccountOrThrow
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
class AssetDetailsDeepLinkHandler(
private val router: AssetsRouter,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val automaticInteractionGate: AutomaticInteractionGate,
private val assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator
) : DeepLinkHandler {
override val callbackFlow = MutableSharedFlow<CallbackEvent>()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(assetDetailsDeepLinkConfigurator.deepLinkPrefix)
}
override suspend fun handleDeepLink(data: Uri): Result<Unit> = runCatching {
automaticInteractionGate.awaitInteractionAllowed()
val address = data.getAddress()
val chainId = data.getChainIdOrPolkadot()
val assetId = data.getAssetId() ?: throw IllegalStateException()
val chain = chainRegistry.getChain(chainId)
require(chain.isEnabled)
address?.let { selectMetaAccount(chain, address) }
val payload = AssetPayload(chainId, assetId)
router.openAssetDetailsFromDeepLink(payload)
}
private suspend fun selectMetaAccount(chain: Chain, address: String) = withContext(Dispatchers.Default) {
val metaAccount = accountRepository.findMetaAccountOrThrow(chain.accountIdOf(address), chain.id)
accountRepository.selectMetaAccount(metaAccount.id)
}
private fun Uri.getAddress(): String? {
return getQueryParameter(assetDetailsDeepLinkConfigurator.addressParam)
}
private fun Uri.getChainIdOrPolkadot(): String {
return getQueryParameter(assetDetailsDeepLinkConfigurator.chainIdParam) ?: ChainGeneses.POLKADOT
}
private fun Uri.getAssetId(): Int? {
return getQueryParameter(assetDetailsDeepLinkConfigurator.assetIdParam)?.toIntOrNull()
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailFragment
@Subcomponent(
modules = [
BalanceDetailModule::class
]
)
@ScreenScope
interface BalanceDetailComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance assetPayload: AssetPayload,
): BalanceDetailComponent
}
fun inject(fragment: BalanceDetailFragment)
}
@@ -0,0 +1,145 @@
package io.novafoundation.nova.feature_assets.presentation.balance.detail.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase
import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractor
import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractorImpl
import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor
import io.novafoundation.nova.feature_assets.domain.send.SendInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin
import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailViewModel
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory
import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryMixin
import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryProvider
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class BalanceDetailModule {
@Provides
@ScreenScope
fun provideBalanceLocksInteractor(
chainRegistry: ChainRegistry,
balanceLocksRepository: BalanceLocksRepository,
balanceHoldsRepository: BalanceHoldsRepository,
accountRepository: AccountRepository
): BalanceLocksInteractor {
return BalanceLocksInteractorImpl(
chainRegistry = chainRegistry,
balanceLocksRepository = balanceLocksRepository,
balanceHoldsRepository = balanceHoldsRepository,
accountRepository = accountRepository
)
}
@Provides
@ScreenScope
fun provideTransferHistoryMixin(
walletInteractor: WalletInteractor,
assetsRouter: AssetsRouter,
historyFiltersProviderFactory: HistoryFiltersProviderFactory,
assetSourceRegistry: AssetSourceRegistry,
resourceManager: ResourceManager,
assetPayload: AssetPayload,
addressDisplayUseCase: AddressDisplayUseCase,
chainRegistry: ChainRegistry,
currencyRepository: CurrencyRepository,
assetIconProvider: AssetIconProvider
): TransactionHistoryMixin {
return TransactionHistoryProvider(
walletInteractor = walletInteractor,
router = assetsRouter,
historyFiltersProviderFactory = historyFiltersProviderFactory,
resourceManager = resourceManager,
addressDisplayUseCase = addressDisplayUseCase,
assetsSourceRegistry = assetSourceRegistry,
chainRegistry = chainRegistry,
chainId = assetPayload.chainId,
assetId = assetPayload.chainAssetId,
currencyRepository = currencyRepository,
assetIconProvider
)
}
@Provides
@IntoMap
@ViewModelKey(BalanceDetailViewModel::class)
fun provideViewModel(
walletInteractor: WalletInteractor,
balanceLocksInteractor: BalanceLocksInteractor,
sendInteractor: SendInteractor,
router: AssetsRouter,
transactionHistoryMixin: TransactionHistoryMixin,
assetPayload: AssetPayload,
accountUseCase: SelectedAccountUseCase,
resourceManager: ResourceManager,
currencyInteractor: CurrencyInteractor,
controllableAssetCheckMixin: ControllableAssetCheckMixin,
externalBalancesInteractor: ExternalBalancesInteractor,
swapAvailabilityInteractor: SwapAvailabilityInteractor,
assetIconProvider: AssetIconProvider,
chartsInteractor: ChartsInteractor,
buySellSelectorMixinFactory: BuySellSelectorMixinFactory,
amountFormatter: AmountFormatter,
chainMigrationInfoUseCase: ChainMigrationInfoUseCase,
giftsAvailableGiftAssetsUseCase: AvailableGiftAssetsUseCase,
giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin,
): ViewModel {
return BalanceDetailViewModel(
walletInteractor = walletInteractor,
balanceLocksInteractor = balanceLocksInteractor,
sendInteractor = sendInteractor,
router = router,
assetPayload = assetPayload,
transactionHistoryMixin = transactionHistoryMixin,
accountUseCase = accountUseCase,
resourceManager = resourceManager,
currencyInteractor = currencyInteractor,
controllableAssetCheck = controllableAssetCheckMixin,
externalBalancesInteractor = externalBalancesInteractor,
swapAvailabilityInteractor = swapAvailabilityInteractor,
assetIconProvider = assetIconProvider,
chartsInteractor = chartsInteractor,
buySellSelectorMixinFactory = buySellSelectorMixinFactory,
amountFormatter = amountFormatter,
chainMigrationInfoUseCase = chainMigrationInfoUseCase,
giftsRestrictionCheckMixin = giftsRestrictionCheckMixin,
giftsAvailableGiftAssetsUseCase = giftsAvailableGiftAssetsUseCase
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory,
): BalanceDetailViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(BalanceDetailViewModel::class.java)
}
}
@@ -0,0 +1,260 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list
import android.view.View
import androidx.recyclerview.widget.ConcatAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.list.EditablePlaceholderAdapter
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.hideKeyboard
import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings
import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator
import io.novafoundation.nova.common.utils.recyclerView.space.SpaceBetween
import io.novafoundation.nova.common.utils.recyclerView.space.addSpaceItemDecoration
import io.novafoundation.nova.common.view.PlaceholderModel
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceListBinding
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.BalanceBreakdownBottomSheet
import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.AssetBaseDecoration
import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensDecoration
import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensItemAnimator
import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.applyDefaultTo
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupBuySellSelectorMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.createForAssets
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetsHeaderAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetsHeaderHolder
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.ManageAssetsAdapter
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.ManageAssetsHolder
import io.novafoundation.nova.feature_banners_api.presentation.BannerHolder
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannerAdapter
import io.novafoundation.nova.feature_banners_api.presentation.bindWithAdapter
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import javax.inject.Inject
class BalanceListFragment :
BaseFragment<BalanceListViewModel, FragmentBalanceListBinding>(),
BalanceListAdapter.ItemAssetHandler,
AssetsHeaderAdapter.Handler,
ManageAssetsAdapter.Handler {
override fun createBinding() = FragmentBalanceListBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private var balanceBreakdownBottomSheet: BalanceBreakdownBottomSheet? = null
private val headerAdapter by lazy(LazyThreadSafetyMode.NONE) {
AssetsHeaderAdapter(this)
}
private val bannerAdapter: PromotionBannerAdapter by lazy(LazyThreadSafetyMode.NONE) {
PromotionBannerAdapter(closable = true)
}
private val manageAssetsAdapter by lazy(LazyThreadSafetyMode.NONE) {
ManageAssetsAdapter(this)
}
private val emptyAssetsPlaceholder by lazy(LazyThreadSafetyMode.NONE) {
EditablePlaceholderAdapter(
model = getAssetsPlaceholderModel(),
clickListener = { buySellClicked() }
)
}
private val assetsAdapter by lazy(LazyThreadSafetyMode.NONE) {
BalanceListAdapter(imageLoader, this)
}
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
ConcatAdapter(headerAdapter, bannerAdapter, manageAssetsAdapter, emptyAssetsPlaceholder, assetsAdapter)
}
override fun applyInsets(rootView: View) {
binder.balanceListAssets.applyStatusBarInsets()
}
override fun initViews() {
hideKeyboard()
setupRecyclerView()
binder.walletContainer.setOnRefreshListener {
viewModel.fullSync()
}
}
private fun setupRecyclerView() {
binder.balanceListAssets.setHasFixedSize(true)
binder.balanceListAssets.adapter = adapter
setupAssetsDecorationForRecyclerView()
setupRecyclerViewSpacing()
}
override fun inject() {
FeatureUtils.getFeature<AssetsFeatureComponent>(
requireContext(),
AssetsFeatureApi::class.java
)
.balanceListComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: BalanceListViewModel) {
setupBuySellSelectorMixin(viewModel.buySellSelectorMixin)
viewModel.bannersMixin.bindWithAdapter(bannerAdapter) {
binder.balanceListAssets.invalidateItemDecorations()
}
viewModel.assetListMixin.assetModelsFlow.observe {
assetsAdapter.submitList(it) {
binder.balanceListAssets.invalidateItemDecorations()
}
}
viewModel.maskingModeEnableFlow.observe(headerAdapter::setMaskingEnabled)
viewModel.totalBalanceFlow.observe(headerAdapter::setTotalBalance)
viewModel.selectedWalletModelFlow.observe(headerAdapter::setSelectedWallet)
viewModel.shouldShowPlaceholderFlow.observe(emptyAssetsPlaceholder::show)
viewModel.nftCountFlow.observe(headerAdapter::setNftCountLabel)
viewModel.nftPreviewsUi.observe(headerAdapter::setNftPreviews)
viewModel.hideRefreshEvent.observeEvent {
binder.walletContainer.isRefreshing = false
}
viewModel.balanceBreakdownFlow.observe {
if (balanceBreakdownBottomSheet?.isShowing == true) {
balanceBreakdownBottomSheet?.setBalanceBreakdown(it)
}
}
viewModel.showBalanceBreakdownEvent.observeEvent { totalBalanceBreakdown ->
if (balanceBreakdownBottomSheet == null) {
balanceBreakdownBottomSheet = BalanceBreakdownBottomSheet(requireContext())
balanceBreakdownBottomSheet?.setOnDismissListener {
balanceBreakdownBottomSheet = null
}
}
balanceBreakdownBottomSheet?.setOnShowListener {
balanceBreakdownBottomSheet?.setBalanceBreakdown(totalBalanceBreakdown)
}
balanceBreakdownBottomSheet?.show()
}
viewModel.walletConnectAccountSessionsUI.observe(headerAdapter::setWalletConnectModel)
viewModel.pendingOperationsCountModel.observe(headerAdapter::setPendingOperationsCountModel)
viewModel.filtersIndicatorIcon.observe(headerAdapter::setFilterIconRes)
viewModel.assetViewModeModelFlow.observe { manageAssetsAdapter.setAssetViewModeModel(it) }
}
override fun assetClicked(asset: Chain.Asset) {
viewModel.assetClicked(asset)
}
override fun tokenGroupClicked(tokenGroup: TokenGroupUi) {
if (tokenGroup.groupType is TokenGroupUi.GroupType.SingleItem) {
viewModel.assetClicked(tokenGroup.groupType.asset)
} else {
val itemAnimator = binder.balanceListAssets.itemAnimator as AssetTokensItemAnimator
itemAnimator.prepareForAnimation()
viewModel.assetListMixin.expandToken(tokenGroup)
}
}
override fun totalBalanceClicked() {
viewModel.balanceBreakdownClicked()
}
override fun manageClicked() {
viewModel.manageClicked()
}
override fun searchClicked() {
viewModel.searchClicked()
}
override fun avatarClicked() {
viewModel.avatarClicked()
}
override fun goToNftsClicked() {
viewModel.goToNftsClicked()
}
override fun walletConnectClicked() {
viewModel.walletConnectClicked()
}
override fun maskClicked() {
viewModel.toggleMasking()
}
override fun sendClicked() {
viewModel.sendClicked()
}
override fun receiveClicked() {
viewModel.receiveClicked()
}
override fun buySellClicked() {
viewModel.buySellClicked()
}
override fun novaCardClick() {
viewModel.novaCardClicked()
}
override fun pendingOperationsClicked() {
viewModel.pendingOperationsClicked()
}
override fun assetViewModeClicked() {
viewModel.switchViewMode()
}
override fun swapClicked() {
viewModel.swapClicked()
}
override fun giftClicked() {
viewModel.giftClicked()
}
private fun setupRecyclerViewSpacing() {
binder.balanceListAssets.addSpaceItemDecoration {
add(SpaceBetween(AssetsHeaderHolder, BannerHolder, spaceDp = 4))
add(SpaceBetween(BannerHolder, ManageAssetsHolder, spaceDp = 4))
add(SpaceBetween(AssetsHeaderHolder, ManageAssetsHolder, spaceDp = 24))
}
}
private fun setupAssetsDecorationForRecyclerView() {
val animationSettings = ExpandableAnimationSettings.createForAssets()
val animator = ExpandableAnimator(binder.balanceListAssets, animationSettings, assetsAdapter)
AssetBaseDecoration.applyDefaultTo(binder.balanceListAssets, assetsAdapter)
binder.balanceListAssets.addItemDecoration(AssetTokensDecoration(requireContext(), assetsAdapter, animator))
binder.balanceListAssets.itemAnimator = AssetTokensItemAnimator(animationSettings, animator)
}
private fun getAssetsPlaceholderModel() = PlaceholderModel(
text = getString(R.string.wallet_assets_empty),
imageRes = R.drawable.ic_planet_outline,
buttonText = getString(R.string.assets_buy_tokens_placeholder_button)
)
}
@@ -0,0 +1,407 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.model.AssetViewMode
import io.novafoundation.nova.common.data.model.MaskingMode
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.dataOrNull
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.common.presentation.masking.MaskableModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.common.utils.formatting.formatAsPercentage
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.NftPreviews
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdown
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdownInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownAmount
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownItem
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownTotal
import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.TotalBalanceBreakdownModel
import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetListMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.NftPreviewUi
import io.novafoundation.nova.feature_assets.presentation.balance.list.model.TotalBalanceModel
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetViewModeModel
import io.novafoundation.nova.feature_assets.presentation.balance.list.view.PendingOperationsCountModel
import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.assetsSource
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.feature_nft_api.data.model.Nft
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider
import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig
import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_api.presentation.mapNumberOfActiveSessionsToUi
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
private typealias SyncAction = suspend (MetaAccount) -> Unit
class BalanceListViewModel(
private val promotionBannersMixinFactory: PromotionBannersMixinFactory,
private val bannerSourceFactory: BannersSourceFactory,
private val walletInteractor: WalletInteractor,
private val assetsListInteractor: AssetsListInteractor,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val router: AssetsRouter,
private val currencyInteractor: CurrencyInteractor,
private val balanceBreakdownInteractor: BalanceBreakdownInteractor,
private val resourceManager: ResourceManager,
private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase,
private val swapAvailabilityInteractor: SwapAvailabilityInteractor,
private val assetListMixinFactory: AssetListMixinFactory,
private val amountFormatter: AmountFormatter,
private val fiatFormatter: FiatFormatter,
private val maskableValueFormatterProvider: MaskableValueFormatterProvider,
private val buySellSelectorMixinFactory: BuySellSelectorMixinFactory,
private val multisigPendingOperationsService: MultisigPendingOperationsService,
private val novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin,
private val maskingModeUseCase: MaskingModeUseCase,
private val giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin
) : BaseViewModel() {
private val maskableAmountFormatterFlow = maskableValueFormatterProvider.provideFormatter()
.shareInBackground()
private val _hideRefreshEvent = MutableLiveData<Event<Unit>>()
val hideRefreshEvent: LiveData<Event<Unit>> = _hideRefreshEvent
private val _showBalanceBreakdownEvent = MutableLiveData<Event<TotalBalanceBreakdownModel>>()
val showBalanceBreakdownEvent: LiveData<Event<TotalBalanceBreakdownModel>> = _showBalanceBreakdownEvent
val bannersMixin = promotionBannersMixinFactory.create(bannerSourceFactory.assetsSource(), viewModelScope)
private val selectedCurrency = currencyInteractor.observeSelectCurrency()
.inBackground()
.share()
private val fullSyncActions: List<SyncAction> = listOf(
{ walletInteractor.syncAssetsRates(selectedCurrency.first()) },
walletInteractor::syncAllNfts
)
val buySellSelectorMixin = buySellSelectorMixinFactory.create(BuySellSelectorMixin.SelectorType.AllAssets, viewModelScope)
val assetListMixin = assetListMixinFactory.create(viewModelScope)
private val externalBalancesFlow = assetListMixin.externalBalancesFlow
private val isFiltersEnabledFlow = walletInteractor.isFiltersEnabledFlow()
private val accountChangeSyncActions: List<SyncAction> = listOf(
walletInteractor::syncAllNfts
)
private val selectedMetaAccount = selectedAccountUseCase.selectedMetaAccountFlow()
.share()
val selectedWalletModelFlow = selectedAccountUseCase.selectedWalletModelFlow()
.shareInBackground()
private val balanceBreakdown = balanceBreakdownInteractor.balanceBreakdownFlow(assetListMixin.assetsFlow, externalBalancesFlow)
.shareInBackground()
private val nftsPreviews = assetsListInteractor.observeNftPreviews()
.inBackground()
.share()
val nftCountFlow = nftsPreviews
.combine(maskableAmountFormatterFlow, ::formatNftCount)
.inBackground()
.share()
val nftPreviewsUi = nftsPreviews
.combine(maskableAmountFormatterFlow, ::mapNftPreviewToUi)
.inBackground()
.share()
val maskingModeEnableFlow = maskingModeUseCase.observeMaskingMode()
.map { it == MaskingMode.ENABLED }
.shareInBackground()
val totalBalanceFlow = combine(
balanceBreakdown,
swapAvailabilityInteractor.anySwapAvailableFlow(),
maskableAmountFormatterFlow
) { breakdown, swapSupported, maskableAmountFormatter ->
val currency = selectedCurrency.first()
TotalBalanceModel(
isBreakdownAvailable = breakdown.breakdown.isNotEmpty(),
totalBalanceFiat = maskableAmountFormatter.format {
fiatFormatter.formatFiat(
breakdown.total,
currency,
config = FiatConfig(
abbreviationStyle = FiatConfig.AbbreviationStyle.SIMPLE_ABBREVIATION,
fractionPartStyling = FractionPartStyling.Styled(R.dimen.total_balance_fraction_size)
)
)
},
lockedBalanceFiat = maskableAmountFormatter.format { fiatFormatter.formatFiat(breakdown.locksTotal.amount, currency) },
enableSwap = swapSupported
)
}
.inBackground()
.share()
val shouldShowPlaceholderFlow = assetListMixin.assetModelsFlow.map { it.isEmpty() }
val balanceBreakdownFlow = balanceBreakdown.map {
val currency = selectedCurrency.first()
val total = it.total.formatAsCurrency(currency)
TotalBalanceBreakdownModel(total, mapBreakdownToList(it, currency))
}
.shareInBackground()
private val walletConnectAccountSessionCount = selectedMetaAccount.flatMapLatest {
walletConnectSessionsUseCase.activeSessionsNumberFlow(it)
}
.shareInBackground()
val walletConnectAccountSessionsUI = walletConnectAccountSessionCount
.map(::mapNumberOfActiveSessionsToUi)
.shareInBackground()
val filtersIndicatorIcon = isFiltersEnabledFlow
.map { if (it) R.drawable.ic_chip_filter_indicator else R.drawable.ic_chip_filter }
.shareInBackground()
val assetViewModeModelFlow = assetListMixin.assetsViewModeFlow.map {
when (it) {
AssetViewMode.NETWORKS -> AssetViewModeModel(R.drawable.ic_asset_view_networks, R.string.asset_view_networks)
AssetViewMode.TOKENS -> AssetViewModeModel(R.drawable.ic_asset_view_tokens, R.string.asset_view_tokens)
}
}.distinctUntilChanged()
val pendingOperationsCountModel = multisigPendingOperationsService.pendingOperationsCountFlow()
.withSafeLoading()
.combine(maskableAmountFormatterFlow, ::formatPendingOperationsCount)
.shareInBackground()
init {
selectedCurrency
.onEach { fullSync() }
.launchIn(this)
nftsPreviews
.debounce(1L.seconds)
.onEach { nfts ->
nfts.nftPreviews
.filter { it.details is Nft.Details.Loadable }
.forEach { assetsListInteractor.fullSyncNft(it) }
}
.inBackground()
.launchIn(this)
selectedMetaAccount
.mapLatest { syncWith(accountChangeSyncActions, it) }
.launchIn(this)
walletInteractor.nftSyncTrigger()
.onEach { trigger -> walletInteractor.syncChainNfts(selectedMetaAccount.first(), trigger.chain) }
.launchIn(viewModelScope)
}
fun fullSync() {
viewModelScope.launch {
syncWith(fullSyncActions, selectedMetaAccount.first())
_hideRefreshEvent.value = Event(Unit)
}
}
fun assetClicked(asset: Chain.Asset) {
val payload = AssetPayload(
chainId = asset.chainId,
chainAssetId = asset.id
)
router.openAssetDetails(payload)
}
fun avatarClicked() {
router.openSwitchWallet()
}
fun manageClicked() {
router.openManageTokens()
}
fun goToNftsClicked() {
router.openNfts()
}
fun searchClicked() {
router.openAssetSearch()
}
fun walletConnectClicked() {
launch {
if (walletConnectAccountSessionCount.first() > 0) {
val metaAccount = selectedMetaAccount.first()
router.openWalletConnectSessions(metaAccount.id)
} else {
router.openWalletConnectScan()
}
}
}
fun balanceBreakdownClicked() {
launch {
val totalBalance = totalBalanceFlow.first()
if (totalBalance.isBreakdownAvailable) {
val balanceBreakdown = balanceBreakdownFlow.first()
_showBalanceBreakdownEvent.value = Event(balanceBreakdown)
}
}
}
private suspend fun syncWith(syncActions: List<SyncAction>, metaAccount: MetaAccount) = if (syncActions.size == 1) {
val syncAction = syncActions.first()
syncAction(metaAccount)
} else {
val syncJobs = syncActions.map { async { it(metaAccount) } }
syncJobs.joinAll()
}
private fun mapNftPreviewToUi(nftPreviews: NftPreviews, maskableValueFormatter: MaskableValueFormatter): MaskableModel<List<NftPreviewUi>> {
return maskableValueFormatter.format {
nftPreviews.nftPreviews.map {
when (val details = it.details) {
Nft.Details.Loadable -> LoadingState.Loading()
is Nft.Details.Loaded -> {
LoadingState.Loaded(details.media)
}
}
}
}
}
private fun mapBreakdownToList(balanceBreakdown: BalanceBreakdown, currency: Currency): List<BalanceBreakdownItem> {
return buildList {
add(
BalanceBreakdownTotal(
resourceManager.getString(R.string.wallet_balance_transferable),
balanceBreakdown.transferableTotal.amount.formatAsCurrency(currency),
R.drawable.ic_transferable,
balanceBreakdown.transferableTotal.percentage.formatAsPercentage()
)
)
add(
BalanceBreakdownTotal(
resourceManager.getString(R.string.wallet_balance_locked),
balanceBreakdown.locksTotal.amount.formatAsCurrency(currency),
R.drawable.ic_lock,
balanceBreakdown.locksTotal.percentage.formatAsPercentage()
)
)
val breakdown = balanceBreakdown.breakdown.map {
BalanceBreakdownAmount(
name = it.token.configuration.symbol.value + " " + mapBalanceIdToUi(resourceManager, it.id),
amount = amountFormatter.formatAmountToAmountModel(it.tokenAmount, it.token)
)
}
addAll(breakdown)
}
}
private fun formatPendingOperationsCount(
operationsLoadingState: ExtendedLoadingState<Int>,
formatter: MaskableValueFormatter
): PendingOperationsCountModel {
return when (val count = operationsLoadingState.dataOrNull) {
null, 0 -> PendingOperationsCountModel.Gone
else -> PendingOperationsCountModel.Visible(formatter.format { count.format() })
}
}
fun sendClicked() {
router.openSendFlow()
}
fun receiveClicked() {
router.openReceiveFlow()
}
fun buySellClicked() {
buySellSelectorMixin.openSelector()
}
fun swapClicked() {
router.openSwapFlow()
}
fun giftClicked() = launchUnit {
giftsRestrictionCheckMixin.checkRestrictionAndDo {
router.openGifts()
}
}
fun novaCardClicked() = launchUnit {
novaCardRestrictionCheckMixin.checkRestrictionAndDo {
router.openNovaCard()
}
}
fun switchViewMode() {
launch { assetListMixin.switchViewMode() }
}
fun pendingOperationsClicked() {
router.openPendingMultisigOperations()
}
private fun formatNftCount(nftPreviews: NftPreviews, formatter: MaskableValueFormatter): MaskableModel<String>? {
if (nftPreviews.totalNftsCount == 0) return null
return formatter.format { nftPreviews.totalNftsCount.format() }
}
fun toggleMasking() {
maskingModeUseCase.toggleMaskingMode()
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_assets.presentation.balance.list.BalanceListFragment
@Subcomponent(
modules = [
BalanceListModule::class
]
)
@ScreenScope
interface BalanceListComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): BalanceListComponent
}
fun inject(fragment: BalanceListFragment)
}
@@ -0,0 +1,138 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor
import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor
import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdownInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetListMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory
import io.novafoundation.nova.feature_assets.presentation.balance.list.BalanceListViewModel
import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter
import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider
import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
@Module(includes = [ViewModelModule::class])
class BalanceListModule {
@Provides
@ScreenScope
fun provideInteractor(
accountRepository: AccountRepository,
nftRepository: NftRepository,
assetsViewModeRepository: AssetsViewModeRepository
) = AssetsListInteractor(accountRepository, nftRepository, assetsViewModeRepository)
@Provides
@ScreenScope
fun provideBalanceBreakdownInteractor(
accountRepository: AccountRepository,
balanceLocksRepository: BalanceLocksRepository,
balanceHoldsRepository: BalanceHoldsRepository
): BalanceBreakdownInteractor {
return BalanceBreakdownInteractor(
accountRepository,
balanceLocksRepository,
balanceHoldsRepository
)
}
@Provides
@ScreenScope
fun provideAssetListMixinFactory(
walletInteractor: WalletInteractor,
assetsListInteractor: AssetsListInteractor,
externalBalancesInteractor: ExternalBalancesInteractor,
expandableAssetsMixinFactory: ExpandableAssetsMixinFactory
): AssetListMixinFactory {
return AssetListMixinFactory(
walletInteractor,
assetsListInteractor,
externalBalancesInteractor,
expandableAssetsMixinFactory
)
}
@Provides
@IntoMap
@ViewModelKey(BalanceListViewModel::class)
fun provideViewModel(
promotionBannersMixinFactory: PromotionBannersMixinFactory,
bannerSourceFactory: BannersSourceFactory,
walletInteractor: WalletInteractor,
assetsListInteractor: AssetsListInteractor,
selectedAccountUseCase: SelectedAccountUseCase,
router: AssetsRouter,
currencyInteractor: CurrencyInteractor,
balanceBreakdownInteractor: BalanceBreakdownInteractor,
resourceManager: ResourceManager,
walletConnectSessionsUseCase: WalletConnectSessionsUseCase,
swapAvailabilityInteractor: SwapAvailabilityInteractor,
assetListMixinFactory: AssetListMixinFactory,
amountFormatter: AmountFormatter,
buySellSelectorMixinFactory: BuySellSelectorMixinFactory,
multisigPendingOperationsService: MultisigPendingOperationsService,
novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin,
maskableValueFormatterProvider: MaskableValueFormatterProvider,
maskingModeUseCase: MaskingModeUseCase,
fiatFormatter: FiatFormatter,
giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin,
): ViewModel {
return BalanceListViewModel(
promotionBannersMixinFactory = promotionBannersMixinFactory,
bannerSourceFactory = bannerSourceFactory,
walletInteractor = walletInteractor,
assetsListInteractor = assetsListInteractor,
selectedAccountUseCase = selectedAccountUseCase,
router = router,
currencyInteractor = currencyInteractor,
balanceBreakdownInteractor = balanceBreakdownInteractor,
resourceManager = resourceManager,
walletConnectSessionsUseCase = walletConnectSessionsUseCase,
swapAvailabilityInteractor = swapAvailabilityInteractor,
assetListMixinFactory = assetListMixinFactory,
amountFormatter = amountFormatter,
maskableValueFormatterProvider = maskableValueFormatterProvider,
buySellSelectorMixinFactory = buySellSelectorMixinFactory,
multisigPendingOperationsService = multisigPendingOperationsService,
novaCardRestrictionCheckMixin = novaCardRestrictionCheckMixin,
maskingModeUseCase = maskingModeUseCase,
fiatFormatter = fiatFormatter,
giftsRestrictionCheckMixin = giftsRestrictionCheckMixin
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory,
): BalanceListViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(BalanceListViewModel::class.java)
}
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model
import io.novafoundation.nova.common.presentation.LoadingState
typealias NftMedia = String?
typealias NftPreviewUi = LoadingState<NftMedia>

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