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,31 @@
package io.novafoundation.nova.feature_gift_impl.data
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val GIFT_SECRETS = "GIFT_SECRETS"
interface GiftSecretsRepository {
suspend fun putGiftAccountSeed(accountId: ByteArray, seed: ByteArray)
suspend fun getGiftAccountSeed(accountId: ByteArray): ByteArray?
}
class RealGiftSecretsRepository(
private val encryptedPreferences: EncryptedPreferences,
) : GiftSecretsRepository {
override suspend fun putGiftAccountSeed(accountId: ByteArray, seed: ByteArray) = withContext(Dispatchers.IO) {
encryptedPreferences.putEncryptedString(giftAccountKey(accountId), seed.toHexString())
}
override suspend fun getGiftAccountSeed(accountId: ByteArray): ByteArray? = withContext(Dispatchers.IO) {
encryptedPreferences.getDecryptedString(giftAccountKey(accountId))?.fromHex()
}
private fun giftAccountKey(accountId: ByteArray) = "${accountId.toHexString()}:$GIFT_SECRETS"
}
@@ -0,0 +1,101 @@
package io.novafoundation.nova.feature_gift_impl.data
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.GiftsDao
import io.novafoundation.nova.core_db.model.GiftLocal
import io.novafoundation.nova.feature_gift_impl.domain.models.Gift
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import java.math.BigInteger
import java.util.Date
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface GiftsRepository {
suspend fun getGift(giftId: Long): Gift
suspend fun getGifts(): List<Gift>
fun observeGift(giftId: Long): Flow<Gift>
fun observeGifts(): Flow<List<Gift>>
suspend fun saveNewGift(
accountIdKey: AccountIdKey,
amount: BigInteger,
creatorMetaId: Long,
fullChainAssetId: FullChainAssetId
): Long
suspend fun setGiftState(id: Long, status: Gift.Status)
}
class RealGiftsRepository(
private val giftsDao: GiftsDao
) : GiftsRepository {
override suspend fun getGift(giftId: Long): Gift {
return giftsDao.getGiftById(giftId).toDomain()
}
override suspend fun getGifts(): List<Gift> {
return giftsDao.getAllGifts().map { it.toDomain() }
}
override fun observeGift(giftId: Long): Flow<Gift> {
return giftsDao.observeGiftById(giftId)
.map { it.toDomain() }
}
override fun observeGifts(): Flow<List<Gift>> {
return giftsDao.observeAllGifts()
.mapList { it.toDomain() }
}
override suspend fun saveNewGift(
accountIdKey: AccountIdKey,
amount: BigInteger,
creatorMetaId: Long,
fullChainAssetId: FullChainAssetId
): Long {
return giftsDao.createNewGift(
GiftLocal(
amount = amount,
giftAccountId = accountIdKey.value,
creatorMetaId = creatorMetaId,
chainId = fullChainAssetId.chainId,
assetId = fullChainAssetId.assetId,
status = GiftLocal.Status.PENDING,
creationDate = Date().time
)
)
}
override suspend fun setGiftState(id: Long, status: Gift.Status) {
giftsDao.setGiftState(id, status.toDomain())
}
private fun GiftLocal.toDomain() = Gift(
id = id,
creatorMetaId = creatorMetaId,
chainId = chainId,
giftAccountId = giftAccountId,
assetId = assetId,
amount = amount,
status = when (status) {
GiftLocal.Status.PENDING -> Gift.Status.PENDING
GiftLocal.Status.CLAIMED -> Gift.Status.CLAIMED
GiftLocal.Status.RECLAIMED -> Gift.Status.RECLAIMED
},
creationDate = Date(this.creationDate)
)
private fun Gift.Status.toDomain(): GiftLocal.Status {
return when (this) {
Gift.Status.PENDING -> GiftLocal.Status.PENDING
Gift.Status.CLAIMED -> GiftLocal.Status.CLAIMED
Gift.Status.RECLAIMED -> GiftLocal.Status.RECLAIMED
}
}
}
@@ -0,0 +1,63 @@
package io.novafoundation.nova.feature_gift_impl.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_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.di.modules.deeplinks.DeepLinkModule
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.amount.di.SelectGiftAmountComponent
import io.novafoundation.nova.feature_gift_impl.presentation.claim.di.ClaimGiftComponent
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.di.CreateGiftConfirmComponent
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.di.GiftsComponent
import io.novafoundation.nova.feature_gift_impl.presentation.share.di.ShareGiftComponent
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
GiftFeatureDependencies::class
],
modules = [
GiftFeatureModule::class,
DeepLinkModule::class
]
)
@FeatureScope
interface GiftFeatureComponent : GiftFeatureApi {
fun giftsComponentFactory(): GiftsComponent.Factory
fun selectGiftAmountComponentFactory(): SelectGiftAmountComponent.Factory
fun createGiftConfirmComponentFactory(): CreateGiftConfirmComponent.Factory
fun shareGiftComponentFactory(): ShareGiftComponent.Factory
fun claimGiftComponentFactory(): ClaimGiftComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: GiftRouter,
deps: GiftFeatureDependencies
): GiftFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
AccountFeatureApi::class,
DbApi::class,
WalletFeatureApi::class,
RuntimeApi::class,
DeepLinkingFeatureApi::class
]
)
interface GiftFeatureDependenciesComponent : GiftFeatureDependencies
}
@@ -0,0 +1,134 @@
package io.novafoundation.nova.feature_gift_impl.di
import android.content.Context
import coil.ImageLoader
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.DialogMessageManager
import io.novafoundation.nova.common.utils.IntegrityService
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.core_db.dao.GiftsDao
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
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.CreateGiftMetaAccountUseCase
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.mixin.selectSingleWallet.SelectSingleWalletMixin
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.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory
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.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
interface GiftFeatureDependencies {
val amountFormatter: AmountFormatter
val context: Context
val preferences: Preferences
val integrityService: IntegrityService
val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory
val assetSourceRegistry: AssetSourceRegistry
val validationExecutor: ValidationExecutor
val maxActionProviderFactory: MaxActionProviderFactory
val selectedAccountUseCase: SelectedAccountUseCase
val enoughAmountValidatorFactory: EnoughAmountValidatorFactory
val minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory
val amountChooserMixinFactory: AmountChooserMixin.Factory
val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory
val assetUseCase: ArbitraryAssetUseCase
val createSecretsRepository: CreateSecretsRepository
val sendUseCase: SendUseCase
val walletUiUseCase: WalletUiUseCase
val externalAccountActions: ExternalActions.Presentation
val addressIconGenerator: AddressIconGenerator
val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
val encryptionDefaults: EncryptionDefaults
val encryptedPreferences: EncryptedPreferences
val linkBuilderFactory: LinkBuilderFactory
val assetIconProvider: AssetIconProvider
val tokenFormatter: TokenFormatter
val fileProvider: FileProvider
val createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase
val automaticInteractionGate: AutomaticInteractionGate
val dialogMessageManager: DialogMessageManager
val accountRepository: AccountRepository
val selectSingleWalletMixin: SelectSingleWalletMixin.Factory
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val accountInteractor: AccountInteractor
val computationalCache: ComputationalCache
val feePaymentRegistry: FeePaymentProviderRegistry
val feePaymentFacade: CustomFeeCapabilityFacade
fun giftsDao(): GiftsDao
fun resourceManager(): ResourceManager
fun appLinksProvider(): AppLinksProvider
fun chainRegistry(): ChainRegistry
fun imageLoader(): ImageLoader
fun secretStoreV2(): SecretStoreV2
fun apiCreator(): NetworkApiCreator
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_gift_impl.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_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class GiftFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: GiftRouter
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dependencies = DaggerGiftFeatureComponent_GiftFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java))
.build()
return DaggerGiftFeatureComponent.factory()
.create(router, dependencies)
}
}
@@ -0,0 +1,180 @@
package io.novafoundation.nova.feature_gift_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.GiftsDao
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository
import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository
import io.novafoundation.nova.feature_gift_impl.data.RealGiftSecretsRepository
import io.novafoundation.nova.feature_gift_impl.data.RealGiftsRepository
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor
import io.novafoundation.nova.feature_gift_impl.domain.RealGiftsInteractor
import io.novafoundation.nova.feature_gift_impl.domain.RealCreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.GiftSecretsUseCase
import io.novafoundation.nova.feature_gift_impl.domain.RealGiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_impl.domain.RealAvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_gift_impl.domain.RealClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.RealShareGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.amount.GiftMinAmountProviderFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module()
class GiftFeatureModule {
@Provides
@FeatureScope
fun providesGiftsRepository(
giftsDao: GiftsDao
): GiftsRepository {
return RealGiftsRepository(giftsDao)
}
@Provides
@FeatureScope
fun providesGiftsInteractor(
repository: GiftsRepository,
assetSourceRegistry: AssetSourceRegistry,
chainRegistry: ChainRegistry,
selectedAccountUseCase: SelectedAccountUseCase
): GiftsInteractor {
return RealGiftsInteractor(repository, assetSourceRegistry, chainRegistry, selectedAccountUseCase)
}
@Provides
@FeatureScope
fun providesGiftSecretsRepository(encryptedPreferences: EncryptedPreferences): GiftSecretsRepository {
return RealGiftSecretsRepository(encryptedPreferences)
}
@Provides
@FeatureScope
fun provideSelectGiftAmountInteractor(
assetSourceRegistry: AssetSourceRegistry,
chainRegistry: ChainRegistry,
giftSecretsRepository: GiftSecretsRepository,
giftsRepository: GiftsRepository,
sendUseCase: SendUseCase,
giftSecretsUseCase: GiftSecretsUseCase
): CreateGiftInteractor {
return RealCreateGiftInteractor(
assetSourceRegistry,
chainRegistry,
giftSecretsRepository,
giftsRepository,
sendUseCase,
giftSecretsUseCase
)
}
@Provides
@FeatureScope
fun provideGiftMinAmountProviderFactory(
createGiftInteractor: CreateGiftInteractor
): GiftMinAmountProviderFactory {
return GiftMinAmountProviderFactory(createGiftInteractor)
}
@Provides
@FeatureScope
fun providesShareGiftInteractor(
giftsRepository: GiftsRepository,
giftSecretsRepository: GiftSecretsRepository
): ShareGiftInteractor {
return RealShareGiftInteractor(giftsRepository, giftSecretsRepository)
}
@Provides
@FeatureScope
fun providesPackingGiftAnimationFactory(): PackingGiftAnimationFactory {
return PackingGiftAnimationFactory()
}
@Provides
@FeatureScope
fun providesUnpackingGiftAnimationFactory(): UnpackingGiftAnimationFactory {
return UnpackingGiftAnimationFactory()
}
@Provides
@FeatureScope
fun provideClaimGiftInteractor(
giftSecretsUseCase: GiftSecretsUseCase,
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry,
sendUseCase: SendUseCase,
createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase,
secretStoreV2: SecretStoreV2,
accountRepository: AccountRepository
): ClaimGiftInteractor {
return RealClaimGiftInteractor(
giftSecretsUseCase,
chainRegistry,
assetSourceRegistry,
sendUseCase,
createGiftMetaAccountUseCase,
secretStoreV2,
accountRepository
)
}
@Provides
@FeatureScope
fun provideGiftSecretsUseCase(
createSecretsRepository: CreateSecretsRepository,
encryptionDefaults: EncryptionDefaults
): GiftSecretsUseCase {
return GiftSecretsUseCase(
createSecretsRepository,
encryptionDefaults
)
}
@Provides
@FeatureScope
fun provideClaimGiftMixinFactory(claimGiftInteractor: ClaimGiftInteractor) = ClaimGiftMixinFactory(claimGiftInteractor)
@Provides
@FeatureScope
fun provideAvailableGiftAssetsUseCase(
chainRegistry: ChainRegistry,
computationalCache: ComputationalCache,
feePaymentRegistry: FeePaymentProviderRegistry,
feePaymentFacade: CustomFeeCapabilityFacade,
assetSourceRegistry: AssetSourceRegistry,
): AvailableGiftAssetsUseCase {
return RealAvailableGiftAssetsUseCase(
chainRegistry,
computationalCache,
feePaymentRegistry,
feePaymentFacade,
assetSourceRegistry
)
}
@Provides
@FeatureScope
fun provideAreGiftsSupportedUseCase(selectedAccountUseCase: SelectedAccountUseCase): GiftsAccountSupportedUseCase {
return RealGiftsAccountSupportedUseCase(selectedAccountUseCase)
}
}
@@ -0,0 +1,54 @@
package io.novafoundation.nova.feature_gift_impl.di.modules.deeplinks
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_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_gift_api.di.GiftDeepLinks
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator
import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkHandler
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideDeepLinkConfigurator(
linkBuilderFactory: LinkBuilderFactory
): ClaimGiftDeepLinkConfigurator {
return ClaimGiftDeepLinkConfigurator(linkBuilderFactory)
}
@Provides
@FeatureScope
fun provideClaimGiftDeepLinkHandler(
router: GiftRouter,
chainRegistry: ChainRegistry,
automaticInteractionGate: AutomaticInteractionGate,
dialogMessageManager: DialogMessageManager,
claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator,
claimGiftInteractor: ClaimGiftInteractor
): ClaimGiftDeepLinkHandler {
return ClaimGiftDeepLinkHandler(
router,
chainRegistry,
automaticInteractionGate,
dialogMessageManager,
claimGiftDeepLinkConfigurator,
claimGiftInteractor
)
}
@Provides
@FeatureScope
fun provideDeepLinks(
claimGiftDeepLinkInteractor: ClaimGiftDeepLinkHandler
): GiftDeepLinks {
return GiftDeepLinks(listOf(claimGiftDeepLinkInteractor))
}
}
@@ -0,0 +1,193 @@
package io.novafoundation.nova.feature_gift_impl.domain
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.keypair
import io.novafoundation.nova.common.data.secrets.v2.publicKey
import io.novafoundation.nova.common.utils.coerceToUnit
import io.novafoundation.nova.common.utils.finally
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.model.decimalAmount
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.isControllableWallet
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.BaseAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.asWeighted
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.emptyAccountId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.CoroutineScope
import java.math.BigDecimal
import java.math.BigInteger
interface ClaimGiftInteractor {
suspend fun getClaimableGift(secret: ByteArray, chainId: String, assetId: Int): ClaimableGift
suspend fun getGiftAmountWithFee(
claimableGift: ClaimableGift,
giftMetaAccount: MetaAccount,
coroutineScope: CoroutineScope
): GiftAmountWithFee
suspend fun createTempMetaAccount(claimableGift: ClaimableGift): MetaAccount
suspend fun isGiftAlreadyClaimed(claimableGift: ClaimableGift): Boolean
suspend fun claimGift(
claimableGift: ClaimableGift,
giftAmountWithFee: GiftAmountWithFee,
giftMetaAccount: MetaAccount,
giftRecipient: MetaAccount,
coroutineScope: CoroutineScope
): Result<Unit>
suspend fun getMetaAccount(metaId: Long): MetaAccount
suspend fun getMetaAccountToClaimGift(): MetaAccount
}
class RealClaimGiftInteractor(
private val giftSecretsUseCase: GiftSecretsUseCase,
private val chainRegistry: ChainRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
private val sendUseCase: SendUseCase,
private val createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase,
private val secretStoreV2: SecretStoreV2,
private val accountRepository: AccountRepository
) : ClaimGiftInteractor {
override suspend fun getClaimableGift(secret: ByteArray, chainId: String, assetId: Int): ClaimableGift {
val chain = chainRegistry.getChain(chainId)
val giftSecrets = giftSecretsUseCase.createGiftSecrets(chain, secret)
return ClaimableGift(
accountId = chain.accountIdOf(giftSecrets.keypair.publicKey),
chain = chain,
chainAsset = chain.assetsById.getValue(assetId),
secrets = giftSecrets
)
}
override suspend fun createTempMetaAccount(claimableGift: ClaimableGift): MetaAccount {
return createGiftMetaAccountUseCase.createTemporaryGiftMetaAccount(claimableGift.chain, claimableGift.secrets)
}
override suspend fun isGiftAlreadyClaimed(claimableGift: ClaimableGift): Boolean {
val giftBalance = getGiftAccountBalance(claimableGift)
return giftBalance.isZero
}
override suspend fun getGiftAmountWithFee(
claimableGift: ClaimableGift,
giftMetaAccount: MetaAccount,
coroutineScope: CoroutineScope
): GiftAmountWithFee {
val accountBalance = claimableGift.chainAsset.amountFromPlanks(getGiftAccountBalance(claimableGift))
val transferModel = createTransfer(
claimableGift,
giftMetaAccount,
recipientAccountId = claimableGift.chain.emptyAccountId(),
accountBalance
)
val claimFee = assetSourceRegistry.sourceFor(claimableGift.chainAsset)
.transfers
.calculateFee(transferModel, coroutineScope)
return GiftAmountWithFee(accountBalance - claimFee.decimalAmount, claimFee)
}
override suspend fun claimGift(
claimableGift: ClaimableGift,
giftAmountWithFee: GiftAmountWithFee,
giftMetaAccount: MetaAccount,
giftRecipient: MetaAccount,
coroutineScope: CoroutineScope
): Result<Unit> {
// Put secrets for temporary meta account in storage but for this operation only since signer logic requires secrets in secret storage
secretStoreV2.putChainAccountSecrets(giftMetaAccount.id, claimableGift.accountId, claimableGift.secrets)
return claimGiftInternal(
giftModel = claimableGift,
giftMetaAccount = giftMetaAccount,
giftRecipient = giftRecipient,
giftAmountWithFee = giftAmountWithFee,
coroutineScope = coroutineScope
)
.finally {
// Remove secrets for temporary meta account from storage after claim or failure
secretStoreV2.clearChainAccountsSecrets(giftMetaAccount.id, listOf(claimableGift.accountId))
}
}
override suspend fun getMetaAccount(metaId: Long): MetaAccount {
return accountRepository.getMetaAccount(metaId)
}
override suspend fun getMetaAccountToClaimGift(): MetaAccount {
val selectedMetaAccount = accountRepository.getSelectedMetaAccount()
if (selectedMetaAccount.type.isControllableWallet()) return selectedMetaAccount
val firstControllableWallet = accountRepository.getActiveMetaAccounts()
.firstOrNull { it.type.isControllableWallet() }
return firstControllableWallet ?: selectedMetaAccount
}
private suspend fun claimGiftInternal(
giftModel: ClaimableGift,
giftMetaAccount: MetaAccount,
giftRecipient: MetaAccount,
giftAmountWithFee: GiftAmountWithFee,
coroutineScope: CoroutineScope
): Result<Unit> {
val originFee = OriginFee(submissionFee = giftAmountWithFee.fee, deliveryFee = null)
val giftTransfer = createTransfer(
giftModel,
giftMetaAccount,
recipientAccountId = giftRecipient.requireAccountIdIn(giftModel.chain),
amount = giftAmountWithFee.amount
).asWeighted(originFee)
return sendUseCase.performOnChainTransferAndAwaitExecution(giftTransfer, originFee.submissionFee, coroutineScope)
.coerceToUnit()
}
private suspend fun getGiftAccountBalance(claimableGift: ClaimableGift): BigInteger {
val assetBalanceSource = assetSourceRegistry.sourceFor(claimableGift.chainAsset).balance
return assetBalanceSource.queryAccountBalance(
claimableGift.chain,
claimableGift.chainAsset,
claimableGift.accountId
).total
}
private fun createTransfer(
giftModel: ClaimableGift,
giftMetaAccount: MetaAccount,
recipientAccountId: AccountId,
amount: BigDecimal
): BaseAssetTransfer {
return BaseAssetTransfer(
sender = giftMetaAccount,
recipient = giftModel.chain.addressOf(recipientAccountId),
originChain = giftModel.chain,
originChainAsset = giftModel.chainAsset,
destinationChain = giftModel.chain,
destinationChainAsset = giftModel.chainAsset,
feePaymentCurrency = giftModel.chainAsset.toFeePaymentCurrency(),
amount = amount,
transferringMaxAmount = true
)
}
}
@@ -0,0 +1,164 @@
package io.novafoundation.nova.feature_gift_impl.domain
import android.util.Log
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.secrets.v2.keypair
import io.novafoundation.nova.common.data.secrets.v2.publicKey
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.model.EvmFee
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository
import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository
import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.BaseAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.emptyAccountIdKey
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
typealias GiftId = Long
interface CreateGiftInteractor {
fun validationSystemFor(chainAsset: Chain.Asset, coroutineScope: CoroutineScope): AssetTransfersValidationSystem
suspend fun getFee(
model: CreateGiftModel,
transferAllToCreateGift: Boolean,
coroutineScope: CoroutineScope
): GiftFee
suspend fun getExistentialDeposit(chainAsset: Chain.Asset): BigDecimal
suspend fun createAndSaveGift(
giftModel: CreateGiftModel,
transfer: WeightedAssetTransfer,
fee: SubmissionFee,
coroutineScope: CoroutineScope
): Result<GiftId>
}
class RealCreateGiftInteractor(
private val assetSourceRegistry: AssetSourceRegistry,
private val chainRegistry: ChainRegistry,
private val giftSecretsRepository: GiftSecretsRepository,
private val giftsRepository: GiftsRepository,
private val sendUseCase: SendUseCase,
private val giftSecretsUseCase: GiftSecretsUseCase
) : CreateGiftInteractor {
override fun validationSystemFor(chainAsset: Chain.Asset, coroutineScope: CoroutineScope): AssetTransfersValidationSystem {
return getAssetTransfers(chainAsset)
.getValidationSystem(coroutineScope)
}
override suspend fun getFee(
model: CreateGiftModel,
transferAllToCreateGift: Boolean,
coroutineScope: CoroutineScope
): GiftFee = withContext(Dispatchers.Default) {
val claimGiftFee = getSubmissionFee(
model = model,
transferMax = true,
giftAccountId = model.chain.emptyAccountIdKey(),
coroutineScope = coroutineScope
).doubleFeeForEvm()
val claimFeeAmount = model.chainAsset.amountFromPlanks(claimGiftFee.amount)
val createGiftFee = getSubmissionFee(
model = model.copy(amount = model.amount + claimFeeAmount),
transferMax = transferAllToCreateGift,
giftAccountId = model.chain.emptyAccountIdKey(),
coroutineScope = coroutineScope
)
GiftFee(
createGiftFee = createGiftFee,
claimGiftFee = claimGiftFee
)
}
override suspend fun getExistentialDeposit(chainAsset: Chain.Asset): BigDecimal {
return assetSourceRegistry.existentialDeposit(chainAsset)
}
override suspend fun createAndSaveGift(
giftModel: CreateGiftModel,
transfer: WeightedAssetTransfer,
fee: SubmissionFee,
coroutineScope: CoroutineScope
): Result<GiftId> {
val giftAccountId = createAndStoreRandomGiftAccount(giftModel.chain.id)
val gitAddress = giftModel.chain.addressOf(giftAccountId)
val giftTransfer = transfer.copy(recipient = gitAddress)
return sendUseCase.performOnChainTransferAndAwaitExecution(giftTransfer, fee, coroutineScope)
.map {
Log.d(LOG_TAG, "Gift was created successfully. Address in ${giftModel.chain.name}: $gitAddress")
giftsRepository.saveNewGift(
accountIdKey = giftAccountId,
amount = giftModel.chainAsset.planksFromAmount(giftModel.amount),
creatorMetaId = giftModel.senderMetaAccount.id,
fullChainAssetId = giftModel.chainAsset.fullId
)
}
}
private suspend fun getSubmissionFee(
model: CreateGiftModel,
transferMax: Boolean,
giftAccountId: AccountIdKey,
coroutineScope: CoroutineScope
): SubmissionFee {
return withContext(Dispatchers.Default) {
val transfer = model.mapToAssetTransfer(giftAccountId, transferMax)
getAssetTransfers(model.chainAsset).calculateFee(transfer, coroutineScope = coroutineScope)
}
}
private fun getAssetTransfers(chainAsset: Chain.Asset) = assetSourceRegistry.sourceFor(chainAsset).transfers
private fun CreateGiftModel.mapToAssetTransfer(giftAccountId: AccountIdKey, transferMax: Boolean) = BaseAssetTransfer(
sender = senderMetaAccount,
recipient = chain.addressOf(giftAccountId),
originChain = chain,
originChainAsset = chainAsset,
destinationChain = chain,
destinationChainAsset = chainAsset,
feePaymentCurrency = chainAsset.toFeePaymentCurrency(),
amount = amount,
transferringMaxAmount = transferMax
)
private suspend fun createAndStoreRandomGiftAccount(chainId: String): AccountIdKey {
val chain = chainRegistry.getChain(chainId)
val giftSeed = giftSecretsUseCase.createRandomGiftSeed()
val giftSecrets = giftSecretsUseCase.createGiftSecrets(chain, giftSeed)
val accountId = chain.accountIdOf(giftSecrets.keypair.publicKey)
giftSecretsRepository.putGiftAccountSeed(accountId, giftSeed)
return accountId.intoKey()
}
private fun SubmissionFee.doubleFeeForEvm(): SubmissionFee {
return when (this) {
is EvmFee -> copy(gasLimit = gasLimit * 2.toBigInteger())
else -> this
}
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_gift_impl.domain
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
import io.novafoundation.nova.feature_account_api.domain.account.common.forChain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.encrypt.seed.SeedCreator
import io.novasama.substrate_sdk_android.scale.EncodableStruct
import org.bouncycastle.crypto.generators.SCrypt
private const val GIFT_SEED_SIZE_BYTES = 10
class GiftSecretsUseCase(
private val createSecretsRepository: CreateSecretsRepository,
private val encryptionDefaults: EncryptionDefaults
) {
companion object {
private const val GIFT_SALT = "gift"
private const val SCRYPT_KEY_SIZE = 32
private const val N = 16384
private const val p = 1
private const val r = 8
}
suspend fun createGiftSecrets(chain: Chain, seed: ByteArray): EncodableStruct<ChainAccountSecrets> {
val encryption = encryptionDefaults.forChain(chain)
return createSecretsRepository.createSecretsWithSeed(
seed = getGiftSeedHash(seed),
cryptoType = encryption.cryptoType,
derivationPath = encryption.derivationPath,
isEthereum = chain.isEthereumBased
)
}
fun createRandomGiftSeed(): ByteArray {
return SeedCreator.randomSeed(sizeBytes = GIFT_SEED_SIZE_BYTES)
}
private fun getGiftSeedHash(seed: ByteArray): ByteArray {
val saltBytes = GIFT_SALT.toByteArray()
return SCrypt.generate(seed, saltBytes, N, r, p, SCRYPT_KEY_SIZE)
}
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_gift_impl.domain
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.common.utils.onEachAsync
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository
import io.novafoundation.nova.feature_gift_impl.domain.models.Gift
import io.novafoundation.nova.feature_gift_impl.domain.models.isClaimed
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.collections.sortedBy
interface GiftsInteractor {
fun observeGifts(fullChainAssetId: FullChainAssetId?): Flow<List<Gift>>
suspend fun syncGiftsState()
}
class RealGiftsInteractor(
private val giftsRepository: GiftsRepository,
private val assetSourceRegistry: AssetSourceRegistry,
private val chainRegistry: ChainRegistry,
private val selectedAccountUseCase: SelectedAccountUseCase
) : GiftsInteractor {
override fun observeGifts(fullChainAssetId: FullChainAssetId?): Flow<List<Gift>> = flowOfAll {
val selectedMetaAccount = selectedAccountUseCase.getSelectedMetaAccount()
giftsRepository.observeGifts()
.map { gifts ->
gifts.filter { it.creatorMetaId == selectedMetaAccount.id }
.filter { it.filterByAsset(fullChainAssetId) }
.sortedByDescending { it.creationDate.time }
.sortedBy { it.status.isClaimed() }
}
}
private fun Gift.filterByAsset(fullChainAssetId: FullChainAssetId?): Boolean {
if (fullChainAssetId == null) return true
return chainId == fullChainAssetId.chainId && assetId == fullChainAssetId.assetId
}
override suspend fun syncGiftsState() {
giftsRepository.getGifts()
.filter { it.status == Gift.Status.PENDING }
.onEachAsync {
val (chain, chainAsset) = chainRegistry.chainWithAsset(it.chainId, it.assetId)
val balanceSource = assetSourceRegistry.sourceFor(chainAsset).balance
val giftBalance = balanceSource.queryAccountBalance(chain, chainAsset, it.giftAccountId)
if (giftBalance.total.isZero) {
giftsRepository.setGiftState(it.id, Gift.Status.CLAIMED)
}
}
}
}
@@ -0,0 +1,100 @@
package io.novafoundation.nova.feature_gift_impl.domain
import android.util.Log
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.mapNotNullToSet
import io.novafoundation.nova.common.utils.mapToSet
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
import io.novafoundation.nova.feature_account_api.data.fee.fastLookupCustomFeeCapabilityOrDefault
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.isSelfSufficientAsset
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.getAssetOrThrow
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.FullChainAssetId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.withContext
class RealAvailableGiftAssetsUseCase(
private val chainRegistry: ChainRegistry,
private val computationalCache: ComputationalCache,
private val feePaymentRegistry: FeePaymentProviderRegistry,
private val feePaymentFacade: CustomFeeCapabilityFacade,
private val assetSourceRegistry: AssetSourceRegistry,
) : AvailableGiftAssetsUseCase {
companion object {
private const val GIFT_ASSETS_CACHE = "AssetSearchUseCase.GIFT_ASSETS_CACHE"
}
override suspend fun isGiftsAvailable(chainAsset: Chain.Asset): Boolean {
return withContext(Dispatchers.Default) {
val canPayFee = feePaymentFacade.canPayFeeInCurrency(chainAsset.toFeePaymentCurrency())
val isSelfSufficient = assetSourceRegistry.isSelfSufficientAsset(chainAsset)
canPayFee && isSelfSufficient
}
}
override fun getAvailableGiftAssets(coroutineScope: CoroutineScope): Flow<Set<FullChainAssetId>> {
return computationalCache.useSharedFlow(GIFT_ASSETS_CACHE, coroutineScope) {
flow {
// Fast first emission - show all native assets
emit(chainRegistry.allNativeAssetIds())
if (feePaymentFacade.hasGlobalFeePaymentRestrictions()) return@flow
// Then do the full scan - via slower fee capability check
emitPerChainAvailableAssets()
}
.runningFold(emptySet<FullChainAssetId>()) { acc, newAssets -> if (newAssets.isEmpty()) acc else acc + newAssets }
.distinctUntilChangedBy { it.size } // we are only adding so deduplication by size is enough
.onEach { Log.d("AssetSearchUseCase", "# of assets available for gifts: ${it.size}") }
}
}
private suspend fun ChainRegistry.allNativeAssetIds(): Set<FullChainAssetId> {
return currentChains.first().mapToSet { it.utilityAsset.fullId }
}
context(FlowCollector<Set<FullChainAssetId>>)
private suspend fun emitPerChainAvailableAssets() {
val chains = chainRegistry.currentChains.first()
chains.map { chain -> flowOf { collectAllAssetsAllowedForGiftsInChain(chain) } }
.merge()
.collect { emit(it) }
}
private suspend fun collectAllAssetsAllowedForGiftsInChain(chain: Chain): Set<FullChainAssetId> {
val canBeUsedForFeePayment = feePaymentRegistry
.providerFor(chain.id)
.fastLookupCustomFeeCapabilityOrDefault()
.nonUtilityFeeCapableTokens
return canBeUsedForFeePayment.mapNotNullToSet {
val asset = chain.getAssetOrThrow(it)
val isSufficient = assetSourceRegistry.isSelfSufficientAsset(asset)
if (isSufficient) {
asset.fullId
} else {
null
}
}
}
}
@@ -0,0 +1,44 @@
package io.novafoundation.nova.feature_gift_impl.domain
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
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.asMultisig
import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsSupportedState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RealGiftsAccountSupportedUseCase(
private val selectedAccountUseCase: SelectedAccountUseCase,
) : GiftsAccountSupportedUseCase {
override suspend fun supportedState(): GiftsSupportedState {
val selectedAccount = selectedAccountUseCase.getSelectedMetaAccount()
return selectedAccount.supportsGifts()
}
override fun areGiftsSupportedFlow(): Flow<GiftsSupportedState> {
return selectedAccountUseCase.selectedMetaAccountFlow()
.map { it.supportsGifts() }
}
private fun MetaAccount.supportsGifts(): GiftsSupportedState {
return when (type) {
LightMetaAccount.Type.SECRETS,
LightMetaAccount.Type.WATCH_ONLY,
LightMetaAccount.Type.PARITY_SIGNER,
LightMetaAccount.Type.LEDGER_LEGACY,
LightMetaAccount.Type.LEDGER,
LightMetaAccount.Type.PROXIED,
LightMetaAccount.Type.POLKADOT_VAULT -> GiftsSupportedState.SUPPORTED
LightMetaAccount.Type.MULTISIG -> if (asMultisig().isThreshold1()) {
GiftsSupportedState.SUPPORTED
} else {
GiftsSupportedState.UNSUPPORTED_MULTISIG_ACCOUNTS
}
}
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_gift_impl.domain
import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository
import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository
import io.novafoundation.nova.feature_gift_impl.domain.models.Gift
import io.novasama.substrate_sdk_android.extensions.toHexString
import kotlinx.coroutines.flow.Flow
interface ShareGiftInteractor {
fun observeGift(giftId: Long): Flow<Gift>
suspend fun getGiftSeed(giftId: Long): String
suspend fun setGiftStateAsReclaimed(id: Long)
}
class RealShareGiftInteractor(
private val giftsRepository: GiftsRepository,
private val giftSecretsRepository: GiftSecretsRepository,
) : ShareGiftInteractor {
override fun observeGift(giftId: Long): Flow<Gift> {
return giftsRepository.observeGift(giftId)
}
override suspend fun getGiftSeed(giftId: Long): String {
val gift = giftsRepository.getGift(giftId)
val seed = giftSecretsRepository.getGiftAccountSeed(gift.giftAccountId) ?: error("No secrets for gift found")
return seed.toHexString()
}
override suspend fun setGiftStateAsReclaimed(id: Long) {
giftsRepository.setGiftState(id, Gift.Status.RECLAIMED)
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_gift_impl.domain.models
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.scale.EncodableStruct
class ClaimableGift(
val accountId: AccountId,
val chain: Chain,
val chainAsset: Chain.Asset,
val secrets: EncodableStruct<ChainAccountSecrets>
)
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_gift_impl.domain.models
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
data class CreateGiftModel(
val senderMetaAccount: MetaAccount,
val chain: Chain,
val chainAsset: Chain.Asset,
val amount: BigDecimal
)
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_gift_impl.domain.models
import io.novafoundation.nova.feature_gift_impl.domain.models.Gift.Status.PENDING
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import java.math.BigInteger
import java.util.Date
class Gift(
val id: Long,
val amount: BigInteger,
val creatorMetaId: Long,
val chainId: ChainId,
val assetId: ChainAssetId,
val status: Status,
val giftAccountId: ByteArray,
val creationDate: Date
) {
enum class Status {
PENDING,
CLAIMED,
RECLAIMED
}
}
fun Gift.Status.isClaimed() = this != PENDING
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_gift_impl.domain.models
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import java.math.BigDecimal
class GiftAmountWithFee(
val amount: BigDecimal,
val fee: SubmissionFee
)
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_gift_impl.domain.models
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import io.novafoundation.nova.feature_account_api.data.model.getAmount
import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigInteger
data class GiftFee(
val createGiftFee: SubmissionFee,
val claimGiftFee: SubmissionFee
) : FeeBase, MaxAvailableDeduction {
fun replaceSubmission(newSubmissionFee: SubmissionFee): GiftFee {
return copy(createGiftFee = newSubmissionFee)
}
override fun maxAmountDeductionFor(amountAsset: Chain.Asset): BigInteger {
val createFeeAmount = createGiftFee.getAmount(amountAsset).orZero()
val claimFeeAmount = claimGiftFee.getAmount(amountAsset).orZero()
return createFeeAmount + claimFeeAmount
}
override val amount: BigInteger = createGiftFee.amount + claimGiftFee.amount
override val asset: Chain.Asset = createGiftFee.asset
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_gift_impl.presentation
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_gift_impl.domain.GiftId
import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
interface GiftRouter : ReturnableRouter {
fun finishCreateGift()
fun openGiftsFlow()
fun openSelectGiftAmount(assetPayload: AssetPayload)
fun openConfirmCreateGift(payload: CreateGiftConfirmPayload)
fun openGiftSharing(giftId: GiftId, isSecondOpen: Boolean = false)
fun openMainScreen()
fun openClaimGift(claimGiftPayload: ClaimGiftPayload)
fun openManageWallets()
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount
import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_wallet_api.presentation.common.MinAmountProvider
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GiftMinAmountProviderFactory(
private val createGiftInteractor: CreateGiftInteractor,
) {
fun create(
chainAssetFlow: Flow<Chain.Asset>
): MinAmountProvider {
return GiftMinAmountProvider(
createGiftInteractor,
chainAssetFlow
)
}
}
class GiftMinAmountProvider(
private val createGiftInteractor: CreateGiftInteractor,
private val chainAssetFlow: Flow<Chain.Asset>
) : MinAmountProvider {
override fun provideMinAmount(): Flow<BigDecimal> {
return chainAssetFlow
.map { createGiftInteractor.getExistentialDeposit(it) }
}
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount
import android.view.View
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
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.hideKeyboard
import io.novafoundation.nova.common.utils.insets.ImeInsetsState
import io.novafoundation.nova.common.utils.insets.applySystemBarInsets
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.databinding.FragmentSelectGiftAmountBinding
import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.bindGetAsset
class SelectGiftAmountFragment : BaseFragment<SelectGiftAmountViewModel, FragmentSelectGiftAmountBinding>() {
companion object : PayloadCreator<SelectGiftAmountPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentSelectGiftAmountBinding.inflate(layoutInflater)
override fun applyInsets(rootView: View) {
binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED)
}
override fun initViews() {
binder.giftAmountToolbar.setHomeButtonListener {
hideKeyboard()
viewModel.back()
}
binder.giftAmountContinue.prepareForProgress(this)
binder.giftAmountContinue.setOnClickListener { viewModel.nextClicked() }
}
override fun inject() {
FeatureUtils.getFeature<GiftFeatureComponent>(this, GiftFeatureApi::class.java)
.selectGiftAmountComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: SelectGiftAmountViewModel) {
observeValidations(viewModel)
setupAmountChooser(viewModel.amountChooserMixin, binder.giftsAmount)
viewModel.feeMixin.setupFeeLoading(binder.giftAmountFee)
viewModel.getAssetOptionsMixin.bindGetAsset(binder.giftAmountGetTokens)
viewModel.chainModelFlow.observe { binder.giftAmountChain.setModel(it) }
viewModel.continueButtonStateFlow.observe(binder.giftAmountContinue::setState)
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount
import android.os.Parcelable
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import kotlinx.parcelize.Parcelize
@Parcelize
data class SelectGiftAmountPayload(val assetPayload: AssetPayload) : Parcelable
@@ -0,0 +1,227 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
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.isPositive
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.validation.CompoundFieldValidator
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.FieldValidator
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.isErrorWithTag
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.view.ChainChipModel
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.createForGiftsWithDefaultDisplay
import io.novafoundation.nova.feature_gift_impl.presentation.common.buildGiftValidationPayload
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountFieldValidator
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.isMaxAction
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter
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.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create
import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload
import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chainFlow
import java.math.BigDecimal
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class SelectGiftAmountViewModel(
private val router: GiftRouter,
private val chainRegistry: ChainRegistry,
private val validationExecutor: ValidationExecutor,
private val assetUseCase: ArbitraryAssetUseCase,
private val payload: SelectGiftAmountPayload,
private val maxActionProviderFactory: MaxActionProviderFactory,
private val amountFormatter: AmountFormatter,
private val resourceManager: ResourceManager,
private val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory,
private val createGiftInteractor: CreateGiftInteractor,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val enoughAmountValidatorFactory: EnoughAmountValidatorFactory,
private val minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory,
private val giftMinAmountProviderFactory: GiftMinAmountProviderFactory,
amountChooserMixinFactory: AmountChooserMixin.Factory,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
) : BaseViewModel(),
Validatable by validationExecutor {
private val chainFlow = chainRegistry.chainFlow(payload.assetPayload.chainId)
private val chainAssetFlow = chainFlow.map { it.assetsById.getValue(payload.assetPayload.chainAssetId) }
private val metaAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow()
@OptIn(ExperimentalCoroutinesApi::class)
private val assetFlow = chainAssetFlow.flatMapLatest(assetUseCase::assetFlow)
.shareInBackground()
val chainModelFlow = chainFlow.map {
ChainChipModel(
chainUi = mapChainToUi(it),
changeable = false
)
}.shareInBackground()
private val feeFormatter = DefaultFeeFormatter<GiftFee>(amountFormatter)
val feeMixin = feeLoaderMixinFactory.createForGiftsWithDefaultDisplay(
chainAssetFlow,
feeFormatter
)
private val maxActionProvider = maxActionProviderFactory.create(
viewModelScope = viewModelScope,
assetInFlow = assetFlow,
feeLoaderMixin = feeMixin
)
val amountChooserMixin: AmountChooserMixin.Presentation = amountChooserMixinFactory.create(
scope = this,
assetFlow = assetFlow,
maxActionProvider = maxActionProvider,
fieldValidator = getAmountValidator()
)
private val notEnoughAmountErrorFlow = combine(assetFlow, amountChooserMixin.fieldError) { asset, fieldError ->
asset.transferable.isZero || fieldError.isErrorWithTag(EnoughAmountFieldValidator.ERROR_TAG)
}
val getAssetOptionsMixin = getAssetOptionsMixinFactory.create(
assetFlow = chainAssetFlow,
additionalButtonFilter = notEnoughAmountErrorFlow,
scope = viewModelScope,
)
private val validationInProgressFlow = MutableStateFlow(false)
val continueButtonStateFlow = combine(
validationInProgressFlow,
amountChooserMixin.fieldError,
amountChooserMixin.amountState
) { validating, fieldError, amountState ->
when {
validating -> DescriptiveButtonState.Loading
fieldError is FieldValidationResult.Error -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_other_amount))
amountState.value.orZero().isPositive -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue))
else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.gift_enter_amount_disabled_button_state))
}
}.onStart { emit(DescriptiveButtonState.Disabled(resourceManager.getString(R.string.gift_enter_amount_disabled_button_state))) }
init {
setupFees()
}
fun back() {
router.back()
}
fun nextClicked() = launchUnit {
validationInProgressFlow.value = true
val fee = feeMixin.awaitFee()
val amountState = amountChooserMixin.amountState.first()
val giftAmount = amountState.value ?: return@launchUnit
val chain = chainFlow.first()
val giftModel = CreateGiftModel(
senderMetaAccount = selectedAccountUseCase.getSelectedMetaAccount(),
chain = chain,
chainAsset = chainAssetFlow.first(),
amount = giftAmount,
)
val payload = buildGiftValidationPayload(
giftModel,
asset = assetFlow.first(),
amountState.inputKind.isMaxAction(),
feeMixin.feePaymentCurrency(),
fee
)
validationExecutor.requireValid(
validationSystem = createGiftInteractor.validationSystemFor(giftModel.chainAsset, viewModelScope),
payload = payload,
progressConsumer = validationInProgressFlow.progressConsumer(),
autoFixPayload = ::autoFixSendValidationPayload,
validationFailureTransformerCustom = { status, actions ->
viewModelScope.mapAssetTransferValidationFailureToUI(
resourceManager = resourceManager,
status = status,
actions = actions,
setFee = { feeMixin.setFee(fee.replaceSubmission(it)) }
)
},
) {
validationInProgressFlow.value = false
openConfirmScreen(it, giftAmount)
}
}
private fun openConfirmScreen(validPayload: AssetTransferPayload, giftAmount: BigDecimal) = launch {
val payload = CreateGiftConfirmPayload(
amount = giftAmount,
transferringMaxAmount = validPayload.transfer.transferringMaxAmount,
assetPayload = payload.assetPayload
)
router.openConfirmCreateGift(payload)
}
private fun setupFees() {
feeMixin.connectWith(
chainFlow,
chainAssetFlow,
amountChooserMixin.amountState,
) { feePaymentCurrency, chain, chainAsset, amountState ->
val metaAccount = metaAccountFlow.first()
val createGiftModel = CreateGiftModel(
senderMetaAccount = metaAccount,
chain = chain,
chainAsset = chainAsset,
amount = amountState.value.orZero(),
)
createGiftInteractor.getFee(
createGiftModel,
amountState.inputKind.isMaxAction(),
viewModelScope
)
}
}
private fun getAmountValidator(): FieldValidator {
val minAmountProvider = giftMinAmountProviderFactory.create(chainAssetFlow)
return CompoundFieldValidator(
enoughAmountValidatorFactory.create(maxActionProvider),
minAmountFieldValidatorFactory.create(chainAssetFlow, minAmountProvider, R.string.gift_min_balance_validation_message)
)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.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_gift_impl.presentation.amount.SelectGiftAmountFragment
import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountPayload
@Subcomponent(
modules = [
SelectGiftAmountModule::class
]
)
@ScreenScope
interface SelectGiftAmountComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: SelectGiftAmountPayload
): SelectGiftAmountComponent
}
fun inject(fragment: SelectGiftAmountFragment)
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.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.common.validation.ValidationExecutor
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.amount.GiftMinAmountProviderFactory
import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountPayload
import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountViewModel
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SelectGiftAmountModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): SelectGiftAmountViewModel {
return ViewModelProvider(fragment, factory).get(SelectGiftAmountViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(SelectGiftAmountViewModel::class)
fun provideViewModel(
router: GiftRouter,
chainRegistry: ChainRegistry,
validationExecutor: ValidationExecutor,
assetUseCase: ArbitraryAssetUseCase,
payload: SelectGiftAmountPayload,
maxActionProviderFactory: MaxActionProviderFactory,
amountFormatter: AmountFormatter,
resourceManager: ResourceManager,
getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory,
createGiftInteractor: CreateGiftInteractor,
selectedAccountUseCase: SelectedAccountUseCase,
enoughAmountValidatorFactory: EnoughAmountValidatorFactory,
minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory,
giftMinAmountProviderFactory: GiftMinAmountProviderFactory,
amountChooserMixinFactory: AmountChooserMixin.Factory,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
): ViewModel {
return SelectGiftAmountViewModel(
router = router,
chainRegistry = chainRegistry,
validationExecutor = validationExecutor,
assetUseCase = assetUseCase,
payload = payload,
maxActionProviderFactory = maxActionProviderFactory,
amountFormatter = amountFormatter,
resourceManager = resourceManager,
getAssetOptionsMixinFactory = getAssetOptionsMixinFactory,
createGiftInteractor = createGiftInteractor,
selectedAccountUseCase = selectedAccountUseCase,
enoughAmountValidatorFactory = enoughAmountValidatorFactory,
minAmountFieldValidatorFactory = minAmountFieldValidatorFactory,
giftMinAmountProviderFactory = giftMinAmountProviderFactory,
amountChooserMixinFactory = amountChooserMixinFactory,
feeLoaderMixinFactory = feeLoaderMixinFactory,
)
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
class GiftFeeDisplay(
val networkFee: FeeDisplay,
val claimGiftFee: FeeDisplay,
)
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
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.mixin.fee.formatter.FeeFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay
class GiftFeeDisplayFormatter(
private val amountFormatter: AmountFormatter
) : FeeFormatter<GiftFee, GiftFeeDisplay> {
override suspend fun formatFee(
fee: GiftFee,
configuration: FeeFormatter.Configuration,
context: FeeFormatter.Context
): GiftFeeDisplay {
val networkFee = amountFormatter.formatAmountToAmountModel(
amountInPlanks = fee.createGiftFee.amount,
token = context.token(fee.asset),
AmountConfig(includeZeroFiat = configuration.showZeroFiat)
).toFeeDisplay()
val claimFee = amountFormatter.formatAmountToAmountModel(
amountInPlanks = fee.claimGiftFee.amount,
token = context.token(fee.asset),
AmountConfig(includeZeroFiat = configuration.showZeroFiat)
).toFeeDisplay()
return GiftFeeDisplay(
networkFee = networkFee,
claimGiftFee = claimFee
)
}
override suspend fun createLoadingStatus(): FeeStatus.Loading {
return FeeStatus.Loading(visibleDuringProgress = true)
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class GiftFeeInspector : FeeInspector<GiftFee> {
override fun inspectFeeAmount(fee: GiftFee): FeeInspector.InspectedFeeAmount {
return FeeInspector.InspectedFeeAmount(
checkedAgainstMinimumBalance = fee.amount,
deductedFromTransferable = fee.amount
)
}
override fun getSubmissionFeeAsset(fee: GiftFee): Chain.Asset {
return fee.createGiftFee.asset
}
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromSelf
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
context(BaseViewModel)
fun FeeLoaderMixinV2.Factory.createForGiftsWithDefaultDisplay(
originChainAsset: Flow<Chain.Asset>,
formatter: DefaultFeeFormatter<GiftFee>
): FeeLoaderMixinV2.Presentation<GiftFee, FeeDisplay> {
return create(
scope = viewModelScope,
feeContextFlow = originChainAsset.asFeeContextFromSelf(),
feeFormatter = formatter,
feeInspector = GiftFeeInspector(),
configuration = FeeLoaderMixinV2.Configuration()
)
}
context(BaseViewModel)
fun FeeLoaderMixinV2.Factory.createForGiftsWithGiftFeeDisplay(
originChainAsset: Flow<Chain.Asset>,
formatter: GiftFeeDisplayFormatter
): FeeLoaderMixinV2.Presentation<GiftFee, GiftFeeDisplay> {
return create(
scope = viewModelScope,
feeContextFlow = originChainAsset.asFeeContextFromSelf(),
feeFormatter = formatter,
feeInspector = GiftFeeInspector(),
configuration = FeeLoaderMixinV2.Configuration()
)
}
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim
import android.animation.Animator
import android.view.View
import androidx.core.view.postDelayed
import coil.ImageLoader
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.makeInvisible
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.setModelOrHide
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_account_api.R
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.bindSelectWallet
import io.novafoundation.nova.feature_account_api.view.setSelectable
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.databinding.FragmentClaimGiftBinding
import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent
import javax.inject.Inject
private const val HIDE_ANIMATION_DURATION = 400L
private const val UNPACKING_START_FRAME = 180
class ClaimGiftFragment : BaseFragment<ClaimGiftViewModel, FragmentClaimGiftBinding>() {
companion object : PayloadCreator<ClaimGiftPayload> by FragmentPayloadCreator()
private val giftAnimationListener = object : Animator.AnimatorListener {
override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
viewModel.onGiftClaimAnimationFinished()
}
}
@Inject
lateinit var imageLoader: ImageLoader
override fun createBinding() = FragmentClaimGiftBinding.inflate(layoutInflater)
override fun initViews() {
binder.claimGiftToolbar.setHomeButtonListener { viewModel.back() }
binder.claimGiftButton.setOnClickListener { viewModel.claimGift() }
binder.claimGiftButton.prepareForProgress(this)
}
override fun inject() {
FeatureUtils.getFeature<GiftFeatureComponent>(this, GiftFeatureApi::class.java)
.claimGiftComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: ClaimGiftViewModel) {
bindSelectWallet(viewModel.selectWalletMixin) { isAvailableToSelect ->
binder.claimGiftAccount.setSelectable(isAvailableToSelect) {
viewModel.selectWalletToClaim()
}
binder.claimGiftAccount.setActionTint(R.color.icon_secondary)
}
viewModel.giftAnimationRes.observe {
binder.claimGiftAnimation.setMinAndMaxFrame(0, UNPACKING_START_FRAME)
binder.claimGiftAnimation.setAnimation(it)
binder.claimGiftAnimation.playAnimation()
}
viewModel.amountModel.observe {
binder.claimGiftTokenIcon.setTokenIcon(it.tokenIcon, imageLoader)
binder.claimGiftAmount.text = it.amount
}
viewModel.giftClaimedEvent.observeEvent {
hideAllViewsWithAnimation()
}
viewModel.selectedWalletModel.observe {
binder.claimGiftAccount.setModel(it)
}
viewModel.confirmButtonStateFlow.observe {
binder.claimGiftButton.setState(it)
}
viewModel.alertModelFlow.observe {
binder.claimGiftAlert.setModelOrHide(it)
}
}
private fun hideAllViewsWithAnimation() {
binder.claimGiftToolbar.hideWithAnimation()
binder.claimGiftTokenIcon.hideWithAnimation()
binder.claimGiftAmount.hideWithAnimation()
binder.claimGiftButton.hideWithAnimation()
binder.claimGiftTitle.hideWithAnimation()
binder.claimGiftAccountTitle.hideWithAnimation()
binder.claimGiftAccount.hideWithAnimation()
binder.root.postDelayed(HIDE_ANIMATION_DURATION) {
val maxFrame = binder.claimGiftAnimation.composition?.endFrame?.toInt() ?: 0
binder.claimGiftAnimation.setMinAndMaxFrame(UNPACKING_START_FRAME, maxFrame)
binder.claimGiftAnimation.addAnimatorListener(giftAnimationListener)
binder.claimGiftAnimation.playAnimation()
}
}
private fun View.hideWithAnimation() {
animate().alpha(0f)
.setDuration(HIDE_ANIMATION_DURATION)
.withEndAction { makeInvisible() }
.start()
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class ClaimGiftPayload(val secret: ByteArray, val chainId: String, val assetId: Int) : Parcelable
@@ -0,0 +1,235 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.view.AlertModel
import io.novafoundation.nova.common.view.AlertView
import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.isControllableWallet
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_account_api.presenatation.common.mapMetaAccountTypeToNameRes
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin
import io.novafoundation.nova.feature_account_api.view.AccountView
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftException
import io.novafoundation.nova.feature_gift_impl.presentation.share.model.GiftAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class ClaimGiftViewModel(
private val router: GiftRouter,
private val payload: ClaimGiftPayload,
private val claimGiftInteractor: ClaimGiftInteractor,
private val chainRegistry: ChainRegistry,
private val unpackingGiftAnimationFactory: UnpackingGiftAnimationFactory,
private val assetIconProvider: AssetIconProvider,
private val tokenFormatter: TokenFormatter,
private val resourceManager: ResourceManager,
private val walletUiUseCase: WalletUiUseCase,
private val claimGiftMixinFactory: ClaimGiftMixinFactory,
private val accountInteractor: AccountInteractor,
selectSingleWalletMixin: SelectSingleWalletMixin.Factory,
) : BaseViewModel() {
private val giftFlow = flowOf { claimGiftInteractor.getClaimableGift(payload.secret, payload.chainId, payload.assetId) }
.shareInBackground()
private val metaIdToClaimGiftFlow = MutableStateFlow<Long?>(null)
private val metaAccountToClaimGiftFlow = metaIdToClaimGiftFlow.filterNotNull()
.map { claimGiftInteractor.getMetaAccount(it) }
.shareInBackground()
private val tempMetaAccountFlow = giftFlow.map { claimGiftInteractor.createTempMetaAccount(it) }
.shareInBackground()
private val giftAmountWithFee = combine(giftFlow, tempMetaAccountFlow) { gift, metaAccount ->
claimGiftInteractor.getGiftAmountWithFee(gift, metaAccount, coroutineScope)
}.shareInBackground()
val selectedWalletModel = combine(giftFlow, metaAccountToClaimGiftFlow) { gift, metaAccountToClaimGift ->
val addressModel = walletUiUseCase.walletAddressModelOrNull(
metaAccountToClaimGift,
gift.chain,
AddressIconGenerator.SIZE_MEDIUM
)
addressModel.asAccountViewModelOrNoAddress(metaAccountToClaimGift, gift.chain)
}
val amountModel = combine(giftFlow, giftAmountWithFee) { gift, giftAmountWithFee ->
val tokenIcon = assetIconProvider.getAssetIconOrFallback(gift.chainAsset)
val giftAmount = tokenFormatter.formatToken(giftAmountWithFee.amount, gift.chainAsset.symbol)
GiftAmountModel(tokenIcon, giftAmount)
}
private val _giftClaimedEvent = MutableLiveData<Event<Unit>>()
val giftClaimedEvent: LiveData<Event<Unit>> = _giftClaimedEvent
val giftAnimationRes = giftFlow.map {
val chainAsset = chainRegistry.asset(it.chain.id, it.chainAsset.id)
unpackingGiftAnimationFactory.getAnimationForAsset(chainAsset.symbol)
}.distinctUntilChanged()
.shareInBackground()
private val selectWalletPayloadFlow = giftFlow.map {
SelectSingleWalletMixin.Payload(
chain = it.chain,
filter = SelectAccountFilter.ControllableWallets()
)
}
val selectWalletMixin = selectSingleWalletMixin.create(
coroutineScope = this,
payloadFlow = selectWalletPayloadFlow,
onWalletSelect = ::onWalletSelect
)
val alertModelFlow = combine(giftFlow, metaAccountToClaimGiftFlow) { gift, metaAccount ->
when {
!metaAccount.type.isControllableWallet() -> {
val metaAccountTypeName = resourceManager.getString(metaAccount.type.mapMetaAccountTypeToNameRes())
AlertModel(
style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING),
message = resourceManager.getString(
R.string.claim_gift_uncontrollable_wallet_title,
metaAccountTypeName.lowercase()
),
subMessages = listOf(resourceManager.getString(R.string.claim_gift_uncontrollable_wallet_message)),
linkAction = AlertModel.ActionModel(
text = resourceManager.getString(R.string.common_manage_wallets),
listener = ::manageWallets
),
)
}
!metaAccount.hasAccountIn(gift.chain) -> AlertModel(
style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING),
message = resourceManager.getString(R.string.claim_gift_no_account_alert_title, gift.chain.name),
subMessages = listOf(),
)
else -> null
}
}
private val claimGiftMixin = claimGiftMixinFactory.create(this)
val confirmButtonStateFlow = combine(
claimGiftMixin.claimingInProgressFlow,
giftFlow,
metaAccountToClaimGiftFlow
) { claimingInProgress, giftFlow, claimMetaAccount ->
when {
claimingInProgress -> DescriptiveButtonState.Loading
!claimMetaAccount.type.isControllableWallet() -> DescriptiveButtonState.Gone
!claimMetaAccount.hasAccountIn(giftFlow.chain) -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.account_select_wallet))
}
else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.claim_gift_button))
}
}
init {
launch {
val metaAccount = claimGiftInteractor.getMetaAccountToClaimGift()
metaIdToClaimGiftFlow.value = metaAccount.id
}
}
fun back() {
router.back()
}
fun claimGift() = launchUnit {
val gift = giftFlow.first()
val amountWithFee = giftAmountWithFee.first()
val tempMetaAccount = tempMetaAccountFlow.first()
val metaAccountToClaimGift = metaAccountToClaimGiftFlow.first()
claimGiftMixin.claimGift(
gift = gift,
amountWithFee = amountWithFee,
giftMetaAccount = tempMetaAccount,
giftRecipient = metaAccountToClaimGift
)
.onSuccess {
val metaAccountToClaimGift = metaAccountToClaimGiftFlow.first()
accountInteractor.selectMetaAccount(metaAccountToClaimGift.id)
_giftClaimedEvent.value = Unit.event()
}
.onFailure {
when (it as ClaimGiftException) {
is ClaimGiftException.GiftAlreadyClaimed -> showError(
resourceManager.getString(R.string.claim_gift_already_claimed_title),
resourceManager.getString(R.string.claim_gift_already_claimed_message)
)
is ClaimGiftException.UnknownError -> showError(
resourceManager.getString(R.string.claim_gift_default_error_title),
resourceManager.getString(R.string.claim_gift_default_error_message)
)
}
}
}
fun onGiftClaimAnimationFinished() = launchUnit {
showToast(resourceManager.getString(R.string.claim_gift_success_message))
router.openMainScreen()
}
fun selectWalletToClaim() {
launch {
val selectedMetaAccount = metaAccountToClaimGiftFlow.first()
selectWalletMixin.openSelectWallet(selectedMetaAccount.id)
}
}
private fun onWalletSelect(metaId: Long) {
metaIdToClaimGiftFlow.value = metaId
}
private fun manageWallets() = launchUnit {
router.openManageWallets()
}
private fun AddressModel?.asAccountViewModelOrNoAddress(
metaAccountToClaimGift: MetaAccount,
chain: Chain
): AccountView.Model {
return this?.let { AccountView.Model.Address(it) }
?: AccountView.Model.NoAddress(
metaAccountToClaimGift.name,
resourceManager.getString(R.string.account_chain_not_found, chain.name)
)
}
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink
import android.net.Uri
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull
import io.novafoundation.nova.runtime.ext.ChainGeneses
import kotlin.math.min
class ClaimGiftDeepLinkData(
val seed: String,
val chainId: String,
val symbol: TokenSymbol
)
class ClaimGiftPayloadParams(
val seed: String,
val chainIdPrefix: String?,
val symbol: TokenSymbol?
)
class ClaimGiftDeepLinkConfigurator(
private val linkBuilderFactory: LinkBuilderFactory
) : DeepLinkConfigurator<ClaimGiftDeepLinkData> {
val action = "open"
val screen = "gift"
val deepLinkPrefix = "/$action/$screen"
val payloadParam = "payload"
val chainIdLength = 6
private val defaultChainId = ChainGeneses.POLKADOT_ASSET_HUB
private val defaultAssetId = "DOT"
override fun configure(payload: ClaimGiftDeepLinkData, type: DeepLinkConfigurator.Type): Uri {
val data = if (payload.chainId == defaultChainId) {
if (payload.symbol.value == defaultAssetId) {
payload.seed
} else {
makePayload(payload.seed, payload.symbol.value)
}
} else {
val normalizedChainId = normaliseChainId(payload.chainId)
makePayload(payload.seed, payload.symbol.value, normalizedChainId)
}
return linkBuilderFactory.newLink(type)
.setAction(action)
.setScreen(screen)
.addParamIfNotNull(payloadParam, data)
.build()
}
fun normaliseChainId(chainId: String): String {
val noPrefixChainId = chainId.removePrefix("eip155:")
val substringEnd = min(noPrefixChainId.length, chainIdLength)
return noPrefixChainId.substring(startIndex = 0, endIndex = substringEnd)
}
fun fromPayload(payload: String): ClaimGiftPayloadParams {
val payloadParams = payload.split("_")
val seed = payloadParams[0]
val symbol = payloadParams.getOrNull(1)
val chainIdPrefix = payloadParams.getOrNull(2)
return ClaimGiftPayloadParams(
seed = seed,
symbol = symbol?.let { TokenSymbol(it) },
chainIdPrefix = chainIdPrefix
)
}
private fun makePayload(vararg params: String): String {
return params.joinToString("_")
}
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink
import android.net.Uri
import io.novafoundation.nova.common.utils.DialogMessageManager
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import kotlinx.coroutines.flow.MutableSharedFlow
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload
import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.findChain
import io.novasama.substrate_sdk_android.extensions.fromHex
import java.security.InvalidParameterException
class ClaimGiftDeepLinkHandler(
private val router: GiftRouter,
private val chainRegistry: ChainRegistry,
private val automaticInteractionGate: AutomaticInteractionGate,
private val dialogMessageManager: DialogMessageManager,
private val claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator,
private val claimGiftInteractor: ClaimGiftInteractor
) : DeepLinkHandler {
override val callbackFlow = MutableSharedFlow<CallbackEvent>()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(claimGiftDeepLinkConfigurator.deepLinkPrefix)
}
override suspend fun handleDeepLink(uri: Uri) = runCatching {
automaticInteractionGate.awaitInteractionAllowed()
val data = uri.getQueryParameter(claimGiftDeepLinkConfigurator.payloadParam) ?: throw InvalidParameterException()
val payloadParams = claimGiftDeepLinkConfigurator.fromPayload(data)
val secretBytes = payloadParams.seed.fromHex()
val chain = findChain(payloadParams.chainIdPrefix) ?: throw InvalidParameterException()
val chainAsset = findAsset(chain, payloadParams.symbol)
require(chain.isEnabled)
val claimableGift = claimGiftInteractor.getClaimableGift(secretBytes, chain.id, chainAsset.id)
val isGiftAlreadyClaimed = claimGiftInteractor.isGiftAlreadyClaimed(claimableGift)
if (isGiftAlreadyClaimed) {
dialogMessageManager.showDialog {
setTitle(R.string.claim_gift_already_claimed_title)
setMessage(R.string.claim_gift_already_claimed_message)
setPositiveButton(R.string.common_got_it, null)
}
return@runCatching
}
router.openClaimGift(ClaimGiftPayload(secretBytes, chain.id, chainAsset.id))
}
private suspend fun findChain(chainIdPrefix: String?): Chain? {
if (chainIdPrefix == null) return chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
return chainRegistry.findChain { chainIdPrefix == claimGiftDeepLinkConfigurator.normaliseChainId(it.id) }
}
private fun findAsset(chain: Chain, tokenSymbol: TokenSymbol?): Chain.Asset {
if (tokenSymbol == null) return chain.utilityAsset
return chain.assets.first { it.symbol.value == tokenSymbol.value }
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim.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_gift_impl.presentation.claim.ClaimGiftFragment
import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload
@Subcomponent(
modules = [
ClaimGiftModule::class
]
)
@ScreenScope
interface ClaimGiftComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: ClaimGiftPayload
): ClaimGiftComponent
}
fun inject(fragment: ClaimGiftFragment)
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_gift_impl.presentation.claim.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.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload
import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftViewModel
import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class ClaimGiftModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ClaimGiftViewModel {
return ViewModelProvider(fragment, factory).get(ClaimGiftViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(ClaimGiftViewModel::class)
fun provideViewModel(
router: GiftRouter,
payload: ClaimGiftPayload,
claimGiftInteractor: ClaimGiftInteractor,
chainRegistry: ChainRegistry,
unpackingGiftAnimationFactory: UnpackingGiftAnimationFactory,
assetIconProvider: AssetIconProvider,
tokenFormatter: TokenFormatter,
resourceManager: ResourceManager,
walletUiUseCase: WalletUiUseCase,
claimGiftMixinFactory: ClaimGiftMixinFactory,
accountInteractor: AccountInteractor,
selectSingleWalletMixin: SelectSingleWalletMixin.Factory,
): ViewModel {
return ClaimGiftViewModel(
router = router,
payload = payload,
claimGiftInteractor = claimGiftInteractor,
chainRegistry = chainRegistry,
unpackingGiftAnimationFactory = unpackingGiftAnimationFactory,
assetIconProvider = assetIconProvider,
tokenFormatter = tokenFormatter,
resourceManager = resourceManager,
walletUiUseCase = walletUiUseCase,
claimGiftMixinFactory = claimGiftMixinFactory,
selectSingleWalletMixin = selectSingleWalletMixin,
accountInteractor = accountInteractor
)
}
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_gift_impl.presentation.common
import androidx.annotation.RawRes
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.feature_gift_impl.R
private enum class GiftKnownTicker {
DOT, KSM, USDT, HDX, AZERO, ASTR, UNKNOW;
companion object {
fun tickerOrUnknown(symbol: TokenSymbol): GiftKnownTicker {
return GiftKnownTicker.entries.firstOrNull { it.name == symbol.value } ?: UNKNOW
}
}
}
interface GiftAnimationFactory {
@RawRes
fun getAnimationForAsset(symbol: TokenSymbol): Int
}
class PackingGiftAnimationFactory : GiftAnimationFactory {
override fun getAnimationForAsset(symbol: TokenSymbol): Int {
val ticker = GiftKnownTicker.tickerOrUnknown(symbol)
return when (ticker) {
GiftKnownTicker.DOT -> R.raw.dot_packing
GiftKnownTicker.KSM -> R.raw.ksm_packing
GiftKnownTicker.USDT -> R.raw.usdt_packing
GiftKnownTicker.HDX -> R.raw.hdx_packing
GiftKnownTicker.AZERO -> R.raw.azero_packing
GiftKnownTicker.ASTR -> R.raw.astr_packing
GiftKnownTicker.UNKNOW -> R.raw.default_packing
}
}
}
class UnpackingGiftAnimationFactory : GiftAnimationFactory {
override fun getAnimationForAsset(symbol: TokenSymbol): Int {
val ticker = GiftKnownTicker.tickerOrUnknown(symbol)
return when (ticker) {
GiftKnownTicker.DOT -> R.raw.dot_upacking
GiftKnownTicker.KSM -> R.raw.ksm_unpacking
GiftKnownTicker.USDT -> R.raw.usdt_unpacking
GiftKnownTicker.HDX -> R.raw.hdx_unpacking
GiftKnownTicker.AZERO -> R.raw.azero_unpacking
GiftKnownTicker.ASTR -> R.raw.astr_unpacking
GiftKnownTicker.UNKNOW -> R.raw.default_unpacking
}
}
}
@@ -0,0 +1,75 @@
package io.novafoundation.nova.feature_gift_impl.presentation.common
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.emptyAccountIdKey
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
fun buildGiftValidationPayload(
createGiftModel: CreateGiftModel,
asset: Asset,
transferMax: Boolean,
feePaymentCurrency: FeePaymentCurrency,
fee: GiftFee,
): AssetTransferPayload {
val transferAmount = createGiftModel.amount + createGiftModel.chainAsset.amountFromPlanks(fee.claimGiftFee.amount)
val transfer = buildTransfer(
metaAccount = createGiftModel.senderMetaAccount,
chain = createGiftModel.chain,
chainAsset = createGiftModel.chainAsset,
amount = transferAmount,
transferringMaxAmount = transferMax,
feePaymentCurrency = feePaymentCurrency,
address = createGiftModel.chain.addressOf(createGiftModel.chain.emptyAccountIdKey()),
)
val originFee = OriginFee(
submissionFee = fee.createGiftFee,
deliveryFee = null
)
return AssetTransferPayload(
transfer = WeightedAssetTransfer(
assetTransfer = transfer,
fee = originFee
),
crossChainFee = null,
originFee = originFee,
originCommissionAsset = asset,
originUsedAsset = asset
)
}
private fun buildTransfer(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
feePaymentCurrency: FeePaymentCurrency,
amount: BigDecimal,
transferringMaxAmount: Boolean,
address: String,
): AssetTransfer {
val chainWithAsset = ChainWithAsset(chain, chainAsset)
return buildAssetTransfer(
metaAccount = metaAccount,
feePaymentCurrency = feePaymentCurrency,
origin = chainWithAsset,
destination = chainWithAsset,
amount = amount,
transferringMaxAmount = transferringMaxAmount,
address = address
)
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_gift_impl.presentation.common.claim
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee
import kotlinx.coroutines.flow.MutableStateFlow
interface ClaimGiftMixin {
val claimingInProgressFlow: MutableStateFlow<Boolean>
suspend fun claimGift(
gift: ClaimableGift,
amountWithFee: GiftAmountWithFee,
giftMetaAccount: MetaAccount,
giftRecipient: MetaAccount
): Result<Unit>
}
sealed class ClaimGiftException : Exception() {
class GiftAlreadyClaimed : ClaimGiftException()
class UnknownError(throwable: Throwable) : ClaimGiftException()
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_gift_impl.presentation.common.claim
import io.novafoundation.nova.common.utils.mapFailure
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
class ClaimGiftMixinFactory(private val claimGiftInteractor: ClaimGiftInteractor) {
fun create(
coroutineScope: CoroutineScope
): ClaimGiftMixin {
return RealClaimGiftMixin(
claimGiftInteractor,
coroutineScope
)
}
}
class RealClaimGiftMixin(
private val claimGiftInteractor: ClaimGiftInteractor,
private val coroutineScope: CoroutineScope,
) : ClaimGiftMixin {
override val claimingInProgressFlow = MutableStateFlow(false)
override suspend fun claimGift(
gift: ClaimableGift,
amountWithFee: GiftAmountWithFee,
giftMetaAccount: MetaAccount,
giftRecipient: MetaAccount
): Result<Unit> {
claimingInProgressFlow.value = true
if (claimGiftInteractor.isGiftAlreadyClaimed(gift)) {
claimingInProgressFlow.value = false
return Result.failure(ClaimGiftException.GiftAlreadyClaimed())
}
return claimGiftInteractor.claimGift(
claimableGift = gift,
giftAmountWithFee = amountWithFee,
giftMetaAccount = giftMetaAccount,
giftRecipient = giftRecipient,
coroutineScope = coroutineScope
)
.mapFailure {
claimingInProgressFlow.value = false
ClaimGiftException.UnknownError(it)
}
}
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm
import android.view.View
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.presentation.showLoadingState
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.insets.ImeInsetsState
import io.novafoundation.nova.common.utils.insets.applySystemBarInsets
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.common.view.setState
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.showChain
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.databinding.FragmentCreateGiftConfirmBinding
import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount
class CreateGiftConfirmFragment : BaseFragment<CreateGiftConfirmViewModel, FragmentCreateGiftConfirmBinding>() {
companion object : PayloadCreator<CreateGiftConfirmPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentCreateGiftConfirmBinding.inflate(layoutInflater)
override fun applyInsets(rootView: View) {
binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED)
}
override fun initViews() {
binder.confirmCreateGiftToolbar.setHomeButtonListener { viewModel.back() }
binder.confirmCreateGiftAccount.setOnClickListener { viewModel.accountClicked() }
binder.confirmCreateGiftButton.prepareForProgress(this)
binder.confirmCreateGiftButton.setOnClickListener { viewModel.confirmClicked() }
}
override fun inject() {
FeatureUtils.getFeature<GiftFeatureComponent>(this, GiftFeatureApi::class.java)
.createGiftConfirmComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: CreateGiftConfirmViewModel) {
setupExternalActions(viewModel)
observeValidations(viewModel)
viewModel.feeMixin.setupGiftFeeLoading(binder.confirmCreateGiftNetworkFee, binder.confirmCreateGiftClaimFee)
viewModel.senderGiftAccount.observe(binder.confirmCreateGiftAccount::showAddress)
viewModel.confirmButtonStateLiveData.observe(binder.confirmCreateGiftButton::setState)
viewModel.wallet.observe(binder.confirmCreateGiftWallet::showWallet)
viewModel.chainModelFlow.observe { binder.confirmCreateGiftNetwork.showChain(it) }
viewModel.totalAmountModel.observe(binder.confirmCreateGiftTotalAmount::showLoadingState)
viewModel.giftAmountModel.observe(binder.confirmCreateGiftAmount::showAmount)
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm
import android.os.Parcelable
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import java.math.BigDecimal
import kotlinx.parcelize.Parcelize
@Parcelize
class CreateGiftConfirmPayload(
val amount: BigDecimal,
val transferringMaxAmount: Boolean,
val assetPayload: AssetPayload
) : Parcelable
@@ -0,0 +1,218 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.asLoaded
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.inBackground
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn
import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel
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_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.GiftId
import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.GiftFeeDisplayFormatter
import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.createForGiftsWithGiftFeeDisplay
import io.novafoundation.nova.feature_gift_impl.presentation.common.buildGiftValidationPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
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.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign
import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload
import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chainFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class CreateGiftConfirmViewModel(
private val router: GiftRouter,
private val chainRegistry: ChainRegistry,
private val validationExecutor: ValidationExecutor,
private val assetUseCase: ArbitraryAssetUseCase,
private val walletUiUseCase: WalletUiUseCase,
private val payload: CreateGiftConfirmPayload,
private val amountFormatter: AmountFormatter,
private val resourceManager: ResourceManager,
private val externalActions: ExternalActions.Presentation,
private val createGiftInteractor: CreateGiftInteractor,
private val addressIconGenerator: AddressIconGenerator,
private val selectedAccountUseCase: SelectedAccountUseCase,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
) : BaseViewModel(),
ExternalActions by externalActions,
Validatable by validationExecutor {
private val chainFlow = chainRegistry.chainFlow(payload.assetPayload.chainId)
private val chainAssetFlow = chainFlow.map { it.assetsById.getValue(payload.assetPayload.chainAssetId) }
private val metaAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow()
@OptIn(ExperimentalCoroutinesApi::class)
private val assetFlow = chainAssetFlow.flatMapLatest(assetUseCase::assetFlow)
.shareInBackground()
val chainModelFlow = chainFlow.map {
mapChainToUi(it)
}.shareInBackground()
val wallet = walletUiUseCase.selectedWalletUiFlow()
.inBackground()
.share()
val senderGiftAccount = combine(metaAccountFlow, chainFlow) { metaAccount, chain ->
createAddressModel(
address = metaAccount.requireAddressIn(chain),
chain = chain
)
}
.inBackground()
.share()
private val feeFormatter = GiftFeeDisplayFormatter(amountFormatter)
val feeMixin = feeLoaderMixinFactory.createForGiftsWithGiftFeeDisplay(
chainAssetFlow,
feeFormatter
)
val totalAmountModel = assetFlow.map { asset ->
amountFormatter.formatAmountToAmountModel(payload.amount, asset, AmountConfig(tokenAmountSign = AmountSign.NEGATIVE))
.asLoaded()
}
val giftAmountModel = assetFlow.map { asset ->
amountFormatter.formatAmountToAmountModel(payload.amount, asset)
}
private val validationInProgressFlow = MutableStateFlow(false)
val confirmButtonStateLiveData = validationInProgressFlow.map { submitting ->
if (submitting) {
DescriptiveButtonState.Loading
} else {
DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_confirm))
}
}
init {
setupFees()
}
fun back() {
router.back()
}
fun accountClicked() = launchUnit {
val chain = chainFlow.first()
val address = senderGiftAccount.first()
externalActions.showAddressActions(address.address, chain)
}
fun confirmClicked() = launchUnit {
validationInProgressFlow.value = true
val fee = feeMixin.awaitFee()
val chain = chainFlow.first()
val giftModel = CreateGiftModel(
senderMetaAccount = selectedAccountUseCase.getSelectedMetaAccount(),
chain = chain,
chainAsset = chainAssetFlow.first(),
amount = payload.amount,
)
val payload = buildGiftValidationPayload(
giftModel,
asset = assetFlow.first(),
payload.transferringMaxAmount,
feeMixin.feePaymentCurrency(),
fee
)
validationExecutor.requireValid(
validationSystem = createGiftInteractor.validationSystemFor(giftModel.chainAsset, viewModelScope),
payload = payload,
progressConsumer = validationInProgressFlow.progressConsumer(),
autoFixPayload = ::autoFixSendValidationPayload,
validationFailureTransformerCustom = { status, actions ->
viewModelScope.mapAssetTransferValidationFailureToUI(
resourceManager = resourceManager,
status = status,
actions = actions,
setFee = { feeMixin.setFee(fee.replaceSubmission(it)) }
)
},
) { validPayload ->
performTransfer(giftModel, validPayload.transfer, validPayload.originFee.submissionFee)
}
}
private fun performTransfer(
giftModel: CreateGiftModel,
transfer: WeightedAssetTransfer,
fee: SubmissionFee
) = launch {
createGiftInteractor.createAndSaveGift(giftModel, transfer, fee, viewModelScope)
.onSuccess {
finishCreateGift(giftId = it)
}.onFailure(::showError)
validationInProgressFlow.value = false
}
private fun finishCreateGift(giftId: GiftId) {
router.openGiftSharing(giftId)
}
private fun setupFees() {
feeMixin.loadFee {
val metaAccount = metaAccountFlow.first()
val chain = chainFlow.first()
val createGiftModel = CreateGiftModel(
senderMetaAccount = metaAccount,
chain = chain,
chainAsset = chainAssetFlow.first(),
amount = payload.amount,
)
createGiftInteractor.getFee(
createGiftModel,
payload.transferringMaxAmount,
viewModelScope
)
}
}
private suspend fun createAddressModel(
address: String,
chain: Chain
) = addressIconGenerator.createAddressModel(
chain = chain,
sizeInDp = AddressIconGenerator.SIZE_MEDIUM,
address = address,
background = AddressIconGenerator.BACKGROUND_TRANSPARENT,
addressDisplayUseCase = null
)
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm.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_gift_impl.presentation.confirm.CreateGiftConfirmFragment
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload
@Subcomponent(
modules = [
CreateGiftConfirmModule::class
]
)
@ScreenScope
interface CreateGiftConfirmComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: CreateGiftConfirmPayload
): CreateGiftConfirmComponent
}
fun inject(fragment: CreateGiftConfirmFragment)
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm.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.address.AddressIconGenerator
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.common.validation.ValidationExecutor
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_gift_impl.domain.CreateGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload
import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmViewModel
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class CreateGiftConfirmModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): CreateGiftConfirmViewModel {
return ViewModelProvider(fragment, factory).get(CreateGiftConfirmViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(CreateGiftConfirmViewModel::class)
fun provideViewModel(
router: GiftRouter,
chainRegistry: ChainRegistry,
validationExecutor: ValidationExecutor,
assetUseCase: ArbitraryAssetUseCase,
walletUiUseCase: WalletUiUseCase,
payload: CreateGiftConfirmPayload,
amountFormatter: AmountFormatter,
resourceManager: ResourceManager,
externalActions: ExternalActions.Presentation,
createGiftInteractor: CreateGiftInteractor,
addressIconGenerator: AddressIconGenerator,
selectedAccountUseCase: SelectedAccountUseCase,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
): ViewModel {
return CreateGiftConfirmViewModel(
router = router,
chainRegistry = chainRegistry,
validationExecutor = validationExecutor,
assetUseCase = assetUseCase,
walletUiUseCase = walletUiUseCase,
payload = payload,
amountFormatter = amountFormatter,
resourceManager = resourceManager,
externalActions = externalActions,
createGiftInteractor = createGiftInteractor,
addressIconGenerator = addressIconGenerator,
selectedAccountUseCase = selectedAccountUseCase,
feeLoaderMixinFactory = feeLoaderMixinFactory
)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.confirm
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee
import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.GiftFeeDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.mapDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading
import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
context(BaseFragment<V, *>)
fun <V : BaseViewModel> FeeLoaderMixinV2<GiftFee, GiftFeeDisplay>.setupGiftFeeLoading(networkFee: FeeView, claimFee: FeeView) {
setupFeeLoading(
setFeeStatus = {
val originFee = it.mapDisplay(GiftFeeDisplay::networkFee)
val crossChainFee = it.mapDisplay(GiftFeeDisplay::claimGiftFee)
networkFee.setFeeStatus(originFee)
claimFee.setFeeStatus(crossChainFee)
},
setUserCanChangeFeeAsset = {
networkFee.setFeeEditable(it) {
changePaymentCurrencyClicked()
}
}
)
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts
import androidx.annotation.DrawableRes
import io.novafoundation.nova.common.utils.images.Icon
data class GiftRVItem(
val id: Long,
val isClaimed: Boolean,
val amount: CharSequence,
val assetIcon: Icon,
val subtitle: String,
@DrawableRes val imageRes: Int
)
@@ -0,0 +1,77 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts
import androidx.recyclerview.widget.ConcatAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.list.CustomPlaceholderAdapter
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
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.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.databinding.FragmentGiftsBinding
import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.list.GiftsHeaderAdapter
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.list.GiftsInstructionsAdapter
import javax.inject.Inject
class GiftsFragment : BaseFragment<GiftsViewModel, FragmentGiftsBinding>(), GiftsHeaderAdapter.ItemHandler, GiftsListAdapter.Handler {
companion object : PayloadCreator<GiftsPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentGiftsBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val headerAdapter = GiftsHeaderAdapter(this)
private val instructionsAdapter = GiftsInstructionsAdapter()
private val giftsTitleAdapter = CustomPlaceholderAdapter(R.layout.item_gifts_title)
private val giftsAdapter by lazy(LazyThreadSafetyMode.NONE) { GiftsListAdapter(this, imageLoader) }
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
ConcatAdapter(
headerAdapter,
giftsTitleAdapter,
giftsAdapter,
instructionsAdapter
)
}
override fun initViews() {
binder.giftsToolbar.setHomeButtonListener { viewModel.back() }
binder.giftsList.adapter = adapter
binder.giftsCreate.setOnClickListener { viewModel.createGiftClicked() }
}
override fun inject() {
FeatureUtils.getFeature<GiftFeatureComponent>(this, GiftFeatureApi::class.java)
.giftsComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: GiftsViewModel) {
observeBrowserEvents(viewModel)
viewModel.gifts.observe {
instructionsAdapter.show(it.isEmpty())
giftsTitleAdapter.show(it.isNotEmpty())
giftsAdapter.submitList(it)
}
}
override fun onLearnMoreClicked() {
viewModel.learnMoreClicked()
}
override fun onGiftClicked(referendum: GiftRVItem) {
viewModel.giftClicked(referendum)
}
}
@@ -0,0 +1,89 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.common.utils.AlphaColorFilter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawableWithRipple
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftBinding
class GiftsListAdapter(
private val handler: Handler,
private val imageLoader: ImageLoader
) : ListAdapter<GiftRVItem, GiftViewHolder>(GiftsDiffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): GiftViewHolder {
return GiftViewHolder(
ItemGiftBinding.inflate(parent.inflater(), parent, false),
handler,
imageLoader
)
}
override fun onBindViewHolder(holder: GiftViewHolder, position: Int) {
holder.bind(getItem(position))
}
interface Handler {
fun onGiftClicked(referendum: GiftRVItem)
}
}
private object GiftsDiffCallback : DiffUtil.ItemCallback<GiftRVItem>() {
override fun areItemsTheSame(
oldItem: GiftRVItem,
newItem: GiftRVItem
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: GiftRVItem,
newItem: GiftRVItem
): Boolean {
return oldItem == newItem
}
}
class GiftViewHolder(
private val binder: ItemGiftBinding,
private val handler: GiftsListAdapter.Handler,
private val imageLoader: ImageLoader
) : BaseViewHolder(binder.root) {
fun bind(item: GiftRVItem) = with(binder) {
// Set content
giftAmount.text = item.amount
giftAssetIcon.setTokenIcon(item.assetIcon, imageLoader)
giftCreationDate.text = item.subtitle
giftImage.setImageResource(item.imageRes)
root.setOnClickListener { handler.onGiftClicked(item) }
val amountColor = if (item.isClaimed) R.color.text_secondary else R.color.text_primary
val assetIconAlpha = if (item.isClaimed) 0.56f else 1f
giftAmount.setTextColorRes(amountColor)
giftAssetIcon.colorFilter = AlphaColorFilter(assetIconAlpha)
giftAmountChevron.isVisible = !item.isClaimed
root.background = if (item.isClaimed) background(R.color.block_background) else background(R.color.gift_block_background)
}
private fun background(@ColorRes colorRes: Int): Drawable {
return context.getRoundedCornerDrawableWithRipple(fillColorRes = colorRes, cornerSizeInDp = 12)
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts
import android.os.Parcelable
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import kotlinx.parcelize.Parcelize
sealed interface GiftsPayload : Parcelable {
@Parcelize
object AllAssets : GiftsPayload
@Parcelize
data class ByAsset(val assetPayload: AssetPayload) : GiftsPayload
}
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor
import io.novafoundation.nova.feature_gift_impl.domain.models.Gift
import io.novafoundation.nova.feature_gift_impl.domain.models.isClaimed
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
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.model.fullChainAssetId
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.chain.model.FullChainAssetId
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class GiftsViewModel(
private val router: GiftRouter,
private val appLinksProvider: AppLinksProvider,
private val giftsInteractor: GiftsInteractor,
private val chainRegistry: ChainRegistry,
private val giftsPayload: GiftsPayload,
private val tokenFormatter: TokenFormatter,
private val assetIconProvider: AssetIconProvider,
private val resourceManager: ResourceManager
) : BaseViewModel(), Browserable {
private val dateFormatter: SimpleDateFormat = SimpleDateFormat("d.M.yyyy", Locale.getDefault())
override val openBrowserEvent = MutableLiveData<Event<String>>()
private val chains = chainRegistry.chainsById
val gifts = combine(
giftsInteractor.observeGifts(giftsPayload.fullChainAssetIdOrNull()),
chains
) { gifts, chains ->
gifts.mapNotNull { mapGift(it, chains) }
}
init {
launch {
giftsInteractor.syncGiftsState()
}
}
fun back() {
router.back()
}
fun learnMoreClicked() {
openBrowserEvent.value = Event(appLinksProvider.giftsWikiUrl)
}
fun createGiftClicked() {
when (giftsPayload) {
GiftsPayload.AllAssets -> router.openGiftsFlow()
is GiftsPayload.ByAsset -> router.openSelectGiftAmount(giftsPayload.assetPayload)
}
}
fun giftClicked(gift: GiftRVItem) {
if (gift.isClaimed) return
router.openGiftSharing(gift.id, isSecondOpen = true)
}
private fun mapGift(gift: Gift, chains: Map<ChainId, Chain>): GiftRVItem? {
val asset = chains[gift.chainId]?.assetsById[gift.assetId] ?: return null
val isClaimed = gift.status.isClaimed()
val subtitle = when (gift.status) {
Gift.Status.CLAIMED -> resourceManager.getString(R.string.gift_claimed_subtitle)
Gift.Status.RECLAIMED -> resourceManager.getString(R.string.gift_reclaimed_subtitle)
Gift.Status.PENDING -> resourceManager.getString(
R.string.gift_created_subtitle,
dateFormatter.format(gift.creationDate)
)
}
return GiftRVItem(
id = gift.id,
isClaimed = isClaimed,
amount = tokenFormatter.formatToken(gift.amount, asset),
assetIcon = assetIconProvider.getAssetIconOrFallback(asset),
subtitle = subtitle,
imageRes = if (isClaimed) R.drawable.ic_gift_unpacked else R.drawable.ic_gift_packed,
)
}
}
private fun GiftsPayload.fullChainAssetIdOrNull(): FullChainAssetId? {
return when (this) {
is GiftsPayload.ByAsset -> assetPayload.fullChainAssetId
GiftsPayload.AllAssets -> null
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts.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_gift_impl.presentation.gifts.GiftsFragment
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsPayload
@Subcomponent(
modules = [
GiftsModule::class
]
)
@ScreenScope
interface GiftsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: GiftsPayload
): GiftsComponent
}
fun inject(fragment: GiftsFragment)
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsPayload
import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsViewModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class GiftsModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): GiftsViewModel {
return ViewModelProvider(fragment, factory).get(GiftsViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(GiftsViewModel::class)
fun provideViewModel(
router: GiftRouter,
appLinksProvider: AppLinksProvider,
giftsInteractor: GiftsInteractor,
giftsPayload: GiftsPayload,
chainRegistry: ChainRegistry,
tokenFormatter: TokenFormatter,
assetIconProvider: AssetIconProvider,
resourceManager: ResourceManager
): ViewModel {
return GiftsViewModel(
router = router,
appLinksProvider = appLinksProvider,
giftsInteractor = giftsInteractor,
giftsPayload = giftsPayload,
tokenFormatter = tokenFormatter,
assetIconProvider = assetIconProvider,
chainRegistry = chainRegistry,
resourceManager = resourceManager
)
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftsHeaderBinding
class GiftsHeaderAdapter(
private val handler: ItemHandler
) : SingleItemAdapter<GiftsHeaderHolder>(isShownByDefault = true) {
interface ItemHandler {
fun onLearnMoreClicked()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GiftsHeaderHolder {
return GiftsHeaderHolder(
ItemGiftsHeaderBinding.inflate(parent.inflater(), parent, false),
handler
)
}
override fun onBindViewHolder(holder: GiftsHeaderHolder, position: Int) {
}
}
class GiftsHeaderHolder(binder: ItemGiftsHeaderBinding, handler: GiftsHeaderAdapter.ItemHandler) : RecyclerView.ViewHolder(binder.root) {
init {
binder.giftsHeaderLearnMore.setOnClickListener { handler.onLearnMoreClicked() }
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_gift_impl.presentation.gifts.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.addColor
import io.novafoundation.nova.common.utils.formatting.spannable.spannableFormatting
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftsInstructionPlaceholderBinding
class GiftsInstructionsAdapter() : SingleItemAdapter<GiftsInstructionsHolder>(isShownByDefault = false) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GiftsInstructionsHolder {
return GiftsInstructionsHolder(ItemGiftsInstructionPlaceholderBinding.inflate(parent.inflater(), parent, false))
}
override fun onBindViewHolder(holder: GiftsInstructionsHolder, position: Int) {
}
}
class GiftsInstructionsHolder(binder: ItemGiftsInstructionPlaceholderBinding) : RecyclerView.ViewHolder(binder.root) {
init {
with(binder.root.context) {
val highlightColor = getColor(R.color.text_primary)
binder.giftsInstructionStep1.setStepText(getString(R.string.gifts_placeholder_step_1).addColor(highlightColor))
binder.giftsInstructionStep2.setStepText(
getString(R.string.gifts_placeholder_step_2)
.spannableFormatting(getString(R.string.gifts_placeholder_step_2_highlight).addColor(highlightColor))
)
binder.giftsInstructionStep3.setStepText(
getString(R.string.gifts_placeholder_step_3)
.spannableFormatting(getString(R.string.gifts_placeholder_step_3_highlight).addColor(highlightColor))
)
}
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share
import androidx.annotation.RawRes
class ShareGiftAnimationState(
@RawRes val res: Int,
val state: State
) {
enum class State {
START,
IDLE_END
}
}
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share
import android.animation.Animator
import android.view.View
import coil.ImageLoader
import io.novafoundation.nova.common.R
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.payload
import io.novafoundation.nova.common.utils.share.shareImageWithText
import io.novafoundation.nova.common.view.dialog.warningDialog
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_gift_impl.databinding.FragmentShareGiftBinding
import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent
import javax.inject.Inject
class ShareGiftFragment : BaseFragment<ShareGiftViewModel, FragmentShareGiftBinding>() {
companion object : PayloadCreator<ShareGiftPayload> by FragmentPayloadCreator()
@Inject
lateinit var imageLoader: ImageLoader
private val giftAnimationListener = object : Animator.AnimatorListener {
override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
showAllViews()
}
}
override fun createBinding() = FragmentShareGiftBinding.inflate(layoutInflater)
override fun initViews() {
binder.shareGiftToolbar.setHomeButtonListener { viewModel.back() }
binder.shareGiftToolbar.setRightActionClickListener { viewModel.reclaimClicked() }
binder.shareGiftButton.setOnClickListener { viewModel.shareDeepLinkClicked() }
}
override fun inject() {
FeatureUtils.getFeature<GiftFeatureComponent>(this, GiftFeatureApi::class.java)
.shareGiftComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: ShareGiftViewModel) {
viewModel.giftAnimationRes.observe {
binder.shareGiftAnimation.setAnimation(it.res)
when (it.state) {
ShareGiftAnimationState.State.START -> {
binder.shareGiftAnimation.playAnimation()
binder.shareGiftAnimation.addAnimatorListener(giftAnimationListener)
}
ShareGiftAnimationState.State.IDLE_END -> {
binder.shareGiftAnimation.progress = 1f
showAllViews(withAnimation = false)
}
}
}
viewModel.amountModel.observe {
binder.shareGiftTokenIcon.setTokenIcon(it.tokenIcon, imageLoader)
binder.shareGiftAmount.text = it.amount
}
viewModel.shareEvent.observeEvent {
shareImageWithText(sharingData = it, chooserTitle = null)
}
viewModel.isReclaimInProgress.observe {
binder.shareGiftToolbar.showProgress(it)
}
viewModel.reclaimButtonVisible.observe {
binder.shareGiftToolbar.setRightTextVisible(it)
}
viewModel.confirmReclaimGiftAction.awaitableActionLiveData.observeEvent { event ->
warningDialog(
context = providedContext,
onPositiveClick = { event.onSuccess(Unit) },
positiveTextRes = R.string.common_continue,
negativeTextRes = R.string.common_cancel,
onNegativeClick = { event.onCancel() },
styleRes = R.style.AccentAlertDialogTheme
) {
setTitle(getString(R.string.reclaim_gift_confirmation_title, event.payload.amount))
setMessage(R.string.reclaim_gift_confirmation_message)
}
}
}
private fun showAllViews(withAnimation: Boolean = true) {
binder.shareGiftToolbar.show(withAnimation)
binder.shareGiftTokenIcon.show(withAnimation)
binder.shareGiftAmount.show(withAnimation)
binder.shareGiftButton.show(withAnimation)
binder.shareGiftTitle.show(withAnimation)
}
private fun View.show(withAnimation: Boolean) {
if (withAnimation) {
animate().alpha(1f)
.setDuration(400)
.start()
} else {
alpha = 1f
}
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class ShareGiftPayload(val giftId: Long, val isSecondOpen: Boolean) : Parcelable
@@ -0,0 +1,162 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share
import android.net.Uri
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.share.ImageWithTextSharing
import io.novafoundation.nova.common.utils.write
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator
import io.novafoundation.nova.feature_gift_impl.R
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator
import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkData
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftException
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory
import io.novafoundation.nova.feature_gift_impl.presentation.share.model.GiftAmountModel
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.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novasama.substrate_sdk_android.extensions.fromHex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
private const val GIFT_FILE_NAME = "share-gift.png"
class ShareGiftViewModel(
private val router: GiftRouter,
private val payload: ShareGiftPayload,
private val shareGiftInteractor: ShareGiftInteractor,
private val chainRegistry: ChainRegistry,
private val packingGiftAnimationFactory: PackingGiftAnimationFactory,
private val assetIconProvider: AssetIconProvider,
private val tokenFormatter: TokenFormatter,
private val claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator,
private val fileProvider: FileProvider,
private val claimGiftMixinFactory: ClaimGiftMixinFactory,
private val claimGiftInteractor: ClaimGiftInteractor,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val resourceManager: ResourceManager
) : BaseViewModel() {
private val giftFlow = shareGiftInteractor.observeGift(payload.giftId)
.shareInBackground()
private val chainAssetFlow = giftFlow.map {
chainRegistry.asset(it.chainId, it.assetId)
}.shareInBackground()
val giftAnimationRes = giftFlow.map {
val chainAsset = chainRegistry.asset(it.chainId, it.assetId)
val animationRes = packingGiftAnimationFactory.getAnimationForAsset(chainAsset.symbol)
when (payload.isSecondOpen) {
true -> ShareGiftAnimationState(animationRes, ShareGiftAnimationState.State.IDLE_END)
false -> ShareGiftAnimationState(animationRes, ShareGiftAnimationState.State.START)
}
}.distinctUntilChanged()
.shareInBackground()
val amountModel = giftFlow.map {
val chainAsset = chainRegistry.asset(it.chainId, it.assetId)
val tokenIcon = assetIconProvider.getAssetIconOrFallback(chainAsset)
val giftAmount = tokenFormatter.formatToken(it.amount, chainAsset)
GiftAmountModel(tokenIcon, giftAmount)
}
private val _shareEvent = MutableLiveData<Event<ImageWithTextSharing>>()
val shareEvent: LiveData<Event<ImageWithTextSharing>> = _shareEvent
private val claimGiftMixin = claimGiftMixinFactory.create(this)
private val _isReclaimInProgress = MutableStateFlow(false)
val isReclaimInProgress: Flow<Boolean> = _isReclaimInProgress
val reclaimButtonVisible = flowOf { payload.isSecondOpen }
val confirmReclaimGiftAction = actionAwaitableMixinFactory.confirmingAction<GiftAmountModel>()
fun back() {
router.finishCreateGift()
}
fun shareDeepLinkClicked() = launchUnit {
runCatching {
val giftAmount = amountModel.first().amount
val sharingLink = getSharingLink()
val sharingText = resourceManager.getString(R.string.share_gift_text, giftAmount, sharingLink.toString())
val giftImageFile = generateShareImageFile()
_shareEvent.value = Event(ImageWithTextSharing(giftImageFile, sharingText))
}.onFailure(::showError)
}
private suspend fun getSharingLink(): Uri {
val chainAsset = chainAssetFlow.first()
val giftSeed = shareGiftInteractor.getGiftSeed(payload.giftId)
val sharingPayload = ClaimGiftDeepLinkData(giftSeed, chainAsset.chainId, chainAsset.symbol)
return claimGiftDeepLinkConfigurator.configure(sharingPayload, type = DeepLinkConfigurator.Type.APP_LINK)
}
suspend fun generateShareImageFile(): Uri = withContext(Dispatchers.IO) {
val shareBitmap = resourceManager.getDrawable(R.drawable.ic_pezkuwi_gift).toBitmap()
val file = fileProvider.generateTempFile(fixedName = GIFT_FILE_NAME)
file.write(shareBitmap)
fileProvider.uriOf(file)
}
fun reclaimClicked() = launchUnit {
val giftAmount = amountModel.first()
confirmReclaimGiftAction.awaitAction(giftAmount)
_isReclaimInProgress.value = true
val giftModel = giftFlow.first()
val giftSeed = shareGiftInteractor.getGiftSeed(payload.giftId)
val claimableGift = claimGiftInteractor.getClaimableGift(giftSeed.fromHex(), giftModel.chainId, giftModel.assetId)
val tempMetaAccount = claimGiftInteractor.createTempMetaAccount(claimableGift)
val amountWithFee = claimGiftInteractor.getGiftAmountWithFee(claimableGift, tempMetaAccount, coroutineScope)
val recipientMetaAccount = selectedAccountUseCase.getSelectedMetaAccount()
claimGiftMixin.claimGift(gift = claimableGift, amountWithFee = amountWithFee, giftMetaAccount = tempMetaAccount, giftRecipient = recipientMetaAccount)
.onSuccess {
shareGiftInteractor.setGiftStateAsReclaimed(giftModel.id)
showToast(resourceManager.getString(R.string.reclaim_gift_success))
router.back()
}
.onFailure {
_isReclaimInProgress.value = false
when (it as ClaimGiftException) {
is ClaimGiftException.GiftAlreadyClaimed -> showError(
resourceManager.getString(R.string.claim_gift_already_claimed_title),
resourceManager.getString(R.string.claim_gift_already_claimed_message)
)
is ClaimGiftException.UnknownError -> showError(
resourceManager.getString(R.string.claim_gift_default_error_title),
resourceManager.getString(R.string.claim_gift_default_error_message)
)
}
}
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share.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_gift_impl.presentation.share.ShareGiftFragment
import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftPayload
@Subcomponent(
modules = [
ShareGiftModule::class
]
)
@ScreenScope
interface ShareGiftComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: ShareGiftPayload
): ShareGiftComponent
}
fun inject(fragment: ShareGiftFragment)
}
@@ -0,0 +1,71 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share.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.interfaces.FileProvider
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.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor
import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor
import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter
import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory
import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftPayload
import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftViewModel
import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator
import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class ShareGiftModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ShareGiftViewModel {
return ViewModelProvider(fragment, factory).get(ShareGiftViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(ShareGiftViewModel::class)
fun provideViewModel(
router: GiftRouter,
payload: ShareGiftPayload,
shareGiftInteractor: ShareGiftInteractor,
chainRegistry: ChainRegistry,
packingGiftAnimationFactory: PackingGiftAnimationFactory,
assetIconProvider: AssetIconProvider,
tokenFormatter: TokenFormatter,
claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator,
fileProvider: FileProvider,
claimGiftMixinFactory: ClaimGiftMixinFactory,
claimGiftInteractor: ClaimGiftInteractor,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
selectedAccountUseCase: SelectedAccountUseCase,
resourceManager: ResourceManager
): ViewModel {
return ShareGiftViewModel(
router = router,
payload = payload,
shareGiftInteractor = shareGiftInteractor,
chainRegistry = chainRegistry,
packingGiftAnimationFactory = packingGiftAnimationFactory,
assetIconProvider = assetIconProvider,
tokenFormatter = tokenFormatter,
claimGiftDeepLinkConfigurator = claimGiftDeepLinkConfigurator,
fileProvider = fileProvider,
claimGiftMixinFactory = claimGiftMixinFactory,
claimGiftInteractor = claimGiftInteractor,
actionAwaitableMixinFactory = actionAwaitableMixinFactory,
selectedAccountUseCase = selectedAccountUseCase,
resourceManager = resourceManager
)
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_gift_impl.presentation.share.model
import io.novafoundation.nova.common.utils.images.Icon
class GiftAmountModel(
val tokenIcon: Icon,
val amount: CharSequence
)
@@ -0,0 +1,111 @@
<?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:orientation="vertical">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/claimGiftToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:homeButtonIcon="@drawable/ic_close"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/claimGiftTitle"
style="@style/TextAppearance.NovaFoundation.Bold.Title1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:paddingHorizontal="16dp"
android:text="@string/claim_gift_title"
android:textColor="@color/text_primary"
app:layout_constraintTop_toBottomOf="@+id/claimGiftToolbar" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/claimGiftAnimation"
android:layout_width="280dp"
android:layout_height="280dp"
app:layout_constraintBottom_toTopOf="@+id/claimGiftAmount"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/claimGiftTitle"
app:layout_constraintVertical_bias="0.31"
app:layout_constraintVertical_chainStyle="packed"
tools:lottie_rawRes="@raw/dot_unpacking" />
<ImageView
android:id="@+id/claimGiftTokenIcon"
android:layout_width="44dp"
android:layout_height="44dp"
app:layout_constraintBottom_toBottomOf="@+id/claimGiftAmount"
app:layout_constraintEnd_toStartOf="@+id/claimGiftAmount"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/claimGiftAnimation"
app:layout_constraintTop_toTopOf="@+id/claimGiftAmount" />
<TextView
android:id="@+id/claimGiftAmount"
style="@style/TextAppearance.NovaFoundation.Bold.LargeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/text_primary"
app:layout_constraintBottom_toTopOf="@+id/claimGiftAccount"
app:layout_constraintEnd_toEndOf="@+id/claimGiftAnimation"
app:layout_constraintStart_toEndOf="@+id/claimGiftTokenIcon"
app:layout_constraintTop_toBottomOf="@+id/claimGiftAnimation"
tools:text="2 DOT" />
<TextView
android:id="@+id/claimGiftAccountTitle"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toTopOf="@+id/claimGiftAccount"
app:layout_constraintStart_toStartOf="@+id/claimGiftAccount"
tools:text="Your wallet to receive the gift" />
<io.novafoundation.nova.feature_account_api.view.AccountView
android:id="@+id/claimGiftAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
app:layout_constraintBottom_toTopOf="@+id/claimGiftAlert"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_goneMarginBottom="24dp" />
<io.novafoundation.nova.common.view.AlertView
android:id="@+id/claimGiftAlert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="24dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/claimGiftButton" />
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/claimGiftButton"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:text="@string/claim_gift_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,102 @@
<?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">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/confirmCreateGiftToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:titleText="@string/common_gift" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.PrimaryAmountView
android:id="@+id/confirmCreateGiftTotalAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmCreateGiftNetwork"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_network" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmCreateGiftWallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/tabbar_wallet_title" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmCreateGiftAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueEndIcon="@drawable/ic_info"
app:title="@string/common_account" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmCreateGiftAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_your_gift" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
android:id="@+id/confirmCreateGiftNetworkFee"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
android:id="@+id/confirmCreateGiftClaimFee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/gifts_claim_fee"/>
</io.novafoundation.nova.common.view.TableView>
</LinearLayout>
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:textColor="@color/text_secondary"
android:gravity="center"
android:text="@string/confirm_create_gift_hint" />
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/confirmCreateGiftButton"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@string/common_confirm" />
</LinearLayout>
@@ -0,0 +1,38 @@
<?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="match_parent">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/giftsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:homeButtonIcon="@drawable/ic_close"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/giftsList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/giftsToolbar" />
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/giftsCreate"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:text="@string/gifts_create"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,87 @@
<?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">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/giftAmountToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/giftAmountTitleContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:flexDirection="row"
app:flexWrap="wrap"
app:layout_constraintTop_toBottomOf="@+id/giftAmountToolbar">
<TextView
android:id="@+id/giftAmountTitle"
style="@style/TextAppearance.NovaFoundation.Bold.Title2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginBottom="8dp"
android:includeFontPadding="false"
android:text="@string/select_gift_amount_title"
android:textColor="@color/text_primary" />
<io.novafoundation.nova.feature_account_api.view.ChainChipView
android:id="@+id/giftAmountChain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp" />
</com.google.android.flexbox.FlexboxLayout>
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.ChooseAmountView
android:id="@+id/giftsAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@+id/giftAmountTitleContainer" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/giftAmountGetTokens"
style="@style/Widget.Nova.Button.AccentSecondaryTransparent.Small"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/giftsAmount"
tools:text="Get DOT"
tools:visibility="visible" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
android:id="@+id/giftAmountFee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:dividerVisible="false"
app:title="@string/common_total_fee"
app:layout_constraintTop_toBottomOf="@+id/giftAmountGetTokens" />
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/giftAmountContinue"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@string/common_continue" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,91 @@
<?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:orientation="vertical">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/shareGiftToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0"
app:dividerVisible="false"
app:homeButtonIcon="@drawable/ic_close"
app:layout_constraintTop_toTopOf="parent"
app:textRight="@string/share_gift_reclaim"
app:textRightVisible="false"
tools:alpha="1" />
<TextView
android:id="@+id/shareGiftTitle"
style="@style/TextAppearance.NovaFoundation.Bold.Title1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:alpha="0"
android:gravity="center"
android:paddingHorizontal="16dp"
android:text="@string/share_gift_title"
app:layout_constraintTop_toBottomOf="@+id/shareGiftToolbar"
tools:alpha="1" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/shareGiftAnimation"
android:layout_width="280dp"
android:layout_height="280dp"
app:layout_constraintBottom_toTopOf="@+id/shareGiftAmount"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/shareGiftTitle"
app:layout_constraintVertical_bias="0.31"
app:layout_constraintVertical_chainStyle="packed"
tools:lottie_rawRes="@raw/DOT_packing_optimized" />
<ImageView
android:id="@+id/shareGiftTokenIcon"
android:layout_width="44dp"
android:layout_height="44dp"
android:alpha="0"
app:layout_constraintBottom_toBottomOf="@+id/shareGiftAmount"
app:layout_constraintEnd_toStartOf="@+id/shareGiftAmount"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/shareGiftAnimation"
app:layout_constraintTop_toTopOf="@+id/shareGiftAmount" />
<TextView
android:id="@+id/shareGiftAmount"
style="@style/TextAppearance.NovaFoundation.Bold.LargeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:alpha="0"
android:gravity="center"
app:layout_constraintBottom_toTopOf="@+id/shareGiftButton"
app:layout_constraintEnd_toEndOf="@+id/shareGiftAnimation"
app:layout_constraintStart_toEndOf="@+id/shareGiftTokenIcon"
app:layout_constraintTop_toBottomOf="@+id/shareGiftAnimation"
tools:alpha="1"
tools:text="2 DOT" />
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/shareGiftButton"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:alpha="0"
android:text="@string/share_gift_button"
app:icon="@drawable/ic_share_outline"
app:iconGravity="textStart"
app:iconTint="@color/icon_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:alpha="1" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,74 @@
<?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:gravity="center_vertical"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="4dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/giftAssetIcon"
style="@style/Widget.Nova.AssetIcon.Primary"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginVertical="12dp"
android:layout_marginStart="12dp"
android:background="@drawable/bg_token_container"
android:src="@drawable/ic_star_filled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:padding="5dp" />
<TextView
android:id="@+id/giftAmount"
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:includeFontPadding="false"
android:textColor="@color/text_primary"
app:layout_constraintBottom_toTopOf="@id/giftCreationDate"
app:layout_constraintStart_toEndOf="@+id/giftAssetIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="2 DOT" />
<ImageView
android:id="@+id/giftAmountChevron"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginStart="2dp"
android:src="@drawable/ic_chevron_right"
app:layout_constraintBottom_toBottomOf="@+id/giftAmount"
app:layout_constraintStart_toEndOf="@+id/giftAmount"
app:layout_constraintTop_toTopOf="@+id/giftAmount" />
<TextView
android:id="@+id/giftCreationDate"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:includeFontPadding="false"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/giftAssetIcon"
app:layout_constraintTop_toBottomOf="@+id/giftAmount"
tools:text="Created: 02.10.2025" />
<ImageView
android:id="@+id/giftImage"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_gift_packed" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,32 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.NovaFoundation.Bold.Title1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/gifts_header_title" />
<TextView
style="@style/TextAppearance.NovaFoundation.Regular.SubHeadline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/gifts_header_subtitle"
android:textColor="@color/text_secondary" />
<io.novafoundation.nova.common.view.LinkView
android:id="@+id/giftsHeaderLearnMore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="32dp"
android:paddingVertical="8dp"
app:linkText="@string/common_learn_more" />
</LinearLayout>
@@ -0,0 +1,37 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<io.novafoundation.nova.common.view.InstructionStepView
android:id="@+id/giftsInstructionStep1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
app:stepNumber="1" />
<io.novafoundation.nova.common.view.InstructionStepView
android:id="@+id/giftsInstructionStep2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="24dp"
app:stepNumber="2" />
<io.novafoundation.nova.common.view.InstructionStepView
android:id="@+id/giftsInstructionStep3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="24dp"
app:stepNumber="3" />
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:src="@drawable/ic_gifts_placeholder" />
</LinearLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.NovaFoundation.SemiBold.Title3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:text="@string/gifts_list_title" />
</LinearLayout>