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
+1
View File
@@ -0,0 +1 @@
/build
+66
View File
@@ -0,0 +1,66 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: "../scripts/secrets.gradle"
android {
namespace 'io.novafoundation.nova.feature_gift_impl'
defaultConfig {
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-wallet-api')
implementation project(':feature-account-api')
implementation project(':feature-deep-linking')
implementation project(':feature-gift-api')
implementation project(':runtime')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation flexBoxDep
implementation lottie, withoutTransitiveAndroidX
implementation coroutinesDep
implementation daggerDep
ksp daggerCompiler
implementation lifecycleDep
ksp lifecycleCompiler
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation retrofitDep
implementation coroutinesFutureDep
testImplementation jUnitDep
testImplementation mockitoDep
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,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>