Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

## Features
- Full Pezkuwichain support (HEZ & PEZ tokens)
- Polkadot ecosystem compatibility
- Staking, Governance, DeFi, NFTs
- XCM cross-chain transfers
- Hardware wallet support (Ledger, Polkadot Vault)
- WalletConnect v2
- Push notifications

## Languages
- English, Turkish, Kurmanci (Kurdish), Spanish, French, German, Russian, Japanese, Chinese, Korean, Portuguese, Vietnamese

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -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,173 @@
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.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 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,125 @@
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)
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 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,130 @@
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() }
)
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 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,460 @@
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.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
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)
.shareInBackground()
private val balanceHoldsFlow = balanceLocksInteractor.balanceHoldsFlow(assetPayload.chainId, assetPayload.chainAssetId)
.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) }
.shareInBackground()
val giftsButtonEnabled = chainAssetFlow.map {
giftsAvailableGiftAssetsUseCase.isGiftsAvailable(it)
}
.onStart { 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 {
swapAvailabilityInteractor.sync(viewModelScope)
val currency = currencyInteractor.getSelectedCurrency()
val deferredAssetSync = async { walletInteractor.syncAssetsRates(currency) }
val deferredTransactionsSync = async { transactionHistoryMixin.syncFirstOperationsPage() }
awaitAll(deferredAssetSync, deferredTransactionsSync)
_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>
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model
import io.novafoundation.nova.common.presentation.masking.MaskableModel
class TotalBalanceModel(
val isBreakdownAvailable: Boolean,
val totalBalanceFiat: MaskableModel<CharSequence>,
val lockedBalanceFiat: MaskableModel<CharSequence>,
val enableSwap: Boolean
)
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model
class WalletConnectModel(val connections: String?)
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items
import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
interface BalanceListRvItem : ExpandableBaseItem {
val itemId: String
override fun getId(): String {
return itemId
}
}
interface AssetGroupRvItem : BalanceListRvItem
interface AssetRvItem : BalanceListRvItem {
val asset: AssetModel
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.feature_assets.presentation.model.AssetModel
import io.novafoundation.nova.runtime.ext.fullId
data class NetworkAssetUi(override val asset: AssetModel, val icon: Icon) : AssetRvItem {
override val itemId: String = "network_" + asset.token.configuration.fullId.toString()
}

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