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,14 @@
package io.novafoundation.nova.feature_multisig_operations.di
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator
import io.novafoundation.nova.feature_multisig_operations.di.deeplink.MultisigDeepLinks
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
interface MultisigOperationsFeatureApi {
val multisigDeepLinks: MultisigDeepLinks
val multisigOperationDeepLinkConfigurator: MultisigOperationDeepLinkConfigurator
val multisigCallFormatter: MultisigCallFormatter
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_multisig_operations.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_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.deeplink.DeepLinkModule
import io.novafoundation.nova.feature_multisig_operations.presentation.created.di.MultisigCreatedComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.di.MultisigOperationFullDetailsComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.di.MultisigOperationEnterCallComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.list.di.MultisigPendingOperationsComponent
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
MultisigOperationsFeatureDependencies::class,
],
modules = [
MultisigOperationsFeatureModule::class,
DeepLinkModule::class
]
)
@FeatureScope
interface MultisigOperationsFeatureComponent : MultisigOperationsFeatureApi {
fun multisigPendingOperations(): MultisigPendingOperationsComponent.Factory
fun multisigOperationDetails(): MultisigOperationDetailsComponent.Factory
fun multisigOperationFullDetails(): MultisigOperationFullDetailsComponent.Factory
fun multisigOperationEnterCall(): MultisigOperationEnterCallComponent.Factory
fun multisigCreated(): MultisigCreatedComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: MultisigOperationsRouter,
deps: MultisigOperationsFeatureDependencies
): MultisigOperationsFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
RuntimeApi::class,
DbApi::class,
WalletFeatureApi::class,
AccountFeatureApi::class,
DeepLinkingFeatureApi::class
]
)
interface MultisigOperationsFeatureDependenciesComponent : MultisigOperationsFeatureDependencies
}
@@ -0,0 +1,130 @@
package io.novafoundation.nova.feature_multisig_operations.di
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher
import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.DialogMessageManager
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository
import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider
import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity
import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalWithOnChainIdentity
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.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
interface MultisigOperationsFeatureDependencies {
val tokenFormatter: TokenFormatter
val amountFormatter: AmountFormatter
val assetIconProvider: AssetIconProvider
val extrinsicSplitter: ExtrinsicSplitter
val accountRepository: AccountRepository
val extrinsicService: ExtrinsicService
val resourceManager: ResourceManager
val multisigPendingOperationsService: MultisigPendingOperationsService
val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory
val imageLoader: ImageLoader
val externalActions: ExternalActions.Presentation
val validationExecutor: ValidationExecutor
val selectedAccountUseCase: SelectedAccountUseCase
val walletUiUseCase: WalletUiUseCase
val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
val edValidationFactory: EnoughTotalToStayAboveEDValidationFactory
val assetSourceRegistry: AssetSourceRegistry
val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository
val callTraversal: CallTraversal
val addressIconGenerator: AddressIconGenerator
val multisigFormatter: MultisigFormatter
val proxyFormatter: ProxyFormatter
val accountInteractor: AccountInteractor
val arbitraryTokenUseCase: ArbitraryTokenUseCase
val toggleFeatureRepository: ToggleFeatureRepository
val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory
val multisigValidationsRepository: MultisigValidationsRepository
val tokenRepository: TokenRepository
val chainRegistry: ChainRegistry
val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher
val copyTextLauncher: CopyTextLauncher.Presentation
val accountUIUseCase: AccountUIUseCase
val linkBuilderFactory: LinkBuilderFactory
val automaticInteractionGate: AutomaticInteractionGate
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val multisigDetailsRepository: MultisigDetailsRepository
fun dialogMessageManager(): DialogMessageManager
@LocalIdentity
fun localIdentityProvider(): IdentityProvider
@LocalWithOnChainIdentity
fun localWithOnChainIdentityProvider(): IdentityProvider
@ExtrinsicSerialization
fun extrinsicGson(): Gson
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_multisig_operations.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_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class MultisigOperationsFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: MultisigOperationsRouter,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val accountFeatureDependencies = DaggerMultisigOperationsFeatureComponent_MultisigOperationsFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java))
.build()
return DaggerMultisigOperationsFeatureComponent.factory()
.create(
router = router,
deps = accountFeatureDependencies
)
}
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_multisig_operations.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureModule.BindsModule
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegate
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.RealMultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.TransferMultisigActionFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.UtilityBatchesActionFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.SignatoryListFormatter
@Module(includes = [BindsModule::class])
class MultisigOperationsFeatureModule {
@Module
internal interface BindsModule {
@Binds
fun bindMultisigCallFormatter(real: RealMultisigCallFormatter): MultisigCallFormatter
@Binds
@IntoSet
fun bindTransferCallFormatter(real: TransferMultisigActionFormatter): MultisigActionFormatterDelegate
@Binds
@IntoSet
fun bindUtilityBatchCallFormatter(real: UtilityBatchesActionFormatter): MultisigActionFormatterDelegate
}
@Provides
@FeatureScope
fun provideSignatoryListFormatter(
accountInteractor: AccountInteractor,
proxyFormatter: ProxyFormatter,
multisigFormatter: MultisigFormatter
): SignatoryListFormatter {
return SignatoryListFormatter(
accountInteractor,
proxyFormatter,
multisigFormatter
)
}
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_multisig_operations.di.deeplink
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.RealMultisigOperationDeepLinkConfigurator
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.DialogMessageManager
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDetailsDeepLinkHandler
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideDeepLinkConfigurator(
linkBuilderFactory: LinkBuilderFactory
): MultisigOperationDeepLinkConfigurator {
return RealMultisigOperationDeepLinkConfigurator(linkBuilderFactory)
}
@Provides
@FeatureScope
fun provideMultisigOperationDetailsDeepLinkHandler(
router: MultisigOperationsRouter,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
automaticInteractionGate: AutomaticInteractionGate,
dialogMessageManager: DialogMessageManager,
multisigCallFormatter: MultisigCallFormatter,
): MultisigOperationDetailsDeepLinkHandler {
return MultisigOperationDetailsDeepLinkHandler(
router,
accountRepository,
chainRegistry,
automaticInteractionGate,
dialogMessageManager,
multisigCallFormatter
)
}
@Provides
@FeatureScope
fun provideDeepLinks(operationDeepLink: MultisigOperationDetailsDeepLinkHandler): MultisigDeepLinks {
return MultisigDeepLinks(listOf(operationDeepLink))
}
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_multisig_operations.di.deeplink
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
class MultisigDeepLinks(val deepLinkHandlers: List<DeepLinkHandler>)
@@ -0,0 +1,247 @@
package io.novafoundation.nova.feature_multisig_operations.domain.details
import com.google.gson.Gson
import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2
import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.callHash
import io.novafoundation.nova.common.utils.toHex
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository
import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigAsMulti
import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigCancelAsMulti
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId
import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository
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.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.SavedMultisigOperationCall
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_account_api.domain.multisig.intoCallHash
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.queryAccountBalanceCatching
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.ext.utilityAsset
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.getRuntime
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
interface MultisigOperationDetailsInteractor {
suspend fun setCall(operation: PendingMultisigOperation, call: String)
fun callDetails(call: GenericCall.Instance): String
suspend fun callHash(call: GenericCall.Instance, chainId: ChainId): String
suspend fun estimateActionFee(operation: PendingMultisigOperation): Fee?
suspend fun performAction(operation: PendingMultisigOperation): Result<ExtrinsicExecutionResult>
fun signatoryFlow(signatoryMetaId: Long): Flow<MetaAccount>
suspend fun getSignatoryBalance(signatory: MetaAccount, chain: Chain): Result<ChainAssetBalance>
fun isCallValid(operation: PendingMultisigOperation, enteredCall: String): Boolean
fun setSkipRejectConfirmation(value: Boolean)
fun getSkipRejectConfirmation(): Boolean
suspend fun callDataAsString(call: GenericCall.Instance, chainId: ChainId): String
suspend fun isOperationAvailable(operationId: PendingMultisigOperationId): Boolean
}
private const val SKIP_REJECT_CONFIRMATION_KEY = "SKIP_REJECT_CONFIRMATION_KEY"
@FeatureScope
class RealMultisigOperationDetailsInteractor @Inject constructor(
private val extrinsicService: ExtrinsicService,
private val extrinsicSplitter: ExtrinsicSplitter,
private val accountRepository: AccountRepository,
private val assetSourceRegistry: AssetSourceRegistry,
private val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository,
@ExtrinsicSerialization
private val extrinsicGson: Gson,
private val chainRegistry: ChainRegistry,
private val toggleFeatureRepository: ToggleFeatureRepository,
private val multisigDetailsRepository: MultisigDetailsRepository
) : MultisigOperationDetailsInteractor {
override suspend fun setCall(operation: PendingMultisigOperation, call: String) {
val metaAccount = accountRepository.getSelectedMetaAccount()
multisigOperationLocalCallRepository.setMultisigCall(
SavedMultisigOperationCall(
metaId = metaAccount.id,
chainId = operation.chain.id,
callHash = operation.callHash.value,
callInstance = call
)
)
}
override fun callDetails(call: GenericCall.Instance): String {
return extrinsicGson.toJson(call)
}
override suspend fun callHash(call: GenericCall.Instance, chainId: ChainId): String {
val runtime = chainRegistry.getRuntime(chainId)
return call.callHash(runtime).toHexString(withPrefix = true)
}
override suspend fun callDataAsString(call: GenericCall.Instance, chainId: ChainId): String {
val runtime = chainRegistry.getRuntime(chainId)
return call.toHex(runtime)
}
override suspend fun isOperationAvailable(operationId: PendingMultisigOperationId): Boolean {
val chain = chainRegistry.getChain(operationId.chainId)
val metaAccount = accountRepository.getMetaAccount(operationId.metaId)
val callHash = operationId.callHash.intoCallHash()
return multisigDetailsRepository.hasMultisigOperation(chain, metaAccount.requireAccountIdKeyIn(chain), callHash)
}
override suspend fun estimateActionFee(operation: PendingMultisigOperation): Fee? {
val action = operation.userAction().toInternalAction() ?: return null
return when (action) {
Action.APPROVE -> estimateApproveFee(operation)
Action.REJECT -> estimateRejectFee(operation)
}
}
override suspend fun performAction(operation: PendingMultisigOperation): Result<ExtrinsicExecutionResult> {
val action = operation.userAction().toInternalAction() ?: return Result.failure(IllegalStateException("No action found"))
return when (action) {
Action.APPROVE -> performApprove(operation)
Action.REJECT -> performReject(operation)
}
}
override fun signatoryFlow(signatoryMetaId: Long): Flow<MetaAccount> {
return accountRepository.metaAccountFlow(signatoryMetaId)
}
override suspend fun getSignatoryBalance(signatory: MetaAccount, chain: Chain): Result<ChainAssetBalance> {
val asset = chain.utilityAsset
val signatoryAccountId = signatory.requireAccountIdIn(chain)
return assetSourceRegistry.sourceFor(asset).balance.queryAccountBalanceCatching(chain, asset, signatoryAccountId)
}
override fun isCallValid(operation: PendingMultisigOperation, enteredCall: String): Boolean = runCatching {
val operationHash = operation.callHash.value
val enteredHash = enteredCall.callHash()
operationHash.contentEquals(enteredHash)
}.getOrDefault(false)
override fun getSkipRejectConfirmation(): Boolean {
return toggleFeatureRepository.get(SKIP_REJECT_CONFIRMATION_KEY, false)
}
override fun setSkipRejectConfirmation(value: Boolean) {
toggleFeatureRepository.set(SKIP_REJECT_CONFIRMATION_KEY, value)
}
private suspend fun estimateApproveFee(operation: PendingMultisigOperation): Fee? {
if (operation.call == null) return null
return extrinsicService.estimateFee(
chain = operation.chain,
origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId)
) {
// Use zero weight to speed up fee calculation
approve(operation, maxWeight = WeightV2.zero())
}
}
private suspend fun estimateRejectFee(operation: PendingMultisigOperation): Fee? {
return extrinsicService.estimateFee(
chain = operation.chain,
origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId)
) {
// Use zero weight to speed up fee calculation
reject(operation)
}
}
private suspend fun performApprove(operation: PendingMultisigOperation): Result<ExtrinsicExecutionResult> {
val call = operation.call
requireNotNull(call) { "Call data not found" }
return extrinsicService.submitExtrinsicAndAwaitExecution(
chain = operation.chain,
origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId)
) { buildingContext ->
val weight = extrinsicSplitter.estimateCallWeight(buildingContext.signer, call, buildingContext.chain)
approve(operation, weight)
}.requireOk()
}
private suspend fun performReject(operation: PendingMultisigOperation): Result<ExtrinsicExecutionResult> {
return extrinsicService.submitExtrinsicAndAwaitExecution(
chain = operation.chain,
origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId)
) {
reject(operation)
}.requireOk()
}
private suspend fun ExtrinsicBuilder.approve(
operation: PendingMultisigOperation,
maxWeight: WeightV2
) {
val selectedAccount = accountRepository.getSelectedMetaAccount() as MultisigMetaAccount
val approveCall = runtime.composeMultisigAsMulti(
multisigMetaAccount = selectedAccount,
maybeTimePoint = operation.timePoint,
call = operation.call!!,
maxWeight = maxWeight
)
call(approveCall)
}
private suspend fun ExtrinsicBuilder.reject(operation: PendingMultisigOperation) {
val selectedAccount = accountRepository.getSelectedMetaAccount() as MultisigMetaAccount
val approveCall = runtime.composeMultisigCancelAsMulti(
multisigMetaAccount = selectedAccount,
maybeTimePoint = operation.timePoint,
callHash = operation.callHash
)
call(approveCall)
}
private fun MultisigAction.toInternalAction(): Action? {
return when (this) {
is MultisigAction.CanApprove -> Action.APPROVE
is MultisigAction.CanReject -> Action.REJECT
is MultisigAction.Signed -> null
}
}
private enum class Action {
APPROVE, REJECT
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_multisig_operations.domain.details.validations
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
sealed class ApproveMultisigOperationValidationFailure {
class NotEnoughBalanceToPayFees(
val signatory: MetaAccount,
val chainAsset: Chain.Asset,
val minimumNeeded: BigDecimal,
val available: BigDecimal
) : ApproveMultisigOperationValidationFailure()
data object TransactionIsNotAvailable : ApproveMultisigOperationValidationFailure()
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_multisig_operations.domain.details.validations
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class ApproveMultisigOperationValidationPayload(
val fee: Fee,
val signatoryBalance: ChainAssetBalance,
val signatory: MetaAccount,
val operation: PendingMultisigOperation,
val multisig: MultisigMetaAccount
)
val ApproveMultisigOperationValidationPayload.chain: Chain
get() = operation.chain
val ApproveMultisigOperationValidationPayload.multisigAccountId: AccountIdKey
get() = multisig.requireAccountIdKeyIn(chain)
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_multisig_operations.domain.details.validations
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.countedTowardsEdAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.transferableAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance
import io.novafoundation.nova.feature_wallet_api.domain.validation.validate
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
typealias ApproveMultisigOperationValidationSystem = ValidationSystem<ApproveMultisigOperationValidationPayload, ApproveMultisigOperationValidationFailure>
typealias ApproveMultisigOperationValidation = Validation<ApproveMultisigOperationValidationPayload, ApproveMultisigOperationValidationFailure>
typealias ApproveMultisigOperationValidationSystemBuilder =
ValidationSystemBuilder<ApproveMultisigOperationValidationPayload, ApproveMultisigOperationValidationFailure>
fun ValidationSystem.Companion.approveMultisigOperation(
edFactory: EnoughTotalToStayAboveEDValidationFactory,
operationStillPendingValidation: OperationIsStillPendingValidation,
): ApproveMultisigOperationValidationSystem = ValidationSystem {
enoughToPayFeesAndStayAboveEd(edFactory)
enoughToPayFees()
validate(operationStillPendingValidation)
}
private fun ApproveMultisigOperationValidationSystemBuilder.enoughToPayFees() {
sufficientBalance(
fee = { it.fee },
available = { it.signatoryBalance.transferableAmount() },
error = {
ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees(
signatory = it.payload.signatory,
chainAsset = it.payload.signatoryBalance.chainAsset,
minimumNeeded = it.fee,
available = it.payload.signatoryBalance.transferableAmount()
)
}
)
}
private fun ApproveMultisigOperationValidationSystemBuilder.enoughToPayFeesAndStayAboveEd(edFactory: EnoughTotalToStayAboveEDValidationFactory) {
edFactory.validate(
fee = { it.fee },
balance = { it.signatoryBalance.countedTowardsEdAmount() },
chainWithAsset = { ChainWithAsset(it.chain, it.signatoryBalance.chainAsset) },
error = { payload, errorModel ->
ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees(
signatory = payload.signatory,
chainAsset = payload.signatoryBalance.chainAsset,
minimumNeeded = errorModel.minRequiredBalance,
available = errorModel.availableBalance
)
}
)
}
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_multisig_operations.domain.details.validations
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.isTrueOrError
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository
import javax.inject.Inject
@FeatureScope
class OperationIsStillPendingValidation @Inject constructor(
private val multisigValidationsRepository: MultisigValidationsRepository
) : ApproveMultisigOperationValidation {
override suspend fun validate(value: ApproveMultisigOperationValidationPayload): ValidationStatus<ApproveMultisigOperationValidationFailure> {
val hasPendingCallHash = multisigValidationsRepository.hasPendingCallHash(value.chain.id, value.multisigAccountId, value.operation.callHash)
return hasPendingCallHash isTrueOrError {
ApproveMultisigOperationValidationFailure.TransactionIsNotAvailable
}
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_multisig_operations.presentation
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
interface MultisigOperationsRouter : ReturnableRouter {
fun openPendingOperations()
fun openMain()
fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload)
fun openMultisigFullDetails(payload: MultisigOperationPayload)
fun openEnterCallDetails(payload: MultisigOperationPayload)
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class MultisigCallDetailsModel(
val title: String,
val primaryAmount: AmountModel?,
val tableEntries: List<TableEntry>,
val onBehalfOf: AddressModel?
) {
class TableEntry(
val name: String,
val value: TableValue
)
sealed class TableValue {
class Account(val addressModel: AddressModel, val chain: Chain) : TableValue()
}
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
interface MultisigCallFormatter {
suspend fun formatPreview(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain,
): MultisigCallPreviewModel
suspend fun formatDetails(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): MultisigCallDetailsModel
suspend fun formatPushNotificationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): MultisigCallPushNotificationModel
suspend fun formatExecutedOperationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): String
suspend fun formatRejectedOperationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
rejectedAccountName: String,
chain: Chain
): String
}
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting
class MultisigCallNotificationModel(val message: String)
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.common.utils.images.Icon
data class MultisigCallPreviewModel(
val title: String,
val subtitle: String?,
val primaryValue: CharSequence?,
val icon: Icon,
val onBehalfOf: AddressModel?
)
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting
import io.novafoundation.nova.common.address.AddressModel
class MultisigCallPushNotificationModel(
val formattedCall: String,
val onBehalfOf: AddressModel?
)
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetIdWithAmount
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
interface MultisigActionFormatterDelegate {
suspend fun formatPreview(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegatePreviewResult?
suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult?
suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String?
}
class MultisigActionFormatterDelegatePreviewResult(
val title: String,
val subtitle: String?,
val primaryValue: CharSequence?,
val icon: Icon,
)
class MultisigActionFormatterDelegateDetailsResult(
val title: String,
val primaryAmount: ChainAssetIdWithAmount?,
val tableEntries: List<TableEntry>,
) {
class TableEntry(
val name: String,
val value: TableValue
)
sealed class TableValue {
class Account(val accountId: AccountIdKey, val chain: Chain) : TableValue()
}
}
@@ -0,0 +1,306 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.AddressModel
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.common.utils.capitalize
import io.novafoundation.nova.common.utils.images.asIcon
import io.novafoundation.nova.common.utils.splitAndCapitalizeWords
import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider
import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity
import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPreviewModel
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPushNotificationModel
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase
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.TokenConfig
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.collect
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.tryFindNonNull
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import javax.inject.Inject
@FeatureScope
class RealMultisigCallFormatter @Inject constructor(
private val delegates: Set<@JvmSuppressWildcards MultisigActionFormatterDelegate>,
private val resourceManager: ResourceManager,
private val callTraversal: CallTraversal,
@LocalIdentity private val identityProvider: IdentityProvider,
private val addressIconGenerator: AddressIconGenerator,
private val assetIconProvider: AssetIconProvider,
private val tokenUseCase: ArbitraryTokenUseCase,
private val amountFormatter: AmountFormatter
) : MultisigCallFormatter {
override suspend fun formatPreview(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): MultisigCallPreviewModel {
return formatCall(
call = call,
initialOrigin = initialOrigin,
chain = chain,
formatUnknown = ::formatUnknownPreview,
formatDefault = { formatDefaultPreview(it, chain) },
formatSpecific = { delegate, callVisit -> delegate.formatPreview(callVisit, chain) },
constructFinalResult = { delegateResult, onBehalfOf -> createCallPreview(delegateResult, onBehalfOf) }
)
}
override suspend fun formatDetails(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): MultisigCallDetailsModel {
return formatCall(
call = call,
initialOrigin = initialOrigin,
chain = chain,
formatUnknown = ::formatUnknownDetails,
formatDefault = { formatDetails(it) },
formatSpecific = { delegate, callVisit -> delegate.formatDetails(callVisit, chain) },
constructFinalResult = { delegateResult, onBehalfOf -> createCallDetails(delegateResult, onBehalfOf) }
)
}
override suspend fun formatPushNotificationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): MultisigCallPushNotificationModel {
return formatCall(
call = call,
initialOrigin = initialOrigin,
chain = chain,
formatUnknown = { formatPushNotification(chain, pushUnknownCall()) },
formatDefault = { formatPushNotification(chain, it.format()) },
formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) },
constructFinalResult = { delegateResult, onBehalfOf -> formatPushNotification(chain, delegateResult, onBehalfOf) }
)
}
override suspend fun formatExecutedOperationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain
): String {
return formatCall(
call = call,
initialOrigin = initialOrigin,
chain = chain,
formatUnknown = { formatExecutedMessage(chain, dialogUnknownCall()) },
formatDefault = { formatExecutedMessage(chain, it.format()) },
formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) },
constructFinalResult = { delegateResult, _ -> formatExecutedMessage(chain, delegateResult) }
)
}
override suspend fun formatRejectedOperationMessage(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
rejectedAccountName: String,
chain: Chain
): String {
return formatCall(
call = call,
initialOrigin = initialOrigin,
chain = chain,
formatUnknown = { formatRejectedMessage(chain, dialogUnknownCall(), rejectedAccountName) },
formatDefault = { formatRejectedMessage(chain, it.format(), rejectedAccountName) },
formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) },
constructFinalResult = { delegateResult, _ -> formatRejectedMessage(chain, delegateResult, rejectedAccountName) }
)
}
private suspend fun <D : Any, R> formatCall(
call: GenericCall.Instance?,
initialOrigin: AccountIdKey,
chain: Chain,
formatUnknown: () -> R,
formatDefault: suspend (GenericCall.Instance) -> R,
formatSpecific: suspend (MultisigActionFormatterDelegate, CallVisit) -> D?,
constructFinalResult: suspend (D, onBehalfOf: AddressModel?) -> R
): R {
if (call == null) return formatUnknown()
val firstFormattedCall = callTraversal.collect(call, initialOrigin)
.map { formatCallVisit(it) { delegate -> formatSpecific(delegate, it) } }
.getFirstFormated()
return if (firstFormattedCall != null) {
val (singleMatch, singleMatchVisit) = firstFormattedCall
val onBehalfOf = createOnBehalfOf(singleMatchVisit, initialOrigin, chain)
return constructFinalResult(singleMatch!!, onBehalfOf)
} else {
formatDefault(call)
}
}
private fun DelegateResultWithVisit<*>.isFormatted() = first != null
private fun <T> List<DelegateResultWithVisit<T>>.getFirstFormated(): DelegateResultWithVisit<T>? {
return firstOrNull { it.isFormatted() }
}
private fun createCallPreview(
delegateResult: MultisigActionFormatterDelegatePreviewResult,
onBehalfOf: AddressModel?
): MultisigCallPreviewModel {
return with(delegateResult) { MultisigCallPreviewModel(title, subtitle, primaryValue, icon, onBehalfOf) }
}
private suspend fun createCallDetails(
delegateResult: MultisigActionFormatterDelegateDetailsResult,
onBehalfOf: AddressModel?
): MultisigCallDetailsModel {
return MultisigCallDetailsModel(
title = delegateResult.title,
primaryAmount = delegateResult.primaryAmount?.let {
val token = tokenUseCase.getToken(it.chainAssetId)
amountFormatter.formatAmountToAmountModel(
it.amount,
token,
config = AmountConfig(
tokenConfig = TokenConfig(tokenAmountSign = AmountSign.NEGATIVE)
)
)
},
tableEntries = delegateResult.tableEntries.map { it.toUi() },
onBehalfOf = onBehalfOf
)
}
private suspend fun MultisigActionFormatterDelegateDetailsResult.TableEntry.toUi(): MultisigCallDetailsModel.TableEntry {
return MultisigCallDetailsModel.TableEntry(
name = name,
value = value.toUi()
)
}
private suspend fun MultisigActionFormatterDelegateDetailsResult.TableValue.toUi(): MultisigCallDetailsModel.TableValue {
return when (this) {
is MultisigActionFormatterDelegateDetailsResult.TableValue.Account -> {
val addressModel = addressIconGenerator.createAccountAddressModel(
chain = chain,
accountId = accountId.value,
name = identityProvider.identityFor(accountId.value, chain.id)?.name
)
MultisigCallDetailsModel.TableValue.Account(addressModel, chain)
}
}
}
private suspend fun createOnBehalfOf(
callVisit: CallVisit,
initialOrigin: AccountIdKey,
chain: Chain
): AddressModel? {
if (callVisit.callOrigin == initialOrigin) return null
val onBehalfOf = callVisit.callOrigin.value
return addressIconGenerator.createAccountAddressModel(
chain = chain,
accountId = onBehalfOf,
name = identityProvider.identityFor(onBehalfOf, chain.id)?.name
)
}
private suspend fun <D : Any> formatCallVisit(
callVisit: CallVisit,
format: suspend (MultisigActionFormatterDelegate) -> D?
): DelegateResultWithVisit<D> {
val result = delegates.tryFindNonNull { format(it) }
return result to callVisit
}
private fun formatDefaultPreview(call: GenericCall.Instance, chain: Chain): MultisigCallPreviewModel {
return MultisigCallPreviewModel(
title = call.function.name.splitAndCapitalizeWords(),
subtitle = call.module.name.splitAndCapitalizeWords(),
primaryValue = null,
icon = assetIconProvider.multisigFormatAssetIcon(chain),
onBehalfOf = null
)
}
private fun formatUnknownPreview(): MultisigCallPreviewModel {
return MultisigCallPreviewModel(
title = resourceManager.getString(R.string.multisig_operations_unknown_calldata),
subtitle = null,
primaryValue = null,
icon = R.drawable.ic_unknown_operation.asIcon(),
onBehalfOf = null
)
}
private fun formatDetails(call: GenericCall.Instance): MultisigCallDetailsModel {
return MultisigCallDetailsModel(
title = call.function.name.splitAndCapitalizeWords(),
primaryAmount = null,
tableEntries = emptyList(),
onBehalfOf = null
)
}
private fun formatUnknownDetails(): MultisigCallDetailsModel {
return MultisigCallDetailsModel(
title = resourceManager.getString(R.string.multisig_operations_unknown_calldata),
primaryAmount = null,
tableEntries = emptyList(),
onBehalfOf = null
)
}
private fun formatPushNotification(chain: Chain, formattedCall: String, onBehalfOf: AddressModel? = null): MultisigCallPushNotificationModel {
return MultisigCallPushNotificationModel(
resourceManager.getString(
R.string.multisig_notification_init_transaction_message,
formattedCall,
chain.name
),
onBehalfOf = onBehalfOf
)
}
private fun formatExecutedMessage(chain: Chain, formattedCall: String): String {
return resourceManager.getString(
R.string.multisig_transaction_executed_dialog_message,
formattedCall,
chain.name
)
}
private fun formatRejectedMessage(chain: Chain, formattedCall: String, rejectedAccountName: String): String {
return resourceManager.getString(
R.string.multisig_transaction_rejected_dialog_message,
formattedCall,
chain.name,
rejectedAccountName
)
}
private fun pushUnknownCall() = resourceManager.getString(R.string.multisig_operations_unknown_calldata)
private fun dialogUnknownCall() = resourceManager.getString(R.string.multisig_transaction_dialog_message_unknown_call)
private fun GenericCall.Instance.format(): String {
return resourceManager.getString(R.string.multisig_operation_default_call_format, module.name.capitalize(), function.name.capitalize())
}
}
private typealias DelegateResultWithVisit<T> = Pair<T?, CallVisit>
@@ -0,0 +1,95 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.images.asIcon
import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider
import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity
import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegateDetailsResult.TableEntry
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegateDetailsResult.TableValue
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.tryParseTransfer
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.toIdWithAmount
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.TokenConfig
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.tryFindNonNull
import javax.inject.Inject
@FeatureScope
class TransferMultisigActionFormatter @Inject constructor(
@LocalIdentity private val identityProvider: IdentityProvider,
private val assetSourceRegistry: AssetSourceRegistry,
private val resourceManager: ResourceManager,
private val tokenFormatter: TokenFormatter
) : MultisigActionFormatterDelegate {
override suspend fun formatPreview(
visit: CallVisit,
chain: Chain
): MultisigActionFormatterDelegatePreviewResult? {
val parsedTransfer = tryParseTransfer(visit, chain) ?: return null
val destAddress = chain.addressOf(parsedTransfer.destination)
return MultisigActionFormatterDelegatePreviewResult(
title = resourceManager.getString(R.string.transfer_title),
subtitle = resourceManager.getString(R.string.transfer_history_send_to, destAddress),
primaryValue = parsedTransfer.amount.formatAmount(),
icon = R.drawable.ic_arrow_up.asIcon()
)
}
override suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult? {
val parsedTransfer = tryParseTransfer(visit, chain) ?: return null
return MultisigActionFormatterDelegateDetailsResult(
title = resourceManager.getString(R.string.transfer_title),
primaryAmount = parsedTransfer.amount.toIdWithAmount(),
tableEntries = listOf(
TableEntry(
name = resourceManager.getString(R.string.wallet_recipient),
value = TableValue.Account(parsedTransfer.destination, chain)
)
)
)
}
override suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String? {
val parsedTransfer = tryParseTransfer(visit, chain) ?: return null
val accountName = identityProvider.getNameOrAddress(parsedTransfer.destination, chain)
val formattedAmount = parsedTransfer.amount.formatAmount(withSign = false)
return resourceManager.getString(
R.string.multisig_transaction_message_transfer,
formattedAmount,
accountName
)
}
private suspend fun tryParseTransfer(
visit: CallVisit,
chain: Chain
): TransferParsedFromCall? {
return assetSourceRegistry.allSources().tryFindNonNull {
it.transfers.tryParseTransfer(visit.call, chain)
}
}
private fun ChainAssetWithAmount.formatAmount(withSign: Boolean = true): CharSequence {
return if (withSign) {
tokenFormatter.formatToken(amount, chainAsset, config = TokenConfig(tokenAmountSign = AmountSign.NEGATIVE))
} else {
tokenFormatter.formatToken(amount, chainAsset)
}
}
}
@@ -0,0 +1,63 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters
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.common.utils.Modules
import io.novafoundation.nova.common.utils.capitalize
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import javax.inject.Inject
@FeatureScope
class UtilityBatchesActionFormatter @Inject constructor(
private val assetIconProvider: AssetIconProvider,
private val resourceManager: ResourceManager,
) : MultisigActionFormatterDelegate {
override suspend fun formatPreview(
visit: CallVisit,
chain: Chain
): MultisigActionFormatterDelegatePreviewResult? {
val batchCallFormat = visit.formatCall() ?: return null
return MultisigActionFormatterDelegatePreviewResult(
title = batchCallFormat,
subtitle = visit.call.module.name.capitalize(),
primaryValue = null,
icon = assetIconProvider.multisigFormatAssetIcon(chain)
)
}
override suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult? {
val batchCallFormat = visit.formatCall() ?: return null
return MultisigActionFormatterDelegateDetailsResult(
title = batchCallFormat,
primaryAmount = null,
tableEntries = emptyList()
)
}
override suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String? {
val batchCallFormat = visit.formatCall() ?: return null
return resourceManager.getString(
R.string.multisig_operation_default_call_format,
visit.call.module.name.capitalize(),
batchCallFormat
)
}
private fun CallVisit.formatCall(): String? {
if (call.module.name != Modules.UTILITY) return null
return when (call.function.name) {
"batch" -> resourceManager.getString(R.string.multisig_operation_utility_batch_title)
"batch_all" -> resourceManager.getString(R.string.multisig_operation_utility_batch_all_title)
"force_batch" -> resourceManager.getString(R.string.multisig_operation_utility_force_batch_title)
else -> null
}
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters
import io.novafoundation.nova.common.data.model.AssetIconMode
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
fun AssetIconProvider.multisigFormatAssetIcon(chain: Chain) = getAssetIconOrFallback(chain.utilityAsset, AssetIconMode.WHITE)
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.common
import android.os.Parcelable
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId
import kotlinx.parcelize.Parcelize
@Parcelize
class MultisigOperationPayload(
val chainId: String,
val metaId: Long,
val callHash: String
) : Parcelable {
companion object;
}
fun MultisigOperationPayload.Companion.fromOperationId(operationId: PendingMultisigOperationId): MultisigOperationPayload {
return MultisigOperationPayload(
chainId = operationId.chainId,
metaId = operationId.metaId,
callHash = operationId.callHash
)
}
fun MultisigOperationPayload.toOperationId(): PendingMultisigOperationId {
return PendingMultisigOperationId(
chainId = chainId,
metaId = metaId,
callHash = callHash
)
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.created
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.bottomSheet.action.fragment.ActionBottomSheetDialogFragment
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent
class MultisigCreatedBottomSheet : ActionBottomSheetDialogFragment<MultisigCreatedViewModel>() {
companion object : PayloadCreator<MultisigCreatedPayload> by FragmentPayloadCreator()
override fun inject() {
FeatureUtils.getFeature<MultisigOperationsFeatureComponent>(requireContext(), MultisigOperationsFeatureApi::class.java)
.multisigCreated()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: MultisigCreatedViewModel) {}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.created
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class MultisigCreatedPayload(
val walletWasSwitched: Boolean
) : Parcelable
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.created
import android.view.Gravity
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText
import io.novafoundation.nova.common.view.AlertModel
import io.novafoundation.nova.common.view.AlertView
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetPayload
import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.fragment.ActionBottomSheetViewModel
import io.novafoundation.nova.common.view.bottomSheet.action.primary
import io.novafoundation.nova.common.view.bottomSheet.action.secondary
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
class MultisigCreatedViewModel(
private val resourceManager: ResourceManager,
private val router: MultisigOperationsRouter,
private val payload: MultisigCreatedPayload
) : ActionBottomSheetViewModel() {
override fun getPayload(): ActionBottomSheetPayload {
return ActionBottomSheetPayload(
imageRes = R.drawable.ic_multisig,
title = resourceManager.getString(R.string.multisig_transaction_created_title),
subtitle = getSubtitle(),
actionButtonPreferences = ButtonPreferences.primary(primaryButtonText()),
neutralButtonPreferences = ButtonPreferences.secondary(secondaryButtonText()),
alertModel = getAlertModel(),
checkBoxPreferences = null
)
}
private fun getAlertModel(): AlertModel? {
return if (payload.walletWasSwitched) {
AlertModel(
style = AlertView.Style.fromPreset(AlertView.StylePreset.INFO, iconGravity = Gravity.CENTER),
message = resourceManager.getString(R.string.alert_nova_has_selected_ms_wallet)
)
} else {
null
}
}
override fun onActionClicked() {
router.openPendingOperations()
}
private fun getSubtitle() = resourceManager.highlightedText(
mainRes = R.string.multisig_transaction_created_subtitle,
R.string.multisig_transaction_created_subtitle_highlight
)
private fun primaryButtonText() = resourceManager.getString(R.string.multisig_transaction_created_view_details)
private fun secondaryButtonText() = resourceManager.getString(R.string.common_close)
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.created.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_multisig_operations.presentation.created.MultisigCreatedBottomSheet
import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload
@Subcomponent(
modules = [
MultisigCreatedModule::class
]
)
@ScreenScope
interface MultisigCreatedComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: MultisigCreatedPayload
): MultisigCreatedComponent
}
fun inject(fragment: MultisigCreatedBottomSheet)
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.created.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedViewModel
@Module(includes = [ViewModelModule::class])
class MultisigCreatedModule {
@Provides
@IntoMap
@ViewModelKey(MultisigCreatedViewModel::class)
fun provideViewModel(
resourceManager: ResourceManager,
router: MultisigOperationsRouter,
payload: MultisigCreatedPayload
): ViewModel {
return MultisigCreatedViewModel(resourceManager, router, payload)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): MultisigCreatedViewModel {
return ViewModelProvider(
fragment,
viewModelFactory
).get(MultisigCreatedViewModel::class.java)
}
}
@@ -0,0 +1,100 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.ACTION
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CALL_HASH_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CHAIN_ID_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.MULTISIG_ADDRESS_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.SCREEN
import android.net.Uri
import io.novafoundation.nova.common.utils.doIf
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilder
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.ACTOR_IDENTITY_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CALL_DATA_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.OPERATION_STATE_PARAM
import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.SIGNATORY_ADDRESS_PARAM
import io.novafoundation.nova.runtime.ext.ChainGeneses
class MultisigOperationDeepLinkData(
val chainId: String,
val multisigAddress: String,
val signatoryAddress: String,
val callHash: String,
val callData: String?,
val operationState: State?,
) {
sealed interface State {
companion object;
data object Active : State
class Rejected(val actorIdentity: String) : State
class Executed(val actorIdentity: String) : State
}
}
interface MultisigOperationDeepLinkConfigurator : DeepLinkConfigurator<MultisigOperationDeepLinkData> {
companion object {
const val ACTION = "open"
const val SCREEN = "multisigOperation"
const val PREFIX = "/$ACTION/$SCREEN"
const val CHAIN_ID_PARAM = "chainId"
const val MULTISIG_ADDRESS_PARAM = "multisigAddress"
const val SIGNATORY_ADDRESS_PARAM = "signatoryAddress"
const val ACTOR_IDENTITY_PARAM = "actorIdentity"
const val CALL_HASH_PARAM = "callHash"
const val CALL_DATA_PARAM = "callData"
const val OPERATION_STATE_PARAM = "operationState"
}
}
class RealMultisigOperationDeepLinkConfigurator(
private val linkBuilderFactory: LinkBuilderFactory
) : MultisigOperationDeepLinkConfigurator {
override fun configure(payload: MultisigOperationDeepLinkData, type: DeepLinkConfigurator.Type): Uri {
// We not add Polkadot chain id to simplify deep link
val appendChainIdParam = payload.chainId != ChainGeneses.POLKADOT
return linkBuilderFactory.newLink(type)
.setAction(ACTION)
.setScreen(SCREEN)
.doIf(appendChainIdParam) { addParam(CHAIN_ID_PARAM, payload.chainId) }
.addState(payload.operationState)
.addParam(MULTISIG_ADDRESS_PARAM, payload.multisigAddress)
.addParam(SIGNATORY_ADDRESS_PARAM, payload.signatoryAddress)
.addParam(CALL_HASH_PARAM, payload.callHash)
.addParamIfNotNull(CALL_DATA_PARAM, payload.callData)
.build()
}
}
fun Uri.getOperationState(): MultisigOperationDeepLinkData.State? {
return MultisigOperationDeepLinkData.State.fromString(getQueryParameter(OPERATION_STATE_PARAM), this)
}
private fun LinkBuilder.addState(state: MultisigOperationDeepLinkData.State?): LinkBuilder {
return addParamIfNotNull(OPERATION_STATE_PARAM, state?.mapToString())
.addParamIfNotNull(ACTOR_IDENTITY_PARAM, state?.actorIdentityOrNull())
}
private fun MultisigOperationDeepLinkData.State.mapToString() = when (this) {
MultisigOperationDeepLinkData.State.Active -> "active"
is MultisigOperationDeepLinkData.State.Executed -> "executed"
is MultisigOperationDeepLinkData.State.Rejected -> "rejected"
}
private fun MultisigOperationDeepLinkData.State.Companion.fromString(value: String?, uri: Uri) = when (value) {
"active" -> MultisigOperationDeepLinkData.State.Active
"executed" -> MultisigOperationDeepLinkData.State.Executed(uri.getQueryParameter(ACTOR_IDENTITY_PARAM)!!)
"rejected" -> MultisigOperationDeepLinkData.State.Rejected(uri.getQueryParameter(ACTOR_IDENTITY_PARAM)!!)
else -> null
}
fun MultisigOperationDeepLinkData.State.actorIdentityOrNull() = when (this) {
MultisigOperationDeepLinkData.State.Active -> null
is MultisigOperationDeepLinkData.State.Executed -> actorIdentity
is MultisigOperationDeepLinkData.State.Rejected -> actorIdentity
}
@@ -0,0 +1,120 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink
import android.net.Uri
import io.novafoundation.nova.common.utils.DialogMessageManager
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId
import io.novafoundation.nova.feature_account_api.data.multisig.model.create
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn
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_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.toAccountIdKey
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class MultisigOperationDetailsDeepLinkHandler(
private val router: MultisigOperationsRouter,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val automaticInteractionGate: AutomaticInteractionGate,
private val dialogMessageManager: DialogMessageManager,
private val multisigCallFormatter: MultisigCallFormatter,
) : DeepLinkHandler {
override val callbackFlow: Flow<CallbackEvent> = emptyFlow()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(MultisigOperationDeepLinkConfigurator.PREFIX)
}
override suspend fun handleDeepLink(data: Uri): Result<Unit> = runCatching {
automaticInteractionGate.awaitInteractionAllowed()
val chainId = data.getChainId()
val chain = chainRegistry.getChain(chainId)
val multisigAccount = data.getMultisigAddress()?.toAccountIdKey(chain) ?: error("Multisig address not found")
val signatoryAccount = data.getSignatoryAddress()?.toAccountIdKey(chain) ?: error("Signatory address not found")
val callHash = data.getCallHash() ?: error("Call hash not found")
val callData = data.getCallData(chainId)
val operationState = data.getOperationState()
val multisigMetaAccount = accountRepository.getActiveMetaAccounts()
.filterIsInstance<MultisigMetaAccount>()
.firstOrNull { it.accountIdKeyIn(chain) == multisigAccount && it.signatoryAccountId == signatoryAccount }
?: error("Multisig account not found")
accountRepository.selectMetaAccount(multisigMetaAccount.id)
when (operationState) {
null,
MultisigOperationDeepLinkData.State.Active -> {
val operationIdentifier = PendingMultisigOperationId.create(multisigMetaAccount, chain, callHash.removeHexPrefix())
val operationPayload = MultisigOperationPayload.fromOperationId(operationIdentifier)
router.openMultisigOperationDetails(
MultisigOperationDetailsPayload(
operationPayload,
navigationButtonMode = MultisigOperationDetailsPayload.NavigationButtonMode.CLOSE
)
)
}
is MultisigOperationDeepLinkData.State.Executed -> showDialog(
R.string.multisig_transaction_executed_dialog_title,
multisigCallFormatter.formatExecutedOperationMessage(callData, signatoryAccount, chain)
)
is MultisigOperationDeepLinkData.State.Rejected -> showDialog(
R.string.multisig_transaction_rejected_dialog_title,
multisigCallFormatter.formatRejectedOperationMessage(callData, signatoryAccount, operationState.actorIdentity, chain)
)
}
}
private fun showDialog(titleRes: Int, messageText: String) {
dialogMessageManager.showDialog {
setTitle(titleRes)
setMessage(messageText)
setPositiveButton(R.string.common_got_it, null)
}
}
private fun Uri.getChainId(): String {
return getQueryParameter(MultisigOperationDeepLinkConfigurator.CHAIN_ID_PARAM) ?: ChainGeneses.POLKADOT
}
private fun Uri.getMultisigAddress(): String? {
return getQueryParameter(MultisigOperationDeepLinkConfigurator.MULTISIG_ADDRESS_PARAM)
}
private fun Uri.getSignatoryAddress(): String? {
return getQueryParameter(MultisigOperationDeepLinkConfigurator.SIGNATORY_ADDRESS_PARAM)
}
private fun Uri.getCallHash(): String? {
return getQueryParameter(MultisigOperationDeepLinkConfigurator.CALL_HASH_PARAM)
}
private suspend fun Uri.getCallData(chainId: String): GenericCall.Instance? {
val callDataString = getQueryParameter(MultisigOperationDeepLinkConfigurator.CALL_DATA_PARAM) ?: return null
val runtime = chainRegistry.getRuntime(chainId)
return GenericCall.fromHex(runtime, callDataString)
}
}
@@ -0,0 +1,54 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.full
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.copy.setupCopyText
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions
import io.novafoundation.nova.feature_account_api.view.showAccountWithLoading
import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationFullDetailsBinding
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount
class MultisigOperationFullDetailsFragment : BaseFragment<MultisigOperationFullDetailsViewModel, FragmentMultisigOperationFullDetailsBinding>() {
companion object : PayloadCreator<MultisigOperationPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentMultisigOperationFullDetailsBinding.inflate(layoutInflater)
override fun initViews() {
binder.multisigPendingOperationFullDetailsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.multisigPendingOperationDetailsDepositor.setOnClickListener { viewModel.onDepositorClicked() }
binder.multisigPendingOperationDetailsDeposit.setOnClickListener { viewModel.depositClicked() }
binder.multisigPendingOperationDetailsCallHash.setOnClickListener { viewModel.callHashClicked() }
binder.multisigPendingOperationDetailsCallData.setOnClickListener { viewModel.callDataClicked() }
}
override fun inject() {
FeatureUtils.getFeature<MultisigOperationsFeatureComponent>(
requireContext(),
MultisigOperationsFeatureApi::class.java
)
.multisigOperationFullDetails()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: MultisigOperationFullDetailsViewModel) {
observeDescription(viewModel)
setupExternalActions(viewModel)
setupCopyText(viewModel)
viewModel.depositorAccountModel.observe { binder.multisigPendingOperationDetailsDepositor.showAccountWithLoading(it) }
viewModel.depositAmount.observe { binder.multisigPendingOperationDetailsDeposit.showAmount(it) }
viewModel.ellipsizedCallHash.observe { binder.multisigPendingOperationDetailsCallHash.showValueOrHide(it, null) }
viewModel.ellipsizedCallData.observe { binder.multisigPendingOperationDetailsCallData.showValueOrHide(it, null) }
viewModel.formattedCall.observe { binder.multisigPendingOperationDetailsCall.text = it }
}
}
@@ -0,0 +1,120 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.full
import io.novafoundation.nova.common.address.toHexWithPrefix
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher
import io.novafoundation.nova.common.mixin.copy.showCopyCallHash
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.ellipsizeMiddle
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase
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.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.utilityAsset
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private const val CALL_HASH_SHOWN_SYMBOLS = 9
class MultisigOperationFullDetailsViewModel(
private val router: MultisigOperationsRouter,
private val resourceManager: ResourceManager,
private val interactor: MultisigOperationDetailsInteractor,
private val multisigOperationsService: MultisigPendingOperationsService,
private val externalActions: ExternalActions.Presentation,
private val payload: MultisigOperationPayload,
private val accountUIUseCase: AccountUIUseCase,
private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
private val copyTextLauncher: CopyTextLauncher.Presentation,
private val arbitraryTokenUseCase: ArbitraryTokenUseCase,
private val amountFormatter: AmountFormatter
) : BaseViewModel(),
ExternalActions by externalActions,
CopyTextLauncher by copyTextLauncher,
DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher {
fun backClicked() {
router.back()
}
private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.toOperationId())
.filterNotNull()
.shareInBackground()
private val tokenFlow = operationFlow.map {
arbitraryTokenUseCase.getToken(it.chain.utilityAsset.fullId)
}.shareInBackground()
val depositorAccountModel = operationFlow.map {
accountUIUseCase.getAccountModel(it.depositor, it.chain)
}.withSafeLoading()
.shareInBackground()
val depositAmount = combine(operationFlow, tokenFlow) { operation, token ->
amountFormatter.formatAmountToAmountModel(operation.deposit, token)
}.shareInBackground()
private val callDataFlow = operationFlow.map { operation ->
operation.call?.let { interactor.callDataAsString(it, operation.chain.id) }
}.shareInBackground()
val ellipsizedCallData = callDataFlow.map { it?.ellipsizeMiddle(CALL_HASH_SHOWN_SYMBOLS) }
val formattedCall = operationFlow.map { operation ->
operation.call?.let { interactor.callDetails(it) }
}.shareInBackground()
private val callHash = operationFlow.map { operation ->
operation.callHash.toHexWithPrefix()
}.shareInBackground()
val ellipsizedCallHash = callHash.map {
it.ellipsizeMiddle(CALL_HASH_SHOWN_SYMBOLS)
}.shareInBackground()
fun onDepositorClicked() = launchUnit {
val chain = operationFlow.first().chain
depositorAccountModel.first().onLoaded {
externalActions.showAddressActions(it.address(), chain)
}
}
fun callDataClicked() = launchUnit {
val callDataEllipsized = ellipsizedCallData.first() ?: return@launchUnit
val callData = callDataFlow.first() ?: return@launchUnit
copyTextLauncher.showCopyTextDialog(
CopyTextLauncher.Payload(
title = callDataEllipsized.toString(),
textToCopy = callData,
resourceManager.getString(R.string.common_copy_call_data),
resourceManager.getString(R.string.common_share_call_data)
)
)
}
fun depositClicked() {
launchDescriptionBottomSheet(
titleRes = R.string.multisig_deposit,
descriptionRes = R.string.multisig_deposit_description
)
}
fun callHashClicked() = launchUnit {
copyTextLauncher.showCopyCallHash(resourceManager, callHash.first())
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.full.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_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.MultisigOperationFullDetailsFragment
@Subcomponent(
modules = [
MultisigOperationFullDetailsModule::class
]
)
@ScreenScope
interface MultisigOperationFullDetailsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: MultisigOperationPayload,
): MultisigOperationFullDetailsComponent
}
fun inject(fragment: MultisigOperationFullDetailsFragment)
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.full.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.MultisigOperationFullDetailsViewModel
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
@Module(includes = [ViewModelModule::class, BindsModule::class])
class MultisigOperationFullDetailsModule {
@Provides
@IntoMap
@ViewModelKey(MultisigOperationFullDetailsViewModel::class)
fun provideViewModel(
router: MultisigOperationsRouter,
resourceManager: ResourceManager,
interactor: MultisigOperationDetailsInteractor,
multisigOperationsService: MultisigPendingOperationsService,
externalActions: ExternalActions.Presentation,
payload: MultisigOperationPayload,
descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
copyTextLauncher: CopyTextLauncher.Presentation,
accountUIUseCase: AccountUIUseCase,
arbitraryTokenUseCase: ArbitraryTokenUseCase,
amountFormatter: AmountFormatter
): ViewModel {
return MultisigOperationFullDetailsViewModel(
router = router,
resourceManager = resourceManager,
interactor = interactor,
multisigOperationsService = multisigOperationsService,
externalActions = externalActions,
payload = payload,
descriptionBottomSheetLauncher = descriptionBottomSheetLauncher,
copyTextLauncher = copyTextLauncher,
accountUIUseCase = accountUIUseCase,
arbitraryTokenUseCase = arbitraryTokenUseCase,
amountFormatter = amountFormatter
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): MultisigOperationFullDetailsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationFullDetailsViewModel::class.java)
}
}
@@ -0,0 +1,145 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.core.view.isGone
import androidx.core.view.isVisible
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.isLoaded
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.TableCellView
import io.novafoundation.nova.common.view.bindWithHideShowButton
import io.novafoundation.nova.common.view.setExtraInfoAvailable
import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.common.view.shape.addRipple
import io.novafoundation.nova.common.view.shape.getBlockDrawable
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions
import io.novafoundation.nova.feature_account_api.view.showAddress
import io.novafoundation.nova.feature_account_api.view.showAddressOrHide
import io.novafoundation.nova.feature_account_api.view.showChain
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationDetailsBinding
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoriesAdapter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading
import io.novafoundation.nova.feature_wallet_api.presentation.view.amount.setAmountOrHide
class MultisigOperationDetailsFragment : BaseFragment<MultisigOperationDetailsViewModel, FragmentMultisigOperationDetailsBinding>() {
companion object : PayloadCreator<MultisigOperationDetailsPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentMultisigOperationDetailsBinding.inflate(layoutInflater)
private val adapter by lazy(LazyThreadSafetyMode.NONE) { SignatoriesAdapter(viewModel::onSignatoryClicked) }
override fun initViews() {
binder.multisigPendingOperationDetailsToolbar.setHomeButtonIcon(viewModel.getNavigationIconRes())
binder.multisigPendingOperationDetailsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.multisigOperationSignatories.adapter = adapter
binder.multisigPendingOperationDetailsEnterCallData.setOnClickListener { viewModel.enterCallDataClicked() }
binder.multisigPendingOperationDetailsAction.prepareForProgress(viewLifecycleOwner)
binder.multisigPendingOperationDetailsAction.setOnClickListener { viewModel.actionClicked() }
binder.multisigPendingOperationCallDetails.setOnClickListener { viewModel.callDetailsClicked() }
binder.multisigPendingOperationCallDetails.background = with(requireContext()) {
addRipple(getBlockDrawable())
}
binder.multisigPendingOperationDetailsWallet.setOnClickListener { viewModel.walletDetailsClicked() }
binder.multisigPendingOperationDetailsBehalfOf.setOnClickListener { viewModel.behalfOfClicked() }
binder.multisigPendingOperationDetailsSignatory.setOnClickListener { viewModel.signatoryDetailsClicked() }
binder.multisigOperationSignatoriesContainer.bindWithHideShowButton(binder.multisigOperationShowHideButton)
}
override fun inject() {
FeatureUtils.getFeature<MultisigOperationsFeatureComponent>(
requireContext(),
MultisigOperationsFeatureApi::class.java
)
.multisigOperationDetails()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: MultisigOperationDetailsViewModel) {
observeValidations(viewModel)
setupExternalActions(viewModel)
setupFeeLoading(viewModel.feeLoaderMixin, binder.multisigPendingOperationDetailsFee)
observeActionBottomSheet(viewModel.actionBottomSheetLauncher)
setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.operationNotFoundAwaitableAction)
viewModel.isOperationLoadingFlow.observe {
binder.multisigPendingOperationProgress.isVisible = it
binder.multisigPendingOperationDetailsContainer.isGone = it
}
viewModel.showCallButtonState.observe(binder.multisigPendingOperationDetailsEnterCallData::isVisible::set)
viewModel.actionButtonState.observe(binder.multisigPendingOperationDetailsAction::setState)
viewModel.buttonAppearance.observe(binder.multisigPendingOperationDetailsAction::setAppearance)
viewModel.chainUiFlow.observe(binder.multisigPendingOperationDetailsNetwork::showChain)
viewModel.walletFlow.observe(binder.multisigPendingOperationDetailsWallet::showWallet)
viewModel.formattedCall.observe {
binder.multisigPendingOperationDetailsBehalfOf.showAddressOrHide(it.onBehalfOf)
binder.multisigPendingOperationDetailsToolbar.setTitle(it.title)
binder.multisigPendingOperationPrimaryAmount.setAmountOrHide(it.primaryAmount)
showFormattedCallTable(it.tableEntries)
}
viewModel.signatoryAccount.observe(binder.multisigPendingOperationDetailsSignatory::showWallet)
viewModel.signatoriesTitle.observe(binder.multisigOperationSignatoriesTitle::setText)
viewModel.formattedSignatories.observe { signatoriesLoadingState ->
binder.multisigOperationSignatoriesShimmering.isVisible = signatoriesLoadingState.isLoading
binder.multisigOperationSignatories.isVisible = signatoriesLoadingState.isLoaded()
signatoriesLoadingState.onLoaded { adapter.submitList(it) }
}
viewModel.callDetailsVisible.observe(binder.multisigPendingOperationCallDetails::setVisible)
}
private fun showFormattedCallTable(tableEntries: List<MultisigCallDetailsModel.TableEntry>) {
binder.multisigPendingOperationDetailsCallTable.removeAllViews()
tableEntries.forEach {
val entryView = createFormattedCallEntryView(it)
binder.multisigPendingOperationDetailsCallTable.addView(entryView)
}
binder.multisigPendingOperationDetailsCallTable.invalidateChildrenVisibility()
}
private fun createFormattedCallEntryView(entry: MultisigCallDetailsModel.TableEntry): TableCellView {
return TableCellView(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
setTitle(entry.name)
when (val value = entry.value) {
is MultisigCallDetailsModel.TableValue.Account -> {
setExtraInfoAvailable(true)
showAddress(value.addressModel)
setOnClickListener { viewModel.onTableAccountClicked(value) }
}
}
}
}
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general
import android.os.Parcelable
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import kotlinx.parcelize.Parcelize
@Parcelize
class MultisigOperationDetailsPayload(
val operation: MultisigOperationPayload,
val navigationButtonMode: NavigationButtonMode = NavigationButtonMode.BACK
) : Parcelable {
enum class NavigationButtonMode {
BACK, CLOSE
}
}
@@ -0,0 +1,427 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.base.TitleAndMessage
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.dataOrNull
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.bold
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter
import io.novafoundation.nova.common.utils.formatting.spannable.format
import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.withLoadingShared
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.common.view.PrimaryButton
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences
import io.novafoundation.nova.common.view.bottomSheet.action.CheckBoxPreferences
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.allSignatories
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationFailure
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationPayload
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationSystem
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoryRvItem
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.runtime.ext.utilityAsset
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class MultisigOperationDetailsViewModel(
private val router: MultisigOperationsRouter,
private val resourceManager: ResourceManager,
private val interactor: MultisigOperationDetailsInteractor,
private val multisigOperationsService: MultisigPendingOperationsService,
private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory,
private val externalActions: ExternalActions.Presentation,
private val validationExecutor: ValidationExecutor,
private val payload: MultisigOperationDetailsPayload,
private val validationSystem: ApproveMultisigOperationValidationSystem,
private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper,
private val signatoryListFormatter: SignatoryListFormatter,
private val multisigCallFormatter: MultisigCallFormatter,
private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory,
private val accountInteractor: AccountInteractor,
private val accountUIUseCase: AccountUIUseCase,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val amountFormatter: AmountFormatter,
selectedAccountUseCase: SelectedAccountUseCase,
walletUiUseCase: WalletUiUseCase,
) : BaseViewModel(),
Validatable by validationExecutor,
ExternalActions by externalActions {
val operationNotFoundAwaitableAction = actionAwaitableMixinFactory.confirmingAction<ConfirmationDialogInfo>()
private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.operation.toOperationId())
.filterNotNull()
.shareInBackground()
private val isLastOperationFlow = flowOf {
val operationsCount = multisigOperationsService.getPendingOperationsCount()
operationsCount == 1
}
.shareInBackground()
private val chainFlow = operationFlow.map { it.chain }
.shareInBackground()
private val chainAssetFlow = chainFlow.map { it.utilityAsset }
.shareInBackground()
val chainUiFlow = chainFlow.map { mapChainToUi(it) }
.shareInBackground()
private val selectedAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow()
.filterIsInstance<MultisigMetaAccount>()
.shareInBackground()
val walletFlow = walletUiUseCase.selectedWalletUiFlow(showAddressIcon = true)
.shareInBackground()
val formattedCall = combine(
selectedAccountFlow,
operationFlow
) { metaAccount, operation ->
val initialOrigin = metaAccount.requireAccountIdKeyIn(operation.chain)
multisigCallFormatter.formatDetails(operation.call, initialOrigin, operation.chain)
}.shareInBackground()
private val signatory = operationFlow
.map { it.signatoryMetaId }
.distinctUntilChanged()
.flatMapLatest(interactor::signatoryFlow)
.shareInBackground()
val signatoryAccount = signatory.map { walletUiUseCase.walletUiFor(it) }
.shareInBackground()
val signatoriesTitle = combine(
selectedAccountFlow,
operationFlow
) { metaAccount, operation ->
resourceManager.getString(R.string.multisig_operation_details_signatories, operation.approvals.size, metaAccount.threshold)
}.shareInBackground()
private val signatoryAccounts = selectedAccountFlow.map { it.allSignatories() }
.distinctUntilChanged()
.map { accountUIUseCase.getAccountModels(it, chainFlow.first()) }
.shareInBackground()
val formattedSignatories = combine(
signatory,
signatoryAccounts,
operationFlow
) { currentSignatory, allSignatories, operation ->
signatoryListFormatter.formatSignatories(
chain = chainFlow.first(),
currentSignatory = currentSignatory,
signatories = allSignatories,
approvals = operation.approvals.toSet()
)
}.withLoadingShared()
.shareInBackground()
private val showNextProgress = MutableStateFlow(false)
val feeLoaderMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, chainAssetFlow)
val showCallButtonState = operationFlow.map { it.call == null }
.shareInBackground()
val actionButtonState = combine(showNextProgress, operationFlow) { submissionInProgress, operation ->
val action = operation.userAction()
when {
submissionInProgress -> DescriptiveButtonState.Loading
action is MultisigAction.CanApprove -> when {
operation.call == null -> DescriptiveButtonState.Gone
action.isFinalApproval -> DescriptiveButtonState.Enabled(
action = resourceManager.getString(R.string.multisig_operation_details_approve_and_execute)
)
else -> DescriptiveButtonState.Enabled(
action = resourceManager.getString(R.string.multisig_operation_details_approve)
)
}
action is MultisigAction.CanReject -> DescriptiveButtonState.Enabled(
action = resourceManager.getString(R.string.multisig_operation_details_reject)
)
else -> DescriptiveButtonState.Gone
}
}.shareInBackground()
val buttonAppearance = operationFlow.map { operation ->
when {
operation.userAction() is MultisigAction.CanReject -> PrimaryButton.Appearance.PRIMARY_NEGATIVE
else -> PrimaryButton.Appearance.PRIMARY
}
}.shareInBackground()
val callDetailsVisible = operationFlow
.map { operation -> operation.call != null }
.shareInBackground()
val actionBottomSheetLauncher = actionBottomSheetLauncherFactory.create()
val isOperationLoadingFlow = operationFlow.withLoadingShared()
.map { it.isLoading }
.shareInBackground()
init {
checkOperationAvailability()
loadFee()
}
private fun checkOperationAvailability() = launchUnit {
val isOperationAvailable = interactor.isOperationAvailable(payload.operation.toOperationId())
if (!isOperationAvailable) {
showErrorAndCloseScreen()
}
}
fun enterCallDataClicked() {
router.openEnterCallDetails(payload.operation)
}
fun actionClicked() {
launch {
val operation = operationFlow.first()
val isReject = operation.userAction() == MultisigAction.CanReject
if (isReject) {
confirmReject(operation.getDepositorName())
}
sendTransactionIfValid()
}
}
private suspend fun PendingMultisigOperation.getDepositorName(): String {
val depositorAccount = withContext(Dispatchers.Default) { accountInteractor.findMetaAccount(chain, depositor.value) }
return depositorAccount?.name ?: chain.addressOf(depositor)
}
private suspend fun confirmReject(depositorName: String) = suspendCancellableCoroutine<Unit> {
if (interactor.getSkipRejectConfirmation()) {
it.resume(Unit)
return@suspendCancellableCoroutine
}
var isAutoContinueChecked = false
actionBottomSheetLauncher.launchBottomSheet(
imageRes = R.drawable.ic_multisig,
title = resourceManager.getString(R.string.multisig_signing_warning_title),
subtitle = resourceManager.highlightedText(R.string.multisig_signing_reject_confirmation_subtitle, depositorName),
actionButtonPreferences = ButtonPreferences(
text = resourceManager.getString(R.string.common_confirm),
style = PrimaryButton.Appearance.PRIMARY,
onClick = {
interactor.setSkipRejectConfirmation(isAutoContinueChecked)
it.resume(Unit)
}
),
neutralButtonPreferences = ButtonPreferences(
text = resourceManager.getString(R.string.common_cancel),
style = PrimaryButton.Appearance.SECONDARY,
onClick = { it.cancel() }
),
checkBoxPreferences = CheckBoxPreferences(
text = resourceManager.getString(R.string.common_check_box_auto_continue),
onCheckChanged = { isAutoContinueChecked = it }
)
)
}
fun backClicked() {
router.back()
}
fun callDetailsClicked() = launch {
router.openMultisigFullDetails(payload.operation)
}
private fun loadFee() {
feeLoaderMixin.connectWith(operationFlow) { _, operation ->
interactor.estimateActionFee(operation)
}
}
private fun sendTransactionIfValid() = launchUnit {
showNextProgress.value = true
val signatory = signatory.first()
val operation = operationFlow.first()
val signatoryBalance = interactor.getSignatoryBalance(signatory, operation.chain)
.onFailure {
showError(it)
showNextProgress.value = false
}
.getOrNull() ?: return@launchUnit
val payload = ApproveMultisigOperationValidationPayload(
fee = feeLoaderMixin.awaitFee(),
signatoryBalance = signatoryBalance,
signatory = signatory,
operation = operation,
multisig = selectedAccountFlow.first()
)
validationExecutor.requireValid(
validationSystem = validationSystem,
payload = payload,
validationFailureTransformer = ::formatValidationFailure,
progressConsumer = showNextProgress.progressConsumer()
) {
sendTransaction()
}
}
private fun formatValidationFailure(failure: ApproveMultisigOperationValidationFailure): TitleAndMessage {
return when (failure) {
is ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees -> {
val title = resourceManager.getString(R.string.common_error_not_enough_tokens)
val message = SpannableFormatter.format(
resourceManager,
R.string.multisig_signatory_validation_ed,
failure.signatory.name.bold(),
failure.minimumNeeded.formatTokenAmount(failure.chainAsset),
failure.available.formatTokenAmount(failure.chainAsset),
)
title to message
}
ApproveMultisigOperationValidationFailure.TransactionIsNotAvailable -> {
resourceManager.getString(R.string.multisig_approve_transaction_unavailable_title) to
resourceManager.getString(R.string.multisig_approve_transaction_unavailable_message)
}
}
}
private fun sendTransaction() = launch {
interactor.performAction(operationFlow.first())
.onFailure(::showError)
.onSuccess {
showToast(resourceManager.getString(R.string.common_transaction_submitted))
extrinsicNavigationWrapper.startNavigation(it.submissionHierarchy) {
val isLeastOperation = isLastOperationFlow.first()
if (isLeastOperation) {
router.openMain()
} else {
router.back()
}
}
}
showNextProgress.value = false
}
fun onSignatoryClicked(signatoryRvItem: SignatoryRvItem) = launchUnit {
showAddressActionForOriginChain(signatoryRvItem.accountModel.address())
}
fun walletDetailsClicked() = launchUnit {
val metaAccount = selectedAccountFlow.first()
val chain = chainFlow.first()
externalActions.showAddressActions(metaAccount, chain)
}
fun onTableAccountClicked(tableAccount: MultisigCallDetailsModel.TableValue.Account) = launchUnit {
externalActions.showAddressActions(tableAccount.addressModel.address, tableAccount.chain)
}
fun behalfOfClicked() = launchUnit {
val behalfOf = formattedCall.first().onBehalfOf ?: return@launchUnit
showAddressActionForOriginChain(behalfOf.address)
}
fun signatoryDetailsClicked() = launchUnit {
val metaAccount = signatory.first()
val chain = chainFlow.first()
externalActions.showAddressActions(metaAccount, chain)
}
fun getNavigationIconRes() = when (payload.navigationButtonMode) {
MultisigOperationDetailsPayload.NavigationButtonMode.BACK -> R.drawable.ic_arrow_back
MultisigOperationDetailsPayload.NavigationButtonMode.CLOSE -> R.drawable.ic_close
}
private suspend fun showAddressActionForOriginChain(address: String) {
externalActions.showAddressActions(address, chainFlow.first())
}
private suspend fun showErrorAndCloseScreen() {
val confirmationInfo = ConfirmationDialogInfo.fromRes(
resourceManager,
title = R.string.multisig_operation_details_not_found_title,
message = R.string.multisig_operation_details_not_found_message,
positiveButton = R.string.common_got_it,
negativeButton = null
)
operationNotFoundAwaitableAction.awaitAction(confirmationInfo)
router.back()
}
private fun isOperationWasExecuted(
old: ExtendedLoadingState<Boolean>,
new: ExtendedLoadingState<Boolean>
) = old.dataOrNull == true && new.dataOrNull == false
}
@@ -0,0 +1,129 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.list.GroupedList
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel
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.domain.model.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoryRvItem
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class SignatoryListFormatter(
private val accountInteractor: AccountInteractor,
private val proxyFormatter: ProxyFormatter,
private val multisigFormatter: MultisigFormatter
) {
private class FormattingContext(
val currentSignatoryAccountId: AccountIdKey,
val currentSignatory: MetaAccount,
val metaAccountByAccountIds: GroupedList<AccountIdKey, MetaAccount>,
val metaAccountsByMetaId: Map<Long, MetaAccount>,
val approvals: Set<AccountIdKey>
) {
/**
* We try to show the most relevant account to user.
* For signatory account that associated with current multisig account we like to show the same account
*/
fun account(accountId: AccountIdKey): MetaAccount? {
if (currentSignatoryAccountId == accountId) return currentSignatory
return metaAccountByAccountIds[accountId]
?.findRelevantAccountToShow()
}
fun account(metaId: Long) = metaAccountsByMetaId[metaId]
fun isApprovedBy(accountId: AccountIdKey): Boolean = accountId in approvals
}
suspend fun formatSignatories(
chain: Chain,
currentSignatory: MetaAccount,
signatories: Map<AccountIdKey, AccountModel>,
approvals: Set<AccountIdKey>
): List<SignatoryRvItem> {
val metaAccounts = accountInteractor.getActiveMetaAccounts()
val formattingContext = formattingContext(chain, currentSignatory, metaAccounts, approvals)
return signatories.map { (signatoryAccountId, signatoryAccountModel) ->
val maybeMetaAccount = formattingContext.account(signatoryAccountId)
SignatoryRvItem(
accountModel = signatoryAccountModel,
subtitle = maybeMetaAccount?.formatSubtitle(formattingContext),
isApproved = formattingContext.isApprovedBy(signatoryAccountId)
)
}
}
private fun formattingContext(
chain: Chain,
currentSignatory: MetaAccount,
metaAccounts: List<MetaAccount>,
approvals: Set<AccountIdKey>
): FormattingContext {
val metaAccountsByAccountIds = metaAccounts
.filter { it.hasAccountIn(chain) }
.groupBy { it.requireAccountIdKeyIn(chain) }
val metaAccountsByMetaIds = metaAccounts.associateBy { it.id }
return FormattingContext(
currentSignatory.requireAccountIdKeyIn(chain),
currentSignatory,
metaAccountsByAccountIds,
metaAccountsByMetaIds,
approvals
)
}
private suspend fun MetaAccount.formatSubtitle(context: FormattingContext): CharSequence? = when (this) {
is ProxiedMetaAccount -> formatSubtitle(context)
is MultisigMetaAccount -> formatSubtitle(context)
else -> null
}
private suspend fun ProxiedMetaAccount.formatSubtitle(context: FormattingContext): CharSequence? {
val proxyMetaAccount = context.account(this.proxy.proxyMetaId) ?: return null
return proxyFormatter.mapProxyMetaAccountSubtitle(
proxyMetaAccount.name,
proxyFormatter.makeAccountDrawable(proxyMetaAccount),
proxy
)
}
private suspend fun MultisigMetaAccount.formatSubtitle(context: FormattingContext): CharSequence? {
val signatory = context.account(this.signatoryMetaId) ?: return null
return multisigFormatter.formatSignatorySubtitle(signatory)
}
}
/**
* Since we may have multiple accounts by one account id it would be better to show the most relevant to user.
* For example we show secrets-account instead of the proxied-account
*/
private fun Collection<MetaAccount>.findRelevantAccountToShow(): MetaAccount? {
return minByOrNull { it.priorityToShowAsSignatory() }
}
private fun MetaAccount.priorityToShowAsSignatory() = when (type) {
LightMetaAccount.Type.SECRETS -> 0
LightMetaAccount.Type.PARITY_SIGNER,
LightMetaAccount.Type.LEDGER_LEGACY,
LightMetaAccount.Type.LEDGER,
LightMetaAccount.Type.POLKADOT_VAULT,
LightMetaAccount.Type.WATCH_ONLY -> 1
LightMetaAccount.Type.PROXIED,
LightMetaAccount.Type.MULTISIG -> 2
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import coil.clear
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_account_api.presenatation.account.common.relevantEllipsizeMode
import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigSignatoryAccountBinding
class SignatoriesAdapter(
private val handler: Handler
) : ListAdapter<SignatoryRvItem, SignatoryViewHolder>(SignatoriesDiffCallback()) {
fun interface Handler {
fun onSignatoryClicked(signatory: SignatoryRvItem)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SignatoryViewHolder {
return SignatoryViewHolder(
ItemMultisigSignatoryAccountBinding.inflate(parent.inflater(), parent, false),
handler
)
}
override fun onBindViewHolder(holder: SignatoryViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
class SignatoryViewHolder(
private val binder: ItemMultisigSignatoryAccountBinding,
private val itemHandler: SignatoriesAdapter.Handler,
) : BaseViewHolder(binder.root) {
fun bind(item: SignatoryRvItem) = with(binder) {
root.setOnClickListener { itemHandler.onSignatoryClicked(item) }
itemSignatoryAccountIcon.setImageDrawable(item.accountModel.drawable())
itemSignatoryAccountSelected.setVisible(item.isApproved, falseState = View.INVISIBLE)
itemSignatoryAccountTitle.text = item.accountModel.nameOrAddress()
itemSignatoryAccountTitle.ellipsize = item.accountModel.relevantEllipsizeMode()
itemSignatoryAccountSubtitle.setTextOrHide(item.subtitle)
}
override fun unbind() {
with(binder) {
itemSignatoryAccountIcon.clear()
}
}
}
class SignatoriesDiffCallback : DiffUtil.ItemCallback<SignatoryRvItem>() {
override fun areItemsTheSame(oldItem: SignatoryRvItem, newItem: SignatoryRvItem): Boolean {
return oldItem.accountModel == newItem.accountModel
}
override fun areContentsTheSame(oldItem: SignatoryRvItem, newItem: SignatoryRvItem): Boolean {
return oldItem == newItem
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel
data class SignatoryRvItem(
val accountModel: AccountModel,
val subtitle: CharSequence?,
val isApproved: Boolean,
)
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.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_multisig_operations.presentation.details.general.MultisigOperationDetailsFragment
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
@Subcomponent(
modules = [
MultisigOperationDetailsModule::class
]
)
@ScreenScope
interface MultisigOperationDetailsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: MultisigOperationDetailsPayload,
): MultisigOperationDetailsComponent
}
fun inject(fragment: MultisigOperationDetailsFragment)
}
@@ -0,0 +1,113 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Binds
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.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
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.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.domain.details.RealMultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationSystem
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.OperationIsStillPendingValidation
import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.approveMultisigOperation
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsViewModel
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.SignatoryListFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
@Module(includes = [ViewModelModule::class, BindsModule::class])
class MultisigOperationDetailsModule {
@Module
interface BindsModule {
@Binds
fun bindInteractor(real: RealMultisigOperationDetailsInteractor): MultisigOperationDetailsInteractor
}
@Provides
@ScreenScope
fun provideValidationSystem(
edValidationFactory: EnoughTotalToStayAboveEDValidationFactory,
operationIsStillPendingValidation: OperationIsStillPendingValidation
): ApproveMultisigOperationValidationSystem {
return ValidationSystem.approveMultisigOperation(edValidationFactory, operationIsStillPendingValidation)
}
@Provides
@IntoMap
@ViewModelKey(MultisigOperationDetailsViewModel::class)
fun provideViewModel(
router: MultisigOperationsRouter,
resourceManager: ResourceManager,
interactor: MultisigOperationDetailsInteractor,
multisigOperationsService: MultisigPendingOperationsService,
feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory,
externalActions: ExternalActions.Presentation,
validationExecutor: ValidationExecutor,
payload: MultisigOperationDetailsPayload,
selectedAccountUseCase: SelectedAccountUseCase,
validationSystem: ApproveMultisigOperationValidationSystem,
extrinsicNavigationWrapper: ExtrinsicNavigationWrapper,
signatoryListFormatter: SignatoryListFormatter,
walletUiUseCase: WalletUiUseCase,
multisigCallFormatter: MultisigCallFormatter,
actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory,
accountInteractor: AccountInteractor,
accountUIUseCase: AccountUIUseCase,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
amountFormatter: AmountFormatter,
): ViewModel {
return MultisigOperationDetailsViewModel(
router = router,
resourceManager = resourceManager,
interactor = interactor,
multisigOperationsService = multisigOperationsService,
feeLoaderMixinV2Factory = feeLoaderMixinV2Factory,
externalActions = externalActions,
validationExecutor = validationExecutor,
payload = payload,
selectedAccountUseCase = selectedAccountUseCase,
walletUiUseCase = walletUiUseCase,
validationSystem = validationSystem,
extrinsicNavigationWrapper = extrinsicNavigationWrapper,
signatoryListFormatter = signatoryListFormatter,
multisigCallFormatter = multisigCallFormatter,
actionBottomSheetLauncherFactory = actionBottomSheetLauncherFactory,
accountInteractor = accountInteractor,
accountUIUseCase = accountUIUseCase,
actionAwaitableMixinFactory = actionAwaitableMixinFactory,
amountFormatter = amountFormatter
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): MultisigOperationDetailsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationDetailsViewModel::class.java)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall
import android.view.View
import androidx.lifecycle.lifecycleScope
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.insets.applySystemBarInsets
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.utils.insets.ImeInsetsState
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationEnterCallBinding
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
class MultisigOperationEnterCallFragment : BaseFragment<MultisigOperationEnterCallViewModel, FragmentMultisigOperationEnterCallBinding>() {
companion object : PayloadCreator<MultisigOperationPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentMultisigOperationEnterCallBinding.inflate(layoutInflater)
override fun inject() {
FeatureUtils.getFeature<MultisigOperationsFeatureComponent>(
requireContext(),
MultisigOperationsFeatureApi::class.java
)
.multisigOperationEnterCall()
.create(this, payload())
.inject(this)
}
override fun applyInsets(rootView: View) {
binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED)
}
override fun initViews() {
binder.multisigOperationEnterCallToolbar.setHomeButtonListener { viewModel.back() }
binder.multisigOperationEnterCallAction.setOnClickListener { viewModel.approve() }
}
override fun subscribe(viewModel: MultisigOperationEnterCallViewModel) {
binder.multisigOperationEnterCallInput.content.bindTo(viewModel.enteredCall, viewLifecycleOwner.lifecycleScope)
viewModel.buttonState.observe {
binder.multisigOperationEnterCallAction.setState(it)
}
}
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.callHashString
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
class MultisigOperationEnterCallViewModel(
private val router: MultisigOperationsRouter,
private val interactor: MultisigOperationDetailsInteractor,
private val multisigOperationsService: MultisigPendingOperationsService,
private val payload: MultisigOperationPayload,
private val resourceManager: ResourceManager
) : BaseViewModel() {
val enteredCall = MutableStateFlow("")
val buttonState = enteredCall.map {
when {
it.isBlank() -> DescriptiveButtonState.Disabled(reason = resourceManager.getString(R.string.enter_call_data_title))
else -> DescriptiveButtonState.Enabled(action = resourceManager.getString(R.string.common_save))
}
}
fun back() {
router.back()
}
fun approve() = launchUnit {
val operation = multisigOperationsService.pendingOperation(payload.toOperationId()) ?: return@launchUnit
if (interactor.isCallValid(operation, enteredCall.value)) {
interactor.setCall(operation, enteredCall.value)
router.back()
} else {
onCallInvalid(enteredCall.value)
}
}
private fun onCallInvalid(enteredCall: String) = try {
val callHash = enteredCall.callHashString()
showError(
resourceManager.getString(R.string.invalid_call_data_title),
resourceManager.getString(R.string.invalid_call_data_message, callHash)
)
} catch (e: Exception) {
showError(
resourceManager.getString(R.string.invalid_call_data_title),
resourceManager.getString(R.string.invalid_call_data_format_message)
)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.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_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.MultisigOperationEnterCallFragment
@Subcomponent(
modules = [
MultisigOperationEnterCallModule::class
]
)
@ScreenScope
interface MultisigOperationEnterCallComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: MultisigOperationPayload,
): MultisigOperationEnterCallComponent
}
fun inject(fragment: MultisigOperationEnterCallFragment)
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.domain.details.RealMultisigOperationDetailsInteractor
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule
import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.MultisigOperationEnterCallViewModel
@Module(includes = [ViewModelModule::class, BindsModule::class])
class MultisigOperationEnterCallModule {
@Module
interface BindsModule {
@Binds
fun bindInteractor(real: RealMultisigOperationDetailsInteractor): MultisigOperationDetailsInteractor
}
@Provides
@IntoMap
@ViewModelKey(MultisigOperationEnterCallViewModel::class)
fun provideViewModel(
router: MultisigOperationsRouter,
interactor: MultisigOperationDetailsInteractor,
multisigOperationsService: MultisigPendingOperationsService,
payload: MultisigOperationPayload,
resourceManager: ResourceManager
): ViewModel {
return MultisigOperationEnterCallViewModel(
router = router,
interactor = interactor,
multisigOperationsService = multisigOperationsService,
payload = payload,
resourceManager = resourceManager,
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): MultisigOperationEnterCallViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationEnterCallViewModel::class.java)
}
}
@@ -0,0 +1,127 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.list
import android.view.ViewGroup
import coil.ImageLoader
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.presentation.setColoredText
import io.novafoundation.nova.common.utils.formatting.formatDaysSinceEpoch
import io.novafoundation.nova.common.utils.images.setIcon
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.letOrHide
import io.novafoundation.nova.common.utils.setDrawableEnd
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.view.shape.addRipple
import io.novafoundation.nova.common.view.shape.getBottomRoundedCornerDrawable
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigPendingOperationBinding
import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigPendingOperationHeaderBinding
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationHeaderModel
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel
class MultisigPendingOperationsAdapter(
private val handler: ItemHandler,
private val imageLoader: ImageLoader,
) : GroupedListAdapter<PendingMultisigOperationHeaderModel, PendingMultisigOperationModel>(PendingMultisigOperationDiffCallback()) {
interface ItemHandler {
fun itemClicked(model: PendingMultisigOperationModel)
}
override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder {
return MultisigPendingOperationHeaderHolder(
viewBinding = ItemMultisigPendingOperationHeaderBinding.inflate(parent.inflater(), parent, false)
)
}
override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder {
return MultisigPendingOperationHolder(
viewBinding = ItemMultisigPendingOperationBinding.inflate(parent.inflater(), parent, false),
itemHandler = handler,
imageLoader = imageLoader
)
}
override fun bindChild(holder: GroupedListHolder, child: PendingMultisigOperationModel) {
(holder as MultisigPendingOperationHolder).bind(child)
}
override fun bindGroup(holder: GroupedListHolder, group: PendingMultisigOperationHeaderModel) {
(holder as MultisigPendingOperationHeaderHolder).bind(group)
}
}
class MultisigPendingOperationHeaderHolder(
private val viewBinding: ItemMultisigPendingOperationHeaderBinding,
) : GroupedListHolder(viewBinding.root) {
fun bind(model: PendingMultisigOperationHeaderModel) {
viewBinding.itemMultisigPendingOperationHeader.text = model.daysSinceEpoch.formatDaysSinceEpoch(viewBinding.root.context)
}
}
class MultisigPendingOperationHolder(
private val viewBinding: ItemMultisigPendingOperationBinding,
private val itemHandler: MultisigPendingOperationsAdapter.ItemHandler,
private val imageLoader: ImageLoader,
) : GroupedListHolder(viewBinding.root) {
init {
with(viewBinding.root) {
background = context.addRipple(context.getRoundedCornerDrawable(R.color.block_background))
}
with(viewBinding.itemPendingOperationOnBehalfOfContainer) {
background = context.getBottomRoundedCornerDrawable(R.color.block_background)
}
}
fun bind(model: PendingMultisigOperationModel) = with(viewBinding) {
itemPendingOperationTitle.text = model.call.title
itemPendingOperationSubtitle.setTextOrHide(model.call.subtitle)
itemPendingOperationChain.loadChainIcon(model.chain.icon, imageLoader)
itemPendingOperationIcon.setIcon(model.call.icon, imageLoader)
itemPendingOperationProgress.text = model.progress
itemPendingOperationAction.letOrHide(model.action) { action ->
itemPendingOperationAction.setColoredText(action.text)
itemPendingOperationAction.setDrawableEnd(action.icon, widthInDp = 16, paddingInDp = 4)
}
itemPendingOperationPrimaryValue.setTextOrHide(model.call.primaryValue)
itemPendingOperationTime.setTextOrHide(model.time)
itemPendingOperationOnBehalfOfContainer.letOrHide(model.call.onBehalfOf) { onBehalfOf ->
itemPendingOperationOnBehalfOfAddress.text = onBehalfOf.nameOrAddress
itemPendingOperationOnBehalfOfIcon.setImageDrawable(onBehalfOf.image)
}
root.setOnClickListener { itemHandler.itemClicked(model) }
}
}
private class PendingMultisigOperationDiffCallback : BaseGroupedDiffCallback<PendingMultisigOperationHeaderModel, PendingMultisigOperationModel>(
PendingMultisigOperationHeaderModel::class.java
) {
override fun areGroupItemsTheSame(oldItem: PendingMultisigOperationHeaderModel, newItem: PendingMultisigOperationHeaderModel): Boolean {
return oldItem.daysSinceEpoch == newItem.daysSinceEpoch
}
override fun areGroupContentsTheSame(oldItem: PendingMultisigOperationHeaderModel, newItem: PendingMultisigOperationHeaderModel): Boolean {
return true
}
override fun areChildItemsTheSame(oldItem: PendingMultisigOperationModel, newItem: PendingMultisigOperationModel): Boolean {
return oldItem.id == newItem.id
}
override fun areChildContentsTheSame(oldItem: PendingMultisigOperationModel, newItem: PendingMultisigOperationModel): Boolean {
return oldItem == newItem
}
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.list
import android.view.View
import androidx.core.view.isVisible
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.domain.onNotLoaded
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigPendingOperationsBinding
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel
import javax.inject.Inject
class MultisigPendingOperationsFragment :
BaseFragment<MultisigPendingOperationsViewModel, FragmentMultisigPendingOperationsBinding>(),
MultisigPendingOperationsAdapter.ItemHandler {
override fun createBinding() = FragmentMultisigPendingOperationsBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val adapter: MultisigPendingOperationsAdapter by lazy(LazyThreadSafetyMode.NONE) { MultisigPendingOperationsAdapter(this, imageLoader) }
override fun applyInsets(rootView: View) {
binder.multisigPendingOperationsToolbar.applyStatusBarInsets()
binder.multisigPendingOperationsList.applyNavigationBarInsets(consume = false)
}
override fun initViews() {
binder.multisigPendingOperationsList.setHasFixedSize(true)
binder.multisigPendingOperationsList.adapter = adapter
binder.multisigPendingOperationsToolbar.setHomeButtonListener { viewModel.backClicked() }
}
override fun inject() {
FeatureUtils.getFeature<MultisigOperationsFeatureComponent>(
requireContext(),
MultisigOperationsFeatureApi::class.java
)
.multisigPendingOperations()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: MultisigPendingOperationsViewModel) {
viewModel.pendingOperationsFlow.observe {
it.onLoaded { data ->
binder.multisigPendingOperationsPlaceholder.isVisible = data.isEmpty()
binder.multisigPendingOperationsProgress.makeGone()
binder.multisigPendingOperationsList.makeVisible()
adapter.submitList(data)
}.onNotLoaded {
binder.multisigPendingOperationsProgress.makeVisible()
binder.multisigPendingOperationsList.makeGone()
}
}
}
override fun itemClicked(model: PendingMultisigOperationModel) {
viewModel.operationClicked(model)
}
}
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.list
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.list.toListWithHeaders
import io.novafoundation.nova.common.presentation.ColoredDrawable
import io.novafoundation.nova.common.presentation.ColoredText
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction
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_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_multisig_operations.R
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationHeaderModel
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel
import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel.SigningAction
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class MultisigPendingOperationsViewModel(
discoveryService: MultisigPendingOperationsService,
private val router: MultisigOperationsRouter,
private val resourceManager: ResourceManager,
private val multisigCallFormatter: MultisigCallFormatter,
private val selectedAccountUseCase: SelectedAccountUseCase,
) : BaseViewModel() {
val account = selectedAccountUseCase.selectedMetaAccountFlow()
val pendingOperationsFlow = discoveryService.pendingOperations()
.map { operations ->
val account = account.first()
operations
.sortedByDescending { it.timestamp }
.groupBy { it.timestamp.inWholeDays }
.toSortedMap(Comparator.reverseOrder())
.toListWithHeaders(
keyMapper = { day, _ -> PendingMultisigOperationHeaderModel(day) },
valueMapper = { operation -> operation.toUi(account) }
)
}
.withSafeLoading()
.shareInBackground()
fun backClicked() {
router.back()
}
fun operationClicked(model: PendingMultisigOperationModel) {
val operationPayload = MultisigOperationPayload.fromOperationId(model.id)
router.openMultisigOperationDetails(MultisigOperationDetailsPayload(operationPayload))
}
private suspend fun PendingMultisigOperation.toUi(selectedAccount: MetaAccount): PendingMultisigOperationModel {
val initialOrigin = selectedAccount.requireAccountIdKeyIn(chain)
val formattedCall = multisigCallFormatter.formatPreview(call, initialOrigin, chain)
return PendingMultisigOperationModel(
id = operationId,
chain = mapChainToUi(chain),
action = formatAction(),
call = formattedCall,
progress = formatProgress(),
time = resourceManager.formatTime(timestamp.inWholeMilliseconds)
)
}
private fun PendingMultisigOperation.formatProgress(): String {
return resourceManager.getString(R.string.multisig_operations_progress, approvals.size.format(), threshold.format())
}
private fun PendingMultisigOperation.formatAction(): SigningAction? {
return when (userAction()) {
is MultisigAction.CanApprove -> null
is MultisigAction.CanReject -> SigningAction(
text = ColoredText(
text = resourceManager.getText(R.string.multisig_operations_created),
colorRes = R.color.text_secondary
),
icon = null
)
MultisigAction.Signed -> SigningAction(
text = ColoredText(
text = resourceManager.getText(R.string.multisig_operations_signed),
colorRes = R.color.text_positive
),
icon = ColoredDrawable(
drawableRes = R.drawable.ic_checkmark_circle_16,
iconColor = R.color.icon_positive
)
)
}
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.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_multisig_operations.presentation.list.MultisigPendingOperationsFragment
@Subcomponent(
modules = [
MultisigPendingOperationsModule::class
]
)
@ScreenScope
interface MultisigPendingOperationsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): MultisigPendingOperationsComponent
}
fun inject(fragment: MultisigPendingOperationsFragment)
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.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.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
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.SelectedAccountUseCase
import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_multisig_operations.presentation.list.MultisigPendingOperationsViewModel
@Module(includes = [ViewModelModule::class])
class MultisigPendingOperationsModule {
@Provides
@IntoMap
@ViewModelKey(MultisigPendingOperationsViewModel::class)
fun provideViewModel(
discoveryService: MultisigPendingOperationsService,
router: MultisigOperationsRouter,
resourceManager: ResourceManager,
multisigCallFormatter: MultisigCallFormatter,
accountUseCase: SelectedAccountUseCase,
): ViewModel {
return MultisigPendingOperationsViewModel(
discoveryService = discoveryService,
router = router,
resourceManager = resourceManager,
multisigCallFormatter = multisigCallFormatter,
selectedAccountUseCase = accountUseCase
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): MultisigPendingOperationsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(MultisigPendingOperationsViewModel::class.java)
}
}
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.list.model
class PendingMultisigOperationHeaderModel(val daysSinceEpoch: Long)
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_multisig_operations.presentation.list.model
import io.novafoundation.nova.common.presentation.ColoredDrawable
import io.novafoundation.nova.common.presentation.ColoredText
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPreviewModel
data class PendingMultisigOperationModel(
val id: PendingMultisigOperationId,
val chain: ChainUi,
val action: SigningAction?,
val call: MultisigCallPreviewModel,
val time: String?,
val progress: String
) {
data class SigningAction(
val text: ColoredText,
val icon: ColoredDrawable?
)
}
@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/multisigPendingOperationDetailsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:layout_constraintTop_toTopOf="parent"
tools:titleText="Balances.transfer" />
<LinearLayout
android:id="@+id/multisigPendingOperationProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/multisigPendingOperationDetailsToolbar">
<ProgressBar
style="@style/Widget.Nova.ProgressBar.Indeterminate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/loading_transaction_details" />
</LinearLayout>
<LinearLayout
android:id="@+id/multisigPendingOperationDetailsContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/multisigPendingOperationDetailsToolbar">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:orientation="vertical">
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.PrimaryAmountView
android:id="@+id/multisigPendingOperationPrimaryAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp" />
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsNetwork"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_network" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsWallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/account_multisig" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsBehalfOf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/multisig_on_behalf_of" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.TableView
android:id="@+id/multisigPendingOperationDetailsCallTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsSignatory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/common_signatory" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
android:id="@+id/multisigPendingOperationDetailsFee"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.ExpandableView
android:id="@+id/multisigOperationSignatoriesContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/bg_block_12"
android:paddingTop="12dp"
app:chevronId="@+id/multisigOperationShowHideButtonIcon"
app:expandableId="@+id/multisigOperationSignatoriesExpandablePart"
app:supportAnimation="false">
<TextView
android:id="@+id/multisigOperationSignatoriesTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="@+id/multisigOperationShowHideButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/multisigOperationShowHideButton"
tools:text="Signatories (1 of 4)" />
<TextView
android:id="@+id/multisigOperationShowHideButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:paddingEnd="4dp"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="@+id/multisigOperationShowHideButtonIcon"
app:layout_constraintEnd_toStartOf="@+id/multisigOperationShowHideButtonIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Show" />
<ImageView
android:id="@+id/multisigOperationShowHideButtonIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_chevron_up"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/icon_secondary" />
<Space
android:layout_width="match_parent"
android:layout_height="12dp"
app:layout_constraintTop_toBottomOf="@+id/multisigOperationShowHideButtonIcon" />
<FrameLayout
android:id="@+id/multisigOperationSignatoriesExpandablePart"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@+id/multisigOperationShowHideButtonIcon">
<LinearLayout
android:id="@+id/multisigOperationSignatoriesShimmering"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<include layout="@layout/item_signatory_shimmering" />
<include layout="@layout/item_signatory_shimmering" />
<include layout="@layout/item_signatory_shimmering" />
<include layout="@layout/item_signatory_shimmering" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/multisigOperationSignatories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="4"
tools:listitem="@layout/item_multisig_signatory_account" />
</FrameLayout>
</io.novafoundation.nova.common.view.ExpandableView>
<io.novafoundation.nova.common.view.GoNextView
android:id="@+id/multisigPendingOperationCallDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/common_full_details"
android:textAppearance="@style/GoNextTransactionDetailsTextAppearance"
android:textColor="@color/button_text_accent"
app:actionIcon="@drawable/ic_chevron_right"
app:actionTint="@color/icon_secondary"
app:dividerVisible="false"
tools:background="@color/block_background" />
</LinearLayout>
</ScrollView>
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/multisigPendingOperationDetailsEnterCallData"
style="@style/Widget.Nova.Button.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/multisig_operation_details_call_data_not_found" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/multisigPendingOperationDetailsAction"
style="@style/Widget.Nova.Button.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/common_confirm" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/multisigPendingOperationDetailsContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/multisigOperationEnterCallToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:titleText="@string/enter_call_data_title" />
<io.novafoundation.nova.common.view.InputField
android:id="@+id/multisigOperationEnterCallInput"
style="@style/Widget.Nova.Input.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:gravity="top"
android:inputType="textMultiLine"
android:maxLines="2"
app:editTextHint="@string/account_import_seed_hint"
app:editTextMinHeight="64dp"
app:endIconDrawable="@drawable/ic_x_clear_filled"
app:endIconMode="clear_text" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/multisigOperationEnterCallAction"
style="@style/Widget.Nova.Button.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
tools:text="@string/common_confirm" />
</LinearLayout>
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/multisigPendingOperationFullDetailsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:titleText="@string/transaction_details_title" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:orientation="vertical">
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsDepositor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/common_depositor" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsDeposit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/multisig_deposit"
app:titleIcon="@drawable/ic_info"
app:titleIconStart="@drawable/ic_lock"
app:titleIconStartTint="@color/icon_secondary" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsCallHash"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/common_call_hash" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/multisigPendingOperationDetailsCallData"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/common_call_data" />
</io.novafoundation.nova.common.view.TableView>
<TextView
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/multisig_extrinsic_details_subtitle"
android:textColor="@color/text_secondary" />
<TextView
android:id="@+id/multisigPendingOperationDetailsCall"
style="@style/TextAppearance.NovaFoundation.Regular.Monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
android:background="@drawable/extrinsic_details_background"
android:padding="12dp"
android:textColor="@color/text_primary"
tools:text="@tools:sample/lorem[200]" />
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/drawable_background_image"
android:orientation="vertical">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/multisigPendingOperationsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/blur_navigation_background"
app:contentBackground="@android:color/transparent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:titleText="@string/multisig_pending_operations" />
<ProgressBar
android:id="@+id/multisigPendingOperationsProgress"
style="@style/Widget.Nova.ProgressBar.Indeterminate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<io.novafoundation.nova.common.view.PlaceholderView
android:id="@+id/multisigPendingOperationsPlaceholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/multisig_operations_placeholder"
android:visibility="gone"
app:image="@drawable/ic_placeholder"
app:imageTint="@color/icon_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/multisigPendingOperationsToolbar"
app:placeholderBackgroundStyle="no_background"
app:showButton="false" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/multisigPendingOperationsList"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/multisigPendingOperationsToolbar"
tools:listitem="@layout/item_multisig_pending_operation" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="4dp"
tools:background="@color/block_background">
<TextView
android:id="@+id/itemPendingOperationProgress"
style="@style/TextAppearance.NovaFoundation.SemiBold.Caps1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
android:textColor="@color/text_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="signing (1 of 3)" />
<TextView
android:id="@+id/item_pending_operation_action"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Signed"
tools:textColor="@color/text_positive" />
<ImageView
android:id="@+id/itemPendingOperationIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:padding="2dp"
android:background="@drawable/bg_icon_container_on_color"
app:layout_constraintBottom_toTopOf="@+id/itemPendingOperationOnBehalfOfContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/itemPendingOperationProgress"
app:layout_goneMarginBottom="20dp"
app:tint="@color/text_secondary"
tools:src="@drawable/ic_arrow_up" />
<ImageView
android:id="@+id/itemPendingOperationChain"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="19dp"
android:layout_marginTop="19dp"
app:layout_constraintStart_toStartOf="@+id/itemPendingOperationIcon"
app:layout_constraintTop_toTopOf="@+id/itemPendingOperationIcon"
tools:src="@drawable/ic_polkadot_24" />
<TextView
android:id="@+id/itemPendingOperationTitle"
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toTopOf="@+id/itemPendingOperationSubtitle"
app:layout_constraintStart_toEndOf="@+id/itemPendingOperationIcon"
app:layout_constraintTop_toTopOf="@+id/itemPendingOperationIcon"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Transfer" />
<TextView
android:id="@+id/itemPendingOperationSubtitle"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="@+id/itemPendingOperationIcon"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/itemPendingOperationTitle"
app:layout_constraintTop_toBottomOf="@+id/itemPendingOperationTitle"
tools:text="To: GybH5si5nAGybH5GybH5" />
<TextView
android:id="@+id/itemPendingOperationPrimaryValue"
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:layout_constraintBottom_toTopOf="@+id/itemPendingOperationTime"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/itemPendingOperationIcon"
app:layout_constraintVertical_chainStyle="packed"
tools:text="10 DOT" />
<TextView
android:id="@+id/itemPendingOperationTime"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:layout_constraintBottom_toBottomOf="@+id/itemPendingOperationIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/itemPendingOperationPrimaryValue"
tools:text="18:00" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/itemPendingOperationOnBehalfOfContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:background="@color/block_background">
<View
android:id="@+id/itemPendingOperationOnBehalfOfDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView4"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="12dp"
android:layout_marginStart="12dp"
android:text="@string/multisig_pending_operations_on_behalf_of"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/itemPendingOperationOnBehalfOfIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
app:layout_constraintBottom_toBottomOf="@+id/itemPendingOperationOnBehalfOfAddress"
app:layout_constraintEnd_toStartOf="@+id/itemPendingOperationOnBehalfOfAddress"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@+id/textView4"
app:layout_constraintStart_toStartOf="@+id/guideline2"
app:layout_constraintTop_toTopOf="@+id/itemPendingOperationOnBehalfOfAddress"
tools:background="@tools:sample/avatars" />
<TextView
android:id="@+id/itemPendingOperationOnBehalfOfAddress"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:ellipsize="middle"
android:singleLine="true"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/itemPendingOperationOnBehalfOfIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Proxy Wallet" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="190dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/TextAppearance.NovaFoundation.Regular.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/itemMultisigPendingOperationHeader"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="9dp"
android:textColor="@color/text_primary"
tools:text="May 7" />
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_primary_list_item">
<ImageView
android:id="@+id/itemSignatoryAccountIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@color/icon_primary" />
<TextView
android:id="@+id/itemSignatoryAccountTitle"
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="40dp"
android:ellipsize="middle"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="@color/text_primary"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/itemSignatoryAccountSubtitle"
app:layout_constraintEnd_toStartOf="@+id/itemSignatoryAccountSelected"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/itemSignatoryAccountIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="✨👍✨ Day7 ✨👍✨" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_info"
app:layout_constraintBottom_toBottomOf="@+id/itemSignatoryAccountTitle"
app:layout_constraintStart_toEndOf="@+id/itemSignatoryAccountTitle"
app:layout_constraintTop_toTopOf="@+id/itemSignatoryAccountTitle" />
<TextView
android:id="@+id/itemSignatoryAccountSubtitle"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="middle"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:lines="1"
android:minHeight="18dp"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/itemSignatoryAccountSelected"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/itemSignatoryAccountIcon"
app:layout_constraintTop_toBottomOf="@+id/itemSignatoryAccountTitle"
app:layout_constraintVertical_chainStyle="packed"
tools:text="$11,529.26" />
<ImageView
android:id="@+id/itemSignatoryAccountSelected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_checkmark_filled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/icon_positive" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="48dp">
<com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/signatoryShimmeringIcon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:background="@drawable/bg_shimmering_circle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="100dp"
android:layout_height="10dp"
android:layout_marginStart="12dp"
android:background="@drawable/bg_shimmering"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/signatoryShimmeringIcon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>