diff --git a/android-foundation b/android-foundation index cd23abbb1f..c7065d188d 160000 --- a/android-foundation +++ b/android-foundation @@ -1 +1 @@ -Subproject commit cd23abbb1f915bf64f2707b94af49e92e69969cf +Subproject commit c7065d188d4e7a95f66b505d0e8ca709dfff4fb0 diff --git a/app/build.gradle b/app/build.gradle index 83ef89f8bc..a4c2644157 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,4 @@ +//file:noinspection GroovyImplicitNullArgumentCall import com.github.triplet.gradle.androidpublisher.ReleaseStatus apply plugin: 'com.android.application' @@ -129,10 +130,6 @@ android { resources.excludes.add("META-INF/**/*") resources.excludes.add("META-INF/*") } - - configurations.configureEach { - exclude group: "org.bouncycastle", module: "bcprov-jdk15on" - } } play { @@ -142,6 +139,7 @@ play { } dependencies { + implementation project(':android-foundation') implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':core-db') implementation project(':common') @@ -179,6 +177,9 @@ dependencies { implementation project(':feature-nft-api') implementation project(':feature-nft-impl') + implementation project(':feature-liquiditypools-api') + implementation project(':feature-liquiditypools-impl') + implementation libs.kotlin.stdlib.jdk7 implementation libs.appcompat @@ -233,8 +234,8 @@ dependencies { androidTestImplementation libs.ext.junit } -task printVersion { - doLast { - println "versionName:${computeVersionName()}" - } +tasks.register('printVersion') { + doLast { + println "versionName:${computeVersionName()}" + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0fd020524a..7ed72a41b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + diff --git a/app/src/main/java/jp/co/soramitsu/app/di/app/NavigationModule.kt b/app/src/main/java/jp/co/soramitsu/app/di/app/NavigationModule.kt index 9f77c855e6..f6f360f8c5 100644 --- a/app/src/main/java/jp/co/soramitsu/app/di/app/NavigationModule.kt +++ b/app/src/main/java/jp/co/soramitsu/app/di/app/NavigationModule.kt @@ -9,6 +9,7 @@ import javax.inject.Singleton import jp.co.soramitsu.account.impl.presentation.AccountRouter import jp.co.soramitsu.app.root.navigation.Navigator import jp.co.soramitsu.crowdloan.impl.presentation.CrowdloanRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsRouter import jp.co.soramitsu.nft.navigation.NFTRouter import jp.co.soramitsu.onboarding.impl.OnboardingRouter import jp.co.soramitsu.polkaswap.api.presentation.PolkaswapRouter @@ -69,4 +70,8 @@ class NavigationModule { @Singleton @Provides fun provideNFTRouter(navigator: Navigator): NFTRouter = navigator + + @Singleton + @Provides + fun provideLiquidityPoolsRouter(navigator: Navigator): LiquidityPoolsRouter = navigator } diff --git a/app/src/main/java/jp/co/soramitsu/app/root/navigation/Navigator.kt b/app/src/main/java/jp/co/soramitsu/app/root/navigation/Navigator.kt index 2ca6d0a52a..2be700acc0 100644 --- a/app/src/main/java/jp/co/soramitsu/app/root/navigation/Navigator.kt +++ b/app/src/main/java/jp/co/soramitsu/app/root/navigation/Navigator.kt @@ -16,7 +16,6 @@ import co.jp.soramitsu.walletconnect.domain.WalletConnectRouter import co.jp.soramitsu.walletconnect.model.ChainChooseResult import co.jp.soramitsu.walletconnect.model.ChainChooseState import it.airgap.beaconsdk.blockchain.substrate.data.SubstrateSignerPayload -import java.math.BigDecimal import jp.co.soramitsu.account.api.domain.model.ImportMode import jp.co.soramitsu.account.api.presentation.account.create.ChainAccountCreatePayload import jp.co.soramitsu.account.api.presentation.actions.AddAccountPayload @@ -46,6 +45,7 @@ import jp.co.soramitsu.account.impl.presentation.node.add.AddNodeFragment import jp.co.soramitsu.account.impl.presentation.node.details.NodeDetailsFragment import jp.co.soramitsu.account.impl.presentation.node.details.NodeDetailsPayload import jp.co.soramitsu.account.impl.presentation.node.list.NodesFragment +import jp.co.soramitsu.account.impl.presentation.nomis_scoring.ScoreDetailsFragment import jp.co.soramitsu.account.impl.presentation.options_switch_node.OptionsSwitchNodeFragment import jp.co.soramitsu.account.impl.presentation.optionsaddaccount.OptionsAddAccountFragment import jp.co.soramitsu.account.impl.presentation.pincode.PinCodeAction @@ -72,6 +72,7 @@ import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContr import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.model.CustomContributePayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.CrowdloanContributeFragment import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.ContributePayload +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsRouter import jp.co.soramitsu.nft.impl.presentation.NFTFlowFragment import jp.co.soramitsu.nft.navigation.NFTRouter import jp.co.soramitsu.onboarding.impl.OnboardingRouter @@ -121,7 +122,6 @@ import jp.co.soramitsu.staking.impl.presentation.validators.change.custom.settin import jp.co.soramitsu.staking.impl.presentation.validators.details.CollatorDetailsFragment import jp.co.soramitsu.staking.impl.presentation.validators.details.ValidatorDetailsFragment import jp.co.soramitsu.staking.impl.presentation.validators.parcel.CollatorDetailsParcelModel -import jp.co.soramitsu.staking.impl.presentation.validators.parcel.ValidatorDetailsParcelModel import jp.co.soramitsu.success.presentation.SuccessFragment import jp.co.soramitsu.success.presentation.SuccessRouter import jp.co.soramitsu.wallet.api.domain.model.XcmChainType @@ -168,7 +168,6 @@ import jp.co.soramitsu.walletconnect.impl.presentation.requestpreview.RequestPre import jp.co.soramitsu.walletconnect.impl.presentation.sessionproposal.SessionProposalFragment import jp.co.soramitsu.walletconnect.impl.presentation.sessionrequest.SessionRequestFragment import jp.co.soramitsu.walletconnect.impl.presentation.transactionrawdata.RawDataFragment -import kotlin.coroutines.coroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -184,6 +183,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.job import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import kotlin.coroutines.coroutineContext @Parcelize class NavComponentDelayedNavigation(val globalActionId: Int, val extras: Bundle? = null) : DelayedNavigation @@ -200,7 +201,8 @@ class Navigator : SuccessRouter, SoraCardRouter, WalletConnectRouter, - NFTRouter + NFTRouter, + LiquidityPoolsRouter { private var navController: NavController? = null @@ -614,8 +616,8 @@ class Navigator : navController?.navigate(R.id.confirmJoinPoolFragment) } - override fun openPoolInfo(poolInfo: PoolInfo) { - navController?.navigate(R.id.poolInfoFragment, PoolInfoFragment.getBundle(poolInfo)) + override fun openPoolInfo(poolId: Int) { + navController?.navigate(R.id.poolInfoFragment, PoolInfoFragment.getBundle(poolId)) } override fun openManagePoolStake() { @@ -728,8 +730,8 @@ class Navigator : navController?.navigate(R.id.close_swap) } - override fun openValidatorDetails(validatorDetails: ValidatorDetailsParcelModel) { - navController?.navigate(R.id.validatorDetailsFragment, ValidatorDetailsFragment.getBundle(validatorDetails)) + override fun openValidatorDetails(validatorIdHex: String) { + navController?.navigate(R.id.validatorDetailsFragment, ValidatorDetailsFragment.getBundle(validatorIdHex)) } override fun openSelectedValidators() { @@ -1511,4 +1513,12 @@ class Navigator : override fun openServiceScreen() { navController?.navigate(R.id.serviceFragment) } + + override fun openScoreDetailsScreen(metaId: Long) { + navController?.navigate(R.id.scoreDetailsFragment, ScoreDetailsFragment.getBundle(metaId)) + } + + override fun openPools() { + navController?.navigate(R.id.poolsFlowFragment) + } } diff --git a/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainViewModel.kt b/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainViewModel.kt index 3c01f15797..690547a203 100644 --- a/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainViewModel.kt +++ b/app/src/main/java/jp/co/soramitsu/app/root/presentation/main/MainViewModel.kt @@ -1,8 +1,6 @@ package jp.co.soramitsu.app.root.presentation.main -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.app.root.domain.RootInteractor import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.core.runtime.ChainConnection @@ -10,8 +8,7 @@ import jp.co.soramitsu.polkaswap.api.domain.PolkaswapInteractor import jp.co.soramitsu.polkaswap.api.presentation.PolkaswapRouter import jp.co.soramitsu.wallet.impl.presentation.WalletRouter import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( @@ -24,29 +21,19 @@ class MainViewModel @Inject constructor( init { externalRequirements.value = ChainConnection.ExternalRequirement.ALLOWED - walletRouter.listenPolkaswapDisclaimerResultFlowFromMainScreen() - .onEach { - if (it) { - walletRouter.openSwapTokensScreen( - chainId = null, - assetIdFrom = null, - assetIdTo = null - ) - } - }.launchIn(viewModelScope) } val stakingAvailableLiveData = interactor.stakingAvailableFlow() .asLiveData() fun navigateToSwapScreen() { - if (polkaswapInteractor.hasReadDisclaimer) { - walletRouter.openSwapTokensScreen( - chainId = null, - assetIdFrom = null, - assetIdTo = null - ) - } else { + walletRouter.openSwapTokensScreen( + chainId = null, + assetIdFrom = null, + assetIdTo = null + ) + + if (!polkaswapInteractor.hasReadDisclaimer) { polkaswapRouter.openPolkaswapDisclaimerFromMainScreen() } } diff --git a/app/src/main/res/navigation/bottom_nav_graph.xml b/app/src/main/res/navigation/bottom_nav_graph.xml index adc152e396..4e3be8dcda 100644 --- a/app/src/main/res/navigation/bottom_nav_graph.xml +++ b/app/src/main/res/navigation/bottom_nav_graph.xml @@ -7,7 +7,6 @@ diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 699ee2d05f..c30ac6ac56 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -934,6 +934,11 @@ app:popExitAnim="?android:attr/fragmentCloseExitAnimation" /> + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 94f390635e..fdda2f6158 100644 --- a/build.gradle +++ b/build.gradle @@ -5,19 +5,20 @@ apply plugin: "org.sonarqube" buildscript { ext { // App version - versionName = '3.6.1' - versionCode = 192 + versionName = '3.7.1' + versionCode = 200 // SDK and tools compileSdkVersion = 34 - minSdkVersion = 24 + minSdkVersion = 26 targetSdkVersion = 34 - composeCompilerVersion = '1.5.11' + composeCompilerVersion = '1.5.14' withoutBasic = { exclude group: 'jp.co.soramitsu.xnetworking', module: 'basic' } withoutJna = { exclude group: 'net.java.dev.jna' } withoutJavaWS = { exclude module: 'java-websocket-lib' } + withoutAndroidFoundation = { exclude module: 'android-foundation' } } repositories { @@ -61,10 +62,24 @@ allprojects { } } } + + configurations { + cleanedAnnotations + implementation.exclude group: 'com.intellij' , module:'annotations' + } + + configurations.configureEach { + resolutionStrategy { + // add dependency substitution rules + dependencySubstitution { + substitute module('org.bouncycastle:bcprov-jdk15on') using module('org.bouncycastle:bcprov-jdk18on:1.78') + } + } + } } tasks.register('clean', Delete) { - delete rootProject.buildDir + delete rootProject.layout.buildDirectory } tasks.register('runTest', GradleBuild) { diff --git a/buildSrc/src/main/kotlin/detekt-setup.gradle.kts b/buildSrc/src/main/kotlin/detekt-setup.gradle.kts index c9d2418e66..5886ec4477 100644 --- a/buildSrc/src/main/kotlin/detekt-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/detekt-setup.gradle.kts @@ -33,6 +33,7 @@ fun Detekt.setup(autoCorrect: Boolean) { // TODO: Remove exclude paths after merge detekt to develop exclude( + "**/androidfoundation/**", "**/common/**", "**/core-api/**", "**/core-db/**", diff --git a/common/build.gradle b/common/build.gradle index da98f04683..db536e857e 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -163,5 +163,5 @@ dependencies { testImplementation libs.mockito.inline testImplementation project(':test-shared') - api libs.sharedFeaturesCoreDep + api libs.sharedFeaturesCoreDep, withoutAndroidFoundation } \ No newline at end of file diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/Address.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/Address.kt index cf628e5c28..51e9fe1239 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/Address.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/Address.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.common.compose.component -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -13,16 +12,15 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import jp.co.soramitsu.common.R -import jp.co.soramitsu.common.compose.theme.FearlessTheme -import jp.co.soramitsu.common.compose.theme.customColors +import jp.co.soramitsu.common.compose.theme.FearlessAppTheme import jp.co.soramitsu.common.compose.theme.customTypography import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.compose.theme.white08 import jp.co.soramitsu.common.utils.formatting.shortenAddress @Composable @@ -32,10 +30,9 @@ fun Address( onClick: () -> Unit ) { Surface( - modifier.background( - color = MaterialTheme.customColors.white08, - shape = RoundedCornerShape(100.dp) - ) + color = white08, + shape = RoundedCornerShape(100.dp), + modifier = modifier ) { Row( modifier = Modifier @@ -45,7 +42,7 @@ fun Address( Text( text = address.shortenAddress(), style = MaterialTheme.customTypography.body2, - color = Color.White, + color = white, maxLines = 1, modifier = Modifier .testTag("address") @@ -68,12 +65,10 @@ fun Address( @Preview @Composable private fun AddressPreview() { - FearlessTheme { - Surface(Modifier.background(Color.Black)) { + FearlessAppTheme { Address( address = "0x32141235qwegtf24315reqwerfasdgqwert243rfasdvgergsdf", onClick = {} ) - } } } diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/AddressInputWithScore.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/AddressInputWithScore.kt new file mode 100644 index 0000000000..0e605c227a --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/AddressInputWithScore.kt @@ -0,0 +1,127 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.black1 +import jp.co.soramitsu.common.compose.theme.black2 +import jp.co.soramitsu.common.compose.theme.white24 +import jp.co.soramitsu.common.compose.theme.white50 +import jp.co.soramitsu.common.utils.withNoFontPadding + +sealed interface AddressInputWithScore { + val title: String + + data class Filled( + override val title: String, + val address: String, + val image: Any, + val score: Int? + ) : AddressInputWithScore + + data class Empty( + override val title: String, + val hint: String + ) : AddressInputWithScore +} + +@Composable +fun AddressInputWithScore( + state: AddressInputWithScore, + onPaste: () -> Unit, + onClear: () -> Unit +) { + + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + borderColor = white24 + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = (state as? AddressInputWithScore.Filled)?.image + ?: R.drawable.ic_address_placeholder, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + MarginHorizontal(margin = 8.dp) + Column(modifier = Modifier.weight(1f)) { + H5( + text = state.title.withNoFontPadding(), + color = black2 + ) + when (state) { + is AddressInputWithScore.Empty -> { + B1(text = state.hint, color = black1) + } + + is AddressInputWithScore.Filled -> { + Row(verticalAlignment = Alignment.CenterVertically) { + B1( + text = state.address, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), + color = white50 + ) + MarginHorizontal(margin = 8.dp) + state.score?.let { ScoreStar(score = it) } + } + + } + } + } + MarginHorizontal(margin = 8.dp) + when (state) { + is AddressInputWithScore.Empty -> { + Badge( + iconResId = R.drawable.ic_copy_16, + labelResId = R.string.chip_paste, + onClick = onPaste + ) + } + + is AddressInputWithScore.Filled -> { + Image( + res = R.drawable.ic_close_16_circle, + modifier = Modifier + .wrapContentSize() + .clickable(onClick = onClear) + ) + } + } + } + } +} + +@Preview +@Composable +private fun AccountInputPreview() { + val filled = AddressInputWithScore.Filled( + "Send to", + "BlueBird", + "0x23d2ef23...23d23f23", + 100 + ) + val empty = AddressInputWithScore.Empty("Send to", "Public address") + Column { + AddressInputWithScore(filled, onClear = {}, onPaste = {}) + MarginVertical(margin = 6.dp) + AddressInputWithScore(empty, onClear = {}, onPaste = {}) + MarginVertical(margin = 6.dp) + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/AmountInput.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/AmountInput.kt index ff7e28761e..caef1c8e3d 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/AmountInput.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/AmountInput.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.common.compose.component -import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,7 +26,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage +import com.valentinilk.shimmer.shimmer import java.math.BigDecimal import jp.co.soramitsu.common.R import jp.co.soramitsu.common.compose.theme.FearlessTheme @@ -36,7 +36,6 @@ import jp.co.soramitsu.common.compose.theme.black2 import jp.co.soramitsu.common.compose.theme.colorAccentDark import jp.co.soramitsu.common.compose.theme.white import jp.co.soramitsu.common.compose.theme.white24 -import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.MAX_DECIMALS_8 import jp.co.soramitsu.common.utils.isZero import jp.co.soramitsu.ui_core.component.input.number.BasicNumberInput @@ -53,8 +52,10 @@ data class AmountInputViewState( val isFocused: Boolean = false, val allowAssetChoose: Boolean = false, val precision: Int = MAX_DECIMALS_8, - val inputEnabled: Boolean = true + val inputEnabled: Boolean = true, + val isShimmerAmounts: Boolean = false ) { + companion object { val defaultObj = AmountInputViewState( tokenName = null, @@ -63,17 +64,6 @@ data class AmountInputViewState( fiatAmount = "$0", tokenAmount = BigDecimal.ZERO ) - - @Deprecated("use defaultObj with copy") - fun default(resourceManager: ResourceManager, @StringRes totalBalanceFormat: Int = R.string.common_balance_format): AmountInputViewState { - return AmountInputViewState( - tokenName = null, - tokenImage = null, - totalBalance = resourceManager.getString(totalBalanceFormat, "0"), - fiatAmount = "$0", - tokenAmount = BigDecimal.ZERO - ) - } } } @@ -133,7 +123,16 @@ fun AmountInput( val title = state.title ?: stringResource(id = R.string.common_amount) H5(text = title, modifier = Modifier.weight(1f), color = black2) state.fiatAmount?.let { - B1(text = it, modifier = Modifier.weight(1f), textAlign = TextAlign.End, color = black2) + B1( + text = it, + modifier = Modifier + .weight(1f) + .then( + if (state.isShimmerAmounts) Modifier.shimmer() else Modifier + ), + textAlign = TextAlign.End, + color = black2 + ) } } @@ -168,7 +167,14 @@ fun AmountInput( modifier = Modifier .fillMaxWidth() .testTag("InputAmountField" + (state.tokenName.orEmpty())) - .wrapContentHeight(), + .wrapContentHeight() + .then( + if (state.isShimmerAmounts) { + Modifier.shimmer() + } else { + Modifier + } + ), onFocusChanged = onInputFocusChange, textStyle = MaterialTheme.customTypography.displayS.copy(textAlign = TextAlign.End, color = textColorState), enabled = state.inputEnabled, @@ -207,10 +213,11 @@ private fun RowScope.TokenIcon( .padding(2.dp) .align(CenterVertically) if (url != null) { - AsyncImage( + SubcomposeAsyncImage( + modifier = imageModifier, model = getImageRequest(LocalContext.current, url), contentDescription = null, - modifier = imageModifier + loading = { Shimmer(Modifier.size(28.dp)) } ) } else { Icon( diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerDemeter.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerDemeter.kt new file mode 100644 index 0000000000..09d07710b7 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerDemeter.kt @@ -0,0 +1,135 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.colorFromHex +import jp.co.soramitsu.common.compose.theme.demeterYellow +import jp.co.soramitsu.common.utils.withNoFontPadding +import jp.co.soramitsu.ui_core.theme.customTypography + +@Composable +fun BannerDemeter( + onShowMoreClick: () -> Unit, + onCloseClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(139.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + brush = Brush.linearGradient( + colorStops = listOfNotNull( + "#46a487".colorFromHex()?.let { 0.05f to it }, + "#46a487".colorFromHex()?.copy(alpha = 0.5F)?.let { 0.65f to it } + ).toTypedArray(), + start = Offset(0f, Float.POSITIVE_INFINITY), + end = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ) + ) { + Image( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(top = 8.dp, bottom = 16.dp), + painter = painterResource(id = R.drawable.demeter_banner_image), + contentDescription = "", + contentScale = ContentScale.FillHeight + ) + NavigationIconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp), + navigationIconResId = R.drawable.ic_cross_32, + onNavigationClick = onCloseClick + ) + + Column( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + val title = stringResource(R.string.banners_demeter_title) + val titleWords = title.split(" ") + val titleFirstWord = titleWords.firstOrNull() + val titleRemainWords = titleFirstWord?.let { title.removePrefix(it) } + val styledText = buildAnnotatedString { + withStyle(style = SpanStyle()) { + append(titleFirstWord) + } + withStyle(style = SpanStyle(color = demeterYellow)) { + append(titleRemainWords) + } + }.withNoFontPadding() + + Text( + modifier = Modifier + .wrapContentWidth(), + text = styledText, + style = MaterialTheme.customTypography.headline2, + color = Color.White + ) + MarginVertical(margin = 8.dp) + Text( + maxLines = 2, + modifier = Modifier + .wrapContentWidth(), + text = stringResource(R.string.banners_demeter_description), + style = MaterialTheme.customTypography.paragraphXS.copy(fontSize = 12.sp), + color = Color.White + ) + + MarginVertical(margin = 11.dp) + + ColoredButton( + modifier = Modifier.defaultMinSize(minWidth = 102.dp), + backgroundColor = demeterYellow, + onClick = onShowMoreClick + ) { + Text( + text = stringResource(R.string.common_show_more), + style = MaterialTheme.customTypography.headline2.copy(fontSize = 12.sp), + color = Color.White, + textAlign = TextAlign.Start + ) + } + } + } +} + +@Preview +@Composable +private fun BannerDemeterPreview() { + BannerDemeter( + onShowMoreClick = {}, + onCloseClick = {} + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerLiquidityPools.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerLiquidityPools.kt new file mode 100644 index 0000000000..da3cb72042 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerLiquidityPools.kt @@ -0,0 +1,160 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.black +import jp.co.soramitsu.common.compose.theme.colorAccentDark +import jp.co.soramitsu.common.compose.theme.colorFromHex +import jp.co.soramitsu.common.compose.theme.transparent +import jp.co.soramitsu.common.compose.theme.white40 +import jp.co.soramitsu.ui_core.theme.customTypography + +@Composable +fun BannerLiquidityPools( + onShowMoreClick: () -> Unit, + onCloseClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(139.dp) + .clip(RoundedCornerShape(15.dp)) + .background( + brush = Brush.linearGradient( + colorStops = listOfNotNull( + "#FF3B7B".colorFromHex()?.let { 0.05f to it }, + "#AB18B8".colorFromHex()?.copy(alpha = 0.4F)?.let { 0.65f to it } + ).toTypedArray(), + start = Offset(0f, Float.POSITIVE_INFINITY), + end = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ) + ) { + HaloIconBannerPools( + modifier = Modifier + .scale(2.1f) + .align(Alignment.BottomEnd) + .offset(x = (-2).dp, y = (-1).dp), + iconRes = R.drawable.ic_polkaswap_logo, + color = colorAccentDark, + ) + NavigationIconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp), + navigationIconResId = R.drawable.ic_cross_32, + onNavigationClick = onCloseClick + ) + + Column( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Text( + modifier = Modifier + .wrapContentWidth(), + text = stringResource(R.string.banners_liquidity_pools_title), + style = MaterialTheme.customTypography.headline2, + color = Color.White + ) + MarginVertical(margin = 8.dp) + Text( + maxLines = 2, + modifier = Modifier + .wrapContentWidth(), + text = stringResource(R.string.lp_banner_text), + style = MaterialTheme.customTypography.paragraphXS.copy(fontSize = 12.sp), + color = Color.White + ) + + MarginVertical(margin = 11.dp) + + ColoredButton( + modifier = Modifier.defaultMinSize(minWidth = 102.dp), + backgroundColor = Color.Unspecified, + border = BorderStroke(1.dp, white40), + onClick = onShowMoreClick + ) { + Text( + text = stringResource(R.string.common_show_details), + style = MaterialTheme.customTypography.headline2.copy(fontSize = 12.sp), + color = Color.White + ) + } + } + } +} + +@Composable +fun HaloIconBannerPools( + @DrawableRes iconRes: Int, + color: Color, + modifier: Modifier = Modifier, + background: Color = transparent, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + val gradientBrush = Brush.radialGradient( + colors = listOf(black, transparent) + ) + + val haloPadding = 16.dp + val haloWidth = 14.dp + val imageSize = 35.dp + val haloSize = imageSize + haloPadding * 2 + haloWidth * 2 + Box( + modifier + .size(haloSize) + .border(haloWidth, gradientBrush, CircleShape) + .padding(haloWidth) + .background(colorAccentDark.copy(alpha = 0.06f), CircleShape) + .padding(contentPadding) + ) { + Image( + modifier = Modifier + .size(imageSize) + .align(Alignment.Center), + res = iconRes, + tint = color + ) + } +} + +@Preview +@Composable +private fun BannerLiquidityPoolsPreview() { + BannerLiquidityPools( + onShowMoreClick = {}, + onCloseClick = {} + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerPageIndicator.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerPageIndicator.kt new file mode 100644 index 0000000000..5a234c594c --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/BannerPageIndicator.kt @@ -0,0 +1,44 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.common.compose.theme.white16 +import jp.co.soramitsu.common.compose.theme.white50 + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BannerPageIndicator( + bannersCount: Int, + pagerState: PagerState +) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + repeat(bannersCount) { iteration -> + val color = if (pagerState.currentPage == iteration) white50 else white16 + Box( + modifier = Modifier + .padding(horizontal = 3.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/EmptyMessage.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/EmptyMessage.kt index 57731bbd03..cd138ec77c 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/EmptyMessage.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/EmptyMessage.kt @@ -9,8 +9,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.FearlessAppTheme import jp.co.soramitsu.common.compose.theme.gray2 @Composable @@ -34,3 +36,13 @@ fun EmptyMessage( ) } } + +@Composable +@Preview +fun EmptyMessagePreview(){ + FearlessAppTheme { + EmptyMessage( + message = R.string.choose_amount_error_balance + ) + } +} \ No newline at end of file diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/InfoTableItemAsset.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/InfoTableItemAsset.kt new file mode 100644 index 0000000000..1003cbfe56 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/InfoTableItemAsset.kt @@ -0,0 +1,92 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import jp.co.soramitsu.common.compose.theme.FearlessTheme +import jp.co.soramitsu.common.compose.theme.black2 +import jp.co.soramitsu.common.compose.theme.bold +import jp.co.soramitsu.common.compose.theme.customTypography + +data class TitleIconValueState( + val title: String, + val iconUrl: String? = null, + val value: String? = null +) + +@Composable +fun InfoTableItemAsset(state: TitleIconValueState) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp) + .padding(vertical = 6.dp, horizontal = 16.dp) + ) { + Row( + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + H5( + modifier = Modifier.align(Alignment.CenterVertically), + text = state.title, + color = black2 + ) + } + MarginHorizontal(margin = 16.dp) + Row( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + horizontalArrangement = Arrangement.End + ) { + state.iconUrl?.let { + AsyncImage( + model = getImageRequest(LocalContext.current, it), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically) + ) + MarginHorizontal(margin = 5.dp) + } + state.value?.let { + Text( + text = it, + modifier = Modifier.align(Alignment.CenterVertically), + style = MaterialTheme.customTypography.header5.bold(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Preview +@Composable +private fun InfoTableCustomPreview() { + val state = TitleIconValueState( + "From", + "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/icons/tokens/coloured/PSWAP.svg", + "PSWAP", + ) + FearlessTheme { + Column { + InfoTableItemAsset(state) + } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/InputWithHint.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/InputWithHint.kt index 3211d07d41..433ea34640 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/InputWithHint.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/InputWithHint.kt @@ -3,7 +3,6 @@ package jp.co.soramitsu.common.compose.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme @@ -22,11 +21,10 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.common.compose.theme.colorAccentDark import jp.co.soramitsu.common.compose.theme.customTypography import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.common.utils.withNoFontPadding @Composable @@ -34,7 +32,7 @@ fun InputWithHint( state: String?, modifier: Modifier = Modifier, cursorBrush: Brush = SolidColor(white), - inputFieldModifier: Modifier = Modifier, + inputFieldModifier: Modifier = Modifier,//.fillMaxWidth(), editable: Boolean = true, onInput: (String) -> Unit, Hint: @Composable () -> Unit @@ -60,7 +58,6 @@ fun InputWithHint( enabled = editable, keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Text, imeAction = ImeAction.None), modifier = inputFieldModifier - .fillMaxWidth() .align(Alignment.CenterStart) .onFocusChanged { focusedState = it.isFocused diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/Notification.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/Notification.kt index 371130064e..e1af8e4526 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/Notification.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/Notification.kt @@ -1,6 +1,7 @@ package jp.co.soramitsu.common.compose.component import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height @@ -23,12 +24,12 @@ data class NotificationState( @DrawableRes val iconRes: Int, val title: String, val value: String, - val buttonText: String, + val buttonText: String? = null, val color: Color ) @Composable -fun Notification(state: NotificationState, onAction: () -> Unit) { +fun Notification(state: NotificationState, onAction: (() -> Unit)? = null) { BackgroundCornered { Row(Modifier.padding(8.dp)) { MarginHorizontal(margin = 8.dp) @@ -44,14 +45,16 @@ fun Notification(state: NotificationState, onAction: () -> Unit) { H6(text = state.title, color = state.color) B1(text = state.value, color = white50) } - TextButtonSmall( - text = state.buttonText, - colors = customButtonColors(state.color), - onClick = onAction, - modifier = Modifier - .height(24.dp) - .align(Alignment.CenterVertically) - ) + onAction?.let { + TextButtonSmall( + text = state.buttonText.orEmpty(), + colors = customButtonColors(state.color), + onClick = onAction, + modifier = Modifier + .height(24.dp) + .align(Alignment.CenterVertically) + ) + } MarginHorizontal(margin = 8.dp) } } @@ -67,7 +70,8 @@ private fun Preview() { stringResource(R.string.staking_unbond_v1_9_0), colorAccent ) - FearlessTheme { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Notification(state) {} + Notification(state, onAction = null) } } diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/ScoreStar.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/ScoreStar.kt new file mode 100644 index 0000000000..46226a6a37 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/ScoreStar.kt @@ -0,0 +1,74 @@ +package jp.co.soramitsu.common.compose.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.FearlessAppTheme +import jp.co.soramitsu.common.compose.theme.greenText +import jp.co.soramitsu.common.compose.theme.warningOrange +import jp.co.soramitsu.common.compose.theme.warningYellow +import jp.co.soramitsu.common.compose.theme.white30 + +@Composable +fun ScoreStar(score: Int, modifier: Modifier = Modifier) { + val starRes = when (score) { + in 0..33 -> R.drawable.ic_score_star_empty + in 33..66 -> R.drawable.ic_score_star_half + in 66..100 -> R.drawable.ic_score_star_full + else -> R.drawable.ic_score_star_empty + } + val color = when (score) { + in 0..25 -> warningOrange + in 25..75 -> warningYellow + in 75..100 -> greenText + else -> white30 + } + + when { + score >= 0 -> { + Row(modifier = modifier) { + Image(res = starRes, tint = color, modifier = Modifier.size(12.dp)) + MarginHorizontal(margin = 3.dp) + B2(text = score.toString(), color = color) + } + } + + score == -1 -> { + // loading + Image( + res = starRes, tint = color, modifier = modifier + .size(12.dp) + .shimmer() + ) + } + + score == -2 -> { + //error + Row(modifier = modifier) { + Image(res = starRes, tint = color, modifier = Modifier.size(12.dp)) + MarginHorizontal(margin = 3.dp) + B2(text = "N/A", color = color) + } + } + } +} + +@Preview +@Composable +private fun ScoreStarPreview() { + FearlessAppTheme { + Column { + ScoreStar(score = -1) + ScoreStar(score = -2) + ScoreStar(score = 0) + ScoreStar(score = 50) + ScoreStar(score = 100) + } + } +} \ No newline at end of file diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/SettingsItem.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/SettingsItem.kt index 92e780e024..5afcf8af42 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/SettingsItem.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/SettingsItem.kt @@ -1,28 +1,54 @@ package jp.co.soramitsu.common.compose.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.SwitchColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.theme.black3 +import jp.co.soramitsu.common.compose.theme.colorAccentDark import jp.co.soramitsu.common.compose.theme.customColors +import jp.co.soramitsu.common.compose.theme.transparent +import jp.co.soramitsu.common.compose.theme.white import jp.co.soramitsu.common.utils.clickableSingle +sealed interface SettingsItemAction { + data object Transition : SettingsItemAction + + data class TransitionWithIcon(@DrawableRes val trailingIconRes: Int) : SettingsItemAction + data class Selector(val text: String) : SettingsItemAction + + data class Switch(val value: Boolean) : SettingsItemAction + +} + @Composable fun SettingsItem( + modifier: Modifier = Modifier, icon: Painter, text: String, - modifier: Modifier = Modifier, + action: SettingsItemAction = SettingsItemAction.Transition, onClick: () -> Unit = {} ) { Row( @@ -51,22 +77,114 @@ fun SettingsItem( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Icon( - modifier = Modifier - .padding(end = 8.dp) - .size(24.dp), - painter = painterResource(R.drawable.ic_arrow_right_24), - contentDescription = null, - tint = MaterialTheme.customColors.white - ) + when (action) { + is SettingsItemAction.Selector -> { + B1(text = action.text, color = white) + MarginHorizontal(margin = 16.dp) + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(24.dp), + painter = painterResource(R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + } + + is SettingsItemAction.Switch -> { + FearlessSwitch(isChecked = action.value, onCheckedChange = {onClick()}) + MarginHorizontal(margin = 16.dp) + } + + SettingsItemAction.Transition -> { + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(24.dp), + painter = painterResource(R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + } + + is SettingsItemAction.TransitionWithIcon -> { + Image( + modifier = Modifier + .size(16.dp), + action.trailingIconRes + ) + MarginHorizontal(margin = 8.dp) + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(24.dp), + painter = painterResource(R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + } + } + } +} + +@Composable +fun FearlessSwitch(isChecked: Boolean, onCheckedChange: (Boolean) -> Unit) { + val trackColor = when { + isChecked -> colorAccentDark + else -> black3 + } + Switch( + colors = switchColors, + checked = isChecked, + onCheckedChange = onCheckedChange, + modifier = Modifier + .background(color = trackColor, shape = RoundedCornerShape(20.dp)) + .padding(3.dp) + .height(20.dp) + .width(36.dp) + ) +} + +val switchColors = object : SwitchColors { + @Composable + override fun thumbColor(enabled: Boolean, checked: Boolean): State { + return rememberUpdatedState(white) + } + + @Composable + override fun trackColor(enabled: Boolean, checked: Boolean): State { + return rememberUpdatedState(transparent) } } @Preview @Composable fun SettingsItemPreview() { - SettingsItem( - icon = painterResource(R.drawable.ic_settings_wallets), - text = "Item" - ) + Column { + SettingsItem( + icon = painterResource(R.drawable.ic_settings_wallets), + text = "Item" + ) + SettingsItem( + icon = painterResource(R.drawable.ic_settings_wallets), + text = "Item", + action = SettingsItemAction.Selector("Value") + ) + SettingsItem( + icon = painterResource(R.drawable.ic_settings_wallets), + text = "Item", + action = SettingsItemAction.Switch(true) + ) + SettingsItem( + icon = painterResource(R.drawable.ic_settings_wallets), + text = "Item", + action = SettingsItemAction.Switch(false) + ) + SettingsItem( + icon = painterResource(R.drawable.ic_settings_wallets), + text = "Item", + action = SettingsItemAction.TransitionWithIcon(R.drawable.ic_warning_filled) + ) + + } } diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/Toolbar.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/Toolbar.kt index 79c5724d54..9234e11d16 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/Toolbar.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/Toolbar.kt @@ -40,24 +40,30 @@ import jp.co.soramitsu.common.compose.theme.backgroundBlurColor import jp.co.soramitsu.common.compose.theme.colorAccent import jp.co.soramitsu.common.compose.theme.customTypography import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.utils.clickableWithNoIndication data class MainToolbarViewState( val title: String, - val homeIconState: ToolbarHomeIconState = ToolbarHomeIconState(), + val homeIconState: ToolbarHomeIconState = ToolbarHomeIconState.Navigation(R.drawable.ic_wallet), val selectorViewState: ChainSelectorViewState ) data class MainToolbarViewStateWithFilters( - val title: String, - val homeIconState: ToolbarHomeIconState = ToolbarHomeIconState(), - val selectorViewState: ChainSelectorViewStateWithFilters + val title: String?, + val homeIconState: ToolbarHomeIconState = ToolbarHomeIconState.Navigation(R.drawable.ic_wallet), + val selectorViewState: ChainSelectorViewStateWithFilters? ) -data class ToolbarHomeIconState( - val walletIcon: Drawable? = null, - @DrawableRes val navigationIcon: Int? = null, - val tint: Color = Color.Unspecified -) +sealed interface ToolbarHomeIconState{ + data class Wallet( + val walletIcon: Drawable, + val score: Int? = null, + ): ToolbarHomeIconState + data class Navigation( + @DrawableRes val navigationIcon: Int, + val tint: Color = Color.Unspecified + ): ToolbarHomeIconState +} data class MenuIconItem( @DrawableRes val icon: Int, @@ -153,6 +159,7 @@ fun MainToolbar( state: MainToolbarViewStateWithFilters, onChangeChainClick: () -> Unit, onNavigationClick: () -> Unit = {}, + onScoreClick: () -> Unit, menuItems: List? = null, modifier: Modifier = Modifier ) { @@ -170,7 +177,8 @@ fun MainToolbar( ) { ToolbarHomeIcon( state = state.homeIconState, - onClick = onNavigationClick + onClick = onNavigationClick, + onScoreClick = onScoreClick ) } Column( @@ -180,19 +188,30 @@ fun MainToolbar( .align(Alignment.Center), horizontalAlignment = CenterHorizontally ) { - Text( - text = state.title, - style = MaterialTheme.customTypography.header4, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) + if (state.title != null) { + Text( + text = state.title, + style = MaterialTheme.customTypography.header4, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } else { + Shimmer(Modifier.height(14.dp)) + } MarginVertical(margin = 4.dp) - ChainSelector( - selectorViewState = state.selectorViewState, - onChangeChainClick = onChangeChainClick - ) + if (state.selectorViewState != null) { + ChainSelector( + selectorViewState = state.selectorViewState, + onChangeChainClick = onChangeChainClick + ) + } else { + Shimmer( + Modifier + .height(12.dp) + .padding(horizontal = 20.dp)) + } } Row( verticalAlignment = CenterVertically, @@ -220,7 +239,7 @@ fun MainToolbar( @Composable fun MainToolbarShimmer( - homeIconState: ToolbarHomeIconState, + homeIconState: ToolbarHomeIconState? = null, menuItems: List? = null, modifier: Modifier = Modifier ) { @@ -236,12 +255,13 @@ fun MainToolbarShimmer( contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(1f) ) { - homeIconState.navigationIcon?.let { + (homeIconState as? ToolbarHomeIconState.Navigation)?.let { IconButton( - painter = painterResource(id = it), + painter = painterResource(id = it.navigationIcon), tint = Color.Unspecified, onClick = {} ) + } } Column( @@ -283,17 +303,31 @@ fun MainToolbarShimmer( } @Composable -fun ToolbarHomeIcon(state: ToolbarHomeIconState, onClick: () -> Unit) { - when { - state.navigationIcon != null -> painterResource(id = state.navigationIcon) - state.walletIcon != null -> rememberAsyncImagePainter(model = state.walletIcon) - else -> null - }?.let { painter -> - IconButton( - painter = painter, - tint = state.tint, - onClick = onClick - ) +fun ToolbarHomeIcon(state: ToolbarHomeIconState, onClick: () -> Unit, onScoreClick: () -> Unit = {}) { + when (state) { + is ToolbarHomeIconState.Navigation -> { + IconButton( + painter = painterResource(id = state.navigationIcon), + tint = state.tint, + onClick = onClick + ) + } + + is ToolbarHomeIconState.Wallet -> { + Column(horizontalAlignment = CenterHorizontally) { + IconButton( + painter = rememberAsyncImagePainter(model = state.walletIcon), + onClick = onClick + ) + MarginVertical(margin = 6.dp) + + state.score?.let { + Box(modifier = Modifier.clickableWithNoIndication { onScoreClick() }) { + ScoreStar(score = it) + } + } + } + } } } @@ -328,15 +362,18 @@ fun Toolbar(state: ToolbarViewState, modifier: Modifier = Modifier, onNavigation verticalAlignment = CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Box( - contentAlignment = CenterStart, - modifier = Modifier.weight(1f) - ) { - ToolbarHomeIcon( - state = ToolbarHomeIconState(navigationIcon = state.navigationIcon), - onClick = onNavigationClick - ) + state.navigationIcon?.let { navIcon -> + Box( + contentAlignment = CenterStart, + modifier = Modifier.weight(1f) + ) { + ToolbarHomeIcon( + state = ToolbarHomeIconState.Navigation(navigationIcon = navIcon, tint = white), + onClick = onNavigationClick + ) + } } + Column( modifier = Modifier .fillMaxWidth() @@ -384,7 +421,7 @@ private fun MainToolbarPreview() { .padding(16.dp) ) { MainToolbarShimmer( - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_wallet), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_wallet), menuItems = listOf( MenuIconItem(icon = R.drawable.ic_scan, {}), MenuIconItem(icon = R.drawable.ic_search, {}) @@ -394,7 +431,7 @@ private fun MainToolbarPreview() { MainToolbar( state = MainToolbarViewState( title = "Fearless wallet very long wallet name", - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_wallet), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_wallet), selectorViewState = ChainSelectorViewState( selectedChainId = "id", selectedChainName = "Crust shadow parachain", diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/component/WalletItem.kt b/common/src/main/java/jp/co/soramitsu/common/compose/component/WalletItem.kt index fb23164513..2255448ab6 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/component/WalletItem.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/component/WalletItem.kt @@ -39,15 +39,17 @@ data class WalletItemViewState( val title: String, val walletIcon: Any, val isSelected: Boolean, - val additionalMetadata: String = "" + val additionalMetadata: String = "", + val score: Int? = null ) @Composable fun WalletItem( state: WalletItemViewState, onOptionsClick: ((WalletItemViewState) -> Unit)? = null, - onSelected: (WalletItemViewState) -> Unit, + onSelected: (WalletItemViewState) -> Unit = {}, onLongClick: (WalletItemViewState) -> Unit = {}, + onScoreClick: (WalletItemViewState) -> Unit = {}, modifier: Modifier = Modifier ) { val borderColor = if (state.isSelected) { @@ -120,6 +122,11 @@ fun WalletItem( } } Spacer(modifier = Modifier.weight(1f)) + state.score?.let { score -> + Box(modifier = Modifier.padding(9.dp).clickableWithNoIndication { onScoreClick(state) }) { + ScoreStar(score = score) + } + } onOptionsClick?.let { optionsAction -> Box( contentAlignment = Alignment.CenterEnd @@ -166,7 +173,8 @@ private fun WalletItemPreview() { title = walletTitle, walletIcon = R.drawable.ic_wallet, isSelected = isSelected, - changeBalanceViewState = changeBalanceViewState + changeBalanceViewState = changeBalanceViewState, + score = 50 ) Column { diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt b/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt index f29d76a0f7..ceb32be104 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt @@ -73,6 +73,8 @@ val accountIconDark = Color(0xFF000000) val errorRed = Color(0xFFFF3B30) val warningOrange = Color(0xFFEE7700) val alertYellow = Color(0xFFEE7700) +val warningYellow = Color(0xFFFFD600) +val demeterYellow = Color(0xFFEC7430) val transparent = Color(0xffffff) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt index 314f778987..9f77f6b14e 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.common.data.network -import android.util.Log import jp.co.soramitsu.common.BuildConfig import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger @@ -9,13 +8,13 @@ const val TAG = "AndroidLogger" class AndroidLogger : Logger { override fun log(message: String?) { if (BuildConfig.DEBUG) { - Log.d(TAG, message.toString()) +// Log.d(TAG, message.toString()) } } override fun log(throwable: Throwable?) { if (BuildConfig.DEBUG) { - throwable?.printStackTrace() +// throwable?.printStackTrace() } } } diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/OptionsProvider.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/OptionsProvider.kt index 2b0ea96e89..1f111473a3 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/OptionsProvider.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/OptionsProvider.kt @@ -9,4 +9,7 @@ object OptionsProvider { val header: String by lazy { "$APPLICATION_ID/$CURRENT_VERSION_NAME/$CURRENT_VERSION_CODE/$CURRENT_BUILD_TYPE" } + + const val soraConfigCommon = "https://config.polkaswap2.io/prod/common.json" + const val soraConfigMobile = "https://config.polkaswap2.io/prod/mobile.json" } diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisApi.kt new file mode 100644 index 0000000000..0291ed2c8d --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisApi.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.common.data.network.nomis + +import retrofit2.http.GET +import retrofit2.http.Path + +interface NomisApi { + @GET("wallet/{address}/score/") + suspend fun getNomisScore( + @Path("address") address: String + ): NomisResponse +} \ No newline at end of file diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisResponse.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisResponse.kt new file mode 100644 index 0000000000..b3055aacba --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/nomis/NomisResponse.kt @@ -0,0 +1,29 @@ +package jp.co.soramitsu.common.data.network.nomis + +import com.google.gson.annotations.SerializedName + +data class NomisResponse( + val data: NomisResponseData +) + +data class NomisResponseData( + val address: String, + val score: Double, + val stats: NomisStats +) + +data class NomisStats( + val nativeBalanceUSD: Double, + val holdTokensBalanceUSD: Double, + @SerializedName("walletAge") + val walletAgeInMonths: Long, + val totalTransactions: Long, + val totalRejectedTransactions: Long, + @SerializedName("averageTransactionTime") + val averageTransactionTimeInHours: Double, + @SerializedName("maxTransactionTime") + val maxTransactionTimeInHours: Double, + @SerializedName("minTransactionTime") + val minTransactionTimeInHours: Double, + val scoredAt: String +) diff --git a/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt b/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt index 4af739ce87..2e717a1bd1 100644 --- a/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt +++ b/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt @@ -15,6 +15,7 @@ import jp.co.soramitsu.common.data.network.AndroidLogger import jp.co.soramitsu.common.data.network.AppLinksProvider import jp.co.soramitsu.common.data.network.HttpExceptionHandler import jp.co.soramitsu.common.data.network.NetworkApiCreator +import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.network.rpc.SocketSingleRequestExecutor import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.shared_utils.wsrpc.SocketService @@ -23,12 +24,17 @@ import jp.co.soramitsu.shared_utils.wsrpc.recovery.Reconnector import jp.co.soramitsu.shared_utils.wsrpc.request.CoroutinesRequestExecutor import jp.co.soramitsu.shared_utils.wsrpc.request.RequestExecutor import okhttp3.Cache +import okhttp3.CacheControl import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory private const val HTTP_CACHE = "http_cache" +private const val NOMIS_CACHE = "nomis_cache" private const val CACHE_SIZE = 50L * 1024L * 1024L // 50 MiB private const val TIMEOUT_SECONDS = 60L +private const val NOMIS_TIMEOUT_MINUTES = 2L @InstallIn(SingletonComponent::class) @Module @@ -60,12 +66,51 @@ class NetworkModule { .retryOnConnectionFailure(true) if (BuildConfig.DEBUG) { - builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) +// builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } return builder.build() } + @Provides + @Singleton + fun provideNomisHttpClient(context: Context): NomisApi { + val builder = OkHttpClient.Builder() + .connectTimeout(NOMIS_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .writeTimeout(NOMIS_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .readTimeout(NOMIS_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .callTimeout(NOMIS_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .cache(Cache(File(context.cacheDir, NOMIS_CACHE), CACHE_SIZE)) + .addInterceptor { + val request = it.request().newBuilder().apply { + addHeader("X-API-Key", "j9Us1Kxoo9fs3nD") + addHeader("X-ClientId", "FCEB90FC-E3F9-4CF5-980E-A8111A3FFF31") + }.build() + it.proceed(request) + } + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .cacheControl( + CacheControl.Builder() + .maxAge(24, TimeUnit.HOURS) + .build()) + .build() + chain.proceed(request) + } + .retryOnConnectionFailure(true) + + val gson = Gson() + + val retrofit = Retrofit.Builder() + .client(builder.build()) + .baseUrl("https://api.nomis.cc/api/v1/multichain-score/") + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + return retrofit.create(NomisApi::class.java) + } + @Provides @Singleton fun provideLogger(): Logger = AndroidLogger() diff --git a/common/src/main/java/jp/co/soramitsu/common/io/MainThreadExecutor.kt b/common/src/main/java/jp/co/soramitsu/common/io/MainThreadExecutor.kt index 3f9abb73f2..8f10c78763 100644 --- a/common/src/main/java/jp/co/soramitsu/common/io/MainThreadExecutor.kt +++ b/common/src/main/java/jp/co/soramitsu/common/io/MainThreadExecutor.kt @@ -2,14 +2,13 @@ package jp.co.soramitsu.common.io import android.os.Handler import android.os.Looper -import androidx.annotation.NonNull import java.util.concurrent.Executor class MainThreadExecutor : Executor { private val mainThreadHandler = Handler(Looper.getMainLooper()) - override fun execute(@NonNull command: Runnable) { + override fun execute(command: Runnable) { mainThreadHandler.post(command) } } diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/DoubleClickPreventUtils.kt b/common/src/main/java/jp/co/soramitsu/common/utils/DoubleClickPreventUtils.kt index c5eedb728e..28961c4dd7 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/DoubleClickPreventUtils.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/DoubleClickPreventUtils.kt @@ -3,14 +3,14 @@ package jp.co.soramitsu.common.utils import android.os.SystemClock import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember private const val DisableClickTime = 1000L @Composable fun rememberLastClickTime(): MutableState { - return remember { mutableStateOf(0L) } + return remember { mutableLongStateOf(0L) } } fun onSingleClick( diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/Ext.kt b/common/src/main/java/jp/co/soramitsu/common/utils/Ext.kt index 052e7b202b..aa279aa606 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/Ext.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/Ext.kt @@ -54,8 +54,7 @@ fun Context.showBrowser(link: String) { fun Context.createSendEmailIntent(targetEmail: String, title: String) { val emailIntent = Intent(Intent.ACTION_SENDTO).apply { putExtra(Intent.EXTRA_EMAIL, targetEmail) - type = "message/rfc822" - data = Uri.parse("mailto:$targetEmail") + setDataAndType(Uri.parse("mailto:$targetEmail"), "message/rfc822") } startActivity(Intent.createChooser(emailIntent, title)) } diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt b/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt index 4ed9cd79fb..88c4dfc7af 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt @@ -184,6 +184,8 @@ object Modules { const val ORACLE = "Oracle" const val VESTING = "Vesting" const val VESTED_REWARDS = "VestedRewards" + + } object Calls { diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/ViewExt.kt b/common/src/main/java/jp/co/soramitsu/common/utils/ViewExt.kt index a177cea20f..99305792d9 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/ViewExt.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/ViewExt.kt @@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes import androidx.annotation.StyleableRes import androidx.core.content.ContextCompat +import androidx.core.widget.TextViewCompat import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -170,7 +171,7 @@ fun RecyclerView.findFirstVisiblePosition(): Int { fun TextView.setCompoundDrawableTint(@ColorRes tintRes: Int) { val tintColor = context.getColor(tintRes) - compoundDrawableTintList = ColorStateList.valueOf(tintColor) + TextViewCompat.setCompoundDrawableTintList(this, ColorStateList.valueOf(tintColor)) } fun TextView.setTextOrHide(newText: String?) { diff --git a/common/src/main/res/drawable/demeter_banner_image.png b/common/src/main/res/drawable/demeter_banner_image.png new file mode 100644 index 0000000000..9a50ef9fa5 Binary files /dev/null and b/common/src/main/res/drawable/demeter_banner_image.png differ diff --git a/common/src/main/res/drawable/ic_score_star_empty.xml b/common/src/main/res/drawable/ic_score_star_empty.xml new file mode 100644 index 0000000000..80bbc5196f --- /dev/null +++ b/common/src/main/res/drawable/ic_score_star_empty.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_score_star_full.xml b/common/src/main/res/drawable/ic_score_star_full.xml new file mode 100644 index 0000000000..2b866550f8 --- /dev/null +++ b/common/src/main/res/drawable/ic_score_star_full.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_score_star_full_24_pink.xml b/common/src/main/res/drawable/ic_score_star_full_24_pink.xml new file mode 100644 index 0000000000..1a2f9e35b3 --- /dev/null +++ b/common/src/main/res/drawable/ic_score_star_full_24_pink.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_score_star_half.xml b/common/src/main/res/drawable/ic_score_star_half.xml new file mode 100644 index 0000000000..f8926f80f6 --- /dev/null +++ b/common/src/main/res/drawable/ic_score_star_half.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/values-in/strings.xml b/common/src/main/res/values-in/strings.xml index cd5f2ee784..32d11d8d63 100644 --- a/common/src/main/res/values-in/strings.xml +++ b/common/src/main/res/values-in/strings.xml @@ -92,13 +92,13 @@ Tunjukan Seed mentah Jika Anda kehilangan akses ke perangkat ini, dana Anda akan hilang, kecuali Anda membuat cadangan! Pemerintahan - Kumpulan Likuiditas Kumpulan Nominasi Detail terkunci Lindungi diri Anda dari kehilangan akses ke dana Anda Beli XOR Beli atau jual token XOR dengan uang tunai euro Beli token XOR + Kumpulan Likuiditas Peringatan: Dengan mengeklik sambungkan, Anda mengizinkan dapp ini melihat alamat publik Anda. Ini adalah langkah keamanan penting untuk melindungi data Anda dari potensi risiko phishing. Terhubung ke %s Terhubung ke @@ -161,6 +161,7 @@ Saldo fiat Nama Popularitas + Perhatian Tersedia: %s Jaringan yang tersedia Dompet cadangan @@ -270,8 +271,9 @@ Rute Simpan Cari - Tidak ada jaringan atau aset yang ditemukan :( + Tidak ada aset yang ditemukan :( Sumimasen! + Tidak ada jaringan atau aset yang ditemukan :( Tidak ada hasil pencarian. \n Pastikan Anda mengetikkan alamat akun lengkap Hasil pencarian: %d Hasil pencarian akan muncul di sini @@ -726,6 +728,7 @@ Cari berdasarkan jaringan Alamat akun atau nama akun Tidak ada hasil pencarian + Alamat publik Alamatnya tidak cocok dengan jaringan tujuan. Anda harus memilih jaringan lain Memiliki obligasi minimum yang relevan PENAFIAN: Saran kolator algoritmik bukan merupakan konsultasi atau nasihat keuangan. Staking adalah aktivitas berisiko tinggi, dan saran kolator algoritmik tidak serta merta memitigasi risiko ini. Seorang pengumpul yang disarankan oleh algoritme masih dapat meninggalkan kumpulan kandidat. Pengumpul yang disarankan oleh algoritme juga dapat mengubah parameternya (misalnya, tarif komisi, dll.) kapan saja setelah disarankan dan/atau dipilih. Anda bisa kehilangan hadiah karena alasan ini atau lainnya. Hanya pertaruhkan token dan gunakan saran kolektor sesuai kebijaksanaan Anda sendiri, setelah melakukan uji tuntas dan dengan cermat mempertimbangkan risiko yang ada. diff --git a/common/src/main/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml index 4081b7217f..bd64dc6220 100644 --- a/common/src/main/res/values-ja/strings.xml +++ b/common/src/main/res/values-ja/strings.xml @@ -156,6 +156,7 @@ 現金残高 名前 人気 + 注意 利用可能: %s 利用可能なネットワーク ウォレットのバックアップ @@ -261,8 +262,9 @@ ルート 保存 検索 - ネットワークまたはアセットが見つかりませんでした + アセットが見つかりませんでした 失礼しました + ネットワークまたはアセットが見つかりませんでした 検索結果はありません。 \n完全なアカウント・アドレスを入力したことを確認してください 検索結果: %d 検索結果はここに表示されます @@ -469,6 +471,24 @@ リンク 試験ネットワーク 言語 + 流動性提供のためのファーミング報酬 + 戦略的ボーナス年利 + 利用可能なプール + 流動性を確認 + 出力は推定です。価格が0.5%以上変動した場合、取引は元に戻されます。 + ネットワーク手数料はSORAシステムの成長と安定したパフォーマンスを確保するために使用されます。 + プールの詳細 + プールトークンを削除すると、あなたのポジションが現在のレートで元のトークンに戻されます。プール内のシェアに比例します。受け取る金額には累積された手数料が含まれます。 + + 流動性を削除 + 流動性を削除 + %s を獲得 + 報酬の支払い先 + スリッページ + 流動性を供給 + 流動性を供給 + ユーザープール + %s プール済み 送信された支払いトランザクション アカウントを追加... トークンまたはネットワークで検索 @@ -711,6 +731,7 @@ ネットワークで検索 アカウントのアドレスまたはアカウント名 検索結果がありません + パブリック・アドレス アドレスが宛先ネットワークと一致しません。別のネットワークを選択してください 関連する最低保証金があること 免責事項:アルゴリズム・コレーターの提案は、金融に関する相談や助言に該当するものではありません。ステーキングはハイリスクな活動であり、アルゴリズムによるコレーターの提案は、必ずしもこのリスクを軽減するものではありません。アルゴリズムによって提案されたコレーターは、候補から外れる可能性があります。アルゴリズムによって提案されたコレーターは、提案および/または選択された後、いつでもパラメーター(例:手数料率など)を変更することができます。これらの理由や他の理由で報酬を失う可能性があります。関連するリスクを注意して慎重に検討した上で、ご自身の判断でトークンをステークし、コレーターの提案を使用するようにしてください diff --git a/common/src/main/res/values-pt/strings.xml b/common/src/main/res/values-pt/strings.xml index 9c2cdb7660..368110ef6c 100644 --- a/common/src/main/res/values-pt/strings.xml +++ b/common/src/main/res/values-pt/strings.xml @@ -156,6 +156,7 @@ Saldo Fiat Nome Popularidade + Atenção Disponível: %s Faça backup da sua carteira Saldo @@ -261,8 +262,9 @@ Rota Salvar Pesquisa - Nenhuma rede ou ativo foi encontrado :( + Nenhum ativo foi encontrado :( Sumimasen! + Nenhuma rede ou ativo foi encontrado :( Pesquisa sem resultados. \n Certifique-se que digita o endereço completo da conta Resultados da pesquisa: %d Os resultados da pesquisa aparecerão aqui @@ -709,6 +711,7 @@ Pesquisar por rede Endereço da conta ou nome da conta Nenhum resultado da pesquisa + Endereço público O endereço não corresponde à rede de destino. Você deve selecionar outra rede Ter vínculo mínimo relevante AVISO: Os detentores de tokens MOVR/GLMR devem realizar a cuidadosa devida diligência acerca dos validadores antes de delegar. Ser listado como um validador não representa um endosso ou recomendação da Moonbeam Network, da Moonriver Network ou da Moonbeam Foundation. A Moonbeam Network, a Moonriver Network e a Moonbeam Foundation não examinaram os validadores da lista e não assumem nenhuma responsabilidade com relação à seleção, desempenho, segurança, precisão ou uso de quaisquer ofertas de terceiros. Você é o único responsável por fazer a sua própria diligência para entender as taxas aplicáveis e todos os riscos presentes, incluindo o monitoramento ativo da atividade dos seus validadores. diff --git a/common/src/main/res/values-ru/strings.xml b/common/src/main/res/values-ru/strings.xml index edc09f5d79..c4ada2b60d 100644 --- a/common/src/main/res/values-ru/strings.xml +++ b/common/src/main/res/values-ru/strings.xml @@ -193,6 +193,12 @@ %d часов %d час + + %d месяц + %d месяца + %d месяцев + %d месяц + Импорт кошелька Важно Информация @@ -233,8 +239,9 @@ Направление Сохранить Поиск - Не найдено ни одной сети или ассета :( + Ассеты не найдены :( Извините! + Не найдено ни одной сети или ассета :( Поиск не дал результатов.\nПроверьте, что вы указали полный адрес аккаунта Результаты поиска: %d Здесь появятся результаты поиска @@ -401,6 +408,24 @@ Ссылка Testnet Язык + Вознаграждение за предоставление ликвидности + Стратегический бонус APY + Доступные пулы + Подтвердите ликвидность + Оценка результата. Если цена изменится более чем на 0.5%, ваша транзакция будет отменена + Комиссия сети используется для обеспечения роста и стабильной работы системы SORA. + Детали пула ликвидности + Удаление токенов из пула конвертирует вашу позицию обратно в исходные токены по текущему курсу, пропорционально вашей доле в пуле. Начисленные комиссии включены в получаемые вами суммы. + ПРИМЕЧАНИЕ + Убрать ликвидность + Убрать ликвидность + Заработать %s + Выплата вознаграждений в + Проскальзывание + Добавить ликвидность + Добавить ликвидность + Пулы пользователя + Ваши %s добавлены в пул Транзакция на выплату отправлена Добавить аккаунт... Поиск по токену @@ -605,6 +630,7 @@ Поиск по сети Адрес аккаунта или имя аккаунта Нет результатов + Публичный адрес Релевантный минимальный стейк Импорт из Google JSON @@ -926,6 +952,7 @@ Валидаторы не найдены Посмотреть в %s Посмотреть кошелек + Все активы скрыты Купить %s с Получить Получить %s diff --git a/common/src/main/res/values-tr/strings.xml b/common/src/main/res/values-tr/strings.xml index 50de2725a1..2a59e39da2 100644 --- a/common/src/main/res/values-tr/strings.xml +++ b/common/src/main/res/values-tr/strings.xml @@ -92,13 +92,13 @@ Ham Tohumu Göster Bu cihaza erişimi kaybederseniz, yedekleme yapmadığınız takdirde paranız kaybolacaktır! Yönetim - Likidite Havuzları Adaylık havuzları Kilitli ayrıntılar Fonlarınıza erişiminizi kaybetmekten kendinizi koruyun XOR\'u satın al XOR tokenını Euro nakit ile satın alın veya satın XOR jetonu satın al + Likidite Havuzları Uyarı: Bağlan\'a tıklayarak bu dapp\'in genel adresinizi görüntülemesine izin vermiş olursunuz. Bu, verilerinizi potansiyel kimlik avı risklerinden korumak için önemli bir güvenlik adımıdır Bağlı%s Bağlı @@ -160,6 +160,7 @@ Fiat dengesi İsim Popülerlik + Dikkat Mevcut: %s Mevcut ağlar Cüzdan yedekleme @@ -270,8 +271,9 @@ Rota Kaydetmek Arama - Hiçbir ağ veya varlık bulunamadı :( + Hiçbir Öğe bulunamadı:( Sumimasen! + Hiçbir ağ veya varlık bulunamadı :( Arama sonucu bulunamadı. \n Tam hesap adresini yazdığınızdan emin olun Arama sonuçları: %d Arama sonuçları burada görünecek. @@ -479,6 +481,24 @@ Bağlantı Testnet Dil + Likidite sağlanmasına yönelik tarım ödülü + Stratejik Bonus APY + Mevcut havuzlar + Likiditeyi Doğrula + Çıktı tahminidir. Fiyatın %0,5\'ten fazla değişmesi durumunda işleminiz geri alınacaktır. + Ağ ücreti, SORA sisteminin büyümesini ve istikrarlı performansını sağlamak için kullanılır. + Havuz detayları + Havuz tokenlarını kaldırmak, pozisyonunuzu havuzdaki payınızla orantılı olarak mevcut oranda temel tokenlara dönüştürür. Tahakkuk eden ücretler, aldığınız tutarlara dahildir. + NOT + Likiditeyi Kaldır + Likiditeyi Kaldır + %s Kazanın + Ödül Ödemesi + Kayma + Arz Likiditesinin + Arz Likiditesinin + Kullanıcı havuzları + %s Havuzunuz Ödeme işlemi gönderildi. Bir hesap ekle Tokene veya ağa göre ara @@ -727,6 +747,7 @@ Ağa göre ara Hesap adresi veya hesap adı Arama sonucu bulunamadı + Açık adresi Adres hedef ağla eşleşmiyor. Başka bir ağ seçmelisiniz İlgili minimum teminata sahip olmak UYARI: MOVR/GLMR token sahipleri, delege etmeden önce harmanlayıcıları üzerinde dikkatli bir araştırma yapmalıdır. Bir harmanlayıcı olarak listelenmek, Moonbeam Network, Moonriver Network veya Moonbeam Foundation\'ın bir onayı veya tavsiyesi değildir. Ne Moonbeam Network, ne Moonriver Network, ne de Moonbeam Foundation, listedeki harmanlayıcıları incelememiştir ve herhangi bir üçüncü taraf tekliflerinin seçimi, performansı, güvenliği, doğruluğu veya kullanımı ile ilgili hiçbir sorumluluk kabul etmez. Harmanlayıcılarınızın faaliyetlerini aktif bir şekilde takip etmek de dahil olmak üzere, uygulanabilecek ücretleri ve mevcut riskleri anlamak için gereken tüm özeni göstermekten yalnızca siz sorumlusunuz diff --git a/common/src/main/res/values-vi/strings.xml b/common/src/main/res/values-vi/strings.xml index 85397407cd..5d9575c032 100644 --- a/common/src/main/res/values-vi/strings.xml +++ b/common/src/main/res/values-vi/strings.xml @@ -47,6 +47,19 @@ Không tìm thấy tài khoản Tùy chọn tài khoản Tài khoản với khóa bí mật chia sẻ + Trung bình thời gian giao dịch + Điểm Multichain của bạn dựa trên hành trình onchain của bạn thông qua 3 hệ sinh thái: Ethereum, Polygon và Binance Smart Chain + Không thể truy xuất thông tin điểm tài khoản. Vui lòng thử lại sau. + Token USD nắm giữ + Thời gian giao dịch tối đa + Thời gian giao dịch tối thiểu + Số dư gốc USD + Giao dịch bị từ chối + Số điểm của bạn + Tổng số giao dịch + Đã cập nhật + Tuổi ví + Hiển thị điểm ví tài khoản %s Thêm tài khoản Tài khoản để xuất @@ -93,13 +106,13 @@ Nếu bạn mất quyền truy cập vào thiết bị này, tiền của bạn sẽ bị mất, trừ khi bạn sao lưu! Bị chặn Quản trị - Pool thanh khoản Nhóm Nomination Chi tiết đã khóa Nếu bạn làm mất thiết bị của mình, bạn\n sẽ mất tiền của bạn mãi mãi Mua XOR Mua hoặc bán tokr XOR với\ntiền Euro Mua token XOR + Pool thanh khoản Cảnh báo: Bằng cách nhấp vào kết nối, bạn cho phép dapp này xem địa chỉ công khai của bạn. Đây là một bước bảo mật quan trọng để bảo vệ dữ liệu của bạn khỏi các nguy cơ lừa đảo tiềm ẩn. Đã kết nối với %s Đã kết nối với @@ -162,6 +175,7 @@ Số dư tiền pháp định Tên Phổ biến + Chú ý Có sẵn: %s Mạng có sẵn Sao lưu ví của bạn @@ -241,6 +255,7 @@ Tin nhắn Tối thiểu nhận được Module + Thêm Mạng của tôi Mạng Phí mạng @@ -274,8 +289,9 @@ Route Lưu Tìm kiếm - Không có mạng hoặc tài sản nào được tìm thấy :( + Không có tài sản nào được tìm thấy :( Sumimasen! + Không có mạng hoặc tài sản nào được tìm thấy :( Không tìm thấy kết quả.\n Hãy chắc chắn bạn nhập địa chỉ tài khoản đầy đủ Kết quả tìm kiếm: %d Kết quả tìm kiếm sẽ xuất hiện tại đây @@ -286,6 +302,7 @@ Chọn mạng Đã chọn Chia sẻ + Hiển thị chi tiết Bỏ qua Bỏ qua quá trình @@ -302,6 +319,7 @@ Tổng cộng Giao dịch Dữ liệu thô giao dịch + Giao dịch đã gửi Giao dịch đã được gửi Chuyển nhượng: %s Thử lại @@ -485,6 +503,25 @@ Liên kết Testnet Ngôn ngữ + Phần thưởng canh tác để cung cấp thanh khoản + APY tiền thưởng chiến lược + Pool có sẵn + Đầu tư tiền của bạn vào Pool\nthanh khoản và nhận phần thưởng + Xác nhận thanh khoản + Kết quả được ước tính. Nếu giá thay đổi hơn 0,5% thì giao dịch của bạn sẽ hoàn trả. + Phí mạng được sử dụng để đảm bảo sự tăng trưởng và hiệu suất ổn định của hệ thống SORA. + Chi tiết Pool + Việc xóa pool token sẽ chuyển đổi vị trí của bạn trở lại thành token cơ bản ở mức giá hiện tại, tỷ lệ thuận với phần chia sẻ của bạn trong pool. Phí tích lũy được bao gồm trong số tiền bạn nhận được. + GHI CHÚ + Rút Thanh Khoản + Rút Thanh Khoản + Kiếm được %s + Thanh toán phần thưởng + Trượt giá + Nguồnn cung thanh khoản + Nguồnn cung thanh khoản + Pool người dùng + %s của bạn đã đóng góp Giao dịch thanh toán đã được gửi Thêm một tài khoản... Tìm kiếm theo tài sản @@ -529,6 +566,7 @@ Bộ sưu tập Người sáng tạo Chưa có bất kỳ NFT nào. Mua hoặc đúc NFT để xem chúng tại đây. + Không thể tải NFT Sở hữu NFTs sẽ sớm ra mắt Sumimasen! @@ -679,6 +717,7 @@ Tổng số bạn stake Đã đạt đến giới hạn pool trong mạng này Bạn không thể tạo thêm pool + Điểm nomis đa chuỗi Tài khoản Dapps và hơn thế nữa... Tính năng thử nghiệm @@ -719,11 +758,15 @@ Cấu hình lỗi thời Bạn đang cố gắng thực hiện chuyển khoản vào cùng một tài khoản. Hoạt động sẽ tính phí và không có ý nghĩa gì Địa chỉ này đã bị gắn cờ do một thực thể có liên quan đến một quốc gia đang bị trừng phạt. - Địa chỉ này đã bị gắn cờ do một thực thể có liên quan đến một quốc gia đang bị trừng phạt. Chúng tôi thực sự khuyên bạn không nên gửi %s tới tài khoản này. + Địa chỉ này đã bị gắn cờ do có liên quan đến một quốc gia đang bị trừng phạt. Chúng tôi thực sự khuyên bạn không nên gửi %s vào tài khoản này. Bổ sung: Chúng tôi thực sự khuyên bạn không nên gửi %s vào tài khoản này. Địa chỉ này đã bị gắn cờ do có bằng chứng lừa đảo. - Địa chỉ này đã bị gắn cờ do có bằng chứng lừa đảo. Chúng tôi thực sự khuyên bạn không nên gửi %s tới tài khoản này. + Địa chỉ bạn sắp chuyển đến có hoạt động onchain thấp, điều này có thể chỉ ra kẻ lừa đảo hoặc kẻ tấn công sybil tiềm ẩn + Điểm nomis đa chuỗi + Mạng ít hoạt động + Tiến hành thận trọng + Địa chỉ này đã bị gắn cờ do có bằng chứng lừa đảo. Chúng tôi thực sự khuyên bạn không nên gửi {asset} tới tài khoản này. Quét mã từ người nhận Quét mã QR Tải lên từ thư viện @@ -732,6 +775,7 @@ Tìm kiếm theo mạng Địa chỉ tài khoản hoặc tên tài khoản Không có kết quả tìm kiếm + Địa chỉ public Địa chỉ không khớp với mạng đích. Bạn nên chọn mạng khác Có trái phiếu tối thiểu có liên quan TUYÊN BỐ TỪ CHỐI: Các đề xuất của trình xác minh thuật toán không cấu thành tư vấn hoặc lời khuyên tài chính. Staking là một hoạt động có rủi ro cao và các đề xuất của trình xác thực thuật toán không nhất thiết giảm thiểu rủi ro này. Một сollator do thuật toán đề xuất vẫn có thể rời khỏi nhóm ứng cử viên. Một сollator do thuật toán đề xuất cũng có thể thay đổi các thông số của chúng (ví dụ: tỷ lệ hoa hồng, v.v.) bất kỳ lúc nào sau khi được đề xuất và/hoặc chọn. Bạn có thể mất phần thưởng vì những lý do này hoặc lý do khác. Chỉ đặt cược mã thông báo và sử dụng các đề xuất сollator theo quyết định của riêng bạn, sau khi tiến hành thẩm định và xem xét cẩn thận các rủi ro liên quan. @@ -1035,6 +1079,7 @@ Substrate keypair crypto type Substrate secret derivation path\n Bạn có thể quay lại trình duyệt của mình ngay bây giờ + Giao dịch của bạn đã được gửi thành công tới blockchain Hỗ trợ & Phản hồi Chuyển đổi node Tự động chọn node @@ -1116,7 +1161,7 @@ Số dư tối thiểu Xác nhận chuyển khoản Giao dịch chuyển tiền của bạn sẽ không thành công vì số tiền cuối cùng trên tài khoản đích sẽ ít hơn số dư tối thiểu. Hãy cố gắng tăng số lượng. - Chuyển khoản của bạn sẽ không thành công vì số tiền cuối cùng (%s) trên tài khoản đích sẽ nhỏ hơn số dư tối thiểu (%s). Vui lòng tăng thêm %s + Chuyển khoản của bạn sẽ không thành công vì số tiền cuối cùng (%1$s) trên tài khoản đích sẽ nhỏ hơn số dư tối thiểu (%2$s). Vui lòng tăng thêm %3$s Số dư Ethereum không đủ trong tài khoản của người nhận sẽ ngăn cản việc hoàn tất chuyển mã thông báo ERC20. Vui lòng đảm bảo người nhận có đủ Ethereum để tiến hành chuyển khoản. Chuyển khoản của bạn sẽ xóa tài khoản khỏi blockstore vì nó sẽ làm cho tổng số dư còn lại thấp hơn số dư tối thiểu. Chuyển khoản sẽ xóa tài khoản diff --git a/common/src/main/res/values-zh/strings.xml b/common/src/main/res/values-zh/strings.xml index 113c1f43ea..7571540ea3 100644 --- a/common/src/main/res/values-zh/strings.xml +++ b/common/src/main/res/values-zh/strings.xml @@ -91,14 +91,15 @@ 显示助记词短语 展示原始种子 如果你失去了对这个设备的访问权限,除非你进行备份,否则你的资金将会丢失! + 受阻 治理 - 流动性池 提名池 锁定的详细信息 保护自己避免失去对资金的访问权限。 购买XOR 使用欧元现金购买或出售XOR代币 购买XOR代币 + 流动性池 警告:点击连接按钮,您将允许此dapp查看您的公共地址。这是一个重要的安全步骤,以保护您的数据免受潜在的网络钓鱼风险。 连接到%s 已连接至 @@ -161,6 +162,7 @@ 法币余额 名称 人气 + 注意 可用:%s 可用网络 钱包备份 @@ -244,6 +246,9 @@ 网络 网络费用 网络管理 + + %d 网络 + 下一个 请勿截屏,这可能会被第三方恶意软件收集。 @@ -270,8 +275,9 @@ 路径 保存 搜索 - 未找到任何网络或资产 :( + 未找到任何资产 :( 对不起! + 未找到任何网络或资产 :( 没有搜索结果。\n请确保您输入了完整的账户地址 搜索结果:%d 搜索结果将会在这里显示 @@ -287,6 +293,7 @@ 跳过流程 排序按照 质押 + 开始 截止到 %s 剩余时间 跨链 @@ -297,6 +304,7 @@ 总共 交易 交易原始数据 + 交易已发送 交易已提交 可转让:%s 请再试一次 @@ -480,6 +488,24 @@ 链接 测试网 语言 + 提供流动性的农场奖励 + 战略奖励APY + 可用的流动池 + 确认流动性 + 输出是估计的。如果价格变动超过0.5%,您的交易将会回滚。 + 网络费用用于确保 SORA 系统的增长和稳定性能。 + 流动池详情 + 移除流动性池代币会按照您在池中所占份额的比例,将您的头寸转换回当前汇率下的基础代币。已累积的费用将包含在您收到的金额中。 + 注意 + 移除流动性 + 移除流动性 + 赚取%s + 奖励支付以 + 滑点 + 供应流动性 + 供应流动性 + 用户流动池 + 你提供流动性的%s 支付交易已发送 添加一个账户... 按资产搜索 @@ -506,6 +532,7 @@ 添加账户 切换节点 添加一个账户 + 连接错误:无法连接到网络。请重试。 网络不可用 节点不可用 网络问题 @@ -713,11 +740,11 @@ 过时的配置 您正在尝试向同一账户进行转账。此操作将收取费用且没有任何意义。 此地址因与受制裁国家相关的实体有关而被标记。 - 由于与受制裁国家相关的实体,此地址已被标记。我们强烈建议您不要向此账户发送%s。 + 由于与受制裁国家相关的实体,此地址已被标记。我们强烈建议您不要向此账户发送%s。 附加: 我们强烈建议您不要向此账户发送%s。 此地址因涉嫌欺诈行为已被标记。 - 这个地址因涉嫌欺诈行为而被标记。我们强烈建议您不要向该账户转账%s。 + 此地址已被标记存在诈骗嫌疑。我们强烈建议您不要将{asset}发送到此账户。 从接收方扫描代码 二维码 从相册上传 @@ -726,6 +753,7 @@ 按网络搜索 账户地址或账户名称 无搜索结果 + 公共地址 地址与目标网络不匹配。您应该选择另一个网络。 具备相关的最低绑定额 免责声明:算法收集人建议并不构成金融咨询或建议。质押是一项高风险活动,算法收集人建议并不能完全减轻这种风险。由算法提供的收集人仍有可能离开候选池。由算法提供的收集人也可以在被推荐和/或选择后随时更改其参数(例如佣金率等)。您可能因此或其他原因而失去奖励。请在进行尽职调查和慎重考虑风险后,根据自己的判断谨慎使用质押代币和使用收集人建议。 @@ -747,7 +775,7 @@ 社交媒体 您尝试转账的金额不足以支付%s网络上的交易费用。尽管交易无法完成,您仍将在Sora网络上被收取费用。 目前,为了确保SORA网络的稳定性和安全性,跨链桥转账的最低金额为%s。感谢您的理解。 - 0欧元年度服务费 + 0欧元年服务费 申请被排除 某些国家的居民
无法申请SORA卡片。
查看名单]]>
启用卡片 @@ -1028,6 +1056,7 @@ Substrate密钥对加密类型 Substrate密钥派生路径 你现在可以返回到你的浏览器了 + 您的交易已成功发送至区块链。 支持与反馈 切换节点 自动选择节点 @@ -1066,6 +1095,8 @@ 此名称仅会显示给您,并且仅在您的移动设备上本地存储。 创建一个新的钱包 佣金 + 活跃提名人中的最低质押金额为%s。要获得奖励,您需要质押更多。 + 活跃提名人的最低质押 此账户尚未被网络选中参与当前时间段 未找到验证人 由于每个平行链的独特归属计划,我们的应用无法显示可认领的锁定代币数量。请注意,如果预计金额微不足道且与涉及的交易费用相当,发起认领可能是不切实际的。要全面了解您的锁定余额,请咨询Subscan区块浏览器。我们敦促您仔细评估信息,并根据自己的判断继续操作。 @@ -1107,6 +1138,7 @@ 最低余额 确认转账 您的转账将失败,因为目标账户上的最终金额将低于最低余额。请尝试增加金额。 + 您的转账将失败,因为目标账户的最终金额(%1$s)将低于最低余额(%2$s)。请增加%3$s的金额。 接收方账户中的以太币余额不足,导致无法完成ERC20代币转账。请确保接收方有足够的以太币来进行转账。 您的转账将从区块存储中移除该账户,因为这将使总余额低于最低余额要求。 转账将删除账户 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 7bde9d8712..9fbd9b77d7 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -47,6 +47,19 @@ No account found Account option Accounts with a shared secret + Avg. transaction time + Your Multichain Score is based on your onchain journey through 3 ecosystems: Ethereum, Polygon and Binance Smart Chain + Unable to retrieve account score information. Please try again later. + Hold tokens USD + Max transaction time + Min transaction time + Native balance USD + Rejected transactions + Your score + Total transactions + Updated + Wallet age + Show wallet score %s account Add an account Accounts to export @@ -93,13 +106,15 @@ If you loose access to this device, your funds will be lost, unless you back up! Blocked Governance - Liquidity Pools Nomination Pools Locked details Protect yourself from losing access to your funds + Stake and farm to earn yield\non Demeter Protocol + Demeter Protocol Buy XOR Buy or sell XOR token with\nEuro cash Buy XOR token + Liquidity Pools Warning: By clicking connect, you allow this dapp to view your public address. This is an important security step to protect your data from potential phishing risks. Connected to %s Connected to @@ -233,6 +248,10 @@ %d hour %d hours + + %d month + %d months + Import wallet Important Info @@ -244,6 +263,7 @@ Message Min received Module + More My networks Network Network fee @@ -278,8 +298,9 @@ Route Save Search - No networks or assets were found :( + No assets were found :( Sumimasen! + No networks or assets were found :( No search results.\nMake sure you type the full account address Search results: %d Search results will appear here @@ -290,6 +311,8 @@ Select Network Selected Share + Show details + Show more Sign Skip Skip process @@ -490,6 +513,26 @@ Link Testnet Language + Farming reward for liquidity provision + Strategic Bonus APY + Available pools + Invest your funds in Liquidity\npools and receive rewards + Confirm Liquidity + Output is estimated. If the price changes more than 0.5% your transaction will revert. + Your supply to Liquidity pools has been successfully completed + Network fee is used to ensure SORA system’s growth and stable performance. + Pool details + Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive. + NOTE + Remove Liquidity + Remove Liquidity + Earn %s + Rewards Payout In + Slippage + Supply Liquidity + Supply Liquidity + User pools + Your %s Pooled Payout transaction sent Add an account... Search by asset @@ -686,6 +729,7 @@ Your total stake The limit of pools in this network has been reached You cannot create more pools + Nomis multichain score Accounts Dapps and more... Experimental features @@ -731,7 +775,11 @@ Additional: We strongly recommend that you don\'t send %s to this account. This address has been flagged due to evidence of a scam. - This address has been flagged due to evidence of a scam. We strongly recommend that you don\'t send %s to this account. + The address you are about to transfer to has low onchain activity, which may indicate a potential scammer or sybil attacker + Nomis multi-chain score + Low network activity + Proceed with caution + This address has been flagged due to evidence of a scam. We strongly recommend that you don\'t send {asset} to this account. Scan code from receiver QR Code Upload from gallery @@ -740,6 +788,7 @@ Search by network Account address or account name No search results + Public address The address doesn\'t match the destination network. You should select another network Having relevant minimum bond DISCLAIMER: Algorithmic сollator suggestions do not constitute financial consultation or advice. Staking is a high-risk activity, and algorithmic сollator suggestions do not necessarily mitigate this risk. A сollator suggested by the algorithm could still leave the pool of candidates. A сollator suggested by the algorithm could also change their parameters (e.g.,commission rates, etc.) at any time after having been suggested and/or selected. You could lose rewards for these or other reasons. Only stake tokens and use сollator suggestions at your own discretion, after conducting due diligence and carefully considering the risks involved. @@ -1044,7 +1093,7 @@ Substrate keypair crypto type Substrate secret derivation path\n You can return to your browser now - Your transaction has been successfully sent to blockchain + Your transaction has been successfully sent to the blockchain Support & Feedback Switch node Auto select nodes @@ -1126,7 +1175,7 @@ Minimal balance Confirm transfer Your transfer will fail since the final amount on the destination account will be less than the minimal balance. Please try to increase the amount. - Your transfer will fail since the final amount (%s) on the destination account will be less than the minimal balance (%s). Please increase the amount by %s + Your transfer will fail since the final amount (%1$s) on the destination account will be less than the minimal balance (%2$s). Please increase the amount by %3$s Insufficient Ethereum balance in the recipient\'s account prevents the completion of ERC20 token transfer. Please ensure the receiver has enough Ethereum to proceed with the transfer. Your transfer will remove the account from blockstore since it will make the total balance lower than the minimal balance. The transfer will remove the account diff --git a/core-api/build.gradle b/core-api/build.gradle index 03af2589c2..629d01e93e 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -27,5 +27,5 @@ android { dependencies { implementation libs.coroutines.core - implementation libs.sharedFeaturesCoreDep + implementation libs.sharedFeaturesCoreDep, withoutAndroidFoundation } \ No newline at end of file diff --git a/core-db/build.gradle b/core-db/build.gradle index 985c15b7be..a17df28244 100644 --- a/core-db/build.gradle +++ b/core-db/build.gradle @@ -59,5 +59,7 @@ dependencies { androidTestImplementation libs.ext.junit androidTestImplementation libs.room.testing - androidTestImplementation project(':test-shared') + androidTestImplementation projects.testShared + testImplementation libs.junit + testImplementation projects.testShared } \ No newline at end of file diff --git a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt index b93321504d..468b583d3f 100644 --- a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt +++ b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt @@ -46,7 +46,8 @@ fun chainOf( rank = null, isChainlinkProvider = false, supportNft = false, - isUsesAppId = false + isUsesAppId = false, + identityChain = null ) fun ChainLocal.nodeOf( diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt index 56c02ed207..dc356981f1 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt @@ -16,8 +16,10 @@ import jp.co.soramitsu.coredb.dao.AddressBookDao import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.ChainDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.dao.OperationDao import jp.co.soramitsu.coredb.dao.PhishingDao +import jp.co.soramitsu.coredb.dao.PoolDao import jp.co.soramitsu.coredb.dao.SoraCardDao import jp.co.soramitsu.coredb.dao.StakingTotalRewardDao import jp.co.soramitsu.coredb.dao.StorageDao @@ -70,6 +72,8 @@ import jp.co.soramitsu.coredb.migrations.Migration_63_64 import jp.co.soramitsu.coredb.migrations.Migration_64_65 import jp.co.soramitsu.coredb.migrations.Migration_65_66 import jp.co.soramitsu.coredb.migrations.Migration_66_67 +import jp.co.soramitsu.coredb.migrations.Migration_67_68 +import jp.co.soramitsu.coredb.migrations.Migration_68_69 import jp.co.soramitsu.coredb.migrations.RemoveAccountForeignKeyFromAsset_17_18 import jp.co.soramitsu.coredb.migrations.RemoveLegacyData_35_36 import jp.co.soramitsu.coredb.migrations.RemoveStakingRewardsTable_22_23 @@ -78,12 +82,15 @@ import jp.co.soramitsu.coredb.model.AccountLocal import jp.co.soramitsu.coredb.model.AccountStakingLocal import jp.co.soramitsu.coredb.model.AddressBookContact import jp.co.soramitsu.coredb.model.AssetLocal +import jp.co.soramitsu.coredb.model.BasicPoolLocal +import jp.co.soramitsu.coredb.model.NomisWalletScoreLocal import jp.co.soramitsu.coredb.model.OperationLocal import jp.co.soramitsu.coredb.model.PhishingLocal import jp.co.soramitsu.coredb.model.SoraCardInfoLocal import jp.co.soramitsu.coredb.model.StorageEntryLocal import jp.co.soramitsu.coredb.model.TokenPriceLocal import jp.co.soramitsu.coredb.model.TotalRewardLocal +import jp.co.soramitsu.coredb.model.UserPoolLocal import jp.co.soramitsu.coredb.model.chain.ChainAccountLocal import jp.co.soramitsu.coredb.model.chain.ChainAssetLocal import jp.co.soramitsu.coredb.model.chain.ChainExplorerLocal @@ -95,7 +102,7 @@ import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal import jp.co.soramitsu.coredb.model.chain.MetaAccountLocal @Database( - version = 67, + version = 69, entities = [ AccountLocal::class, AddressBookContact::class, @@ -116,7 +123,10 @@ import jp.co.soramitsu.coredb.model.chain.MetaAccountLocal ChainAccountLocal::class, ChainExplorerLocal::class, SoraCardInfoLocal::class, - ChainTypesLocal::class + ChainTypesLocal::class, + NomisWalletScoreLocal::class, + BasicPoolLocal::class, + UserPoolLocal::class ] ) @TypeConverters( @@ -183,6 +193,8 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(Migration_64_65) .addMigrations(Migration_65_66) .addMigrations(Migration_66_67) + .addMigrations(Migration_67_68) + .addMigrations(Migration_68_69) .build() } return instance!! @@ -212,4 +224,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun addressBookDao(): AddressBookDao abstract fun soraCardDao(): SoraCardDao + + abstract fun nomisScoresDao(): NomisScoresDao + + abstract fun poolDao(): PoolDao } diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/NomisScoresDao.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/NomisScoresDao.kt new file mode 100644 index 0000000000..fec70ae17c --- /dev/null +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/NomisScoresDao.kt @@ -0,0 +1,26 @@ +package jp.co.soramitsu.coredb.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import jp.co.soramitsu.coredb.model.NomisWalletScoreLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface NomisScoresDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(scores: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(scores: NomisWalletScoreLocal) + + @Query("SELECT * FROM nomis_wallet_score") + suspend fun getScores(): List + + @Query("SELECT * FROM nomis_wallet_score") + fun observeScores(): Flow> + + @Query("SELECT * FROM nomis_wallet_score WHERE metaId = :metaId") + fun observeScore(metaId: Long): Flow +} \ No newline at end of file diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/OperationDao.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/OperationDao.kt index dd4d7b644a..f2ce6df6d8 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/OperationDao.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/OperationDao.kt @@ -59,6 +59,22 @@ abstract class OperationDao { ) abstract fun observeOperationAddresses(chainId: String, address: String, limit: Int): Flow> + @Query(""" + SELECT DISTINCT(CASE + WHEN address != sender THEN sender + WHEN address != receiver THEN receiver + ELSE NULL + END) AS result + FROM operations + WHERE chainId = :chainId + AND address = :address + AND result IS NOT NULL + ORDER BY time DESC + LIMIT :limit + """ + ) + abstract fun getOperationAddresses(chainId: String, address: String, limit: Int): List + @Transaction open suspend fun insertFromSubquery( accountAddress: String, diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/PoolDao.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/PoolDao.kt new file mode 100644 index 0000000000..fe803062a3 --- /dev/null +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/PoolDao.kt @@ -0,0 +1,85 @@ +package jp.co.soramitsu.coredb.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import jp.co.soramitsu.coredb.model.BasicPoolLocal +import jp.co.soramitsu.coredb.model.UserPoolJoinedLocal +import jp.co.soramitsu.coredb.model.UserPoolJoinedLocalNullable +import jp.co.soramitsu.coredb.model.UserPoolLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface PoolDao { + + companion object { + private const val userPoolJoinBasic = """ + SELECT * FROM userpools + left join allpools on userpools.userTokenIdBase = allpools.tokenIdBase + and userpools.userTokenIdTarget = allpools.tokenIdTarget + """ + } + + @Query( + """ + select * from allpools where tokenIdBase=:base and tokenIdTarget=:target + """ + ) + suspend fun getBasicPool(base: String, target: String): BasicPoolLocal? + + @Query("select * from allpools") + suspend fun getBasicPools(): List + + @Query("DELETE FROM userpools where accountAddress = :curAccount") + suspend fun clearTable(curAccount: String) + + @Query("DELETE FROM allpools") + suspend fun clearBasicTable() + + @Query( + """ + $userPoolJoinBasic where userpools.accountAddress = :accountAddress + """ + ) + fun subscribePoolsList(accountAddress: String): Flow> + + @Query( + """ + select * from allpools a + left join userpools u on a.tokenIdBase = u.userTokenIdBase + and a.tokenIdTarget = u.userTokenIdTarget + and u.accountAddress is not null + and u.accountAddress = :accountAddress + """ + ) + fun subscribeAllPools(accountAddress: String?): Flow> + + @Query( + """ + $userPoolJoinBasic where userpools.accountAddress = :accountAddress + """ + ) + suspend fun getPoolsList(accountAddress: String): List + + @Query( + """ + select * from allpools a + left join userpools u on a.tokenIdBase = u.userTokenIdBase + and a.tokenIdTarget = u.userTokenIdTarget + and u.accountAddress is not null + and u.accountAddress = :accountAddress + where a.tokenIdBase = :baseTokenId and a.tokenIdTarget = :targetTokenId + """ + ) + fun subscribePool(accountAddress: String, baseTokenId: String, targetTokenId: String): Flow + + @Delete + suspend fun deleteBasicPools(p: List) + + @Upsert + suspend fun insertBasicPools(pools: List) + + @Upsert + suspend fun insertUserPools(pools: List) +} diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/di/DbModule.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/di/DbModule.kt index 279430ba57..1545d12126 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/di/DbModule.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/di/DbModule.kt @@ -14,6 +14,7 @@ import jp.co.soramitsu.coredb.dao.AddressBookDao import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.ChainDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.dao.OperationDao import jp.co.soramitsu.coredb.dao.PhishingDao import jp.co.soramitsu.coredb.dao.SoraCardDao @@ -21,6 +22,7 @@ import jp.co.soramitsu.coredb.dao.StakingTotalRewardDao import jp.co.soramitsu.coredb.dao.StorageDao import jp.co.soramitsu.coredb.dao.TokenPriceDao import javax.inject.Singleton +import jp.co.soramitsu.coredb.dao.PoolDao @InstallIn(SingletonComponent::class) @Module @@ -107,4 +109,16 @@ class DbModule { fun provideSoraCardDao(appDatabase: AppDatabase): SoraCardDao { return appDatabase.soraCardDao() } + + @Provides + @Singleton + fun provideNomisScoresDao(appDatabase: AppDatabase): NomisScoresDao { + return appDatabase.nomisScoresDao() + } + + @Provides + @Singleton + fun providePoolsDao(appDatabase: AppDatabase): PoolDao { + return appDatabase.poolDao() + } } diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt index 8e0864323f..3874dcad75 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt @@ -3,6 +3,64 @@ package jp.co.soramitsu.coredb.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +val Migration_68_69 = object : Migration(68, 69) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `nomis_wallet_score` ( + `metaId` INTEGER NOT NULL, + `score` INTEGER NOT NULL, + `updated` INTEGER NOT NULL, + `nativeBalanceUsd` TEXT NOT NULL, + `holdTokensUsd` TEXT NOT NULL, + `walletAgeInMonths` INTEGER NOT NULL, + `totalTransactions` INTEGER NOT NULL, + `rejectedTransactions` INTEGER NOT NULL, + `avgTransactionTimeInHours` REAL NOT NULL, + `maxTransactionTimeInHours` REAL NOT NULL, + `minTransactionTimeInHours` REAL NOT NULL, + `scoredAt` TEXT NOT NULL, + PRIMARY KEY(`metaId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `allpools` ( + `tokenIdBase` TEXT NOT NULL, + `tokenIdTarget` TEXT NOT NULL, + `reserveBase` TEXT NOT NULL, + `reserveTarget` TEXT NOT NULL, + `totalIssuance` TEXT NOT NULL, + `reservesAccount` TEXT NOT NULL, + PRIMARY KEY(`tokenIdBase`, `tokenIdTarget`)) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `userpools` ( + `userTokenIdBase` TEXT NOT NULL, + `userTokenIdTarget` TEXT NOT NULL, + `accountAddress` TEXT NOT NULL, + `poolProvidersBalance` TEXT NOT NULL, + PRIMARY KEY(`userTokenIdBase`, `userTokenIdTarget`, `accountAddress`), + FOREIGN KEY(`userTokenIdBase`, `userTokenIdTarget`) REFERENCES `allpools`(`tokenIdBase`, `tokenIdTarget`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_userpools_accountAddress` ON `userpools` (`accountAddress`)") + } +} + +val Migration_67_68 = object : Migration(67, 68) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE meta_accounts SET initialized = 0") + db.execSQL("UPDATE chain_accounts SET initialized = 0") + db.execSQL("DELETE FROM assets") + } +} + val Migration_66_67 = object : Migration(66, 67) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("UPDATE meta_accounts SET initialized = 0") diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt index 2821e929ea..3465a5635c 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt @@ -104,13 +104,13 @@ class V2Migration( val cursor = database.query("SELECT * FROM users") return cursor.map { - val address = getString(getColumnIndex("address")) - val cryptoTypeOrdinal = getInt(getColumnIndex("cryptoType")) - val name = getString(getColumnIndex("username")) + val address = getString(getColumnIndexOrThrow("address")) + val cryptoTypeOrdinal = getInt(getColumnIndexOrThrow("cryptoType")) + val name = getString(getColumnIndexOrThrow("username")) MigratingAccount( address = address, - cryptoType = CryptoType.values()[cryptoTypeOrdinal], + cryptoType = CryptoType.entries[cryptoTypeOrdinal], name = name ) } diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/NomisWalletScoreLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/NomisWalletScoreLocal.kt new file mode 100644 index 0000000000..5f612cd916 --- /dev/null +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/NomisWalletScoreLocal.kt @@ -0,0 +1,65 @@ +package jp.co.soramitsu.coredb.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import java.math.BigDecimal +import jp.co.soramitsu.coredb.model.chain.MetaAccountLocal + +@Entity( + tableName = "nomis_wallet_score", + primaryKeys = ["metaId"], + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class NomisWalletScoreLocal( + val metaId: Long, + val score: Int, + val updated: Long, + val nativeBalanceUsd: BigDecimal, + val holdTokensUsd: BigDecimal, + val walletAgeInMonths: Long, + val totalTransactions: Long, + val rejectedTransactions: Long, + val avgTransactionTimeInHours: Double, + val maxTransactionTimeInHours: Double, + val minTransactionTimeInHours: Double, + val scoredAt: String +) { + companion object { + fun loading(metaId: Long) = NomisWalletScoreLocal( + metaId = metaId, + score = -1, + updated = 0, + nativeBalanceUsd = BigDecimal.ZERO, + holdTokensUsd = BigDecimal.ZERO, + walletAgeInMonths = 0, + totalTransactions = 0, + rejectedTransactions = 0, + avgTransactionTimeInHours = 0.0, + maxTransactionTimeInHours = 0.0, + minTransactionTimeInHours = 0.0, + scoredAt = "" + ) + + fun error(metaId: Long) = NomisWalletScoreLocal( + metaId = metaId, + score = -2, + updated = 0, + nativeBalanceUsd = BigDecimal.ZERO, + holdTokensUsd = BigDecimal.ZERO, + walletAgeInMonths = 0, + totalTransactions = 0, + rejectedTransactions = 0, + avgTransactionTimeInHours = 0.0, + maxTransactionTimeInHours = 0.0, + minTransactionTimeInHours = 0.0, + scoredAt = "" + ) + } +} \ No newline at end of file diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocal.kt new file mode 100644 index 0000000000..793c8ab038 --- /dev/null +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocal.kt @@ -0,0 +1,51 @@ +package jp.co.soramitsu.coredb.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import java.math.BigDecimal + +@Entity( + tableName = "allpools", + primaryKeys = ["tokenIdBase", "tokenIdTarget"], +) +data class BasicPoolLocal( + val tokenIdBase: String, + val tokenIdTarget: String, + val reserveBase: BigDecimal, + val reserveTarget: BigDecimal, + val totalIssuance: BigDecimal, + val reservesAccount: String, +) + +@Entity( + tableName = "userpools", + primaryKeys = ["userTokenIdBase", "userTokenIdTarget", "accountAddress"], + indices = [Index(value = ["accountAddress"])], + foreignKeys = [ + ForeignKey( + entity = BasicPoolLocal::class, + parentColumns = ["tokenIdBase", "tokenIdTarget"], + childColumns = ["userTokenIdBase", "userTokenIdTarget"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.NO_ACTION, + ) + ] +) +data class UserPoolLocal( + val userTokenIdBase: String, + val userTokenIdTarget: String, + val accountAddress: String, + val poolProvidersBalance: BigDecimal, +) + +/* +@Entity( + tableName = "poolBaseTokens" +) +data class PoolBaseTokenLocal( + @PrimaryKey val tokenId: String, + val dexId: Int, +) +*/ diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocalJoined.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocalJoined.kt new file mode 100644 index 0000000000..35396a0f96 --- /dev/null +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/PoolLocalJoined.kt @@ -0,0 +1,34 @@ +package jp.co.soramitsu.coredb.model + +import androidx.room.Embedded +import androidx.room.Relation + +data class UserPoolJoinedLocal( + @Embedded + val userPoolLocal: UserPoolLocal, + @Embedded + val basicPoolLocal: BasicPoolLocal, +) + +data class UserPoolJoinedLocalNullable( + @Embedded + val userPoolLocal: UserPoolLocal?, + @Embedded + val basicPoolLocal: BasicPoolLocal, +) + +//data class TokenFiatLocal( +// @Embedded +// val token: TokenLocal, +// @Relation(parentColumn = "id", entityColumn = "tokenIdFiat") +// val fiat: FiatTokenPriceLocal?, +//) +// +//data class BasicPoolWithTokenFiatLocal( +// @Embedded +// val basicPoolLocal: BasicPoolLocal, +// @Relation(parentColumn = "tokenIdBase", entityColumn = "id", entity = TokenLocal::class) +// val tokenBaseLocal: TokenFiatLocal, +// @Relation(parentColumn = "tokenIdTarget", entityColumn = "id", entity = TokenLocal::class) +// val tokenTargetLocal: TokenFiatLocal, +//) diff --git a/feature-account-api/build.gradle b/feature-account-api/build.gradle index 23b4d2491b..ae3ccf8f23 100644 --- a/feature-account-api/build.gradle +++ b/feature-account-api/build.gradle @@ -27,6 +27,7 @@ android { jvmTarget = '17' } + namespace 'jp.co.soramitsu.feature_account_api' } @@ -45,6 +46,6 @@ dependencies { api project(':core-api') - api libs.sharedFeaturesCoreDep - api libs.sharedFeaturesBackupDep + api libs.sharedFeaturesCoreDep, withoutAndroidFoundation + api libs.sharedFeaturesBackupDep, withoutAndroidFoundation } \ No newline at end of file diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt index e85c8b4cc8..bd8d955507 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt @@ -5,6 +5,7 @@ import jp.co.soramitsu.account.api.domain.model.ImportJsonData import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccountOrdering +import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.backup.domain.models.BackupAccountType import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets import jp.co.soramitsu.common.data.secrets.v2.MetaAccountSecrets @@ -188,4 +189,7 @@ interface AccountRepository { suspend fun getSelectedLightMetaAccount(): LightMetaAccount suspend fun getLightMetaAccount(metaId: Long): LightMetaAccount fun observeFavoriteChains(metaId: Long): Flow> + + fun observeNomisScores(): Flow> + fun observeNomisScore(metaId: Long): Flow } diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/NomisScoreInteractor.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/NomisScoreInteractor.kt new file mode 100644 index 0000000000..cefcacb798 --- /dev/null +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/NomisScoreInteractor.kt @@ -0,0 +1,17 @@ +package jp.co.soramitsu.account.api.domain.interfaces + +import jp.co.soramitsu.account.api.domain.model.NomisScoreData +import kotlinx.coroutines.flow.Flow + +interface NomisScoreInteractor { + fun observeNomisScores(): Flow> + + fun observeCurrentAccountScore(): Flow + fun observeAccountScore(metaId: Long): Flow + + var nomisMultichainScoreEnabled: Boolean + fun observeNomisMultichainScoreEnabled(): Flow + + suspend fun getNomisScore(address: String): NomisScoreData? + fun getNomisScoreFromMemoryCache(address: String): NomisScoreData? +} \ No newline at end of file diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/NomisScoreData.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/NomisScoreData.kt new file mode 100644 index 0000000000..5d4013b417 --- /dev/null +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/NomisScoreData.kt @@ -0,0 +1,26 @@ +package jp.co.soramitsu.account.api.domain.model + +import java.math.BigDecimal + +data class NomisScoreData( + val metaId: Long, + val score: Int, + val updated: Long, + val nativeBalanceUsd: BigDecimal, + val holdTokensUsd: BigDecimal, + val walletAgeInMonths: Long, + val totalTransactions: Long, + val rejectedTransactions: Long, + val avgTransactionTimeInHours: Double, + val maxTransactionTimeInHours: Double, + val minTransactionTimeInHours: Double, + val scoredAt: Long? +) { + val isError = score == -2 + val isLoading = score == -1 + + companion object { + const val LOADING_CODE = -1 + const val ERROR_CODE = -2 + } +} diff --git a/feature-account-impl/build.gradle b/feature-account-impl/build.gradle index 6ea2148632..e70a931926 100644 --- a/feature-account-impl/build.gradle +++ b/feature-account-impl/build.gradle @@ -34,6 +34,7 @@ android { composeOptions { kotlinCompilerExtensionVersion composeCompilerVersion } + namespace 'jp.co.soramitsu.feature_account_impl' } @@ -91,8 +92,8 @@ dependencies { implementation libs.gson - api libs.sharedFeaturesCoreDep + api libs.sharedFeaturesCoreDep, withoutAndroidFoundation - implementation libs.sharedFeaturesBackupDep + implementation libs.sharedFeaturesBackupDep, withoutAndroidFoundation implementation libs.web3jDep } \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt index 13cd096a4d..b534851a68 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt @@ -1,14 +1,19 @@ package jp.co.soramitsu.account.impl.data.mappers +import java.text.SimpleDateFormat +import java.util.Locale import jp.co.soramitsu.account.api.domain.model.Account import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.account.api.domain.model.address import jp.co.soramitsu.account.impl.presentation.node.model.NodeModel import jp.co.soramitsu.account.impl.presentation.view.advanced.encryption.model.CryptoTypeModel +import jp.co.soramitsu.common.data.network.nomis.NomisResponse import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.core.models.ChainNode import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.coredb.model.NomisWalletScoreLocal import jp.co.soramitsu.coredb.model.chain.ChainAccountLocal import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal import jp.co.soramitsu.coredb.model.chain.JoinedMetaAccountInfo @@ -29,11 +34,13 @@ fun mapCryptoTypeToCryptoTypeModel( R.string.sr25519_selection_subtitle ) }" + CryptoType.ED25519 -> "${resourceManager.getString(R.string.ed25519_selection_title)} ${ resourceManager.getString( R.string.ed25519_selection_subtitle ) }" + CryptoType.ECDSA -> "${resourceManager.getString(R.string.ecdsa_selection_title)} ${ resourceManager.getString( R.string.ecdsa_selection_subtitle @@ -94,7 +101,7 @@ fun mapMetaAccountLocalToMetaAccount( keySelector = FavoriteChainLocal::chainId, valueTransform = { MetaAccount.FavoriteChain( - chain = chainsById[it.chainId], + chain = chainsById[it.chainId], isFavorite = it.isFavorite ) } @@ -149,3 +156,62 @@ fun mapChainAccountToAccount( position = 0 ) } + +fun NomisResponse.toLocal(metaId: Long): NomisWalletScoreLocal { + val score = (data.score * 100).toInt() + return NomisWalletScoreLocal( + metaId = metaId, + score = score, + updated = System.currentTimeMillis(), + nativeBalanceUsd = data.stats.nativeBalanceUSD.toBigDecimal(), + holdTokensUsd = data.stats.holdTokensBalanceUSD.toBigDecimal(), + walletAgeInMonths = data.stats.walletAgeInMonths, + totalTransactions = data.stats.totalTransactions, + rejectedTransactions = data.stats.totalRejectedTransactions, + avgTransactionTimeInHours = data.stats.averageTransactionTimeInHours, + maxTransactionTimeInHours = data.stats.maxTransactionTimeInHours, + minTransactionTimeInHours = data.stats.minTransactionTimeInHours, + scoredAt = data.stats.scoredAt + ) +} + +fun NomisWalletScoreLocal.toDomain(): NomisScoreData { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.getDefault()) + val scoredAtMillis = runCatching { formatter.parse(scoredAt)?.time }.getOrNull() + + return NomisScoreData( + metaId = metaId, + score = score, + updated = updated, + nativeBalanceUsd = nativeBalanceUsd, + holdTokensUsd = holdTokensUsd, + walletAgeInMonths = walletAgeInMonths, + totalTransactions = totalTransactions, + rejectedTransactions = rejectedTransactions, + avgTransactionTimeInHours = avgTransactionTimeInHours, + maxTransactionTimeInHours = maxTransactionTimeInHours, + minTransactionTimeInHours = minTransactionTimeInHours, + scoredAt = scoredAtMillis + ) +} + +fun NomisResponse.toDomain(): NomisScoreData { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.getDefault()) + val scoredAtMillis = runCatching { formatter.parse(data.stats.scoredAt)?.time }.getOrNull() + + val score = (data.score * 100).toInt() + return NomisScoreData( + metaId = -1, + score = score, + updated = System.currentTimeMillis(), + nativeBalanceUsd = data.stats.nativeBalanceUSD.toBigDecimal(), + holdTokensUsd = data.stats.holdTokensBalanceUSD.toBigDecimal(), + walletAgeInMonths = data.stats.walletAgeInMonths, + totalTransactions = data.stats.totalTransactions, + rejectedTransactions = data.stats.totalRejectedTransactions, + avgTransactionTimeInHours = data.stats.averageTransactionTimeInHours, + maxTransactionTimeInHours = data.stats.maxTransactionTimeInHours, + minTransactionTimeInHours = data.stats.minTransactionTimeInHours, + scoredAt = scoredAtMillis + ) +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt index 4ef4ca5169..948e81a30b 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt @@ -9,10 +9,11 @@ import jp.co.soramitsu.account.api.domain.model.ImportJsonData import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccountOrdering +import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.account.api.domain.model.address import jp.co.soramitsu.account.api.domain.model.cryptoType import jp.co.soramitsu.account.api.domain.model.hasChainAccount -import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountLocalToMetaAccount +import jp.co.soramitsu.account.impl.data.mappers.toDomain import jp.co.soramitsu.account.impl.data.repository.datasource.AccountDataSource import jp.co.soramitsu.backup.domain.models.BackupAccountType import jp.co.soramitsu.common.data.Keypair @@ -31,16 +32,14 @@ import jp.co.soramitsu.core.crypto.mapEncryptionToCryptoType import jp.co.soramitsu.core.model.Language import jp.co.soramitsu.core.model.SecuritySource import jp.co.soramitsu.core.models.CryptoType -import jp.co.soramitsu.core.models.accountIdOf import jp.co.soramitsu.coredb.dao.AccountDao -import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.model.AccountLocal -import jp.co.soramitsu.coredb.model.AssetLocal +import jp.co.soramitsu.coredb.model.NomisWalletScoreLocal import jp.co.soramitsu.coredb.model.chain.ChainAccountLocal import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal import jp.co.soramitsu.coredb.model.chain.MetaAccountLocal -import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId @@ -62,8 +61,6 @@ import jp.co.soramitsu.shared_utils.runtime.AccountId import jp.co.soramitsu.shared_utils.scale.EncodableStruct import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.addressByte import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.wallet.api.data.cache.AssetCache -import jp.co.soramitsu.wallet.impl.domain.model.Asset import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -87,9 +84,8 @@ class AccountRepositoryImpl( private val jsonSeedDecoder: JsonSeedDecoder, private val jsonSeedEncoder: JsonSeedEncoder, private val languagesHolder: LanguagesHolder, - private val chainRegistry: ChainRegistry, private val chainsRepository: ChainsRepository, - private val assetDao: AssetDao, + private val nomisScoresDao: NomisScoresDao, private val dispatcher: CoroutineDispatcher = Dispatchers.Default ) : AccountRepository { @@ -931,4 +927,16 @@ class AccountRepositoryImpl( } override fun observeFavoriteChains(metaId: Long) = accountDataSource.observeFavoriteChains(metaId).map { list -> list.associate { it.chainId to it.isFavorite } } + + override fun observeNomisScores(): Flow> { + return nomisScoresDao.observeScores().map { scores -> + scores.map(NomisWalletScoreLocal::toDomain) + } + } + + override fun observeNomisScore(metaId: Long): Flow { + return nomisScoresDao.observeScore(metaId).map { score -> + score?.toDomain() + } + } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt index eb4ca939da..86fcef9b6c 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -158,7 +158,7 @@ class AccountDataSourceImpl( override suspend fun getSelectedMetaAccount(): MetaAccount { val chainsById = chainsRepository.getChainsById() val selectedMetaAccount = metaAccountDao.selectedMetaAccountInfo() - return mapMetaAccountLocalToMetaAccount(chainsById,selectedMetaAccount) + return mapMetaAccountLocalToMetaAccount(chainsById, selectedMetaAccount) } override fun selectedMetaAccountFlow(): Flow = selectedMetaAccountFlow diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt index 3d68a6fbd9..32662d9a63 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt @@ -9,6 +9,7 @@ import javax.inject.Singleton import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.interfaces.AssetNotNeedAccountUseCase +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor import jp.co.soramitsu.account.api.domain.interfaces.SelectedAccountUseCase import jp.co.soramitsu.account.api.domain.updaters.AccountUpdateScope import jp.co.soramitsu.account.api.presentation.account.AddressDisplayUseCase @@ -23,12 +24,14 @@ import jp.co.soramitsu.account.impl.domain.AccountInteractorImpl import jp.co.soramitsu.account.impl.domain.AssetNotNeedAccountUseCaseImpl import jp.co.soramitsu.account.impl.domain.BeaconConnectedUseCase import jp.co.soramitsu.account.impl.domain.NodeHostValidator +import jp.co.soramitsu.account.impl.domain.NomisScoreInteractorImpl import jp.co.soramitsu.account.impl.domain.account.details.AccountDetailsInteractor import jp.co.soramitsu.account.impl.presentation.common.mixin.api.CryptoTypeChooserMixin import jp.co.soramitsu.account.impl.presentation.common.mixin.impl.CryptoTypeChooser import jp.co.soramitsu.common.data.network.AppLinksProvider import jp.co.soramitsu.common.data.network.NetworkApiCreator import jp.co.soramitsu.common.data.network.coingecko.CoingeckoApi +import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.secrets.v1.SecretStoreV1 import jp.co.soramitsu.common.data.secrets.v2.SecretStoreV2 import jp.co.soramitsu.common.data.storage.Preferences @@ -43,6 +46,7 @@ import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.coredb.dao.AccountDao import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.dao.TokenPriceDao import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository @@ -76,9 +80,8 @@ class AccountFeatureModule { jsonSeedDecoder: JsonSeedDecoder, jsonSeedEncoder: JsonSeedEncoder, languagesHolder: LanguagesHolder, - chainRegistry: ChainRegistry, chainsRepository: ChainsRepository, - assetDao: AssetDao + nomisScoresDao: NomisScoresDao ): AccountRepository { return AccountRepositoryImpl( accountDataSource, @@ -88,9 +91,8 @@ class AccountFeatureModule { jsonSeedDecoder, jsonSeedEncoder, languagesHolder, - chainRegistry, chainsRepository, - assetDao + nomisScoresDao ) } @@ -105,9 +107,20 @@ class AccountFeatureModule { @Provides fun provideAccountInteractor( accountRepository: AccountRepository, - fileProvider: FileProvider + fileProvider: FileProvider, + preferences: Preferences ): AccountInteractor { - return AccountInteractorImpl(accountRepository, fileProvider) + return AccountInteractorImpl(accountRepository, fileProvider, preferences) + } + + @Provides + @Singleton + fun provideNomisScoresInteractor( + accountRepository: AccountRepository, + preferences: Preferences, + nomisApi: NomisApi + ): NomisScoreInteractor { + return NomisScoreInteractorImpl(accountRepository, preferences, nomisApi) } @Provides diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt index fb21a51fd4..1f303ae7d7 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt @@ -7,6 +7,7 @@ import jp.co.soramitsu.account.api.domain.model.Account import jp.co.soramitsu.account.api.domain.model.ImportJsonData import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccountOrdering +import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.interfaces.FileProvider import jp.co.soramitsu.core.model.Language import jp.co.soramitsu.core.models.CryptoType @@ -21,6 +22,7 @@ import kotlinx.coroutines.withContext class AccountInteractorImpl( private val accountRepository: AccountRepository, private val fileProvider: FileProvider, + private val preferences: Preferences, private val context: CoroutineContext = Dispatchers.Default ) : AccountInteractor { @@ -226,7 +228,8 @@ class AccountInteractorImpl( override fun selectedMetaAccountFlow() = accountRepository.selectedMetaAccountFlow() - override suspend fun selectedMetaAccount() = withContext(context) { accountRepository.getSelectedMetaAccount() } + override suspend fun selectedMetaAccount() = + withContext(context) { accountRepository.getSelectedMetaAccount() } override suspend fun selectedLightMetaAccount() = accountRepository.getSelectedLightMetaAccount() diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/NomisScoreInteractorImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/NomisScoreInteractorImpl.kt new file mode 100644 index 0000000000..22663da1e6 --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/NomisScoreInteractorImpl.kt @@ -0,0 +1,82 @@ +package jp.co.soramitsu.account.impl.domain + +import android.util.Log +import java.util.concurrent.ConcurrentHashMap +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor +import jp.co.soramitsu.account.api.domain.model.NomisScoreData +import jp.co.soramitsu.account.impl.data.mappers.toDomain +import jp.co.soramitsu.common.data.network.nomis.NomisApi +import jp.co.soramitsu.common.data.storage.Preferences +import jp.co.soramitsu.common.utils.flowOf +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext + +class NomisScoreInteractorImpl( + private val accountRepository: AccountRepository, + private val preferences: Preferences, + private val nomisApi: NomisApi, + private val coroutineContext: CoroutineContext = Dispatchers.Default +) + : NomisScoreInteractor { + + private val scoresCache = ConcurrentHashMap() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeNomisScores(): Flow> { + return observeNomisMultichainScoreEnabled().flatMapLatest { + if(it) { + accountRepository.observeNomisScores() + } else { + kotlinx.coroutines.flow.flowOf(emptyList()) + } + }.flowOn(coroutineContext) + } + + override fun observeCurrentAccountScore(): Flow { + return observeNomisMultichainScoreEnabled().flatMapLatest { + if(it) { + accountRepository.selectedMetaAccountFlow() + } else { + kotlinx.coroutines.flow.flowOf(null) + } + } + .flatMapLatest { metaAccount -> + metaAccount?.let { accountRepository.observeNomisScore(it.id) } ?: kotlinx.coroutines.flow.flowOf(null) + }.flowOn(coroutineContext) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeAccountScore(metaId: Long): Flow { + return flowOf { + accountRepository.getMetaAccount(metaId) + }.flatMapLatest { + accountRepository.observeNomisScore(it.id) + } + } + + override fun observeNomisMultichainScoreEnabled(): Flow { + return preferences.booleanFlow("nomis_multichain_score_enabled", true) + } + + override var nomisMultichainScoreEnabled: Boolean + get() = preferences.getBoolean("nomis_multichain_score_enabled", true) + set(value) { + preferences.putBoolean("nomis_multichain_score_enabled", value) + } + + override suspend fun getNomisScore(address: String): NomisScoreData? { + return scoresCache.getOrPut(address) { + withContext(coroutineContext) {runCatching { nomisApi.getNomisScore(address) }.onFailure { Log.d("&&&", "failed to load nomis score: $it") }.getOrNull()?.toDomain() }?: return null + } + } + + override fun getNomisScoreFromMemoryCache(address: String): NomisScoreData? { + return scoresCache[address] + } +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt index bc10d771ec..a0c67e2320 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt @@ -1,22 +1,30 @@ package jp.co.soramitsu.account.impl.domain import android.util.Log -import java.math.BigInteger import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.accountId import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountLocalToMetaAccount +import jp.co.soramitsu.account.impl.data.mappers.toLocal +import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.network.runtime.binding.AssetBalance import jp.co.soramitsu.common.data.network.runtime.binding.toAssetBalance +import jp.co.soramitsu.common.utils.ethereumAddressFromPublicKey import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.positiveOrNull import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.model.AssetLocal +import jp.co.soramitsu.coredb.model.NomisWalletScoreLocal +import jp.co.soramitsu.coredb.model.chain.RelationJoinedMetaAccountInfo import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.model.BSCChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ethereumChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.polygonChainId import jp.co.soramitsu.runtime.multiNetwork.connection.EvmConnectionStatus import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource import jp.co.soramitsu.shared_utils.extensions.toHexString @@ -29,10 +37,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.job import kotlinx.coroutines.joinAll @@ -40,13 +51,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigInteger + private const val TAG = "WalletSyncService" + class WalletSyncService( private val metaAccountDao: MetaAccountDao, private val chainsRepository: ChainsRepository, private val chainRegistry: ChainRegistry, private val remoteStorageSource: RemoteStorageSource, private val assetDao: AssetDao, + private val nomisApi: NomisApi, + private val nomisScoresDao: NomisScoresDao, dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { companion object { @@ -61,11 +77,20 @@ class WalletSyncService( ) }) + private val nomisUpdateScope = + CoroutineScope(dispatcher + SupervisorJob() + CoroutineExceptionHandler { _, throwable -> + Log.d( + TAG, + "Nomis scope error: $throwable" + ) + }) + private var syncJob: Job? = null fun start() { observeNotInitializedMetaAccounts() observeNotInitializedChainAccounts() + observeNomisScores() } private fun observeNotInitializedMetaAccounts() { @@ -94,63 +119,69 @@ class WalletSyncService( launch { ethereumChains.forEach { chain -> launch { - val assetsDeferred = async { - if (chainRegistry.checkChainSyncedUp(chain).not()) { - chainRegistry.setupChain(chain) + if (chainRegistry.checkChainSyncedUp(chain).not()) { + chainRegistry.setupChain(chain) + } + val connection = + withTimeoutOrNull(CHAIN_SYNC_TIMEOUT_MILLIS) { + val connection = + chainRegistry.awaitEthereumConnection(chain.id) + // await connecting to the node + connection.statusFlow.first { it is EvmConnectionStatus.Connected } + connection } - val connection = - withTimeoutOrNull(CHAIN_SYNC_TIMEOUT_MILLIS) { - val connection = - chainRegistry.awaitEthereumConnection(chain.id) - // await connecting to the node - connection.statusFlow.first { it is EvmConnectionStatus.Connected } - connection - } - + val assetsDeferred = async { metaAccounts.mapNotNull { metaAccount -> val accountId = metaAccount.accountId(chain) ?: return@mapNotNull null - chain.assets.map { chainAsset -> - val balance = kotlin.runCatching { - connection?.web3j?.fetchEthBalance( - chainAsset, - accountId.toHexString(true) - ) - }.getOrNull() + val accountBalancesDeferred = + chain.assets.map { chainAsset -> + async { + val balance = kotlin.runCatching { + connection?.web3j?.fetchEthBalance( + chainAsset, + accountId.toHexString(true) + ) + }.getOrNull() - if (balance.positiveOrNull() != null) { - accountHasAssetWithPositiveBalanceMap[metaAccount.id] = - true - } + if (balance.positiveOrNull() != null) { + accountHasAssetWithPositiveBalanceMap[metaAccount.id] = + true + } - val isPopularUtilityAsset = - chain.rank != null && chainAsset.isUtility - val accountHasAssetWithPositiveBalance = - accountHasAssetWithPositiveBalanceMap[metaAccount.id] == true + val isPopularUtilityAsset = + chain.rank != null && chainAsset.isUtility + val accountHasAssetWithPositiveBalance = + accountHasAssetWithPositiveBalanceMap[metaAccount.id] == true - AssetLocal( - id = chainAsset.id, - chainId = chain.id, - accountId = accountId, - metaId = metaAccount.id, - tokenPriceId = chainAsset.priceId, - freeInPlanks = balance, - reservedInPlanks = BigInteger.ZERO, - miscFrozenInPlanks = BigInteger.ZERO, - feeFrozenInPlanks = BigInteger.ZERO, - bondedInPlanks = BigInteger.ZERO, - redeemableInPlanks = BigInteger.ZERO, - unbondingInPlanks = BigInteger.ZERO, - enabled = balance.positiveOrNull() != null || (!accountHasAssetWithPositiveBalance && isPopularUtilityAsset) - ) - } + AssetLocal( + id = chainAsset.id, + chainId = chain.id, + accountId = accountId, + metaId = metaAccount.id, + tokenPriceId = chainAsset.priceId, + freeInPlanks = balance, + reservedInPlanks = BigInteger.ZERO, + miscFrozenInPlanks = BigInteger.ZERO, + feeFrozenInPlanks = BigInteger.ZERO, + bondedInPlanks = BigInteger.ZERO, + redeemableInPlanks = BigInteger.ZERO, + unbondingInPlanks = BigInteger.ZERO, + enabled = balance.positiveOrNull() != null || (!accountHasAssetWithPositiveBalance && isPopularUtilityAsset) + ) + } + } + + accountBalancesDeferred.awaitAll() }.flatten() } val localAssets = assetsDeferred.await() assetDao.insertAssets(localAssets) - hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts(metaAccounts) + hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts( + metaAccounts + ) } } } @@ -172,7 +203,11 @@ class WalletSyncService( chain.utilityAsset != null && chain.utilityAsset!!.typeExtra == ChainAssetType.Equilibrium if (isEquilibriumTypeChain) { - buildEquilibriumAssetsByMetaAccounts(metaAccounts, chain, runtime) + buildEquilibriumAssetsByMetaAccounts( + metaAccounts, + chain, + runtime + ) } else { val allAccountsStorageKeys = metaAccounts.mapNotNull { metaAccount -> @@ -258,7 +293,9 @@ class WalletSyncService( } val localAssets = assetsDeferred.await() assetDao.insertAssets(localAssets) - hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts(metaAccounts) + hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts( + metaAccounts + ) } } } @@ -268,7 +305,9 @@ class WalletSyncService( coroutineScope { metaAccountDao.markAccountsInitialized(metaAccounts.map { it.id }) - hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts(metaAccounts) + hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts( + metaAccounts + ) } } } @@ -279,7 +318,8 @@ class WalletSyncService( metaAccountDao.observeNotInitializedChainAccounts().filter { it.isNotEmpty() } .onEach { chainAccounts -> chainRegistry.configsSyncDeferred.joinAll() - val chains = chainAccounts.map { chainRegistry.getChain(it.chainId) }.associateBy { it.id } + val chains = + chainAccounts.map { chainRegistry.getChain(it.chainId) }.associateBy { it.id } val ethereumChains = chains.values.filter { it.isEthereumChain }.sortedByDescending { it.rank } val substrateChains = @@ -306,30 +346,33 @@ class WalletSyncService( chainAccount.accountId val accountId = chainAccount.accountId - chain.assets.map { chainAsset -> - val balance = kotlin.runCatching { - connection?.web3j?.fetchEthBalance( - chainAsset, - accountId.toHexString(true) - ) - }.getOrNull() + val accountBalances = chain.assets.map { chainAsset -> + async { + val balance = kotlin.runCatching { + connection?.web3j?.fetchEthBalance( + chainAsset, + accountId.toHexString(true) + ) + }.getOrNull() - AssetLocal( - id = chainAsset.id, - chainId = chain.id, - accountId = accountId, - metaId = chainAccount.metaId, - tokenPriceId = chainAsset.priceId, - freeInPlanks = balance, - reservedInPlanks = BigInteger.ZERO, - miscFrozenInPlanks = BigInteger.ZERO, - feeFrozenInPlanks = BigInteger.ZERO, - bondedInPlanks = BigInteger.ZERO, - redeemableInPlanks = BigInteger.ZERO, - unbondingInPlanks = BigInteger.ZERO, - enabled = balance.positiveOrNull()!= null || chainAsset.isUtility - ) + AssetLocal( + id = chainAsset.id, + chainId = chain.id, + accountId = accountId, + metaId = chainAccount.metaId, + tokenPriceId = chainAsset.priceId, + freeInPlanks = balance, + reservedInPlanks = BigInteger.ZERO, + miscFrozenInPlanks = BigInteger.ZERO, + feeFrozenInPlanks = BigInteger.ZERO, + bondedInPlanks = BigInteger.ZERO, + redeemableInPlanks = BigInteger.ZERO, + unbondingInPlanks = BigInteger.ZERO, + enabled = balance.positiveOrNull() != null || chainAsset.isUtility + ) + } } + accountBalances.awaitAll() }.flatten() } val localAssets = assetsDeferred.await() @@ -355,7 +398,11 @@ class WalletSyncService( chain.utilityAsset != null && chain.utilityAsset!!.typeExtra == ChainAssetType.Equilibrium if (isEquilibriumTypeChain) { - buildEquilibriumAssets(chainAccounts.map { it.metaId to it.accountId }, chain, runtime) + buildEquilibriumAssets( + chainAccounts.map { it.metaId to it.accountId }, + chain, + runtime + ) } else { val allAccountsStorageKeys = chainAccounts.map { chainAccount -> @@ -443,7 +490,9 @@ class WalletSyncService( .launchIn(scope) } - private suspend fun hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts(metaAccounts: List) { + private suspend fun hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaAccounts( + metaAccounts: List + ) { hideEmptyAssetsIfThereAreAtLeastOnePositiveBalanceByMetaIds(metaAccounts.map { it.id }) } @@ -464,7 +513,11 @@ class WalletSyncService( chain: Chain, runtime: RuntimeSnapshot? ): List { - return buildEquilibriumAssets(metaAccounts.map { it.id to it.accountId(chain) }, chain, runtime) + return buildEquilibriumAssets( + metaAccounts.map { it.id to it.accountId(chain) }, + chain, + runtime + ) } private suspend fun buildEquilibriumAssets( @@ -527,4 +580,66 @@ class WalletSyncService( } }.flatten() + emptyAssets } + + private fun observeNomisScores() { + var syncJob: Job? = null + val supportedChains = setOf( + ethereumChainId, + BSCChainId, + polygonChainId, + ) + + metaAccountDao.observeJoinedMetaAccountsInfo() + .distinctUntilChangedBy { it.size + it.map { info -> info.chainAccounts }.flatten().size } + .map { metaAccountInfo -> + val existingScores = nomisScoresDao.getScores() + val currentTime = System.currentTimeMillis() + val twelveHoursMillis = 12 * 60 * 60 * 1000L + val existingScoresToUpdate = + existingScores.filter { currentTime - it.updated > twelveHoursMillis } + .map { it.metaId } + + val metaAccounts = metaAccountInfo.asSequence() + .filter { info -> info.metaAccount.ethereumAddress != null || info.chainAccounts.any { it.chainId in supportedChains } } + + val newAccounts = + metaAccounts.filter { it.metaAccount.id !in existingScores.map { score -> score.metaId } } + + val accountsToUpdate = metaAccountInfo.asSequence() + .filter { it.metaAccount.id in existingScoresToUpdate } + + (newAccounts + accountsToUpdate).toSet() + } + .onEach { metaAccounts -> + syncJob?.cancel() + syncJob = nomisUpdateScope.launch { + syncNomisScores(*metaAccounts.toTypedArray()) + } + } + .launchIn(nomisUpdateScope) + } + + private suspend fun syncNomisScores(vararg metaAccount: RelationJoinedMetaAccountInfo) { + return coroutineScope { + val supportedChains = setOf( + ethereumChainId, + BSCChainId, + polygonChainId, + ) + metaAccount.onEach { accountInfo -> + launch { + val id = accountInfo.metaAccount.id + nomisScoresDao.insert(NomisWalletScoreLocal.loading(id)) + runCatching { + val address = accountInfo.metaAccount.ethereumAddress ?: accountInfo.chainAccounts.firstOrNull { it.chainId in supportedChains }?.publicKey?.ethereumAddressFromPublicKey() + nomisApi.getNomisScore(address!!.toHexString(true)) + }.onSuccess { response -> + nomisScoresDao.insert(response.toLocal(id)) + }.onFailure { + nomisScoresDao.insert(NomisWalletScoreLocal.error(id)) + } + } + } + } + } } \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/AccountRouter.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/AccountRouter.kt index 79f28b8e8d..9957187ca7 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/AccountRouter.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/AccountRouter.kt @@ -105,4 +105,6 @@ interface AccountRouter : SecureRouter { fun openImportRemoteWalletDialog() fun openConnectionsScreen() + + fun openScoreDetailsScreen(metaId: Long) } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletContent.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletContent.kt index 6806016716..b02ac65396 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletContent.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/backup_wallet/BackupWalletContent.kt @@ -145,7 +145,7 @@ internal fun BackupWalletContent( } @Composable -private fun SettingsDivider( +fun SettingsDivider( modifier: Modifier = Modifier ) { Divider( diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsFragment.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsFragment.kt new file mode 100644 index 0000000000..47e6ab635f --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsFragment.kt @@ -0,0 +1,37 @@ +package jp.co.soramitsu.account.impl.presentation.nomis_scoring + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.android.AndroidEntryPoint +import jp.co.soramitsu.common.base.BaseComposeBottomSheetDialogFragment +import jp.co.soramitsu.common.compose.component.BottomSheetScreen + +@AndroidEntryPoint +class ScoreDetailsFragment : BaseComposeBottomSheetDialogFragment() { + + companion object { + fun getBundle(metaId: Long) = bundleOf(META_ACCOUNT_ID_KEY to metaId) + + const val META_ACCOUNT_ID_KEY = "meta_account_id" + } + + override val viewModel: ScoreDetailsViewModel by viewModels() + + @Composable + override fun Content(padding: PaddingValues) { + val state by viewModel.state.collectAsStateWithLifecycle() + + BottomSheetScreen( + modifier = Modifier.padding(top = 56.dp) + ) { + ScoreDetailsContent(state, viewModel) + } + } +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsScreen.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsScreen.kt new file mode 100644 index 0000000000..282803141f --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsScreen.kt @@ -0,0 +1,302 @@ +package jp.co.soramitsu.account.impl.presentation.nomis_scoring + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import jp.co.soramitsu.account.api.domain.model.NomisScoreData +import jp.co.soramitsu.common.R +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.Address +import jp.co.soramitsu.common.compose.component.B2 +import jp.co.soramitsu.common.compose.component.ButtonViewState +import jp.co.soramitsu.common.compose.component.EmptyMessage +import jp.co.soramitsu.common.compose.component.FearlessCorneredShape +import jp.co.soramitsu.common.compose.component.H1 +import jp.co.soramitsu.common.compose.component.Image +import jp.co.soramitsu.common.compose.component.InfoTable +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.component.Toolbar +import jp.co.soramitsu.common.compose.component.ToolbarViewState +import jp.co.soramitsu.common.compose.theme.FearlessAppTheme +import jp.co.soramitsu.common.compose.theme.black05 +import jp.co.soramitsu.common.compose.theme.black2 +import jp.co.soramitsu.common.compose.theme.greenText +import jp.co.soramitsu.common.compose.theme.warningOrange +import jp.co.soramitsu.common.compose.theme.warningYellow +import jp.co.soramitsu.common.compose.theme.white24 +import jp.co.soramitsu.common.compose.theme.white30 +import jp.co.soramitsu.common.data.network.runtime.binding.cast + +data class ScoreDetailsScreenState( + val address: String?, + val info: ScoreDetailsViewState +) + +sealed class ScoreDetailsViewState { + data object Loading : ScoreDetailsViewState() + data class Success(val data: ScoreInfoState) : ScoreDetailsViewState() + data object Error : ScoreDetailsViewState() +} + +data class ScoreInfoState( + val score: Int, + val updated: String, + val nativeBalanceUsd: String, + val holdTokensUsd: String, + val walletAge: String, + val totalTransactions: String, + val rejectedTransactions: String, + val avgTransactionTime: String, + val maxTransactionTime: String, + val minTransactionTime: String +) + +interface ScoreDetailsScreenCallback { + fun onCloseClicked() + fun onCopyAddressClicked() +} + +@Composable +fun ScoreDetailsContent( + state: ScoreDetailsScreenState, + callback: ScoreDetailsScreenCallback +) { + val verticalScrollModifier = if (state.info is ScoreDetailsViewState.Success) Modifier.verticalScroll(rememberScrollState()) else Modifier + Column( + modifier = verticalScrollModifier + .fillMaxSize() + ) { + Toolbar( + state = ToolbarViewState( + stringResource(id = R.string.account_stats_title), + R.drawable.ic_cross_24 + ), + onNavigationClick = callback::onCloseClicked + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MarginVertical(margin = 24.dp) + when (state.info) { + is ScoreDetailsViewState.Error -> ScoreBar(-2) + ScoreDetailsViewState.Loading -> ScoreBar(-1) + is ScoreDetailsViewState.Success -> ScoreBar(state.info.data.score) + } + + MarginVertical(margin = 16.dp) + B2( + text = stringResource(id = R.string.account_stats_description_text), + textAlign = TextAlign.Center, + color = black2 + ) + MarginVertical(margin = 6.dp) + state.info.takeIf { it is ScoreDetailsViewState.Success } + ?.cast()?.let { + H1(text = it.data.score.toString()) + } + MarginVertical(margin = 8.dp) + state.address?.let { Address(address = it, onClick = callback::onCopyAddressClicked) } + MarginVertical(margin = 16.dp) + + when (val info = state.info) { + is ScoreDetailsViewState.Error -> { + Box(modifier = Modifier.weight(1f)) { + Error() + } + } + + ScoreDetailsViewState.Loading -> ScoresInfoTable(null) + is ScoreDetailsViewState.Success -> ScoresInfoTable(state = info.data) + } + MarginVertical(margin = 16.dp) + AccentButton( + state = ButtonViewState(text = stringResource(id = R.string.common_close)), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + onClick = callback::onCloseClicked + ) + MarginVertical(margin = 16.dp) + } + } +} + +@Composable +private fun Error() { + Surface( + modifier = Modifier + .fillMaxSize() + .border(1.dp, color = white24, shape = FearlessCorneredShape()), + shape = FearlessCorneredShape(), + color = black05 + ) { + Box(modifier = Modifier.padding(vertical = 64.dp)) { + + EmptyMessage( + message = R.string.account_stats_error_message, + modifier = Modifier.align(Alignment.Center) + ) + } + } + +} + +@Composable +private fun ScoresInfoTable(state: ScoreInfoState?) { + InfoTable( + modifier = Modifier + .fillMaxSize(), + items = listOf( + TitleValueViewState( + title = stringResource(id = R.string.account_stats_updated_title), + value = state?.updated + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_native_balance_usd_title), + value = state?.nativeBalanceUsd + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_hold_tokens_usd_title), + value = state?.holdTokensUsd + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_wallet_age_title), + value = state?.walletAge + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_total_transactions_title), + value = state?.totalTransactions + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_rejected_transactions_title), + value = state?.rejectedTransactions + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_avg_transaction_time_title), + value = state?.avgTransactionTime + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_max_transaction_time_title), + value = state?.maxTransactionTime + ), + TitleValueViewState( + title = stringResource(id = R.string.account_stats_min_transaction_time_title), + value = state?.minTransactionTime + ) + ) + ) +} + +@Composable +fun ScoreBar(score: Int, modifier: Modifier = Modifier) { + val color = when (score) { + in 0..33 -> warningOrange + in 33..66 -> warningYellow + in 66..100 -> greenText + else -> white30 + } + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + when { + score >= 0 -> { + val fullStars = score / 20 + val halfStar = if (score % 20 >= 5) 1 else 0 + val emptyStars = 5 - fullStars - halfStar + + repeat(fullStars) { + Image( + res = R.drawable.ic_score_star_full, + tint = color, + modifier = Modifier.size(30.dp) + ) + } + repeat(halfStar) { + Image( + res = R.drawable.ic_score_star_half, + tint = color, + modifier = Modifier.size(30.dp) + ) + } + repeat(emptyStars) { + Image( + res = R.drawable.ic_score_star_empty, + tint = color, + modifier = Modifier.size(30.dp) + ) + } + } + + score == NomisScoreData.LOADING_CODE -> { + repeat(5) { + Image( + res = R.drawable.ic_score_star_empty, + tint = color, + modifier = Modifier + .size(30.dp) + .shimmer() + ) + } + } + + score == NomisScoreData.ERROR_CODE -> { + repeat(5) { + Image( + res = R.drawable.ic_score_star_empty, + tint = color, + modifier = Modifier.size(30.dp) + ) + } + } + } + } +} + +@Composable +@Preview +private fun ScoreDetailsScreenPreview() { + FearlessAppTheme { + val info = ScoreInfoState( + score = 55, + updated = "Jan 1, 2024", + nativeBalanceUsd = "\$1,337.69", + holdTokensUsd = "\$1,337.69", + walletAge = "1 year", + totalTransactions = "1337", + rejectedTransactions = "40", + avgTransactionTime = "95 hours", + maxTransactionTime = "30 hours", + minTransactionTime = "1 hours" + ) + val successState = ScoreDetailsViewState.Success(info) + val state = ScoreDetailsScreenState( + "Blue Bird 0x23f4g34nign234ij134f0134ifm13i4f134f", +// info = ScoreDetailsViewState.Error + info = successState + ) + ScoreDetailsContent(state, object : ScoreDetailsScreenCallback { + override fun onCloseClicked() = Unit + override fun onCopyAddressClicked() = Unit + }) + } +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt new file mode 100644 index 0000000000..33c684c82e --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt @@ -0,0 +1,105 @@ +package jp.co.soramitsu.account.impl.presentation.nomis_scoring + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor +import jp.co.soramitsu.account.api.domain.model.NomisScoreData +import jp.co.soramitsu.account.impl.presentation.AccountRouter +import jp.co.soramitsu.common.base.BaseViewModel +import jp.co.soramitsu.common.resources.ClipboardManager +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.feature_account_impl.R +import jp.co.soramitsu.shared_utils.extensions.toHexString +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class ScoreDetailsViewModel @Inject constructor( + private val router: AccountRouter, + private val accountInteractor: AccountInteractor, + private val nomisScoreInteractor: NomisScoreInteractor, + private val clipboardManager: ClipboardManager, + private val resourceManager: ResourceManager, + private val savedStateHandle: SavedStateHandle +) : BaseViewModel(), ScoreDetailsScreenCallback { + + val state: MutableStateFlow = MutableStateFlow( + ScoreDetailsScreenState( + address = "", + info = ScoreDetailsViewState.Loading + ) + ) + + private val rawAddress: MutableStateFlow = MutableStateFlow("") + + init { + val metaAccountId = requireNotNull(savedStateHandle.get(ScoreDetailsFragment.META_ACCOUNT_ID_KEY)) + nomisScoreInteractor.observeAccountScore(metaAccountId) + .onEach { nomisData -> + nomisData ?: return@onEach + val newInfoState = when (nomisData.score) { + NomisScoreData.LOADING_CODE -> ScoreDetailsViewState.Loading + NomisScoreData.ERROR_CODE -> ScoreDetailsViewState.Error + else -> { + val scoredAt = nomisData.scoredAt?.let { + val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + val date = Date(it) + formatter.format(date) + } ?: "0" + + ScoreDetailsViewState.Success( + data = ScoreInfoState( + score = nomisData.score, + updated = scoredAt, + nativeBalanceUsd = nomisData.nativeBalanceUsd.formatFiat(null), + holdTokensUsd = nomisData.holdTokensUsd.formatFiat(null), + walletAge = resourceManager.getQuantityString(R.plurals.common_months_format, nomisData.walletAgeInMonths.toInt(), nomisData.walletAgeInMonths.toInt()), + totalTransactions = nomisData.totalTransactions.toString(), + rejectedTransactions = nomisData.rejectedTransactions.toString(), + avgTransactionTime = resourceManager.getQuantityString(R.plurals.common_hours_format, nomisData.avgTransactionTimeInHours.toInt(), nomisData.avgTransactionTimeInHours.toInt()), + maxTransactionTime = resourceManager.getQuantityString(R.plurals.common_hours_format, nomisData.maxTransactionTimeInHours.toInt(), nomisData.maxTransactionTimeInHours.toInt()), + minTransactionTime = resourceManager.getQuantityString(R.plurals.common_hours_format, nomisData.minTransactionTimeInHours.toInt(), nomisData.minTransactionTimeInHours.toInt()), + ) + ) + } + } + state.update { prevState -> prevState.copy(info = newInfoState) } + } + .catch { + state.update { prevState -> prevState.copy(info = ScoreDetailsViewState.Error) } + showError(it) + } + .launchIn(viewModelScope) + + viewModelScope.launch { + val metaAccount = accountInteractor.getMetaAccount(metaAccountId) + val address = (metaAccount.ethereumAddress ?: metaAccount.chainAccounts.values.firstOrNull { it.chain?.isEthereumChain == true || it.chain?.isEthereumBased == true}?.publicKey)?.toHexString(withPrefix = true) + if (address != null) { + rawAddress.value = address + state.update { prevState -> prevState.copy(address = address) } + } else { + state.update { prevState -> prevState.copy(info = ScoreDetailsViewState.Error) } + } + } + } + + override fun onCloseClicked() { + router.back() + } + + override fun onCopyAddressClicked() { + clipboardManager.addToClipboard(rawAddress.value) + showMessage(resourceManager.getString(R.string.common_copied)) + } +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileFragment.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileFragment.kt index 875259f5d4..5499af16c7 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileFragment.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileFragment.kt @@ -1,91 +1,72 @@ package jp.co.soramitsu.account.impl.presentation.profile -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.fragment.app.viewModels import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import jp.co.soramitsu.account.api.presentation.actions.ExternalAccountActions import jp.co.soramitsu.account.api.presentation.actions.copyAddressClicked -import jp.co.soramitsu.common.base.BaseFragment +import jp.co.soramitsu.common.base.BaseComposeFragment +import jp.co.soramitsu.common.compose.theme.black import jp.co.soramitsu.common.data.network.coingecko.FiatCurrency import jp.co.soramitsu.common.mixin.impl.observeBrowserEvents import jp.co.soramitsu.common.presentation.FiatCurrenciesChooserBottomSheetDialog import jp.co.soramitsu.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet -import jp.co.soramitsu.feature_account_impl.databinding.FragmentProfileBinding @AndroidEntryPoint -class ProfileFragment : BaseFragment() { +class ProfileFragment : BaseComposeFragment() { @Inject protected lateinit var imageLoader: ImageLoader - private lateinit var binding: FragmentProfileBinding - override val viewModel: ProfileViewModel by viewModels() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentProfileBinding.inflate(inflater, container, false) - return binding.root + @OptIn(ExperimentalMaterialApi::class) + @Composable + override fun Content( + padding: PaddingValues, + scrollState: ScrollState, + modalBottomSheetState: ModalBottomSheetState + ) { + LaunchedEffect(Unit) { + subscribe(viewModel) + } + val state by viewModel.state.collectAsState() + ProfileScreen(state = state, callback = viewModel) } - override fun initViews() { - with(binding) { - accountView.setWholeClickListener { viewModel.accountActionsClicked() } - - aboutTv.setOnClickListener { viewModel.aboutClicked() } - - profileWallets.setOnClickListener { viewModel.walletsClicked() } - languageWrapper.setOnClickListener { viewModel.languagesClicked() } - changePinCodeTv.setOnClickListener { viewModel.changePinCodeClicked() } - profileCurrency.setOnClickListener { viewModel.currencyClicked() } - profileExperimentalFeatures.setOnClickListener { viewModel.onExperimentalClicked() } - polkaswapDisclaimerTv.setOnClickListener { viewModel.polkaswapDisclaimerClicked() } - profileSoraCard.setOnClickListener { viewModel.onSoraCardClicked() } - profileWalletConnect.setOnClickListener { viewModel.onWalletConnectClick() } - - viewModel.hasChainsWithNoAccountFlow.observe { - missingAccountsIcon.isVisible = it - } - } + @Composable + override fun Background() { + Box(modifier = Modifier.fillMaxSize().background(black)) } - override fun subscribe(viewModel: ProfileViewModel) { + fun subscribe(viewModel: ProfileViewModel) { observeBrowserEvents(viewModel) - viewModel.selectedAccountLiveData.observe { account -> - account.name.let(binding.accountView::setTitle) - } - - viewModel.accountIconLiveData.observe { - binding.accountView.setAccountIcon(it.image) - } - - viewModel.selectedLanguageLiveData.observe { - binding.selectedLanguageTv.text = it.displayName - } - viewModel.showExternalActionsEvent.observeEvent(::showAccountActions) - viewModel.totalBalanceLiveData.observe { - binding.accountView.setText(it) - } - viewModel.showFiatChooser.observeEvent(::showFiatChooser) - - viewModel.selectedFiatLiveData.observe(binding.selectedCurrencyTv::setText) } private fun showFiatChooser(payload: DynamicListBottomSheet.Payload) { - FiatCurrenciesChooserBottomSheetDialog(requireContext(), imageLoader, payload, viewModel::onFiatSelected).show() + FiatCurrenciesChooserBottomSheetDialog( + requireContext(), + imageLoader, + payload, + viewModel::onFiatSelected + ).show() } private fun showAccountActions(payload: ExternalAccountActions.Payload) { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileScreen.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileScreen.kt new file mode 100644 index 0000000000..41cc2b5349 --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileScreen.kt @@ -0,0 +1,112 @@ +package jp.co.soramitsu.account.impl.presentation.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.account.impl.presentation.backup_wallet.SettingsDivider +import jp.co.soramitsu.common.compose.component.ChangeBalanceViewState +import jp.co.soramitsu.common.compose.component.H1 +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.SettingsItem +import jp.co.soramitsu.common.compose.component.SettingsItemAction +import jp.co.soramitsu.common.compose.component.WalletItem +import jp.co.soramitsu.common.compose.component.WalletItemViewState +import jp.co.soramitsu.common.compose.theme.FearlessAppTheme +import jp.co.soramitsu.feature_account_impl.R + +data class ProfileScreenState( + val walletState: WalletItemViewState, + val walletsItemAction: SettingsItemAction = SettingsItemAction.Transition, + val currency: String, + val language: String, + val nomisChecked: Boolean +) + +interface ProfileScreenInterface { + fun onWalletOptionsClick(item: WalletItemViewState) + fun walletsClicked() + + fun onWalletConnectClick() + fun onSoraCardClicked() + fun currencyClicked() + fun languagesClicked() + + fun onNomisMultichainScoreContainerClick() + fun polkaswapDisclaimerClicked() + fun changePinCodeClicked() + + fun aboutClicked() + fun onScoreClick(item: WalletItemViewState) +} + +@Composable +fun ProfileScreen(state: ProfileScreenState, callback: ProfileScreenInterface) { + Column { + MarginVertical(margin = 16.dp) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + H1(text = stringResource(R.string.profile_settings_title)) + MarginVertical(margin = 16.dp) + WalletItem(state = state.walletState, onOptionsClick = callback::onWalletOptionsClick, onScoreClick = callback::onScoreClick) + } + MarginVertical(margin = 16.dp) + SettingsItem(icon = painterResource(R.drawable.ic_settings_wallets), text = stringResource(R.string.profile_wallets_title), action = state.walletsItemAction, onClick = callback::walletsClicked) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_wallet_connect), text = stringResource(R.string.profile_walletconnect_title), onClick = callback::onWalletConnectClick) + SettingsDivider() +// SettingsItem(icon = painterResource(R.drawable.ic_card), text = stringResource(R.string.profile_soracard_title), onClick = callback::onSoraCardClicked) + SettingsItem(icon = painterResource(R.drawable.ic_dollar_circle), text = stringResource(R.string.common_currency), action = SettingsItemAction.Selector(state.currency), onClick = callback::currencyClicked) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_language), text = stringResource(R.string.profile_language_title), action = SettingsItemAction.Selector(state.language), onClick = callback::languagesClicked) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_score_star_full_24_pink), text = stringResource(R.string.profile_account_score_title), action = SettingsItemAction.Switch(state.nomisChecked), onClick = callback::onNomisMultichainScoreContainerClick) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_polkaswap_logo), text = stringResource(R.string.polkaswap_disclaimer_settings_item), onClick = callback::polkaswapDisclaimerClicked) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_pin_24), text = stringResource(R.string.profile_pincode_change_title), onClick = callback::changePinCodeClicked) + SettingsDivider() + SettingsItem(icon = painterResource(R.drawable.ic_info_primary_24), text = stringResource(R.string.about_title), onClick = callback::aboutClicked) + } +} + +@Composable +@Preview +fun ProfileScreenPreview() { + val state = ProfileScreenState( + WalletItemViewState( + id = 111, + balance = "44400.3", + assetSymbol = "$", + title = "My Wallet", + walletIcon = jp.co.soramitsu.common.R.drawable.ic_wallet, + isSelected = false, + changeBalanceViewState = ChangeBalanceViewState( + percentChange = "+5.67%", + fiatChange = "$2345.32" + ), + score = 50 + ), + currency = "USD", + language = "ENG", + nomisChecked = true, + ) + FearlessAppTheme { + ProfileScreen(state, object : ProfileScreenInterface { + override fun onWalletOptionsClick(item: WalletItemViewState) = Unit + override fun walletsClicked() = Unit + override fun onWalletConnectClick() = Unit + override fun onSoraCardClicked() = Unit + override fun currencyClicked() = Unit + override fun languagesClicked() = Unit + override fun onNomisMultichainScoreContainerClick() = Unit + override fun polkaswapDisclaimerClicked() = Unit + override fun changePinCodeClicked() = Unit + override fun aboutClicked() = Unit + override fun onScoreClick(item: WalletItemViewState) = Unit + }) + } +} \ No newline at end of file diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileViewModel.kt index 541a07fba9..fc7ea93ab1 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/profile/ProfileViewModel.kt @@ -2,12 +2,10 @@ package jp.co.soramitsu.account.impl.presentation.profile import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.liveData -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor import jp.co.soramitsu.account.api.domain.interfaces.TotalBalanceUseCase import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.presentation.actions.ExternalAccountActions @@ -18,30 +16,41 @@ import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.address.AddressModel import jp.co.soramitsu.common.address.createAddressModel import jp.co.soramitsu.common.base.BaseViewModel +import jp.co.soramitsu.common.compose.component.ChangeBalanceViewState +import jp.co.soramitsu.common.compose.component.SettingsItemAction +import jp.co.soramitsu.common.compose.component.WalletItemViewState import jp.co.soramitsu.common.data.network.coingecko.FiatChooserEvent import jp.co.soramitsu.common.data.network.coingecko.FiatCurrency import jp.co.soramitsu.common.domain.GetAvailableFiatCurrencies import jp.co.soramitsu.common.domain.SelectedFiat import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.formatAsChange import jp.co.soramitsu.common.utils.formatFiat import jp.co.soramitsu.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import jp.co.soramitsu.feature_account_impl.R import jp.co.soramitsu.soracard.api.domain.SoraCardInteractor import jp.co.soramitsu.soracard.impl.presentation.SoraCardItemViewState -import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject private const val AVATAR_SIZE_DP = 32 @HiltViewModel class ProfileViewModel @Inject constructor( private val interactor: AccountInteractor, - private val accountDetailsInteractor: AccountDetailsInteractor, + accountDetailsInteractor: AccountDetailsInteractor, + private val nomisScoreInteractor: NomisScoreInteractor, private val soraCardInteractor: SoraCardInteractor, private val router: AccountRouter, private val addressIconGenerator: AddressIconGenerator, @@ -49,61 +58,152 @@ class ProfileViewModel @Inject constructor( getTotalBalance: TotalBalanceUseCase, private val getAvailableFiatCurrencies: GetAvailableFiatCurrencies, private val selectedFiat: SelectedFiat, - private val resourceManager: ResourceManager -) : BaseViewModel(), ExternalAccountActions by externalAccountActions { - - val totalBalanceLiveData = combine(getTotalBalance.observe(), selectedFiat.flow()) { balance, fiat -> - val selectedFiatSymbol = getAvailableFiatCurrencies[fiat]?.symbol - balance.balance.formatFiat(selectedFiatSymbol ?: balance.fiatSymbol) - }.asLiveData() - - val selectedAccountLiveData: LiveData = interactor.selectedMetaAccountFlow().asLiveData() - - val accountIconLiveData: LiveData = interactor.polkadotAddressForSelectedAccountFlow() - .map { createIcon(it) } - .asLiveData() + resourceManager: ResourceManager +) : BaseViewModel(), ProfileScreenInterface, ExternalAccountActions by externalAccountActions { - val selectedLanguageLiveData = liveData { - val language = interactor.getSelectedLanguage() + private val selectedAccountFlow: SharedFlow = + interactor.selectedMetaAccountFlow().shareIn(viewModelScope, SharingStarted.Eagerly) - emit(mapLanguageToLanguageModel(language)) + private val accountIconFlow = selectedAccountFlow.map { + addressIconGenerator.createAddressIcon( + it.substrateAccountId, + AddressIconGenerator.SIZE_BIG + ) } private val _showFiatChooser = MutableLiveData() val showFiatChooser: LiveData = _showFiatChooser - val selectedFiatLiveData: LiveData = selectedFiat.flow().asLiveData().map { it.uppercase() } - - val hasChainsWithNoAccountFlow = accountDetailsInteractor.hasChainsWithNoAccount() - .stateIn(this, SharingStarted.Eagerly, false) - -// private val soraCardState = soraCardInteractor.subscribeSoraCardInfo().map { + // private val soraCardState = soraCardInteractor.subscribeSoraCardInfo().map { // val kycStatus = it?.kycStatus?.let(::mapKycStatus) // SoraCardItemViewState(kycStatus, it, null, true) // } private val soraCardState = flowOf(SoraCardItemViewState()) - fun aboutClicked() { + private val defaultWalletItemViewState = WalletItemViewState( + id = 0, + balance = null, + assetSymbol = null, + changeBalanceViewState = null, + title = "", + walletIcon = resourceManager.getDrawable(R.drawable.ic_wallet), + isSelected = false, + additionalMetadata = "", + score = null + ) + + val state: MutableStateFlow = MutableStateFlow( + ProfileScreenState( + walletState = defaultWalletItemViewState, + walletsItemAction = SettingsItemAction.Transition, + currency = selectedFiat.get(), + language = "", + nomisChecked = true + ) + ) + + init { + combine(getTotalBalance.observe(), selectedFiat.flow()) { balance, fiat -> + val selectedFiatSymbol = getAvailableFiatCurrencies[fiat]?.symbol + val formattedBalance = + balance.balance.formatFiat(selectedFiatSymbol ?: balance.fiatSymbol) + + state.update { prevState -> + val newWalletState = prevState.walletState.copy( + balance = formattedBalance, + changeBalanceViewState = ChangeBalanceViewState( + percentChange = balance.rateChange?.formatAsChange().orEmpty(), + fiatChange = balance.balanceChange.abs().formatFiat(balance.fiatSymbol) + ) + ) + prevState.copy( + walletState = newWalletState, + currency = fiat.uppercase(), + ) + } + }.launchIn(viewModelScope) + + selectedAccountFlow + .onEach { account -> + state.update { prevState -> + val newWalletState = prevState.walletState.copy( + id = account.id, + title = account.name + ) + prevState.copy(walletState = newWalletState) + } + }.launchIn(viewModelScope) + + accountIconFlow.onEach { icon -> + state.update { prevState -> + val newWalletState = prevState.walletState.copy( + walletIcon = icon + ) + prevState.copy(walletState = newWalletState) + } + + }.launchIn(viewModelScope) + + nomisScoreInteractor.observeNomisMultichainScoreEnabled() + .onEach { + state.update { prev -> + prev.copy(nomisChecked = it) + } + } + .launchIn(viewModelScope) + + nomisScoreInteractor.observeCurrentAccountScore().onEach { + state.update { prevState -> + val newWalletState = prevState.walletState.copy( + score = it?.score + ) + prevState.copy(walletState = newWalletState) + } + }.launchIn(viewModelScope) + + accountDetailsInteractor.hasChainsWithNoAccount() + .onEach { hasChainsWithNoAccount -> + state.update { prevState -> + prevState.copy( + walletsItemAction = + if (hasChainsWithNoAccount) + SettingsItemAction.TransitionWithIcon(R.drawable.ic_status_warning_16) + else + SettingsItemAction.Transition + ) + } + } + .launchIn(viewModelScope) + + viewModelScope.launch { + val language = interactor.getSelectedLanguage() + val mapped = mapLanguageToLanguageModel(language) + state.update { prevState -> + prevState.copy(language = mapped.displayName) + } + } + } + + override fun aboutClicked() { router.openAboutScreen() } - fun walletsClicked() { + override fun onWalletOptionsClick(item: WalletItemViewState) { + router.openAccountDetails(item.id) + } + + override fun walletsClicked() { router.openSelectWallet() } - fun languagesClicked() { + override fun languagesClicked() { router.openLanguages() } - fun changePinCodeClicked() { + override fun changePinCodeClicked() { router.openChangePinCode() } - fun accountActionsClicked() { - val account = selectedAccountLiveData.value ?: return - router.openAccountDetails(account.id) - } - private suspend fun createIcon(accountAddress: String): AddressModel { return addressIconGenerator.createAddressModel(accountAddress, AVATAR_SIZE_DP) } @@ -112,13 +212,14 @@ class ProfileViewModel @Inject constructor( router.openBeacon(qrContent) } - fun currencyClicked() { + override fun currencyClicked() { viewModelScope.launch { val currencies = getAvailableFiatCurrencies() if (currencies.isEmpty()) return@launch val selected = selectedFiat.get() val selectedItem = currencies.first { it.id == selected } - _showFiatChooser.value = FiatChooserEvent(DynamicListBottomSheet.Payload(currencies, selectedItem)) + _showFiatChooser.value = + FiatChooserEvent(DynamicListBottomSheet.Payload(currencies, selectedItem)) } } @@ -132,11 +233,11 @@ class ProfileViewModel @Inject constructor( router.openExperimentalFeatures() } - fun polkaswapDisclaimerClicked() { + override fun polkaswapDisclaimerClicked() { router.openPolkaswapDisclaimerFromProfile() } - fun onSoraCardClicked() { + override fun onSoraCardClicked() { launch { val soraCardState: SoraCardItemViewState? = soraCardState.firstOrNull() if (soraCardState?.kycStatus == null) { @@ -150,7 +251,16 @@ class ProfileViewModel @Inject constructor( private fun onSoraCardStatusClicked() { } - fun onWalletConnectClick() { + override fun onWalletConnectClick() { router.openConnectionsScreen() } + + override fun onNomisMultichainScoreContainerClick() { + nomisScoreInteractor.nomisMultichainScoreEnabled = + !nomisScoreInteractor.nomisMultichainScoreEnabled + } + + override fun onScoreClick(item: WalletItemViewState) { + router.openScoreDetailsScreen(item.id) + } } diff --git a/feature-account-impl/src/main/res/layout/fragment_profile.xml b/feature-account-impl/src/main/res/layout/fragment_profile.xml deleted file mode 100644 index bf0fc294eb..0000000000 --- a/feature-account-impl/src/main/res/layout/fragment_profile.xml +++ /dev/null @@ -1,361 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/feature-crowdloan-impl/build.gradle b/feature-crowdloan-impl/build.gradle index b906a1af53..b905b6df05 100644 --- a/feature-crowdloan-impl/build.gradle +++ b/feature-crowdloan-impl/build.gradle @@ -29,6 +29,7 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + namespace 'jp.co.soramitsu.feature_crowdloan_impl' } diff --git a/feature-liquiditypools-api/.gitignore b/feature-liquiditypools-api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature-liquiditypools-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-liquiditypools-api/build.gradle.kts b/feature-liquiditypools-api/build.gradle.kts new file mode 100644 index 0000000000..2ad9196a79 --- /dev/null +++ b/feature-liquiditypools-api/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("kotlin-parcelize") +} + +android { + namespace = "jp.co.soramitsu.feature_liquiditypools_api" + compileSdk = rootProject.ext["compileSdkVersion"] as Int + + defaultConfig { + minSdk = rootProject.ext["minSdkVersion"] as Int + targetSdk = rootProject.ext["targetSdkVersion"] as Int + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(projects.androidFoundation) + implementation(projects.featureAccountApi) + implementation(projects.featureWalletApi) + implementation(projects.featureWalletImpl) + implementation(projects.featurePolkaswapApi) + implementation(projects.common) + implementation(projects.runtime) + implementation("javax.inject:javax.inject:1") + + implementation(libs.bundles.coroutines) + implementation(libs.sharedFeaturesCoreDep) { + exclude(module = "android-foundation") + } + + implementation(libs.xnetworking.basic) + implementation(libs.xnetworking.sorawallet) { + exclude(group = "jp.co.soramitsu.xnetworking", module = "basic") + } +} \ No newline at end of file diff --git a/feature-liquiditypools-api/src/main/AndroidManifest.xml b/feature-liquiditypools-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/feature-liquiditypools-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/blockexplorer/BlockExplorerManager.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/blockexplorer/BlockExplorerManager.kt new file mode 100644 index 0000000000..6010c026ef --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/blockexplorer/BlockExplorerManager.kt @@ -0,0 +1,34 @@ +package jp.co.soramitsu.liquiditypools.blockexplorer + +import android.util.Log +import jp.co.soramitsu.xnetworking.sorawallet.blockexplorerinfo.SoraWalletBlockExplorerInfo +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext + +@Singleton +class BlockExplorerManager @Inject constructor(private val info: SoraWalletBlockExplorerInfo) { + + private val coroutineContext: CoroutineContext = Dispatchers.Default + private val coroutineScope = + CoroutineScope( + coroutineContext + SupervisorJob() + CoroutineExceptionHandler { _, throwable -> + Log.e("BlockExplorerManager", throwable.message.orEmpty()) + } + ) + + private val apyDeferred = coroutineScope.async { info.getSpApy().associate { it.id to it.sbApy } } + + suspend fun syncSbApy() { + apyDeferred.await() + } + + suspend fun getApy(id: String): Double? { + return apyDeferred.await()[id]?.times(100) + } +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/DemeterFarmingRepository.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/DemeterFarmingRepository.kt new file mode 100644 index 0000000000..94f92fe889 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/DemeterFarmingRepository.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.liquiditypools.data + +import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingPool + +interface DemeterFarmingRepository { + suspend fun getFarmedPools(chainId: String): List? +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/LiquidityData.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/LiquidityData.kt new file mode 100644 index 0000000000..eb58a25a90 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/LiquidityData.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.liquiditypools.data + +import java.math.BigDecimal + +data class LiquidityData( + val firstReserves: BigDecimal = BigDecimal.ZERO, + val secondReserves: BigDecimal = BigDecimal.ZERO, + val firstPooled: BigDecimal = BigDecimal.ZERO, + val secondPooled: BigDecimal = BigDecimal.ZERO, + val sbApy: Double? = null, +) diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolDataDto.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolDataDto.kt new file mode 100644 index 0000000000..72d2b7c566 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolDataDto.kt @@ -0,0 +1,13 @@ +package jp.co.soramitsu.liquiditypools.data + +import java.math.BigInteger + +data class PoolDataDto( + val baseAssetId: String, + val assetId: String, + val reservesFirst: BigInteger, + val reservesSecond: BigInteger, + val totalIssuance: BigInteger, + val poolProvidersBalance: BigInteger, + val reservesAccount: String, +) diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolsRepository.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolsRepository.kt new file mode 100644 index 0000000000..7b501db2a8 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/data/PoolsRepository.kt @@ -0,0 +1,93 @@ +package jp.co.soramitsu.liquiditypools.data + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.liquiditypools.domain.model.BasicPoolData +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +@Suppress("ComplexInterface") +interface PoolsRepository { + + val poolsChainId: String + + suspend fun isPairAvailable( + chainId: ChainId, + tokenFromId: String, + tokenToId: String, + dexId: Int + ): Boolean + + suspend fun getBasicPools(chainId: ChainId): List + + suspend fun getBasicPool( + chainId: ChainId, + baseTokenId: String, + targetTokenId: String + ): BasicPoolData? + + suspend fun getUserPoolData( + chainId: ChainId, + address: String, + baseTokenId: String, + targetTokenId: ByteArray + ): PoolDataDto? + + @Suppress("LongParameterList") + suspend fun calcAddLiquidityNetworkFee( + chainId: ChainId, + address: String, + tokenBase: Asset, + tokenTarget: Asset, + tokenBaseAmount: BigDecimal, + tokenTargetAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal? + + suspend fun calcRemoveLiquidityNetworkFee( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + ): BigDecimal? + + suspend fun getPoolBaseTokenDexId(chainId: ChainId, tokenId: String?): Int + + suspend fun getPoolStrategicBonusAPY(reserveAccountOfPool: String): Double? + + suspend fun observeRemoveLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + markerAssetDesired: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal + ): Result? + + @Suppress("LongParameterList") + suspend fun observeAddLiquidity( + chainId: ChainId, + address: String, + keypair: Keypair, + tokenBase: Asset, + tokenTarget: Asset, + amountBase: BigDecimal, + amountTarget: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): Result? + + suspend fun updateAccountPools(chainId: ChainId, address: String) + suspend fun updateBasicPools(chainId: ChainId) + + fun subscribePools(address: String): Flow> + fun subscribePool( + address: String, + baseTokenId: String, + targetTokenId: String + ): Flow +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/DemeterFarmingPool.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/DemeterFarmingPool.kt new file mode 100644 index 0000000000..3dcf7eff82 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/DemeterFarmingPool.kt @@ -0,0 +1,22 @@ +package jp.co.soramitsu.liquiditypools.domain + +import jp.co.soramitsu.wallet.impl.domain.model.Asset +import java.math.BigDecimal + +data class DemeterFarmingPool( + val tokenBase: Asset, + val tokenTarget: Asset, + val tokenReward: Asset, + val apr: Double, + val amount: BigDecimal, + val amountReward: BigDecimal, +) + +data class DemeterFarmingBasicPool( + val tokenBase: Asset, + val tokenTarget: Asset, + val tokenReward: Asset, + val apr: Double, + val tvl: BigDecimal, + val fee: Double, +) diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/DemeterFarmingInteractor.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/DemeterFarmingInteractor.kt new file mode 100644 index 0000000000..c3e0329462 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/DemeterFarmingInteractor.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.liquiditypools.domain.interfaces + +import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingPool +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId + +interface DemeterFarmingInteractor { + suspend fun getFarmedPools(chainId: ChainId): List? +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/PoolsInteractor.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/PoolsInteractor.kt new file mode 100644 index 0000000000..e29c9fac48 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/interfaces/PoolsInteractor.kt @@ -0,0 +1,72 @@ +package jp.co.soramitsu.liquiditypools.domain.interfaces + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.liquiditypools.data.PoolDataDto +import jp.co.soramitsu.liquiditypools.domain.model.BasicPoolData +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +@Suppress("ComplexInterface") +interface PoolsInteractor { + val poolsChainId: String + + suspend fun getBasicPools(): List + + fun subscribePoolsCacheOfAccount(address: String): Flow> + fun subscribePoolsCacheCurrentAccount(): Flow> + suspend fun getPoolData(baseTokenId: String, targetTokenId: String): Flow + + suspend fun getUserPoolData( + chainId: ChainId, + address: String, + baseTokenId: String, + tokenId: ByteArray + ): PoolDataDto? + + @Suppress("LongParameterList") + suspend fun calcAddLiquidityNetworkFee( + chainId: ChainId, + address: String, + tokenBase: Asset, + tokenTarget: Asset, + tokenBaseAmount: BigDecimal, + tokenTargetAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal? + + suspend fun calcRemoveLiquidityNetworkFee(tokenBase: Asset, tokenTarget: Asset): BigDecimal? + + suspend fun isPairEnabled(baseTokenId: String, targetTokenId: String): Boolean + + @Suppress("LongParameterList") + suspend fun observeAddLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + amountBase: BigDecimal, + amountTarget: BigDecimal, + enabled: Boolean, + presented: Boolean, + slippageTolerance: Double + ): String + + suspend fun syncPools() + + suspend fun updateAccountPools() + + suspend fun observeRemoveLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + markerAssetDesired: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal, + networkFee: BigDecimal + ): String + + suspend fun getSbApy(id: String): Double? +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/BasicPoolData.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/BasicPoolData.kt new file mode 100644 index 0000000000..700923b122 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/BasicPoolData.kt @@ -0,0 +1,17 @@ +package jp.co.soramitsu.liquiditypools.domain.model + +import jp.co.soramitsu.core.models.Asset +import java.math.BigDecimal + +data class BasicPoolData( + val baseToken: Asset, + val targetToken: Asset?, + val baseReserves: BigDecimal, + val targetReserves: BigDecimal, + val totalIssuance: BigDecimal, + val reserveAccount: String +) { + fun getTvl(baseTokenFiatRate: BigDecimal?): BigDecimal? { + return baseTokenFiatRate?.times(BigDecimal(2))?.multiply(baseReserves) + } +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/UserPoolData.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/UserPoolData.kt new file mode 100644 index 0000000000..b931cf3daa --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/domain/model/UserPoolData.kt @@ -0,0 +1,30 @@ +package jp.co.soramitsu.liquiditypools.domain.model + +import java.math.BigDecimal + +data class CommonUserPoolData( + val basic: BasicPoolData, + val user: UserPoolData, +) + +data class CommonPoolData( + val basic: BasicPoolData, + val user: UserPoolData?, +) + +data class UserPoolData( + val basePooled: BigDecimal, + val targetPooled: BigDecimal, + val poolShare: Double, + val poolProvidersBalance: BigDecimal, +) + +fun BasicPoolData.isFilterMatch(filter: String): Boolean { + val t1 = + targetToken?.symbol?.lowercase()?.contains(filter.lowercase()) == true || + targetToken?.currencyId?.lowercase()?.contains(filter.lowercase()) == true + val t2 = + baseToken.symbol.lowercase().contains(filter.lowercase()) || + baseToken.currencyId?.lowercase()?.contains(filter.lowercase()) == true + return t1 || t2 +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/InternalPoolsRouter.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/InternalPoolsRouter.kt new file mode 100644 index 0000000000..bfbecedc18 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/InternalPoolsRouter.kt @@ -0,0 +1,48 @@ +package jp.co.soramitsu.liquiditypools.navigation + +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +@Suppress("ComplexInterface") +interface InternalPoolsRouter { + fun createNavGraphRoutesFlow(): Flow + fun createNavGraphActionsFlow(): Flow + fun back() + fun popupToScreen(route: LiquidityPoolsNavGraphRoute) + + fun openAllPoolsScreen() + fun openDetailsPoolScreen(ids: StringPair) + + fun openAddLiquidityScreen(ids: StringPair) + fun openAddLiquidityConfirmScreen( + ids: StringPair, + amountBase: BigDecimal, + amountTarget: BigDecimal, + apy: String + ) + + fun openRemoveLiquidityScreen(ids: StringPair) + fun openRemoveLiquidityConfirmScreen( + ids: StringPair, + amountBase: BigDecimal, + amountTarget: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal, + desired: BigDecimal + ) + + fun openPoolListScreen(isUserPools: Boolean) + + fun openErrorsScreen(title: String? = null, message: String) + fun openInfoScreen(title: String, message: String) + fun openInfoScreen(itemId: Int) + fun openSuccessScreen( + txHash: String, + chainId: ChainId, + customMessage: String + ) + + fun destination(clazz: Class): T? +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsNavGraphRoute.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsNavGraphRoute.kt new file mode 100644 index 0000000000..1b950bec54 --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsNavGraphRoute.kt @@ -0,0 +1,75 @@ +package jp.co.soramitsu.liquiditypools.navigation + +import jp.co.soramitsu.androidfoundation.format.StringPair +import java.math.BigDecimal + +sealed interface LiquidityPoolsNavGraphRoute { + + val routeName: String + + data object Loading : LiquidityPoolsNavGraphRoute { + override val routeName: String = "Loading" + } + + class AllPoolsScreen : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "AllPoolsScreen" + } + } + + class ListPoolsScreen( + val isUserPools: Boolean + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "ListPoolsScreen" + } + } + + class PoolDetailsScreen( + val ids: StringPair + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "LiquidityPoolDetailsScreen" + } + } + + class LiquidityAddScreen( + val ids: StringPair + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "LiquidityAddScreens" + } + } + + class LiquidityAddConfirmScreen( + val ids: StringPair, + val amountBase: BigDecimal, + val amountTarget: BigDecimal, + val apy: String + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "LiquidityAddConfirmScreen" + } + } + + class LiquidityRemoveScreen( + val ids: StringPair + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "LiquidityRemoveScreen" + } + } + + class LiquidityRemoveConfirmScreen( + val ids: StringPair, + val amountBase: BigDecimal, + val amountTarget: BigDecimal, + val firstAmountMin: BigDecimal, + val secondAmountMin: BigDecimal, + val desired: BigDecimal, + ) : LiquidityPoolsNavGraphRoute by Companion { + companion object : LiquidityPoolsNavGraphRoute { + override val routeName: String = "LiquidityRemoveConfirmScreen" + } + } +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsRouter.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsRouter.kt new file mode 100644 index 0000000000..bda191f2ab --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/LiquidityPoolsRouter.kt @@ -0,0 +1,6 @@ +package jp.co.soramitsu.liquiditypools.navigation + +interface LiquidityPoolsRouter { + + fun back() +} diff --git a/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/NavAction.kt b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/NavAction.kt new file mode 100644 index 0000000000..89c35dd33b --- /dev/null +++ b/feature-liquiditypools-api/src/main/java/jp/co/soramitsu/liquiditypools/navigation/NavAction.kt @@ -0,0 +1,15 @@ +package jp.co.soramitsu.liquiditypools.navigation + +sealed interface NavAction { + data object BackPressed : NavAction + + class ShowError( + val errorTitle: String?, + val errorText: String + ) : NavAction + + class ShowInfo( + val title: String, + val message: String + ) : NavAction +} diff --git a/feature-liquiditypools-impl/.gitignore b/feature-liquiditypools-impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature-liquiditypools-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-liquiditypools-impl/build.gradle.kts b/feature-liquiditypools-impl/build.gradle.kts new file mode 100644 index 0000000000..6cc5d60fa3 --- /dev/null +++ b/feature-liquiditypools-impl/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.library") + id("dagger.hilt.android.plugin") + id("kotlin-android") + id("kotlin-kapt") + id("kotlin-parcelize") +} +android { + namespace = "jp.co.soramitsu.feature_liquiditypools_impl" + compileSdk = rootProject.ext["compileSdkVersion"] as Int + + defaultConfig { + minSdk = rootProject.ext["minSdkVersion"] as Int + targetSdk = rootProject.ext["targetSdkVersion"] as Int + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + composeOptions { + kotlinCompilerExtensionVersion = rootProject.ext["composeCompilerVersion"] as String + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(projects.androidFoundation) + implementation(projects.common) + implementation(projects.runtime) + implementation(projects.featurePolkaswapApi) + implementation(projects.featureLiquiditypoolsApi) + implementation(projects.featureAccountApi) + implementation(projects.featureWalletApi) + implementation(projects.featureWalletImpl) + + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.bundles.compose) + implementation(libs.fragmentKtx) + implementation(libs.material) + implementation(libs.navigation.compose) + implementation(libs.sora.ui) + implementation(libs.room.ktx) +} diff --git a/feature-liquiditypools-impl/src/main/AndroidManifest.xml b/feature-liquiditypools-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..afa76499b6 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt new file mode 100644 index 0000000000..8c3aed02e8 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt @@ -0,0 +1,312 @@ +package jp.co.soramitsu.liquiditypools.impl.data + +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.androidfoundation.format.addHexPrefix +import jp.co.soramitsu.androidfoundation.format.isZero +import jp.co.soramitsu.androidfoundation.format.mapBalance +import jp.co.soramitsu.androidfoundation.format.orZero +import jp.co.soramitsu.androidfoundation.format.safeCast +import jp.co.soramitsu.common.data.network.rpc.BulkRetriever +import jp.co.soramitsu.common.data.network.rpc.retrieveAllValues +import jp.co.soramitsu.liquiditypools.data.DemeterFarmingRepository +import jp.co.soramitsu.liquiditypools.data.PoolsRepository +import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingBasicPool +import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingPool +import jp.co.soramitsu.runtime.ext.addressOf +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.shared_utils.runtime.metadata.module +import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.shared_utils.wsrpc.executeAsync +import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.GetStorageRequest +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository +import jp.co.soramitsu.wallet.impl.domain.model.Asset +import java.math.BigDecimal +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap + +class DemeterFarmingRepositoryImpl( + private val chainRegistry: ChainRegistry, + private val bulkRetriever: BulkRetriever, + private val accountRepository: AccountRepository, + private val walletRepository: WalletRepository, + private val poolsRepository: PoolsRepository, +) : DemeterFarmingRepository { + + private val cachedFarmedPools = ConcurrentHashMap>() + private var cachedFarmedBasicPools: List? = null + + override suspend fun getFarmedPools(chainId: String): List? { + val soraAccountAddress = accountRepository.getSelectedAccount(chainId).address + + if (cachedFarmedPools.containsKey(soraAccountAddress)) return cachedFarmedPools[soraAccountAddress] + cachedFarmedPools.remove(soraAccountAddress) + + val baseFarms = getFarmedBasicPools(chainId) + val soraAssets = getSoraAssets(chainId) + + val calculated = getDemeter(chainId, soraAccountAddress) + ?.filter { it.farm && it.amount.isZero().not() } + ?.mapNotNull { demeter -> + val base = baseFarms.firstOrNull { base -> + base.tokenBase.token.configuration.currencyId == demeter.base && + base.tokenTarget.token.configuration.currencyId == demeter.pool && + base.tokenReward.token.configuration.currencyId == demeter.reward + } ?: return@mapNotNull null + + val baseTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == demeter.base + } ?: return@mapNotNull null + val poolTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == demeter.pool + } ?: return@mapNotNull null + val rewardTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == demeter.reward + } ?: return@mapNotNull null + + DemeterFarmingPool( + tokenBase = baseTokenMapped, + tokenTarget = poolTokenMapped, + tokenReward = rewardTokenMapped, + apr = base.apr, + amount = mapBalance(demeter.amount, baseTokenMapped.token.configuration.precision), + amountReward = mapBalance(demeter.rewardAmount, rewardTokenMapped.token.configuration.precision), + ) + } ?: return null + return cachedFarmedPools.getOrPut(soraAccountAddress) { calculated } + } + + suspend fun getFarmedBasicPools(chainId: ChainId): List { + if (cachedFarmedBasicPools == null) { + val rewardTokens = getRewardTokens(chainId) + + val soraAssets = getSoraAssets(chainId) + + cachedFarmedBasicPools = getAllFarms(chainId) + .mapNotNull { basic -> + runCatching { + val baseTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == basic.base + } ?: return@mapNotNull null + val poolTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == basic.pool + } ?: return@mapNotNull null + val rewardTokenMapped = soraAssets.firstOrNull { + it.token.configuration.currencyId == basic.reward + } ?: return@mapNotNull null + val rewardToken = rewardTokens.find { it.token == basic.reward } + val emission = getEmission(basic, rewardToken, rewardTokenMapped.token.configuration.precision) + val total = mapBalance(basic.totalTokensInPool, poolTokenMapped.token.configuration.precision) + val poolTokenPrice = poolTokenMapped.token.fiatRate.orZero() + val rewardTokenPrice = rewardTokenMapped.token.fiatRate.orZero() + val tvl = if (basic.isFarm) { + poolsRepository.getBasicPool(chainId, basic.base, basic.pool)?.let { pool -> + val kf = pool.targetReserves.div(pool.totalIssuance) + kf.times(total).times(2.toBigDecimal()).times(poolTokenPrice) + } ?: BigDecimal.ZERO + } else { + total.times(poolTokenPrice) + } + + val apr = if (tvl.isZero()) { + BigDecimal.ZERO + } else { + emission + .times(BLOCKS_PER_YEAR.toBigDecimal()) + .times(rewardTokenPrice) + .div(tvl).times(100.toBigDecimal()) + } + + DemeterFarmingBasicPool( + tokenBase = baseTokenMapped, + tokenTarget = poolTokenMapped, + tokenReward = rewardTokenMapped, + apr = apr.toDouble(), + tvl = tvl, + fee = mapBalance(basic.depositFee, baseTokenMapped.token.configuration.precision).toDouble() + .times(100.0), + ) + }.getOrNull() + } + } + + return cachedFarmedBasicPools ?: emptyList() + } + + private suspend fun getSoraAssets(chainId: ChainId): List { + val soraChain = chainRegistry.getChain(chainId) + val wallet = accountRepository.getSelectedMetaAccount() + val accountId = wallet.accountId(soraChain) + val soraAssets = soraChain.assets.mapNotNull { chainAsset -> + accountId?.let { + walletRepository.getAsset( + metaId = wallet.id, + accountId = accountId, + chainAsset = chainAsset, + minSupportedVersion = null + ) + } + } + return soraAssets + } + + private suspend fun getRewardTokens(chainId: ChainId): List { + val runtime = chainRegistry.getRuntimeOrNull(chainId) ?: return emptyList() + val chain = chainRegistry.getChain(chainId) + + val storage = runtime.metadata.module("DemeterFarmingPlatform") + .storage("TokenInfos") + val type = storage.type.value ?: return emptyList() + val storageKey = storage.storageKey( + runtime + ) + + val socketService = chainRegistry.awaitConnection(chainId).socketService + + return bulkRetriever.retrieveAllValues(socketService, storageKey).mapNotNull { hex -> + hex.value?.let { hexValue -> + runCatching { + type.fromHex(runtime, hexValue) + ?.safeCast()?.let { decoded -> + decoded.get("teamAccount")?.let { chain.addressOf(it) } + DemeterRewardTokenStorage( + token = hex.key.assetIdFromKey(), + account = decoded.get("teamAccount")?.let { chain.addressOf(it) }.orEmpty(), + farmsTotalMultiplier = decoded.get("farmsTotalMultiplier")!!, + stakingTotalMultiplier = decoded.get("stakingTotalMultiplier")!!, + tokenPerBlock = decoded.get("tokenPerBlock")!!, + farmsAllocation = decoded.get("farmsAllocation")!!, + stakingAllocation = decoded.get("stakingAllocation")!!, + teamAllocation = decoded.get("teamAllocation")!!, + ) + } + }.getOrNull() + } + } + } + + private suspend fun getAllFarms(chainId: ChainId): List { + val runtime = chainRegistry.getRuntimeOrNull(chainId) ?: return emptyList() + val storage = runtime.metadata.module("DemeterFarmingPlatform") + .storage("Pools") + val type = storage.type.value ?: return emptyList() + val storageKey = storage.storageKey( + runtime, + ) + val socketService = chainRegistry.awaitConnection(chainId).socketService + val farms = bulkRetriever.retrieveAllValues(socketService, storageKey).mapNotNull { hex -> + hex.value?.let { hexValue -> + val decoded = type.fromHex(runtime, hexValue) + decoded?.safeCast>() + ?.filterIsInstance() + ?.mapNotNull { struct -> + runCatching { + DemeterBasicStorage( + base = struct.mapToToken("baseAsset")!!, + pool = hex.key.assetIdFromKey(1), + reward = hex.key.assetIdFromKey(), + multiplier = struct.get("multiplier")!!, + isCore = struct.get("isCore")!!, + isFarm = struct.get("isFarm")!!, + isRemoved = struct.get("isRemoved")!!, + depositFee = struct.get("depositFee")!!, + totalTokensInPool = struct.get("totalTokensInPool")!!, + rewards = struct.get("rewards")!!, + rewardsToBeDistributed = struct.get("rewardsToBeDistributed")!!, + ) + }.getOrNull() + } + } + }.flatten().filter { + it.isFarm && it.isRemoved.not() + } + return farms + } + + private fun getEmission( + basic: DemeterBasicStorage, + reward: DemeterRewardTokenStorage?, + precision: Int + ): BigDecimal { + val tokenMultiplier = + (if (basic.isFarm) reward?.farmsTotalMultiplier else reward?.stakingTotalMultiplier)?.toBigDecimal( + precision + ) ?: BigDecimal.ZERO + if (tokenMultiplier.isZero()) return BigDecimal.ZERO + val multiplier = basic.multiplier.toBigDecimal(precision).div(tokenMultiplier) + val allocation = + mapBalance( + (if (basic.isFarm) reward?.farmsAllocation else reward?.stakingAllocation) + ?: BigInteger.ZERO, + precision + ) + val tokenPerBlock = reward?.tokenPerBlock?.toBigDecimal(precision) ?: BigDecimal.ZERO + return allocation.times(tokenPerBlock).times(multiplier) + } + + @Suppress("ComplexCondition") + private suspend fun getDemeter(chainId: ChainId, address: String): List? { + val runtime = chainRegistry.getRuntimeOrNull(chainId) ?: return emptyList() + val storage = runtime.metadata.module("DemeterFarmingPlatform") + .storage("UserInfos") + val storageKey = storage.storageKey( + runtime, + address.toAccountId(), + ) + return getStorageHex(chainId, storageKey)?.let { hex -> + storage.type.value + ?.fromHex(runtime, hex) + ?.safeCast>() + ?.filterIsInstance() + ?.mapNotNull { instance -> + val baseToken = instance.mapToToken("baseAsset") + val poolToken = instance.mapToToken("poolAsset") + val rewardToken = instance.mapToToken("rewardAsset") + val isFarm = instance.get("isFarm") + val pooled = instance.get("pooledTokens") + val rewards = instance.get("rewards") + if (isFarm != null && baseToken != null && poolToken != null && + rewardToken != null && pooled != null && rewards != null + ) { + DemeterStorage( + base = baseToken, + pool = poolToken, + reward = rewardToken, + farm = isFarm, + amount = pooled, + rewardAmount = rewards, + ) + } else { + null + } + } + } + } + + private suspend fun getStorageHex(chainId: ChainId, storageKey: String): String? = + chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = pojo(), + ).result + + fun Struct.Instance.mapToToken(field: String) = this.get(field)?.getTokenId()?.toHexString(true) + + fun Struct.Instance.getTokenId() = get>("code") + ?.map { (it as BigInteger).toByte() } + ?.toByteArray() + + fun String.assetIdFromKey() = this.takeLast(ASSET_SIZE).addHexPrefix() + fun String.assetIdFromKey(pos: Int): String = this.substring(0, this.length - ASSET_SIZE * pos).assetIdFromKey() + + companion object { + private const val BLOCKS_PER_YEAR = 5_256_000 + private const val ASSET_SIZE = 64 + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterStorage.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterStorage.kt new file mode 100644 index 0000000000..ec80243c9a --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterStorage.kt @@ -0,0 +1,37 @@ +package jp.co.soramitsu.liquiditypools.impl.data + +import java.math.BigInteger + +class DemeterRewardTokenStorage( + val token: String, + val account: String, + val farmsTotalMultiplier: BigInteger, + val stakingTotalMultiplier: BigInteger, + val tokenPerBlock: BigInteger, + val farmsAllocation: BigInteger, + val stakingAllocation: BigInteger, + val teamAllocation: BigInteger, +) + +class DemeterBasicStorage( + val base: String, + val pool: String, + val reward: String, + val multiplier: BigInteger, + val isCore: Boolean, + val isFarm: Boolean, + val isRemoved: Boolean, + val depositFee: BigInteger, + val totalTokensInPool: BigInteger, + val rewards: BigInteger, + val rewardsToBeDistributed: BigInteger, +) + +class DemeterStorage( + val base: String, + val pool: String, + val reward: String, + val farm: Boolean, + val amount: BigInteger, + val rewardAmount: BigInteger, +) diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt new file mode 100644 index 0000000000..588327d2aa --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt @@ -0,0 +1,860 @@ +package jp.co.soramitsu.liquiditypools.impl.data + +import androidx.room.withTransaction +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.androidfoundation.format.addHexPrefix +import jp.co.soramitsu.androidfoundation.format.mapBalance +import jp.co.soramitsu.androidfoundation.format.safeCast +import jp.co.soramitsu.common.utils.Modules +import jp.co.soramitsu.common.utils.fromHex +import jp.co.soramitsu.core.extrinsic.ExtrinsicService +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.utils.utilityAsset +import jp.co.soramitsu.coredb.AppDatabase +import jp.co.soramitsu.coredb.dao.PoolDao +import jp.co.soramitsu.coredb.model.BasicPoolLocal +import jp.co.soramitsu.coredb.model.UserPoolJoinedLocal +import jp.co.soramitsu.coredb.model.UserPoolJoinedLocalNullable +import jp.co.soramitsu.coredb.model.UserPoolLocal +import jp.co.soramitsu.liquiditypools.blockexplorer.BlockExplorerManager +import jp.co.soramitsu.liquiditypools.data.PoolDataDto +import jp.co.soramitsu.liquiditypools.data.PoolsRepository +import jp.co.soramitsu.liquiditypools.domain.model.BasicPoolData +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.liquiditypools.domain.model.UserPoolData +import jp.co.soramitsu.liquiditypools.impl.data.network.depositLiquidity +import jp.co.soramitsu.liquiditypools.impl.data.network.initializePool +import jp.co.soramitsu.liquiditypools.impl.data.network.liquidityAdd +import jp.co.soramitsu.liquiditypools.impl.data.network.register +import jp.co.soramitsu.liquiditypools.impl.data.network.removeLiquidity +import jp.co.soramitsu.liquiditypools.impl.util.PolkaswapFormulas +import jp.co.soramitsu.runtime.ext.addressOf +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraMainChainId +import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.shared_utils.runtime.metadata.module +import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.shared_utils.scale.Schema +import jp.co.soramitsu.shared_utils.scale.dataType.uint32 +import jp.co.soramitsu.shared_utils.scale.sizedByteArray +import jp.co.soramitsu.shared_utils.scale.uint128 +import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.shared_utils.wsrpc.executeAsync +import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojoList +import jp.co.soramitsu.shared_utils.wsrpc.mappers.scale +import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.GetStorageRequest +import jp.co.soramitsu.shared_utils.wsrpc.subscription.response.SubscriptionChange +import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks +import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.supervisorScope +import java.math.BigDecimal +import java.math.BigInteger +import javax.inject.Inject + +@Suppress("LargeClass", "MagicNumber") +class PoolsRepositoryImpl @Inject constructor( + private val extrinsicService: ExtrinsicService, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val blockExplorerManager: BlockExplorerManager, + private val poolDao: PoolDao, + private val db: AppDatabase +) : PoolsRepository { + override val poolsChainId = soraMainChainId + + override suspend fun isPairAvailable( + chainId: ChainId, + tokenFromId: String, + tokenToId: String, + dexId: Int + ): Boolean { + val request = RuntimeRequest( + method = "liquidityProxy_isPathAvailable", + params = listOf( + dexId, + tokenFromId, + tokenToId + ) + ) + + return chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request, + mapper = pojo().nonNull() + ) + } + + fun ByteArray.mapAssetId() = this.toList().map { it.toInt().toBigInteger() } + fun String.mapAssetId() = this.fromHex().mapAssetId() + fun String.mapCodeToken() = Struct.Instance( + mapOf("code" to this.mapAssetId()) + ) + + fun RuntimeSnapshot.reservesKeyToken(baseTokenId: String): String = this.metadata.module(Modules.POOL_XYK) + .storage("Reserves") + .storageKey( + this, + baseTokenId.mapCodeToken(), + ) + + suspend fun getStorageHex(chainId: ChainId, storageKey: String): String? = + chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = pojo(), + ).result + + suspend fun getStateKeys(chainId: ChainId, partialKey: String): List = + chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = StateKeys(listOf(partialKey)), + mapper = pojoList(), + ).result ?: emptyList() + + class StateKeys(params: List) : RuntimeRequest("state_getKeys", params) + + fun ByteArray.mapCodeToken() = Struct.Instance( + mapOf("code" to this.mapAssetId()) + ) + + object PoolPropertiesResponse : Schema() { + val first by sizedByteArray(32) + val second by sizedByteArray(32) + } + + suspend fun getPoolReserveAccount( + chainId: ChainId, + baseTokenId: String, + tokenId: ByteArray + ): ByteArray? { + val runtimeOrNull = chainRegistry.getRuntimeOrNull(chainId) + val storageKey = runtimeOrNull?.metadata + ?.module(Modules.POOL_XYK) + ?.storage("Properties")?.storageKey( + runtimeOrNull, + baseTokenId.mapCodeToken(), + tokenId.mapCodeToken(), + ) + ?: return null + + return chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = scale(PoolPropertiesResponse), + ) + .result + ?.let { storage -> + storage[storage.schema.first] + } + } + + fun String.assetIdFromKey() = this.takeLast(64).addHexPrefix() + + object TotalIssuance : Schema() { + val value by uint128() + } + + suspend fun getPoolTotalIssuances(chainId: ChainId, reservesAccountId: ByteArray): BigInteger? { + val runtimeOrNull = chainRegistry.getRuntimeOrNull(chainId) + val storageKey = runtimeOrNull?.metadata?.module(Modules.POOL_XYK) + ?.storage("TotalIssuances") + ?.storageKey(runtimeOrNull, reservesAccountId) + ?: return null + return chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = scale(TotalIssuance), + ) + .result + ?.let { storage -> + storage[storage.schema.value] + } + } + + override suspend fun getPoolStrategicBonusAPY(reserveAccountOfPool: String): Double? { + val tempApy = blockExplorerManager.getApy(reserveAccountOfPool) + return tempApy + } + + override suspend fun getBasicPool( + chainId: ChainId, + baseTokenId: String, + targetTokenId: String + ): BasicPoolData? { + val poolLocal = poolDao.getBasicPool(baseTokenId, targetTokenId) ?: return null + + val soraChain = chainRegistry.getChain(chainId) + val soraAssets = soraChain.assets + val baseAsset = soraAssets.firstOrNull { + it.currencyId == baseTokenId + } ?: return null + val targetAsset = soraAssets.firstOrNull { + it.currencyId == targetTokenId + } + + return BasicPoolData( + baseToken = baseAsset, + targetToken = targetAsset, + baseReserves = poolLocal.reserveBase, + targetReserves = poolLocal.reserveTarget, + totalIssuance = poolLocal.totalIssuance, + reserveAccount = poolLocal.reservesAccount + ) + } + + @Suppress("NestedBlockDepth") + override suspend fun getBasicPools(chainId: ChainId): List { + val runtimeOrNull = chainRegistry.getRuntimeOrNull(chainId) + val storage = runtimeOrNull?.metadata + ?.module(Modules.POOL_XYK) + ?.storage("Reserves") + + val list = mutableListOf() + + val soraChain = chainRegistry.getChain(chainId) + + val wallet = accountRepository.getSelectedMetaAccount() + + val accountId = wallet.accountId(soraChain) + val soraAssets = soraChain.assets + + soraAssets.forEach { asset -> + val currencyId = asset.currencyId + val key = currencyId?.let { runtimeOrNull?.reservesKeyToken(it) } + key?.let { + getStateKeys(chainId, it).forEach { storageKey -> + val targetToken = storageKey.assetIdFromKey() + getStorageHex(chainId, storageKey)?.let { storageHex -> + storage?.type?.value + ?.fromHex(runtimeOrNull, storageHex) + ?.safeCast>()?.let { reserves -> + + val reserveAccount = getPoolReserveAccount( + chainId, + currencyId, + targetToken.fromHex() + ) + val total = reserveAccount?.let { + getPoolTotalIssuances(chainId, it) + }?.let { + mapBalance(it, asset.precision) + } + val targetAsset = + soraAssets.firstOrNull { it.currencyId == targetToken } + val reserveAccountAddress = + reserveAccount?.let { soraChain.addressOf(it) } ?: "" + + val element = BasicPoolData( + baseToken = asset, + targetToken = targetAsset, + baseReserves = mapBalance(reserves[0], asset.precision), + targetReserves = mapBalance(reserves[1], asset.precision), + totalIssuance = total ?: BigDecimal.ZERO, + reserveAccount = reserveAccountAddress + ) + + list.add( + element + ) + } + } + } + } + } + + return list + } + + override suspend fun getUserPoolData( + chainId: ChainId, + address: String, + baseTokenId: String, + targetTokenId: ByteArray + ): PoolDataDto? { + return coroutineScope { + val reservesDeferred = + async { getPairWithXorReserves(chainId, baseTokenId, targetTokenId) } + val totalIssuanceAndPropertiesDeferred = + async { + getPoolTotalIssuanceAndProperties( + chainId, + baseTokenId, + targetTokenId, + address + ) + } + val chainDeferred = async { chainRegistry.getChain(chainId) } + + val reserves = kotlin.runCatching { reservesDeferred.await() }.getOrNull() + ?: return@coroutineScope null + + val totalIssuanceAndProperties = + kotlin.runCatching { totalIssuanceAndPropertiesDeferred.await() }.getOrNull() + ?: return@coroutineScope null + + val reservesAccount = chainDeferred.await() + .addressOf(totalIssuanceAndProperties.third) + + PoolDataDto( + baseTokenId, + targetTokenId.toHexString(true), + reserves.first, + reserves.second, + totalIssuanceAndProperties.first, + totalIssuanceAndProperties.second, + reservesAccount, + ) + } + } + + override suspend fun calcAddLiquidityNetworkFee( + chainId: ChainId, + address: String, + tokenBase: Asset, + tokenTarget: Asset, + tokenBaseAmount: BigDecimal, + tokenTargetAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal? { + val amountFromMin = PolkaswapFormulas.calculateMinAmount(tokenBaseAmount, slippageTolerance) + val amountToMin = PolkaswapFormulas.calculateMinAmount(tokenTargetAmount, slippageTolerance) + val dexId = getPoolBaseTokenDexId(chainId, tokenBase.currencyId) + val chain = chainRegistry.getChain(chainId) + + val fee = extrinsicService.estimateFee(chain) { + liquidityAdd( + dexId = dexId, + baseTokenId = tokenBase.currencyId, + targetTokenId = tokenTarget.currencyId, + pairPresented = pairPresented, + pairEnabled = pairEnabled, + tokenBaseAmount = tokenBase.planksFromAmount(tokenBaseAmount), + tokenTargetAmount = tokenTarget.planksFromAmount(tokenTargetAmount), + amountBaseMin = tokenBase.planksFromAmount(amountFromMin), + amountTargetMin = tokenTarget.planksFromAmount(amountToMin), + ) + } + + val feeToken = chain.utilityAsset + return feeToken?.amountFromPlanks(fee) + } + + override suspend fun calcRemoveLiquidityNetworkFee( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + ): BigDecimal? { + val chain = chainRegistry.getChain(chainId) + val baseTokenId = tokenBase.currencyId ?: return null + val targetTokenId = tokenTarget.currencyId ?: return null + + val fee = extrinsicService.estimateFee(chain) { + removeLiquidity( + dexId = getPoolBaseTokenDexId(chainId, baseTokenId), + outputAssetIdA = baseTokenId, + outputAssetIdB = targetTokenId, + markerAssetDesired = tokenBase.planksFromAmount(BigDecimal.ONE), + outputAMin = tokenBase.planksFromAmount(BigDecimal.ONE), + outputBMin = tokenTarget.planksFromAmount(BigDecimal.ONE), + ) + } + val feeToken = chain.utilityAsset + return feeToken?.amountFromPlanks(fee) + } + + override suspend fun getPoolBaseTokenDexId(chainId: ChainId, tokenId: String?): Int { + return getPoolBaseTokens(chainId).first { + it.second == tokenId + }.first + } + + private suspend fun getPoolBaseTokens(chainId: ChainId): List> { + val runtimeSnapshot = chainRegistry.awaitRuntimeProvider(chainId).get() + val metadataStorage = runtimeSnapshot.metadata + .module("DEXManager") + .storage("DEXInfos") + + val partialKey = metadataStorage.storageKey() + val connection = chainRegistry.awaitConnection(chainId) + + val storageKeys = connection.socketService.executeAsync( + request = StateKeys(listOf(partialKey)), + mapper = pojoList().nonNull() + ) + return supervisorScope { + val poolBaseTokensDeferred = storageKeys.map { storageKey -> + async { + val storage = connection.socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = pojo().nonNull() + ) + val storageType = metadataStorage.type.value!! + val storageRawData = storageType.fromHex(runtimeSnapshot, storage) + + (storageRawData as? Struct.Instance)?.let { instance -> + instance.mapToToken("baseAssetId")?.let { token -> + storageKey.takeInt32() to token + } + } + } + } + poolBaseTokensDeferred.awaitAll().filterNotNull() + } + } + + fun Struct.Instance.mapToToken(field: String) = this.get(field)?.getTokenId()?.toHexString(true) + + fun String.takeInt32() = uint32.fromHex(this.takeLast(8)).toInt() + + private suspend fun getPoolTotalIssuanceAndProperties( + chainId: ChainId, + baseTokenId: String, + tokenId: ByteArray, + address: String + ): Triple? { + return getPoolReserveAccount(chainId, baseTokenId, tokenId)?.let { account -> + getPoolTotalIssuances( + chainId, + account + )?.let { + val provider = getPoolProviders( + chainId, + account, + address + ) + Triple(it, provider, account) + } + } + } + + object PoolProviders : Schema() { + val value by uint128() + } + + private suspend fun getPoolProviders( + chainId: ChainId, + reservesAccountId: ByteArray, + currentAddress: String + ): BigInteger { + val storageKey = + chainRegistry.getRuntimeOrNull(chainId)?.let { + it.metadata.module(Modules.POOL_XYK) + .storage("PoolProviders").storageKey( + it, + reservesAccountId, + currentAddress.toAccountId() + ) + } ?: return BigInteger.ZERO + return runCatching { + chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = scale(PoolProviders), + ) + .let { storage -> + storage.result?.let { + it[it.schema.value] + } ?: BigInteger.ZERO + } + }.getOrElse { + it.printStackTrace() + throw it + } + } + + object ReservesResponse : Schema() { + val first by uint128() + val second by uint128() + } + + private suspend fun getPairWithXorReserves( + chainId: ChainId, + baseTokenId: String, + tokenId: ByteArray + ): Pair? { + val storageKey = + chainRegistry.getRuntimeOrNull(chainId)?.reservesKey(baseTokenId, tokenId) + ?: return null + return chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = scale(ReservesResponse), + ) + .result + ?.let { storage -> + storage[storage.schema.first] to storage[storage.schema.second] + } + } + + fun RuntimeSnapshot.reservesKey(baseTokenId: String, tokenId: ByteArray): String = + this.metadata.module(Modules.POOL_XYK) + .storage("Reserves") + .storageKey( + this, + baseTokenId.mapCodeToken(), + tokenId.mapCodeToken(), + ) + + @Suppress("UNCHECKED_CAST", "ThrowsCount") + fun SubscriptionChange.storageChange(): SubscribeStorageResult { + val result = params.result as? Map<*, *> + ?: throw IllegalArgumentException("${params.result} is not a valid storage result") + + val block = result["block"] as? String + ?: throw IllegalArgumentException("$result is not a valid storage result") + val changes = result["changes"] as? List> + ?: throw IllegalArgumentException("$result is not a valid storage result") + + return SubscribeStorageResult(block, changes) + } + + // changes are in format [[storage key, value], [..], ..] + class SubscribeStorageResult(val block: String, val changes: List>) { + fun getSingleChange() = changes.first()[1] + } + + fun Struct.Instance.getTokenId() = get>("code") + ?.map { (it as BigInteger).toByte() } + ?.toByteArray() + + private suspend fun getUserPoolsTokenIdsKeys(chainId: ChainId, address: String): List { + val accountPoolsKey = chainRegistry.getRuntimeOrNull(chainId)?.accountPoolsKey(address) + return chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = StateKeys(listOfNotNull(accountPoolsKey)), + mapper = pojoList().nonNull() + ) + } + + private suspend fun getUserPoolsTokenIds(chainId: ChainId, address: String): List>> { + val storageKeys = getUserPoolsTokenIdsKeys(chainId, address) + return storageKeys.map { storageKey -> + chainRegistry.awaitConnection(chainId).socketService.executeAsync( + request = GetStorageRequest(listOf(storageKey)), + mapper = pojo().nonNull(), + ) + .let { storage -> + val storageType = + chainRegistry.getRuntimeOrNull(chainId)?.metadata?.module(Modules.POOL_XYK) + ?.storage("AccountPools")?.type?.value!! + val storageRawData = + storageType.fromHex(chainRegistry.getRuntimeOrNull(chainId)!!, storage) + val tokens: List = if (storageRawData is List<*>) { + storageRawData.filterIsInstance() + .mapNotNull { struct -> + struct.getTokenId() + } + } else { + emptyList() + } + storageKey.assetIdFromKey() to tokens + } + } + } + + override suspend fun observeRemoveLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + markerAssetDesired: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal + ): Result? { + val soraChain = accountRepository.getChain(chainId) + val accountId = accountRepository.getSelectedMetaAccount().substrateAccountId + val baseTokenId = tokenBase.currencyId ?: return null + val targetTokenId = tokenTarget.currencyId ?: return null + + return extrinsicService.submitExtrinsic( + chain = soraChain, + accountId = accountId + ) { + removeLiquidity( + dexId = getPoolBaseTokenDexId(chainId, baseTokenId), + outputAssetIdA = baseTokenId, + outputAssetIdB = targetTokenId, + markerAssetDesired = tokenBase.planksFromAmount(markerAssetDesired), + outputAMin = tokenBase.planksFromAmount(firstAmountMin), + outputBMin = tokenTarget.planksFromAmount(secondAmountMin), + ) + } + } + + override suspend fun observeAddLiquidity( + chainId: ChainId, + address: String, + keypair: Keypair, + tokenBase: Asset, + tokenTarget: Asset, + amountBase: BigDecimal, + amountTarget: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): Result? { + val amountFromMin = PolkaswapFormulas.calculateMinAmount(amountBase, slippageTolerance) + val amountToMin = PolkaswapFormulas.calculateMinAmount(amountTarget, slippageTolerance) + val dexId = getPoolBaseTokenDexId(chainId, tokenBase.currencyId) + val soraChain = accountRepository.getChain(chainId) + val accountId = accountRepository.getSelectedMetaAccount().substrateAccountId + + val baseTokenId = tokenBase.currencyId + val targetTokenId = tokenTarget.currencyId + if (baseTokenId == null || targetTokenId == null) return null + + return extrinsicService.submitExtrinsic( + chain = soraChain, + accountId = accountId, + useBatchAll = !pairPresented + ) { + if (!pairPresented) { + if (!pairEnabled) { + register( + dexId = dexId, + baseAssetId = baseTokenId, + targetAssetId = targetTokenId + ) + } + initializePool( + dexId = dexId, + baseAssetId = baseTokenId, + targetAssetId = targetTokenId + ) + } + + depositLiquidity( + dexId = dexId, + baseAssetId = baseTokenId, + targetAssetId = targetTokenId, + baseAssetAmount = mapBalance(amountBase, tokenBase.precision), + targetAssetAmount = mapBalance(amountTarget, tokenTarget.precision), + amountFromMin = mapBalance(amountFromMin, tokenBase.precision), + amountToMin = mapBalance(amountToMin, tokenTarget.precision) + ) + } + } + + override suspend fun updateAccountPools(chainId: ChainId, address: String) = supervisorScope { + val assets = chainRegistry.getChain(chainId).assets + val tokenIds = getUserPoolsTokenIds(chainId, address) + val poolsDeferred = tokenIds.map { (baseTokenId, tokensId) -> + async { + val baseToken = assets.firstOrNull { + it.currencyId == baseTokenId + } ?: return@async emptyList() + + val xorPrecision = baseToken.precision + + val poolData = tokensId.map { tokenId -> + async { getUserPoolData(chainId, address, baseTokenId, tokenId) } + } + + poolData.awaitAll().filterNotNull().mapNotNull pool@{ poolDataDto -> + val token = assets.firstOrNull { + it.currencyId == poolDataDto.assetId + } ?: return@pool null + val tokenPrecision = token.precision + + val basicPoolLocal = BasicPoolLocal( + tokenIdBase = baseTokenId, + tokenIdTarget = poolDataDto.assetId, + reserveBase = mapBalance( + poolDataDto.reservesFirst, + xorPrecision + ), + reserveTarget = mapBalance( + poolDataDto.reservesSecond, + tokenPrecision + ), + totalIssuance = mapBalance( + poolDataDto.totalIssuance, + xorPrecision + ), + reservesAccount = poolDataDto.reservesAccount, + ) + + val userPoolLocal = UserPoolLocal( + accountAddress = address, + userTokenIdBase = baseTokenId, + userTokenIdTarget = poolDataDto.assetId, + poolProvidersBalance = mapBalance( + poolDataDto.poolProvidersBalance, + xorPrecision + ) + ) + + return@pool UserPoolJoinedLocal( + userPoolLocal = userPoolLocal, + basicPoolLocal = basicPoolLocal, + ) + } + } + } + + val pools = poolsDeferred.awaitAll().flatten() + + db.withTransaction { + poolDao.clearTable(address) + poolDao.insertBasicPools( + pools.map { + it.basicPoolLocal + } + ) + poolDao.insertUserPools( + pools.map { + it.userPoolLocal + } + ) + } + } + + override suspend fun updateBasicPools(chainId: ChainId) = coroutineScope { + val runtimeOrNull = chainRegistry.awaitRuntimeProvider(chainId).get() + val storage = runtimeOrNull.metadata + .module(Modules.POOL_XYK) + .storage("Reserves") + + val soraChain = chainRegistry.getChain(chainId) + val assets = soraChain.assets + + val basicPoolsLocalDeferred = getPoolBaseTokens(chainId).map { (dexId, tokenId) -> + async { + val asset = assets.firstOrNull { it.currencyId == tokenId } ?: return@async null + val key = runtimeOrNull.reservesKeyToken(tokenId) + + val basicPoolDeferred = getStateKeys(chainId, key).map { storageKey -> + async basicPoolOperation@{ + val targetToken = storageKey.assetIdFromKey() + val storageHex = + getStorageHex(chainId, storageKey) ?: return@basicPoolOperation null + val reserves = storage.type.value + ?.fromHex(runtimeOrNull, storageHex) + ?.safeCast>() ?: return@basicPoolOperation null + + val reserveAccount = getPoolReserveAccount( + chainId, + tokenId, + targetToken.fromHex() + ) + + val total = reserveAccount?.let { + getPoolTotalIssuances(chainId, it) + }?.let { + mapBalance(it, asset.precision) + } + + val reserveAccountAddress = + reserveAccount?.let { soraChain.addressOf(it) } ?: "" + + BasicPoolLocal( + tokenIdBase = tokenId, + tokenIdTarget = targetToken, + reserveBase = mapBalance(reserves[0], asset.precision), + reserveTarget = mapBalance( + reserves[1], + asset.precision + ), + totalIssuance = total ?: BigDecimal.ZERO, + reservesAccount = reserveAccountAddress, + ) + } + } + basicPoolDeferred.awaitAll().filterNotNull() + } + } + val list = basicPoolsLocalDeferred.awaitAll().filterNotNull().flatten() + + val minus = poolDao.getBasicPools().filter { db -> + list.find { it.tokenIdBase == db.tokenIdBase && it.tokenIdTarget == db.tokenIdTarget } == null + } + poolDao.deleteBasicPools(minus) + poolDao.insertBasicPools(list) + } + + private suspend fun getAssets(): List { + return chainRegistry.getChain(poolsChainId).assets + } + + override fun subscribePool( + address: String, + baseTokenId: String, + targetTokenId: String + ): Flow { + return poolDao.subscribePool(address, baseTokenId, targetTokenId).map { pool -> + val assets = getAssets() + mapPoolLocalToData(pool, assets) + } + .mapNotNull { it } + } + + override fun subscribePools(address: String): Flow> { + return poolDao.subscribeAllPools(address).map { pools -> + val assets = getAssets() + pools.mapNotNull { poolLocal -> + mapPoolLocalToData(poolLocal, assets) + } + } + } + + fun RuntimeSnapshot.accountPoolsKey(address: String): String = this.metadata.module(Modules.POOL_XYK) + .storage("AccountPools") + .storageKey(this, address.toAccountId()) + + private fun mapPoolLocalToData(poolLocal: UserPoolJoinedLocalNullable, assets: List): CommonPoolData? { + val baseToken = assets.firstOrNull { + it.currencyId == poolLocal.basicPoolLocal.tokenIdBase + } ?: return null + val token = assets.firstOrNull { + it.currencyId == poolLocal.basicPoolLocal.tokenIdTarget + } ?: return null + + val basicPoolData = BasicPoolData( + baseToken = baseToken, + targetToken = token, + baseReserves = poolLocal.basicPoolLocal.reserveBase, + targetReserves = poolLocal.basicPoolLocal.reserveTarget, + totalIssuance = poolLocal.basicPoolLocal.totalIssuance, + reserveAccount = poolLocal.basicPoolLocal.reservesAccount + ) + + val userPoolData = poolLocal.userPoolLocal?.let { userPoolLocal -> + val basePooled = PolkaswapFormulas.calculatePooledValue( + poolLocal.basicPoolLocal.reserveBase, + userPoolLocal.poolProvidersBalance, + poolLocal.basicPoolLocal.totalIssuance, + baseToken.precision, + ) + val secondPooled = PolkaswapFormulas.calculatePooledValue( + poolLocal.basicPoolLocal.reserveTarget, + userPoolLocal.poolProvidersBalance, + poolLocal.basicPoolLocal.totalIssuance, + token.precision, + ) + val share = PolkaswapFormulas.calculateShareOfPoolFromAmount( + userPoolLocal.poolProvidersBalance, + poolLocal.basicPoolLocal.totalIssuance, + ) + UserPoolData( + basePooled = basePooled, + targetPooled = secondPooled, + poolShare = share, + poolProvidersBalance = userPoolLocal.poolProvidersBalance, + ) + } + return CommonPoolData( + basic = basicPoolData, + user = userPoolData, + ) + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt new file mode 100644 index 0000000000..4a5809d440 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt @@ -0,0 +1,124 @@ +package jp.co.soramitsu.liquiditypools.impl.data.network + +import jp.co.soramitsu.common.utils.Modules +import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import java.math.BigInteger + +fun ExtrinsicBuilder.register( + dexId: Int, + baseAssetId: String, + targetAssetId: String +) = this.call( + "TradingPair", + "register", + mapOf( + "dex_id" to dexId.toBigInteger(), + "base_asset_id" to baseAssetId.mapCodeToken(), + "target_asset_id" to targetAssetId.mapCodeToken(), + ) +) + +fun ExtrinsicBuilder.initializePool( + dexId: Int, + baseAssetId: String, + targetAssetId: String +) = this.call( + Modules.POOL_XYK, + "initialize_pool", + mapOf( + "dex_id" to dexId.toBigInteger(), + "asset_a" to baseAssetId.mapCodeToken(), + "asset_b" to targetAssetId.mapCodeToken(), + ) +) + +fun ExtrinsicBuilder.depositLiquidity( + dexId: Int, + baseAssetId: String, + targetAssetId: String, + baseAssetAmount: BigInteger, + targetAssetAmount: BigInteger, + amountFromMin: BigInteger, + amountToMin: BigInteger +) = this.call( + Modules.POOL_XYK, + "deposit_liquidity", + mapOf( + "dex_id" to dexId.toBigInteger(), + "input_asset_a" to baseAssetId.mapCodeToken(), + "input_asset_b" to targetAssetId.mapCodeToken(), + "input_a_desired" to baseAssetAmount, + "input_b_desired" to targetAssetAmount, + "input_a_min" to amountFromMin, + "input_b_min" to amountToMin + ) +) + +fun ExtrinsicBuilder.removeLiquidity( + dexId: Int, + outputAssetIdA: String, + outputAssetIdB: String, + markerAssetDesired: BigInteger, + outputAMin: BigInteger, + outputBMin: BigInteger +) = this.call( + Modules.POOL_XYK, + "withdraw_liquidity", + mapOf( + "dex_id" to dexId.toBigInteger(), + "output_asset_a" to outputAssetIdA.mapCodeToken(), + "output_asset_b" to outputAssetIdB.mapCodeToken(), + "marker_asset_desired" to markerAssetDesired, + "output_a_min" to outputAMin, + "output_b_min" to outputBMin + ) + ) + +@Suppress("NestedBlockDepth", "LongParameterList") +fun ExtrinsicBuilder.liquidityAdd( + dexId: Int, + baseTokenId: String?, + targetTokenId: String?, + pairPresented: Boolean, + pairEnabled: Boolean, + tokenBaseAmount: BigInteger, + tokenTargetAmount: BigInteger, + amountBaseMin: BigInteger, + amountTargetMin: BigInteger +) { + if (baseTokenId != null && targetTokenId != null) { + if (!pairPresented) { + if (!pairEnabled) { + register( + dexId = dexId, + baseTokenId, + targetTokenId + ) + } + initializePool( + dexId = dexId, + baseTokenId, + targetTokenId + ) + } + + depositLiquidity( + dexId = dexId, + baseTokenId, + targetTokenId, + tokenBaseAmount, + tokenTargetAmount, + amountBaseMin, + amountTargetMin, + ) + } +} + +fun String.mapCodeToken() = Struct.Instance( + mapOf("code" to this.mapAssetId()) +) + +fun String.mapAssetId() = this.fromHex().mapAssetId() +fun ByteArray.mapAssetId() = this.toList().map { it.toInt().toBigInteger() } diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/di/PoolsModule.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/di/PoolsModule.kt new file mode 100644 index 0000000000..063e4ae1a7 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/di/PoolsModule.kt @@ -0,0 +1,91 @@ +package jp.co.soramitsu.liquiditypools.impl.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.common.data.network.rpc.BulkRetriever +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.core.extrinsic.ExtrinsicService +import jp.co.soramitsu.coredb.AppDatabase +import jp.co.soramitsu.coredb.dao.PoolDao +import jp.co.soramitsu.liquiditypools.blockexplorer.BlockExplorerManager +import jp.co.soramitsu.liquiditypools.data.DemeterFarmingRepository +import jp.co.soramitsu.liquiditypools.data.PoolsRepository +import jp.co.soramitsu.liquiditypools.domain.interfaces.DemeterFarmingInteractor +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.impl.data.DemeterFarmingRepositoryImpl +import jp.co.soramitsu.liquiditypools.impl.data.PoolsRepositoryImpl +import jp.co.soramitsu.liquiditypools.impl.domain.DemeterFarmingInteractorImpl +import jp.co.soramitsu.liquiditypools.impl.domain.PoolsInteractorImpl +import jp.co.soramitsu.liquiditypools.impl.navigation.InternalPoolsRouterImpl +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository +import jp.co.soramitsu.wallet.impl.presentation.WalletRouter +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class PoolsModule { + + @Provides + @Singleton + fun providesPoolInteractor( + poolsRepository: PoolsRepository, + accountRepository: AccountRepository, + blockExplorerManager: BlockExplorerManager + ): PoolsInteractor = PoolsInteractorImpl(poolsRepository, accountRepository, blockExplorerManager) + + @Provides + @Singleton + fun provideInternalLiquidityPoolsRouter( + walletRouter: WalletRouter, + resourceManager: ResourceManager + ): InternalPoolsRouter = InternalPoolsRouterImpl( + walletRouter = walletRouter, + resourceManager = resourceManager + ) + + @Provides + @Singleton + fun provideDemeterFarmingInteractor(demeterFarmingRepository: DemeterFarmingRepository): DemeterFarmingInteractor = + DemeterFarmingInteractorImpl(demeterFarmingRepository) + + @Provides + @Singleton + fun provideDemeterFarmingRepository( + chainRegistry: ChainRegistry, + bulkRetriever: BulkRetriever, + accountRepository: AccountRepository, + walletRepository: WalletRepository, + poolsRepository: PoolsRepository, + ): DemeterFarmingRepository = DemeterFarmingRepositoryImpl( + chainRegistry, + bulkRetriever, + accountRepository, + walletRepository, + poolsRepository + ) + + @Provides + @Singleton + fun providePoolsRepositoryImpl( + extrinsicService: ExtrinsicService, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + sorablockexplorer: BlockExplorerManager, + poolDao: PoolDao, + appDataBase: AppDatabase + ): PoolsRepository { + return PoolsRepositoryImpl( + extrinsicService, + chainRegistry, + accountRepository, + sorablockexplorer, + poolDao, + appDataBase + ) + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/DemeterFarmingInteractorImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/DemeterFarmingInteractorImpl.kt new file mode 100644 index 0000000000..1e8279469c --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/DemeterFarmingInteractorImpl.kt @@ -0,0 +1,15 @@ +package jp.co.soramitsu.liquiditypools.impl.domain + +import jp.co.soramitsu.liquiditypools.data.DemeterFarmingRepository +import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingPool +import jp.co.soramitsu.liquiditypools.domain.interfaces.DemeterFarmingInteractor +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId + +class DemeterFarmingInteractorImpl( + private val demeterFarmingRepository: DemeterFarmingRepository, +) : DemeterFarmingInteractor { + + override suspend fun getFarmedPools(chainId: ChainId): List? { + return demeterFarmingRepository.getFarmedPools(chainId) + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/PoolsInteractorImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/PoolsInteractorImpl.kt new file mode 100644 index 0000000000..a8114d35e9 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/domain/PoolsInteractorImpl.kt @@ -0,0 +1,193 @@ +package jp.co.soramitsu.liquiditypools.impl.domain + +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.data.secrets.v1.Keypair +import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema +import jp.co.soramitsu.common.data.secrets.v2.MetaAccountSecrets +import jp.co.soramitsu.common.utils.flowOf +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.liquiditypools.blockexplorer.BlockExplorerManager +import jp.co.soramitsu.liquiditypools.data.PoolDataDto +import jp.co.soramitsu.liquiditypools.data.PoolsRepository +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.domain.model.BasicPoolData +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import java.math.BigDecimal +import kotlin.coroutines.CoroutineContext + +class PoolsInteractorImpl( + private val poolsRepository: PoolsRepository, + private val accountRepository: AccountRepository, + private val blockExplorerManager: BlockExplorerManager, + private val coroutineContext: CoroutineContext = Dispatchers.Default +) : PoolsInteractor { + + override val poolsChainId = poolsRepository.poolsChainId + + private val soraPoolsAddressFlow = flowOf { + val meta = accountRepository.getSelectedMetaAccount() + val chain = accountRepository.getChain(poolsChainId) + meta.address(chain) + }.mapNotNull { it } + .distinctUntilChanged() + + override suspend fun getBasicPools(): List { + return poolsRepository.getBasicPools(poolsChainId) + } + + override fun subscribePoolsCacheOfAccount(address: String): Flow> { + return poolsRepository.subscribePools(address).flowOn(coroutineContext) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun subscribePoolsCacheCurrentAccount(): Flow> { + return soraPoolsAddressFlow.flatMapLatest { address -> + poolsRepository.subscribePools(address) + } + } + + override suspend fun getPoolData(baseTokenId: String, targetTokenId: String): Flow { + val address = accountRepository.getSelectedAccount(poolsChainId).address + return poolsRepository.subscribePool(address, baseTokenId, targetTokenId) + } + + override suspend fun getUserPoolData( + chainId: ChainId, + address: String, + baseTokenId: String, + tokenId: ByteArray + ): PoolDataDto? { + return poolsRepository.getUserPoolData(chainId, address, baseTokenId, tokenId) + } + + override suspend fun calcAddLiquidityNetworkFee( + chainId: ChainId, + address: String, + tokenBase: Asset, + tokenTarget: Asset, + tokenBaseAmount: BigDecimal, + tokenTargetAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal? { + return poolsRepository.calcAddLiquidityNetworkFee( + chainId, + address, + tokenBase, + tokenTarget, + tokenBaseAmount, + tokenTargetAmount, + pairEnabled, + pairPresented, + slippageTolerance, + ) + } + + override suspend fun calcRemoveLiquidityNetworkFee(tokenBase: Asset, tokenTarget: Asset): BigDecimal? { + return poolsRepository.calcRemoveLiquidityNetworkFee( + poolsChainId, + tokenBase, + tokenTarget + ) + } + + override suspend fun isPairEnabled(baseTokenId: String, targetTokenId: String): Boolean { + val dexId = poolsRepository.getPoolBaseTokenDexId(poolsChainId, baseTokenId) + return poolsRepository.isPairAvailable( + poolsChainId, + baseTokenId, + targetTokenId, + dexId + ) + } + + override suspend fun observeRemoveLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + markerAssetDesired: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal, + networkFee: BigDecimal + ): String { + val status = poolsRepository.observeRemoveLiquidity( + chainId, + tokenBase, + tokenTarget, + markerAssetDesired, + firstAmountMin, + secondAmountMin, + ) + + return status?.getOrNull() ?: "" + } + + override suspend fun observeAddLiquidity( + chainId: ChainId, + tokenBase: Asset, + tokenTarget: Asset, + amountBase: BigDecimal, + amountTarget: BigDecimal, + enabled: Boolean, + presented: Boolean, + slippageTolerance: Double + ): String { + val metaAccount = accountRepository.getSelectedMetaAccount() + val address = accountRepository.getSelectedAccount(chainId).address + + val secrets = accountRepository.getMetaAccountSecrets(metaAccount.id)?.get(MetaAccountSecrets.SubstrateKeypair) + requireNotNull(secrets) + val private = secrets[KeyPairSchema.PrivateKey] + val public = secrets[KeyPairSchema.PublicKey] + val nonce = secrets[KeyPairSchema.Nonce] + val keypair = Keypair(public, private, nonce) + + val status = poolsRepository.observeAddLiquidity( + chainId, + address, + keypair, + tokenBase, + tokenTarget, + amountBase, + amountTarget, + enabled, + presented, + slippageTolerance + ) + + return status?.getOrNull() ?: "" + } + + @Suppress("OptionalUnit") + override suspend fun syncPools(): Unit = withContext(Dispatchers.Default) { + val address = accountRepository.getSelectedAccount(poolsChainId).address + supervisorScope { + launch { poolsRepository.updateBasicPools(poolsChainId) } + launch { poolsRepository.updateAccountPools(poolsChainId, address) } + launch { blockExplorerManager.syncSbApy() } + } + } + + @Suppress("OptionalUnit") + override suspend fun updateAccountPools(): Unit = withContext(Dispatchers.Default) { + val address = accountRepository.getSelectedAccount(poolsChainId).address + poolsRepository.updateAccountPools(poolsChainId, address) + } + + override suspend fun getSbApy(id: String): Double? = withContext(coroutineContext) { + blockExplorerManager.getApy(id) + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/navigation/InternalPoolsRouterImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/navigation/InternalPoolsRouterImpl.kt new file mode 100644 index 0000000000..a84bcb4c09 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/navigation/InternalPoolsRouterImpl.kt @@ -0,0 +1,119 @@ +package jp.co.soramitsu.liquiditypools.impl.navigation + +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.liquiditypools.navigation.NavAction +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.wallet.impl.presentation.WalletRouter +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onEach +import java.math.BigDecimal +import java.util.Stack + +class InternalPoolsRouterImpl( + private val walletRouter: WalletRouter, + private val resourceManager: ResourceManager +) : InternalPoolsRouter { + private val routesStack = Stack() + + private val mutableActionsFlow = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val mutableRoutesFlow = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override fun createNavGraphRoutesFlow(): Flow = mutableRoutesFlow.onEach { + routesStack.push(it) + } + override fun createNavGraphActionsFlow(): Flow = + mutableActionsFlow.onEach { if (it is NavAction.BackPressed && !routesStack.isEmpty()) routesStack.pop() } + + override fun back() { + mutableActionsFlow.tryEmit(NavAction.BackPressed) + } + + override fun popupToScreen(route: LiquidityPoolsNavGraphRoute) { + if (routesStack.any { it.routeName == route.routeName }) { + do { + val pop = routesStack.pop() + } while (pop.routeName != route.routeName) + } + } + + override fun openAllPoolsScreen() { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.AllPoolsScreen()) + } + + override fun openDetailsPoolScreen(ids: StringPair) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.PoolDetailsScreen(ids)) + } + + override fun openAddLiquidityScreen(ids: StringPair) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.LiquidityAddScreen(ids)) + } + + override fun openAddLiquidityConfirmScreen( + ids: StringPair, + amountBase: BigDecimal, + amountTarget: BigDecimal, + apy: String + ) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.LiquidityAddConfirmScreen(ids, amountBase, amountTarget, apy)) + } + + override fun openRemoveLiquidityScreen(ids: StringPair) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.LiquidityRemoveScreen(ids)) + } + + override fun openRemoveLiquidityConfirmScreen( + ids: StringPair, + amountBase: BigDecimal, + amountTarget: BigDecimal, + firstAmountMin: BigDecimal, + secondAmountMin: BigDecimal, + desired: BigDecimal + ) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.LiquidityRemoveConfirmScreen(ids, amountBase, amountTarget, firstAmountMin, secondAmountMin, desired)) + } + + override fun openPoolListScreen(isUserPools: Boolean) { + mutableRoutesFlow.tryEmit(LiquidityPoolsNavGraphRoute.ListPoolsScreen(isUserPools)) + } + + override fun openErrorsScreen(title: String?, message: String) { + mutableActionsFlow.tryEmit(NavAction.ShowError(title, message)) + } + + override fun openInfoScreen(title: String, message: String) { + mutableActionsFlow.tryEmit(NavAction.ShowInfo(title, message)) + } + + override fun openInfoScreen(itemId: Int) { + when (itemId) { + PoolsFlowViewModel.ITEM_APY_ID -> { + openInfoScreen(resourceManager.getString(res = R.string.lp_apy_title), resourceManager.getString(res = R.string.lp_apy_alert_text)) + } + PoolsFlowViewModel.ITEM_FEE_ID -> { + openInfoScreen(resourceManager.getString(res = R.string.common_network_fee), resourceManager.getString(res = R.string.lp_network_fee_alert_text)) + } + } + } + + override fun openSuccessScreen( + txHash: String, + chainId: ChainId, + customMessage: String + ) { + walletRouter.openOperationSuccess(txHash, chainId, customMessage) + } + + override fun destination(clazz: Class): T? { + return routesStack.filterIsInstance(clazz).lastOrNull() + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/CoroutinesStore.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/CoroutinesStore.kt new file mode 100644 index 0000000000..0c345de91c --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/CoroutinesStore.kt @@ -0,0 +1,42 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation + +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.cachedOrNew +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlin.properties.Delegates + +@Singleton +class CoroutinesStore @Inject constructor( + private val resourceManager: ResourceManager, + private val internalPoolsRouter: InternalPoolsRouter +) { + + val uiScope: CoroutineScope by Delegates.cachedOrNew(isCorrupted = ::isScopeCanceled) { + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + CoroutineExceptionHandler(::handleException)) + } + + val ioScope: CoroutineScope by Delegates.cachedOrNew(::isScopeCanceled) { + CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler(::handleException)) + } + + private fun isScopeCanceled(scope: CoroutineScope): Boolean { + return scope.coroutineContext[Job]?.isActive != true + } + + @Suppress("UnusedParameter") + private fun handleException(coroutineContext: CoroutineContext, throwable: Throwable?) { + internalPoolsRouter.openErrorsScreen( + title = resourceManager.getString(R.string.common_error_general_title), + message = resourceManager.getString(R.string.common_error_network) + ) + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowFragment.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowFragment.kt new file mode 100644 index 0000000000..1a2ca4653c --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowFragment.kt @@ -0,0 +1,287 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation + +import android.annotation.SuppressLint +import android.app.Dialog +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.google.android.material.bottomsheet.BottomSheetDialog +import dagger.hilt.android.AndroidEntryPoint +import jp.co.soramitsu.common.base.BaseComposeBottomSheetDialogFragment +import jp.co.soramitsu.common.compose.component.BottomSheetDialog +import jp.co.soramitsu.common.compose.component.Image +import jp.co.soramitsu.common.compose.component.MainToolbarShimmer +import jp.co.soramitsu.common.compose.component.NavigationIconButton +import jp.co.soramitsu.common.compose.component.ToolbarBottomSheet +import jp.co.soramitsu.common.compose.component.ToolbarHomeIconState +import jp.co.soramitsu.common.compose.models.TextModel +import jp.co.soramitsu.common.compose.models.retrieveString +import jp.co.soramitsu.common.presentation.InfoDialog +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.AllPoolsScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.AllPoolsScreenWithRefresh +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd.LiquidityAddScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm.LiquidityAddConfirmScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove.LiquidityRemoveScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm.LiquidityRemoveConfirmScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails.PoolDetailsScreen +import jp.co.soramitsu.liquiditypools.impl.presentation.poollist.PoolListScreen +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.liquiditypools.navigation.NavAction +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@AndroidEntryPoint +class PoolsFlowFragment : BaseComposeBottomSheetDialogFragment() { + + override val viewModel: PoolsFlowViewModel by viewModels() + + // Compose BackHandler does not work in DialogFragments, nor does BackPressedDispatcher + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + super.onCreateDialog(savedInstanceState).apply { + setOnKeyListener { _, keyCode, event -> + val isBackPressDetected = + keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP + + if (isBackPressDetected) { + viewModel.onNavigationClick() + } + + return@setOnKeyListener isBackPressDetected + } + } + } else { + // Call to super.onBackPressed() will cancel dialog as default behavior + object : BottomSheetDialog(requireContext(), theme) { + @SuppressLint("MissingSuperCall") + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onBackPressed() { + viewModel.onNavigationClick() + } + } + } + } + + @Composable + override fun Content(padding: PaddingValues) { + val navController = rememberNavController() + val toolbarState = viewModel.toolbarStateFlow.collectAsStateWithLifecycle() + + SetupNavDestinationChangedListener( + navController = navController, + onNavDestinationChanged = remember { + viewModel::onDestinationChanged + } + ) + + LaunchedEffect(Unit) { + viewModel.navGraphRoutesFlow.onEach { + navController.navigate(it.routeName) + }.launchIn(this) + + viewModel.navGraphActionsFlow.onEach { + when (it) { + is NavAction.BackPressed -> { + val isBackNavigationSuccess = navController.popBackStack() + + val currentRoute = navController.currentDestination?.route + val loadingRoute = LiquidityPoolsNavGraphRoute.Loading.routeName + + if (currentRoute == loadingRoute || !isBackNavigationSuccess) { + viewModel.exitFlow() + } + } + + is NavAction.ShowError -> + showErrorDialog( + title = it.errorTitle ?: resources.getString(jp.co.soramitsu.common.R.string.common_error_general_title), + message = it.errorText + ) + + is NavAction.ShowInfo -> + InfoDialog( + title = it.title, + message = it.message + ).show(childFragmentManager) + } + }.launchIn(this) + } + + BottomSheetDialog( + modifier = Modifier.fillMaxSize() + ) { + when (val loadingState = toolbarState.value) { + is LoadingState.Loaded -> + if (loadingState.data.retrieveString().isEmpty()) { + PolkaswapImageToolbar() + } else { + ToolbarBottomSheet( + modifier = Modifier.padding(horizontal = 16.dp), + title = loadingState.data.retrieveString(), + onNavigationClick = remember { + { + viewModel.onNavigationClick() + } + } + ) + } + + is LoadingState.Loading -> + MainToolbarShimmer( + homeIconState = ToolbarHomeIconState.Navigation(jp.co.soramitsu.feature_wallet_impl.R.drawable.ic_arrow_back_24dp) + ) + } + + NavHost( + startDestination = LiquidityPoolsNavGraphRoute.Loading.routeName, + contentAlignment = Alignment.TopCenter, + navController = navController, + modifier = Modifier + .fillMaxSize(), + ) { + composable(LiquidityPoolsNavGraphRoute.AllPoolsScreen.routeName) { + val allPoolsScreenState by viewModel.allPoolsScreenState.collectAsStateWithLifecycle() + AllPoolsScreenWithRefresh( + state = allPoolsScreenState, + callback = viewModel + ) + } + + composable(LiquidityPoolsNavGraphRoute.ListPoolsScreen.routeName) { + val poolListState by viewModel.poolListScreenState.collectAsStateWithLifecycle() + PoolListScreen( + state = poolListState, + callback = viewModel + ) + } + + composable(LiquidityPoolsNavGraphRoute.PoolDetailsScreen.routeName) { + val poolDetailState by viewModel.poolDetailsScreenState.collectAsStateWithLifecycle() + PoolDetailsScreen( + state = poolDetailState, + callbacks = viewModel + ) + } + + composable(LiquidityPoolsNavGraphRoute.LiquidityAddScreen.routeName) { + val liquidityAddState by viewModel.liquidityAddScreenState.collectAsStateWithLifecycle() + LiquidityAddScreen(liquidityAddState, viewModel) + } + + composable(LiquidityPoolsNavGraphRoute.LiquidityAddConfirmScreen.routeName) { + val liquidityAddConfirmState by viewModel.liquidityAddConfirmState.collectAsStateWithLifecycle() + LiquidityAddConfirmScreen(liquidityAddConfirmState, viewModel) + } + + composable(LiquidityPoolsNavGraphRoute.LiquidityRemoveScreen.routeName) { + val liquidityRemoveState by viewModel.liquidityRemoveScreenState.collectAsStateWithLifecycle() + LiquidityRemoveScreen(liquidityRemoveState, viewModel) + } + + composable(LiquidityPoolsNavGraphRoute.LiquidityRemoveConfirmScreen.routeName) { + val liquidityRemoveState by viewModel.liquidityRemoveConfirmState.collectAsStateWithLifecycle() + LiquidityRemoveConfirmScreen(liquidityRemoveState, viewModel) + } + + composable(LiquidityPoolsNavGraphRoute.Loading.routeName) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + } + } + } + + @Composable + private inline fun SetupNavDestinationChangedListener( + navController: NavController, + crossinline onNavDestinationChanged: (newRoute: String) -> Unit + ) { + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val onDestinationChangedListener = + NavController.OnDestinationChangedListener { _, destination, _ -> + onNavDestinationChanged(destination.route!!) + } + + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> + navController.addOnDestinationChangedListener(onDestinationChangedListener) + + Lifecycle.Event.ON_STOP -> + navController.removeOnDestinationChangedListener( + onDestinationChangedListener + ) + + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + onDispose { lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) } + } + } + + @Composable + private fun PolkaswapImageToolbar() { + Row( + modifier = Modifier + .wrapContentHeight() + .padding(bottom = 12.dp) + ) { + NavigationIconButton( + modifier = Modifier.padding(start = 16.dp), + navigationIconResId = R.drawable.ic_cross_32, + onNavigationClick = viewModel::onNavigationClick + ) + Image( + modifier = Modifier + .padding(start = 8.dp) + .align(Alignment.Top) + .size( + width = 100.dp, + height = 28.dp + ), + res = R.drawable.logo_polkaswap_big, + contentDescription = null + ) + } + } + +// override fun setupBehavior(behavior: BottomSheetBehavior) { +// behavior.state = BottomSheetBehavior.STATE_EXPANDED +// behavior.isHideable = false +// behavior.skipCollapsed = true +// } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowViewModel.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowViewModel.kt new file mode 100644 index 0000000000..3ad7a11005 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/PoolsFlowViewModel.kt @@ -0,0 +1,211 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation + +import dagger.hilt.android.lifecycle.HiltViewModel +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.base.BaseViewModel +import jp.co.soramitsu.common.compose.models.TextModel +import jp.co.soramitsu.common.compose.theme.greenText +import jp.co.soramitsu.common.compose.theme.white50 +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.AllPoolsPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.AllPoolsScreenInterface +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.AllPoolsState +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.BasicPoolListItemState +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd.LiquidityAddCallbacks +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd.LiquidityAddPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd.LiquidityAddState +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm.LiquidityAddConfirmCallbacks +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm.LiquidityAddConfirmPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm.LiquidityAddConfirmState +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove.LiquidityRemoveCallbacks +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove.LiquidityRemovePresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove.LiquidityRemoveState +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm.LiquidityRemoveConfirmCallbacks +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm.LiquidityRemoveConfirmPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm.LiquidityRemoveConfirmState +import jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails.PoolDetailsCallbacks +import jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails.PoolDetailsPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails.PoolDetailsState +import jp.co.soramitsu.liquiditypools.impl.presentation.poollist.PoolListPresenter +import jp.co.soramitsu.liquiditypools.impl.presentation.poollist.PoolListScreenInterface +import jp.co.soramitsu.liquiditypools.impl.presentation.poollist.PoolListState +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.NavAction +import jp.co.soramitsu.wallet.impl.domain.model.Token +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import java.math.BigDecimal +import javax.inject.Inject + +@HiltViewModel +class PoolsFlowViewModel @Inject constructor( + allPoolsPresenter: AllPoolsPresenter, + poolListPresenter: PoolListPresenter, + poolDetailsPresenter: PoolDetailsPresenter, + liquidityAddPresenter: LiquidityAddPresenter, + liquidityAddConfirmPresenter: LiquidityAddConfirmPresenter, + liquidityRemovePresenter: LiquidityRemovePresenter, + liquidityRemoveConfirmPresenter: LiquidityRemoveConfirmPresenter, + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val poolsRouter: LiquidityPoolsRouter +) : BaseViewModel(), + LiquidityAddCallbacks by liquidityAddPresenter, + LiquidityAddConfirmCallbacks by liquidityAddConfirmPresenter, + AllPoolsScreenInterface by allPoolsPresenter, + PoolListScreenInterface by poolListPresenter, + PoolDetailsCallbacks by poolDetailsPresenter, + LiquidityRemoveCallbacks by liquidityRemovePresenter, + LiquidityRemoveConfirmCallbacks by liquidityRemoveConfirmPresenter { + + val allPoolsScreenState: StateFlow = + allPoolsPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val poolListScreenState: StateFlow = + poolListPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val poolDetailsScreenState: StateFlow = + poolDetailsPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val liquidityAddScreenState: StateFlow = + liquidityAddPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val liquidityRemoveScreenState: StateFlow = + liquidityRemovePresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val liquidityAddConfirmState: StateFlow = + liquidityAddConfirmPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val liquidityRemoveConfirmState: StateFlow = + liquidityRemoveConfirmPresenter.createScreenStateFlow(coroutinesStore.uiScope) + + val navGraphRoutesFlow: StateFlow = + internalPoolsRouter.createNavGraphRoutesFlow().stateIn( + scope = coroutinesStore.uiScope, + started = SharingStarted.Eagerly, + initialValue = LiquidityPoolsNavGraphRoute.Loading + ) + val navGraphActionsFlow: SharedFlow = + internalPoolsRouter.createNavGraphActionsFlow().shareIn( + scope = coroutinesStore.uiScope, + started = SharingStarted.Eagerly, + replay = 1 + ) + + init { + internalPoolsRouter.openAllPoolsScreen() + } + + private val mutableToolbarStateFlow = MutableStateFlow>(LoadingState.Loading()) + val toolbarStateFlow: StateFlow> = mutableToolbarStateFlow + + fun onDestinationChanged(route: String) { + val newToolbarState: LoadingState = when (route) { + LiquidityPoolsNavGraphRoute.Loading.routeName -> + LoadingState.Loading() + + LiquidityPoolsNavGraphRoute.AllPoolsScreen.routeName -> + LoadingState.Loaded( + TextModel.SimpleString("") + ) + + LiquidityPoolsNavGraphRoute.ListPoolsScreen.routeName -> { + val destinationArgs = internalPoolsRouter.destination(LiquidityPoolsNavGraphRoute.ListPoolsScreen::class.java) + val titleId = if (destinationArgs?.isUserPools == true) { + R.string.lp_user_pools_title + } else { + R.string.lp_available_pools_title + } + + LoadingState.Loaded( + TextModel.ResId(titleId) + ) + } + + LiquidityPoolsNavGraphRoute.PoolDetailsScreen.routeName -> + LoadingState.Loaded( + TextModel.ResId(R.string.lp_pool_details_title) + ) + + LiquidityPoolsNavGraphRoute.LiquidityAddScreen.routeName -> + LoadingState.Loaded( + TextModel.ResId(R.string.lp_supply_liquidity_screen_title) + ) + + LiquidityPoolsNavGraphRoute.LiquidityAddConfirmScreen.routeName -> + LoadingState.Loaded( + TextModel.ResId(R.string.lp_confirm_liquidity_screen_title) + ) + + LiquidityPoolsNavGraphRoute.LiquidityRemoveScreen.routeName -> + LoadingState.Loaded( + TextModel.ResId(R.string.lp_remove_liquidity_screen_title) + ) + + LiquidityPoolsNavGraphRoute.LiquidityRemoveConfirmScreen.routeName -> + LoadingState.Loaded( + TextModel.ResId(R.string.lp_remove_liquidity_screen_title) + ) + + else -> LoadingState.Loading() + } + + mutableToolbarStateFlow.value = newToolbarState + } + + override fun onPoolClicked(pair: StringPair) { + internalPoolsRouter.openDetailsPoolScreen(pair) + } + + fun onNavigationClick() { + internalPoolsRouter.back() + } + + fun exitFlow() { + poolsRouter.back() + } + + companion object { + const val ITEM_APY_ID = 1 + const val ITEM_FEE_ID = 2 + } +} + +fun CommonPoolData.toListItemState(baseToken: Token?): BasicPoolListItemState? { + val tvl = baseToken?.fiatRate?.times(BigDecimal(2)) + ?.multiply(basic.baseReserves) + ?.formatFiat(baseToken.fiatSymbol).orEmpty() + + val baseTokenId = basic.baseToken.currencyId ?: return null + val targetTokenId = basic.targetToken?.currencyId ?: return null + + val baseSymbol = basic.baseToken.symbol + val targetSymbol = basic.targetToken?.symbol + val userPooledInfo = user?.let { + val baseCrypto = it.basePooled.formatCrypto(baseSymbol) + val targetCrypto = it.targetPooled.formatCrypto(targetSymbol) + "$baseCrypto - $targetCrypto" + } + val text2Color = if (user == null) white50 else user.let { greenText } + + return BasicPoolListItemState( + ids = StringPair(baseTokenId, targetTokenId), + token1Icon = basic.baseToken.iconUrl, + token2Icon = basic.targetToken?.iconUrl.orEmpty(), + text1 = "$baseSymbol-$targetSymbol".uppercase(), + text2 = userPooledInfo ?: tvl, + text2Color = text2Color, + apy = LoadingState.Loading(), + text4 = TextModel.ResIdWithArgs(id = R.string.lp_reward_token_text, arrayOf("PSWAP")) + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsPresenter.kt new file mode 100644 index 0000000000..5ac3d96469 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsPresenter.kt @@ -0,0 +1,203 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.allpools + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.androidfoundation.format.compareNullDesc +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.utils.Event +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.impl.presentation.toListItemState +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class AllPoolsPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val poolsInteractor: PoolsInteractor, + private val accountInteractor: AccountInteractor, +) : AllPoolsScreenInterface { + + private val chainDeferred = coroutinesStore.uiScope.async { + chainsRepository.getChain(poolsInteractor.poolsChainId) + } + + private val refresh = MutableStateFlow(Event(Unit)) + private val screenHeight = MutableStateFlow(0.dp) + private val groupHeight = MutableStateFlow(0.dp) + + private val stateFlow = MutableStateFlow(AllPoolsState()) + + fun createScreenStateFlow(scope: CoroutineScope): StateFlow { + subscribeScreenState(scope) + return stateFlow + } + + private fun subscribeScreenState(scope: CoroutineScope) { + scope.launch { + refresh.onEach { + poolsInteractor.syncPools() + }.launchIn(scope) + + val currentAccount = accountInteractor.selectedMetaAccount() + val address = currentAccount.address(chainDeferred.await()) ?: return@launch + + poolsInteractor.subscribePoolsCacheOfAccount(address) + .onEach { commonPoolData: List -> + val allRequiredChainAssets = + (commonPoolData.map { it.basic.baseToken } + commonPoolData.mapNotNull { it.basic.targetToken }).toSet() + + val tokensDeferred = + allRequiredChainAssets.filter { it.priceId != null } + .map { walletInteractor.getToken(it) } + + val tokensMap = tokensDeferred.associateBy { it.configuration.id } + + val poolLists = commonPoolData.groupBy { + it.user != null + }.mapValues { + it.value.sortedWith { current, next -> + val currentTokenFiatRate = tokensMap[current.basic.baseToken.id]?.fiatRate + val nextTokenFiatRate = tokensMap[next.basic.baseToken.id]?.fiatRate + val userPoolData = current.user + val userPoolNextData = next.user + + if (userPoolData != null && userPoolNextData != null) { + val currentPooled = userPoolData.basePooled.applyFiatRate(currentTokenFiatRate) + val nextPooled = userPoolNextData.basePooled.applyFiatRate(nextTokenFiatRate) + compareNullDesc(currentPooled, nextPooled) + } else { + val currentTvl = current.basic.getTvl(currentTokenFiatRate) + val nextTvl = next.basic.getTvl(nextTokenFiatRate) + compareNullDesc(currentTvl, nextTvl) + } + }.mapNotNull { commonPoolData -> + val token = tokensMap[commonPoolData.basic.baseToken.id] + commonPoolData.toListItemState(token) + } + } + val userPools = poolLists[true].orEmpty() + val otherPools = poolLists[false].orEmpty() + + val screenHeight = screenHeight.value + val groupHeight = groupHeight.value + val itemHeight = 40.dp + val margin = 16.dp + + val availableHeight = screenHeight - groupHeight - margin * 2 - if (userPools.isNotEmpty()) { + groupHeight + margin + } else { + 0.dp + } + + val totalItems = (availableHeight / (itemHeight + margin)).toInt() + val maxGroupItems = totalItems / 2 + + val shownUserPools = userPools.take(maxGroupItems) + val userGroupItems = shownUserPools.size + val shownOtherPools = otherPools.take(totalItems - userGroupItems) + + val hasExtraUserPools = shownUserPools.size < userPools.size + val hasExtraAllPools = shownOtherPools.size < otherPools.size + + stateFlow.update { prevState -> + prevState.copy( + userPools = shownUserPools, + allPools = shownOtherPools, + hasExtraUserPools = hasExtraUserPools, + hasExtraAllPools = hasExtraAllPools, + isLoading = false + ) + } + } + .onEach { commonPoolData: List -> + coroutineScope { + commonPoolData.forEach { pool -> + launch pool@{ + val baseTokenId = pool.basic.baseToken.currencyId ?: return@pool + val targetTokenId = + pool.basic.targetToken?.currencyId ?: return@pool + val id = StringPair(baseTokenId, targetTokenId) + val sbApy = poolsInteractor.getSbApy(pool.basic.reserveAccount) + + stateFlow.update { prevState -> + val newUserPools = prevState.userPools.map { + if (it.ids == id) { + it.copy( + apy = LoadingState.Loaded( + sbApy?.let { apy -> + "%s%%".format(apy.toBigDecimal().formatCrypto()) + }.orEmpty() + ) + ) + } else { + it + } + } + + val newAllPools = prevState.allPools.map { + if (it.ids == id) { + it.copy( + apy = LoadingState.Loaded( + sbApy?.let { apy -> + "%s%%".format(apy.toBigDecimal().formatCrypto()) + }.orEmpty() + ) + ) + } else { + it + } + } + + prevState.copy( + userPools = newUserPools, + allPools = newAllPools + ) + } + } + } + } + } + .launchIn(scope) + } + } + + override fun onPoolClicked(pair: StringPair) { + internalPoolsRouter.openDetailsPoolScreen(pair) + } + + override fun onMoreClick(isUserPools: Boolean) { + internalPoolsRouter.openPoolListScreen(isUserPools) + } + + override fun onRefresh() { + refresh.value = Event(Unit) + } + + override fun onWindowHeightChange(heightIs: Dp) { + screenHeight.value = heightIs + } + + override fun onHeaderHeightChange(heightIs: Dp) { + groupHeight.value = heightIs + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt new file mode 100644 index 0000000000..92fa6094f4 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt @@ -0,0 +1,273 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.allpools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.Image +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.models.TextModel +import jp.co.soramitsu.common.compose.theme.customTypography +import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.compose.theme.white08 +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.ui_core.resources.Dimens +import jp.co.soramitsu.wallet.impl.presentation.balance.list.PullRefreshBox + +data class AllPoolsState( + val userPools: List = listOf(), + val allPools: List = listOf(), + val hasExtraUserPools: Boolean = false, + val hasExtraAllPools: Boolean = false, + val isLoading: Boolean = true +) + +interface AllPoolsScreenInterface { + fun onPoolClicked(pair: StringPair) + fun onMoreClick(isUserPools: Boolean) + fun onRefresh() + fun onWindowHeightChange(heightIs: Dp) + fun onHeaderHeightChange(heightIs: Dp) +} + +@Composable +fun AllPoolsScreenWithRefresh(state: AllPoolsState, callback: AllPoolsScreenInterface) { + PullRefreshBox( + onRefresh = callback::onRefresh + ) { + AllPoolsScreen(state = state, callback = callback) + } +} + +@Composable +fun AllPoolsScreen(state: AllPoolsState, callback: AllPoolsScreenInterface) { + val localDensity = LocalDensity.current + var heightIs by remember { + mutableStateOf(0.dp) + } + + Box( + modifier = Modifier + .onGloballyPositioned { coordinates -> + heightIs = with(localDensity) { coordinates.size.height.toDp() } + callback.onWindowHeightChange(heightIs) + } + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MarginVertical(margin = 8.dp) + + if (state.isLoading || state.userPools.isEmpty() && state.allPools.isEmpty()) { + BackgroundCorneredWithBorder( + modifier = Modifier + .padding(horizontal = 16.dp) + ) { + ShimmerPoolList() + } + } else { + if (state.userPools.isNotEmpty()) { + BackgroundCorneredWithBorder( + modifier = Modifier + .padding(horizontal = 16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier + .wrapContentHeight() + ) { + PoolGroupHeader( + title = stringResource(id = R.string.lp_user_pools_title), + onMoreClick = { callback.onMoreClick(true) }.takeIf { state.hasExtraUserPools } + ) + state.userPools.forEach { pool -> + BasicPoolListItem( + modifier = Modifier.padding(vertical = Dimens.x1), + state = pool, + onPoolClick = callback::onPoolClicked, + ) + } + } + } + MarginVertical(margin = 16.dp) + } + if (state.allPools.isNotEmpty()) { + BackgroundCorneredWithBorder( + modifier = Modifier + .padding(horizontal = 16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.wrapContentHeight() + ) { + PoolGroupHeader( + modifier = Modifier.onGloballyPositioned { coordinates -> + heightIs = with(localDensity) { coordinates.size.height.toDp() } + callback.onHeaderHeightChange(heightIs) + }, + title = stringResource(id = R.string.lp_available_pools_title), + onMoreClick = { callback.onMoreClick(false) }.takeIf { state.hasExtraAllPools } + ) + state.allPools.forEach { pool -> + BasicPoolListItem( + modifier = Modifier.padding(vertical = Dimens.x1), + state = pool, + onPoolClick = callback::onPoolClicked, + ) + } + } + } + } + } + } + } +} + +@Composable +fun ShimmerPoolList(size: Int = 10) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.wrapContentHeight() + ) { + PoolGroupHeader( + title = stringResource(id = R.string.lp_available_pools_title), + onMoreClick = null + ) + repeat(size) { + BasicPoolShimmerItem(modifier = Modifier.padding(vertical = Dimens.x1)) + } + } +} + +@Composable +private fun PoolGroupHeader( + modifier: Modifier = Modifier, + title: String, + onMoreClick: (() -> Unit)? +) { + Box(modifier = modifier.wrapContentHeight()) { + Row( + modifier = Modifier + .padding(Dimens.x1_5) + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.wrapContentHeight(), + color = white, + style = MaterialTheme.customTypography.header5, + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + onMoreClick?.let { + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier + .background( + color = white08, + shape = CircleShape, + ) + .clickable(onClick = onMoreClick) + .padding(horizontal = Dimens.x1, vertical = 5.5.dp) + ) { + Text( + text = stringResource(id = R.string.common_more).uppercase(), + modifier = Modifier + .align(Alignment.CenterVertically), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.customTypography.capsTitle2, + color = white, + ) + Image( + res = R.drawable.ic_chevron_right, + modifier = Modifier + .padding(start = 8.dp) + .align(Alignment.CenterVertically) + ) + } + } + } + Box( + modifier = Modifier + .height(1.dp) + .padding(horizontal = Dimens.x1_5) + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background(white08) + ) + } +} + +@Preview +@Composable +private fun PreviewAllPoolsScreen() { + val itemState = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "XOR-VAL", + text2 = "123.4M", + apy = LoadingState.Loaded("1234.3%"), + text4 = TextModel.SimpleString("Earn SWAP"), + ) + + val items = listOf( + itemState, + itemState.copy(text1 = "TEXT1", text2 = "TEXT2", apy = LoadingState.Loaded("TEXT3"), text4 = TextModel.SimpleString("TEXT4")), + itemState.copy(text1 = "text1", text2 = "text2", apy = LoadingState.Loaded("text3"), text4 = TextModel.SimpleString("text4")), + ) + AllPoolsScreen( + state = AllPoolsState( + userPools = items, + allPools = items, +// isLoading = true + isLoading = false, + hasExtraUserPools = true + ), + callback = object : AllPoolsScreenInterface { + override fun onPoolClicked(pair: StringPair) {} + override fun onMoreClick(isUserPools: Boolean) {} + override fun onRefresh() {} + override fun onWindowHeightChange(heightIs: Dp) {} + override fun onHeaderHeightChange(heightIs: Dp) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt new file mode 100644 index 0000000000..c79e45d444 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt @@ -0,0 +1,319 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.allpools + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import coil.compose.SubcomposeAsyncImage +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.Shimmer +import jp.co.soramitsu.common.compose.component.getImageRequest +import jp.co.soramitsu.common.compose.models.TextModel +import jp.co.soramitsu.common.compose.models.retrieveString +import jp.co.soramitsu.common.compose.theme.colorAccentDark +import jp.co.soramitsu.common.compose.theme.customTypography +import jp.co.soramitsu.common.compose.theme.transparent +import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.compose.theme.white50 +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.ui_core.component.button.properties.Size + +data class BasicPoolListItemState( + val ids: StringPair, + val token1Icon: String, + val token2Icon: String, + val text1: String, + val text2: String, + val text2Color: Color = white50, + val apy: LoadingState?, + val text4: TextModel? = null, +) + +@Composable +fun BasicPoolListItem( + modifier: Modifier = Modifier, + state: BasicPoolListItemState, + onPoolClick: ((StringPair) -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .clickable { onPoolClick?.invoke(state.ids) }, + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + ConstraintLayout( + modifier = Modifier + .wrapContentWidth() + .padding(start = 12.dp) + ) { + val (token1, token2) = createRefs() + SubcomposeAsyncImage( + modifier = Modifier + .constrainAs(token1) { + top.linkTo(parent.top, 2.dp) + start.linkTo(parent.start) + bottom.linkTo(parent.bottom, 11.dp) + } + .size(size = 32.dp), + model = getImageRequest(LocalContext.current, state.token1Icon), + contentDescription = null, + loading = { Shimmer(Modifier.size(Size.ExtraSmall)) } + ) + SubcomposeAsyncImage( + modifier = Modifier + .constrainAs(token2) { + top.linkTo(parent.top, 11.dp) + start.linkTo(token1.start, margin = 16.dp) + bottom.linkTo(parent.bottom, 2.dp) + } + .size(size = 32.dp), + model = getImageRequest(LocalContext.current, state.token2Icon), + contentDescription = null, + loading = { Shimmer(Modifier.size(Size.ExtraSmall)) }, + error = { + it.result.throwable.message?.let { it1 -> Log.d("&&&", it1) } + Icon(painterResource(id = R.drawable.ic_token_default), null, modifier = Modifier.size(size = 32.dp)) + } + ) + } + } + Column( + modifier = Modifier + .wrapContentHeight() + .weight(1f) + .padding(start = 8.dp, end = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier + .weight(1f) + .wrapContentHeight(), + color = white, + style = MaterialTheme.customTypography.header6, + text = state.text1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when (state.apy) { + is LoadingState.Loaded -> Text( + modifier = Modifier + .wrapContentHeight(), + color = colorAccentDark, + style = MaterialTheme.customTypography.header6, + text = "%s %s".format( + state.apy.data, + stringResource(id = R.string.staking_only_apy) + ), + maxLines = 1, + textAlign = TextAlign.End + ) + + is LoadingState.Loading -> Shimmer( + Modifier + .height(12.dp) + .width(100.dp) + ) + null -> Unit + } + } + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.wrapContentHeight(), + color = white50, + style = MaterialTheme.customTypography.body2, + text = state.text4?.retrieveString().orEmpty(), + maxLines = 1, + ) + + Text( + modifier = Modifier + .wrapContentHeight() + .padding(start = 6.dp), + color = state.text2Color, + style = MaterialTheme.customTypography.body2, + text = state.text2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +fun BasicPoolShimmerItem(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + ConstraintLayout( + modifier = Modifier + .wrapContentWidth() + .padding(start = 12.dp) + ) { + val (token1, token2) = createRefs() + Shimmer( + Modifier + .size(Size.ExtraSmall) + .constrainAs(token1) { + top.linkTo(parent.top, 2.dp) + start.linkTo(parent.start) + bottom.linkTo(parent.bottom, 11.dp) + } + ) + + Shimmer( + Modifier + .size(Size.ExtraSmall) + .constrainAs(token2) { + top.linkTo(parent.top, 11.dp) + start.linkTo(token1.start, margin = 16.dp) + bottom.linkTo(parent.bottom, 2.dp) + } + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Shimmer( + Modifier + .height(12.dp) + .width(70.dp) + ) + Shimmer( + Modifier + .height(12.dp) + .width(100.dp) + ) + } + MarginVertical(margin = 6.dp) + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Shimmer( + Modifier + .height(12.dp) + .width(70.dp) + ) + Shimmer( + Modifier + .height(12.dp) + .width(90.dp) + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewBasicPoolListItem() { + Column { + BasicPoolListItem( + modifier = Modifier.background(transparent), + state = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "XOR-VAL", + text2 = "123.4M", + apy = LoadingState.Loaded("1234.3%"), + text4 = TextModel.SimpleString("Earn SWAP"), + ) + ) + BasicPoolListItem( + modifier = Modifier.background(transparent), + state = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "text1", + text2 = "text2", + apy = LoadingState.Loaded("text3"), + text4 = TextModel.SimpleString("text4"), + ) + ) + BasicPoolListItem( + modifier = Modifier.background(transparent), + state = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "text1", + text2 = "text2", + apy = LoadingState.Loading(), + text4 = TextModel.SimpleString("text4"), + ) + ) + BasicPoolListItem( + modifier = Modifier.background(transparent), + state = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "text1", + text2 = "text2", + apy = null, + text4 = TextModel.SimpleString("text4"), + ) + ) + BasicPoolShimmerItem() + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddPresenter.kt new file mode 100644 index 0000000000..51901e5fda --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddPresenter.kt @@ -0,0 +1,488 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd + +import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.androidfoundation.format.isZero +import jp.co.soramitsu.common.base.errors.ValidationException +import jp.co.soramitsu.common.compose.component.AmountInputViewState +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.presentation.dataOrNull +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.Event +import jp.co.soramitsu.common.utils.MAX_DECIMALS_8 +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.combine +import jp.co.soramitsu.common.utils.flowOf +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatCryptoDetail +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.common.utils.formatPercent +import jp.co.soramitsu.common.utils.moreThanZero +import jp.co.soramitsu.common.utils.orZero +import jp.co.soramitsu.common.utils.requireValue +import jp.co.soramitsu.core.utils.utilityAsset +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.impl.usecase.ValidateAddLiquidityUseCase +import jp.co.soramitsu.liquiditypools.impl.util.PolkaswapFormulas +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.polkaswap.api.models.WithDesired +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.wallet.api.domain.fromValidationResult +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import jp.co.soramitsu.wallet.impl.domain.model.Asset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject +import kotlin.math.min +import jp.co.soramitsu.core.models.Asset as CoreAsset + +@Suppress("LargeClass") +class LiquidityAddPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val poolsInteractor: PoolsInteractor, + private val accountInteractor: AccountInteractor, + private val resourceManager: ResourceManager, + private val validateAddLiquidityUseCase: ValidateAddLiquidityUseCase, +) : LiquidityAddCallbacks { + private val enteredBaseAmountFlow = MutableStateFlow(BigDecimal.ZERO) + private val enteredTargetAmountFlow = MutableStateFlow(BigDecimal.ZERO) + + private val uiBaseAmountFlow = MutableStateFlow(BigDecimal.ZERO) + private val uiTargetAmountFlow = MutableStateFlow(BigDecimal.ZERO) + + private var amountBase: BigDecimal = BigDecimal.ZERO + private var amountTarget: BigDecimal = BigDecimal.ZERO + + private val isBaseAmountFocused = MutableStateFlow(false) + private val isTargetAmountFocused = MutableStateFlow(false) + + private val isButtonLoading = MutableStateFlow(false) + private val isCalculatingAmounts = MutableStateFlow(null) + + private var desired: WithDesired = WithDesired.INPUT + + private val _stateSlippage = MutableStateFlow(0.5) + private val stateSlippage = _stateSlippage.asStateFlow() + + private val resetFlow = MutableStateFlow(Event(Unit)) + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .distinctUntilChanged(areArgsEquivalent()) + .onEach { + resetFlow.emit(Event(Unit)) + } + .shareIn(coroutinesStore.uiScope, SharingStarted.Eagerly, 1) + + private val loadingAssetsInPoolFlow = MutableStateFlow>>(LoadingState.Loading()) + + private val tokensInPoolFlow = loadingAssetsInPoolFlow.filterIsInstance>>().map { + it.data.first.token.configuration to it.data.second.token.configuration + }.distinctUntilChanged() + + val isPoolPairEnabled = + screenArgsFlow.map { screenArgs -> + val (baseTokenId, targetTokenId) = screenArgs.ids + poolsInteractor.isPairEnabled( + baseTokenId, + targetTokenId + ) + } + + val networkFeeFlow = combine( + enteredBaseAmountFlow, + enteredTargetAmountFlow, + tokensInPoolFlow, + stateSlippage, + isPoolPairEnabled + ) { amountBase, amountTarget, (baseAsset, targetAsset), slippage, pairEnabled -> + getLiquidityNetworkFee( + tokenBase = baseAsset, + tokenTarget = targetAsset, + tokenBaseAmount = amountBase, + tokenToAmount = amountTarget, + pairEnabled = pairEnabled, + pairPresented = true, + slippageTolerance = slippage + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val feeInfoViewStateFlow: Flow = + flowOf { + requireNotNull(chainsRepository.getChain(poolsInteractor.poolsChainId).utilityAsset?.id) + }.flatMapLatest { utilityAssetId -> + combine( + networkFeeFlow, + walletInteractor.assetFlow(poolsInteractor.poolsChainId, utilityAssetId) + ) { networkFee, utilityAsset -> + val tokenSymbol = utilityAsset.token.configuration.symbol + val tokenFiatRate = utilityAsset.token.fiatRate + val tokenFiatSymbol = utilityAsset.token.fiatSymbol + + FeeInfoViewState( + feeAmount = networkFee.formatCryptoDetail(tokenSymbol), + feeAmountFiat = networkFee.applyFiatRate(tokenFiatRate)?.formatFiat(tokenFiatSymbol), + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val poolFlow = screenArgsFlow.flatMapLatest { screenargs -> + poolsInteractor.getPoolData( + baseTokenId = screenargs.ids.first, + targetTokenId = screenargs.ids.second + ) + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun subscribeState(coroutineScope: CoroutineScope) { + screenArgsFlow.onEach { + loadingAssetsInPoolFlow.value = LoadingState.Loading() + }.flatMapLatest { screenArgs -> + val ids = screenArgs.ids + val chainId = poolsInteractor.poolsChainId + val chainAssets = chainsRepository.getChain(chainId).assets + val baseAsset = chainAssets.firstOrNull { it.currencyId == ids.first }?.let { + walletInteractor.getCurrentAssetOrNull(chainId, it.id) + } + val targetAsset = chainAssets.firstOrNull { it.currencyId == ids.second }?.let { + walletInteractor.getCurrentAssetOrNull(chainId, it.id) + } + + val assetsFlow = flowOf { + if (baseAsset == null || targetAsset == null) { + null + } else { + baseAsset to targetAsset + } + }.mapNotNull { it } + + assetsFlow + }.onEach { + loadingAssetsInPoolFlow.value = LoadingState.Loaded(it) + }.launchIn(coroutineScope) + + enteredBaseAmountFlow + .onEach { + desired = WithDesired.INPUT + isCalculatingAmounts.value = desired + } + .debounce(INPUT_DEBOUNCE) + .onEach { amount -> + amountBase = amount + updateAmounts() + }.launchIn(coroutineScope) + + enteredTargetAmountFlow + .onEach { + desired = WithDesired.OUTPUT + isCalculatingAmounts.value = desired + } + .debounce(INPUT_DEBOUNCE) + .onEach { amount -> + amountTarget = amount + updateAmounts() + }.launchIn(coroutineScope) + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + + return resetFlow.onEach { + amountTarget = BigDecimal.ZERO + amountBase = BigDecimal.ZERO + uiBaseAmountFlow.value = BigDecimal.ZERO + uiTargetAmountFlow.value = BigDecimal.ZERO + }.debounce(RESET_DEBOUNCE).flatMapLatest { + combine( + poolFlow, + loadingAssetsInPoolFlow, + uiBaseAmountFlow, + uiTargetAmountFlow, + isBaseAmountFocused, + isTargetAmountFocused, + stateSlippage, + feeInfoViewStateFlow, + isButtonLoading, + isCalculatingAmounts + ) { pool, loadingAssetsState, baseShown, targetShown, baseFocused, targetFocused, slippage, feeInfo, isButtonLoading, isCalulatingAmount -> + val assetBase = loadingAssetsState.dataOrNull()?.first + val assetTarget = loadingAssetsState.dataOrNull()?.second + + val baseAmountInputViewState = if (assetBase == null) { + AmountInputViewState.defaultObj + } else { + val totalBaseCrypto = assetBase.total?.formatCrypto(assetBase.token.configuration.symbol).orEmpty() + val totalBaseFiat = assetBase.fiatAmount?.formatFiat(assetBase.token.fiatSymbol) + val argsBase = totalBaseCrypto + totalBaseFiat?.let { " ($it)" }.orEmpty() + val totalBaseBalance = resourceManager.getString(R.string.common_available_format, argsBase) + + AmountInputViewState( + tokenName = assetBase.token.configuration.symbol, + tokenImage = assetBase.token.configuration.iconUrl, + totalBalance = totalBaseBalance, + tokenAmount = baseShown, + fiatAmount = baseShown.applyFiatRate(assetBase.token.fiatRate)?.formatFiat(assetBase.token.fiatSymbol), + isFocused = baseFocused, + isShimmerAmounts = isCalulatingAmount == WithDesired.OUTPUT + ) + } + + val targetAmountInputViewState = if (assetTarget == null) { + AmountInputViewState.defaultObj + } else { + val totalTargetCrypto = assetTarget.total?.formatCrypto(assetTarget.token.configuration.symbol).orEmpty() + val totalTargetFiat = assetTarget.fiatAmount?.formatFiat(assetTarget.token.fiatSymbol) + val argsTarget = totalTargetCrypto + totalTargetFiat?.let { " ($it)" }.orEmpty() + val totalTargetBalance = resourceManager.getString(R.string.common_available_format, argsTarget) + + AmountInputViewState( + tokenName = assetTarget.token.configuration.symbol, + tokenImage = assetTarget.token.configuration.iconUrl, + totalBalance = totalTargetBalance, + tokenAmount = targetShown, + fiatAmount = targetShown.applyFiatRate(assetTarget.token.fiatRate)?.formatFiat(assetTarget.token.fiatSymbol), + isFocused = targetFocused, + isShimmerAmounts = isCalulatingAmount == WithDesired.INPUT + ) + } + + val isButtonEnabled = amountBase.moreThanZero() && + amountTarget.moreThanZero() && + feeInfo.feeAmount != null && + isCalculatingAmounts.value == null + + LiquidityAddState( + apy = poolsInteractor.getSbApy(pool.basic.reserveAccount)?.toBigDecimal()?.formatPercent()?.let { "$it%" }.orEmpty(), + slippage = "$slippage%", + feeInfo = feeInfo, + buttonEnabled = isButtonEnabled, + buttonLoading = isButtonLoading, + baseAmountInputViewState = baseAmountInputViewState, + targetAmountInputViewState = targetAmountInputViewState + ) + } + }.stateIn(coroutineScope, SharingStarted.Lazily, LiquidityAddState()) + } + + private suspend fun updateAmounts() { + calculateAmount()?.let { targetAmount -> + val scaledTargetAmount = if (targetAmount.isZero()) { + BigDecimal.ZERO + } else { + targetAmount.setScale( + min(MAX_DECIMALS_8, targetAmount.scale()), + RoundingMode.DOWN + ) + } + + if (desired == WithDesired.INPUT) { + uiTargetAmountFlow.value = scaledTargetAmount + amountTarget = scaledTargetAmount + } else { + uiBaseAmountFlow.value = scaledTargetAmount + amountBase = scaledTargetAmount + } + } + + if (isCalculatingAmounts.value == desired) { + isCalculatingAmounts.value = null + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun calculateAmount(): BigDecimal? { + val assets = loadingAssetsInPoolFlow.firstOrNull()?.dataOrNull() + + val baseAmount = if (desired == WithDesired.INPUT) enteredBaseAmountFlow.value else enteredTargetAmountFlow.value + val targetAmount = if (desired == WithDesired.INPUT) enteredTargetAmountFlow.value else enteredBaseAmountFlow.value + + val liquidity = screenArgsFlow.flatMapLatest { screenArgs -> + poolsInteractor.getPoolData( + baseTokenId = screenArgs.ids.first, + targetTokenId = screenArgs.ids.second + ) + }.firstOrNull() + + return assets?.let { (baseAsset, targetAsset) -> + val reservesFirst = liquidity?.basic?.baseReserves.orZero() + val reservesSecond = liquidity?.basic?.targetReserves.orZero() + + if (reservesSecond.isZero() || reservesSecond.isZero()) { + targetAmount + } else { + PolkaswapFormulas.calculateAddLiquidityAmount( + baseAmount = baseAmount, + reservesFirst = reservesFirst, + reservesSecond = reservesSecond, + precisionFirst = baseAsset.token.configuration.precision, + precisionSecond = targetAsset.token.configuration.precision, + desired = desired + ) + } + } + } + + private suspend fun getLiquidityNetworkFee( + tokenBase: CoreAsset, + tokenTarget: CoreAsset, + tokenBaseAmount: BigDecimal, + tokenToAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal { + val chainId = poolsInteractor.poolsChainId + + val soraChain = walletInteractor.getChain(chainId) + val address = accountInteractor.selectedMetaAccount().address(soraChain).orEmpty() + val result = poolsInteractor.calcAddLiquidityNetworkFee( + chainId, + address, + tokenBase, + tokenTarget, + tokenBaseAmount, + tokenToAmount, + pairEnabled, + pairPresented, + slippageTolerance, + ) + return result ?: BigDecimal.ZERO + } + + private fun setButtonLoading(loading: Boolean) { + isButtonLoading.value = loading + } + + override fun onAddReviewClick() { + setButtonLoading(true) + + coroutinesStore.uiScope.launch { + val chainId = poolsInteractor.poolsChainId + val utilityAssetId = requireNotNull(chainsRepository.getChain(chainId).utilityAsset?.id) + val utilityAmount = walletInteractor.getCurrentAsset(chainId, utilityAssetId).total + val feeAmount = networkFeeFlow.firstOrNull().orZero() + + val poolAssets = loadingAssetsInPoolFlow.firstOrNull()?.dataOrNull() ?: return@launch + + val validationResult = validateAddLiquidityUseCase( + assetBase = poolAssets.first, + assetTarget = poolAssets.second, + utilityAssetId = utilityAssetId, + utilityAmount = utilityAmount.orZero(), + amountBase = amountBase, + amountTarget = amountTarget, + feeAmount = feeAmount, + ) + + validationResult.exceptionOrNull()?.let { + showError(it) + return@launch + } + + val validationValue = validationResult.requireValue() + ValidationException.fromValidationResult(validationValue, resourceManager)?.let { + showError(it) + return@launch + } + + val ids = screenArgsFlow.replayCache.lastOrNull()?.ids ?: return@launch + val apy = + poolFlow.firstOrNull()?.basic?.reserveAccount?.let { poolsInteractor.getSbApy(it) } + ?.toBigDecimal()?.formatPercent()?.let { "$it%" }.orEmpty() + + internalPoolsRouter.openAddLiquidityConfirmScreen(ids, amountBase, amountTarget, apy) + }.invokeOnCompletion { + coroutinesStore.uiScope.launch { + delay(DEBOUNCE_300) + setButtonLoading(false) + } + } + } + + override fun onAddBaseAmountChange(amount: BigDecimal) { + enteredBaseAmountFlow.value = amount + uiBaseAmountFlow.value = amount + amountBase = amount + } + + override fun onAddTargetAmountChange(amount: BigDecimal) { + enteredTargetAmountFlow.value = amount + uiTargetAmountFlow.value = amount + amountTarget = amount + } + + override fun onAddBaseAmountFocusChange(isFocused: Boolean) { + isBaseAmountFocused.value = isFocused + if (desired != WithDesired.INPUT) { + desired = WithDesired.INPUT + } + } + + override fun onAddTargetAmountFocusChange(isFocused: Boolean) { + isTargetAmountFocused.value = isFocused + if (desired != WithDesired.OUTPUT) { + desired = WithDesired.OUTPUT + } + } + + override fun onAddTableItemClick(itemId: Int) { + internalPoolsRouter.openInfoScreen(itemId) + } + + private fun showError(throwable: Throwable) { + if (throwable is ValidationException) { + val (title, text) = throwable + internalPoolsRouter.openErrorsScreen(title, text) + } else { + throwable.message?.let { internalPoolsRouter.openErrorsScreen(message = it) } + } + } + + private fun areArgsEquivalent(): ( + old: LiquidityPoolsNavGraphRoute.LiquidityAddScreen, + new: LiquidityPoolsNavGraphRoute.LiquidityAddScreen + ) -> Boolean = + { old, new -> + old.routeName == new.routeName && + old.ids.first == new.ids.first && + old.ids.second == new.ids.second + } + + companion object { + private const val INPUT_DEBOUNCE = 900L + private const val RESET_DEBOUNCE = 200L + private const val DEBOUNCE_300 = 300L + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddScreen.kt new file mode 100644 index 0000000000..ebff1611c0 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityadd/LiquidityAddScreen.kt @@ -0,0 +1,206 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityadd + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.AmountInput +import jp.co.soramitsu.common.compose.component.AmountInputViewState +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.compose.component.InfoTableItem +import jp.co.soramitsu.common.compose.component.InfoTableItemAsset +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.TitleIconValueState +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.theme.backgroundBlack +import jp.co.soramitsu.common.compose.theme.colorAccentDark +import jp.co.soramitsu.common.compose.theme.grayButtonBackground +import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.compose.theme.white08 +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_APY_ID +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_FEE_ID +import java.math.BigDecimal + +data class LiquidityAddState( + val baseAmountInputViewState: AmountInputViewState = AmountInputViewState.defaultObj, + val targetAmountInputViewState: AmountInputViewState = AmountInputViewState.defaultObj, + val slippage: String = "0.5%", + val apy: String? = null, + val feeInfo: FeeInfoViewState = FeeInfoViewState.default, + val buttonEnabled: Boolean = false, + val buttonLoading: Boolean = false +) + +interface LiquidityAddCallbacks { + + fun onAddReviewClick() + + fun onAddBaseAmountChange(amount: BigDecimal) + + fun onAddBaseAmountFocusChange(isFocused: Boolean) + + fun onAddTargetAmountChange(amount: BigDecimal) + + fun onAddTargetAmountFocusChange(isFocused: Boolean) + + fun onAddTableItemClick(itemId: Int) +} + +@Composable +fun LiquidityAddScreen(state: LiquidityAddState, callbacks: LiquidityAddCallbacks) { + val keyboardController = LocalSoftwareKeyboardController.current + val runCallback: (() -> Unit) -> Unit = { block -> + keyboardController?.hide() + block() + } + + Column( + modifier = Modifier + .background(backgroundBlack) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MarginVertical(margin = 16.dp) + + Box( + modifier = Modifier + .padding(top = 8.dp), + contentAlignment = Alignment.Center + ) { + Column { + AmountInput( + state = state.baseAmountInputViewState, + borderColorFocused = colorAccentDark, + onInput = callbacks::onAddBaseAmountChange, + onInputFocusChange = callbacks::onAddBaseAmountFocusChange, + onKeyboardDone = { keyboardController?.hide() } + ) + + MarginVertical(margin = 8.dp) + + AmountInput( + state = state.targetAmountInputViewState, + borderColorFocused = colorAccentDark, + onInput = callbacks::onAddTargetAmountChange, + onInputFocusChange = callbacks::onAddTargetAmountFocusChange, + onKeyboardDone = { keyboardController?.hide() } + ) + } + + Icon( + modifier = Modifier + .clip(CircleShape) + .background(grayButtonBackground) + .border(width = 1.dp, color = white08, shape = CircleShape) + .padding(8.dp), + painter = painterResource(R.drawable.ic_plus_white_24), + contentDescription = null, + tint = white + ) + } + + MarginVertical(margin = 24.dp) + + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + ) { + Column { + InfoTableItem(TitleValueViewState(stringResource(id = R.string.lp_slippage_title), state.slippage)) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.lp_apy_title), + value = state.apy, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_APY_ID) + ), + onClick = { + callbacks.onAddTableItemClick(ITEM_APY_ID) + } + ) + InfoTableItemAsset( + TitleIconValueState( + title = stringResource(id = R.string.lp_reward_token_title), + iconUrl = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/icons/tokens/coloured/PSWAP.svg", + value = "PSWAP" + ) + ) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.common_network_fee), + value = state.feeInfo.feeAmount, + additionalValue = state.feeInfo.feeAmountFiat, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_FEE_ID) + ), + onClick = { + callbacks.onAddTableItemClick(ITEM_FEE_ID) + } + ) + } + } + + MarginVertical(margin = 24.dp) + } + + AccentButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = "Review", + enabled = state.buttonEnabled, + loading = state.buttonLoading, + onClick = { runCallback(callbacks::onAddReviewClick) } + ) + + MarginVertical(margin = 8.dp) + } +} + +@Preview +@Composable +private fun PreviewLiquidityAddScreen() { + LiquidityAddScreen( + state = LiquidityAddState( + baseAmountInputViewState = AmountInputViewState.defaultObj, + targetAmountInputViewState = AmountInputViewState.defaultObj, + apy = "23.3%", + feeInfo = FeeInfoViewState.default, + slippage = "0.5%" + ), + callbacks = object : LiquidityAddCallbacks { + override fun onAddReviewClick() {} + override fun onAddBaseAmountChange(amount: BigDecimal) {} + override fun onAddBaseAmountFocusChange(isFocused: Boolean) {} + override fun onAddTargetAmountChange(amount: BigDecimal) {} + override fun onAddTargetAmountFocusChange(isFocused: Boolean) {} + override fun onAddTableItemClick(itemId: Int) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmPresenter.kt new file mode 100644 index 0000000000..1712f31d2d --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmPresenter.kt @@ -0,0 +1,252 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm + +import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.flowOf +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatCryptoDetail +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.common.utils.orZero +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.utils.utilityAsset +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +class LiquidityAddConfirmPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val poolsInteractor: PoolsInteractor, + private val accountInteractor: AccountInteractor, + private val resourceManager: ResourceManager, +) : LiquidityAddConfirmCallbacks { + + private val _stateSlippage = MutableStateFlow(0.5) + val stateSlippage = _stateSlippage.asStateFlow() + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .shareIn(coroutinesStore.uiScope, SharingStarted.Eagerly, 1) + + val assetsInPoolFlow = screenArgsFlow.flatMapLatest { screenArgs -> + val ids = screenArgs.ids + val chainId = poolsInteractor.poolsChainId + val assetsFlow = walletInteractor.assetsFlow().mapNotNull { + val firstInPair = it.firstOrNull { + it.asset.token.configuration.currencyId == ids.first && + it.asset.token.configuration.chainId == chainId + } + val secondInPair = it.firstOrNull { + it.asset.token.configuration.currencyId == ids.second && + it.asset.token.configuration.chainId == chainId + } + if (firstInPair == null || secondInPair == null) { + return@mapNotNull null + } else { + firstInPair to secondInPair + } + } + assetsFlow + } + + private val tokensInPoolFlow = assetsInPoolFlow.map { + it.first.asset.token to it.second.asset.token + }.distinctUntilChanged() + + private val isPoolPairEnabled = + screenArgsFlow.map { screenArgs -> + poolsInteractor.isPairEnabled( + baseTokenId = screenArgs.ids.first, + targetTokenId = screenArgs.ids.second + ) + } + + val networkFeeFlow = combine( + screenArgsFlow, + tokensInPoolFlow, + stateSlippage, + isPoolPairEnabled + ) { screenArgs, (baseAsset, targetAsset), slippage, pairEnabled -> + getLiquidityNetworkFee( + tokenBase = baseAsset.configuration, + tokenTarget = targetAsset.configuration, + tokenBaseAmount = screenArgs.amountBase, + tokenTargetAmount = screenArgs.amountTarget, + pairEnabled = pairEnabled, + pairPresented = true, + slippageTolerance = slippage + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val feeInfoViewStateFlow: Flow = + flowOf { + requireNotNull(chainsRepository.getChain(poolsInteractor.poolsChainId).utilityAsset?.id) + }.flatMapLatest { utilityAssetId -> + combine( + networkFeeFlow, + walletInteractor.assetFlow(poolsInteractor.poolsChainId, utilityAssetId) + ) { networkFee, utilityAsset -> + val tokenSymbol = utilityAsset.token.configuration.symbol + val tokenFiatRate = utilityAsset.token.fiatRate + val tokenFiatSymbol = utilityAsset.token.fiatSymbol + + FeeInfoViewState( + feeAmount = networkFee.formatCryptoDetail(tokenSymbol), + feeAmountFiat = networkFee.applyFiatRate(tokenFiatRate)?.formatFiat(tokenFiatSymbol), + ) + } + } + + private val stateFlow = MutableStateFlow(LiquidityAddConfirmState()) + + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + return stateFlow + } + + private fun subscribeState(coroutineScope: CoroutineScope) { + combine(screenArgsFlow, tokensInPoolFlow) { screenArgs, (assetBase, assetTarget) -> + stateFlow.value = stateFlow.value.copy( + assetBase = assetBase.configuration, + assetTarget = assetTarget.configuration, + baseAmount = screenArgs.amountBase.formatCrypto(assetBase.configuration.symbol), + targetAmount = screenArgs.amountTarget.formatCrypto(assetTarget.configuration.symbol), + apy = screenArgs.apy + ) + }.launchIn(coroutineScope) + + stateSlippage.onEach { + stateFlow.value = stateFlow.value.copy( + slippage = "$it%" + ) + }.launchIn(coroutineScope) + + feeInfoViewStateFlow.onEach { + stateFlow.value = stateFlow.value.copy( + feeInfo = it, + buttonEnabled = it.feeAmount.isNullOrEmpty().not() + ) + }.launchIn(coroutineScope) + } + + private suspend fun getLiquidityNetworkFee( + tokenBase: Asset, + tokenTarget: Asset, + tokenBaseAmount: BigDecimal, + tokenTargetAmount: BigDecimal, + pairEnabled: Boolean, + pairPresented: Boolean, + slippageTolerance: Double + ): BigDecimal { + val chainId = poolsInteractor.poolsChainId + val soraChain = walletInteractor.getChain(chainId) + val user = accountInteractor.selectedMetaAccount().address(soraChain).orEmpty() + val result = poolsInteractor.calcAddLiquidityNetworkFee( + chainId = chainId, + address = user, + tokenBase = tokenBase, + tokenTarget = tokenTarget, + tokenBaseAmount = tokenBaseAmount, + tokenTargetAmount = tokenTargetAmount, + pairEnabled = pairEnabled, + pairPresented = pairPresented, + slippageTolerance = slippageTolerance, + ) + return result ?: BigDecimal.ZERO + } + + override fun onConfirmClick() { + setButtonLoading(true) + coroutinesStore.ioScope.launch { + val chainId = poolsInteractor.poolsChainId + val tokenBase = tokensInPoolFlow.firstOrNull()?.first?.configuration ?: return@launch + val tokenTarget = tokensInPoolFlow.firstOrNull()?.second?.configuration ?: return@launch + val amountBase = screenArgsFlow.firstOrNull()?.amountBase.orZero() + val amountTarget = screenArgsFlow.firstOrNull()?.amountTarget.orZero() + val pairEnabled = isPoolPairEnabled.firstOrNull() ?: true + var result = "" + try { + result = poolsInteractor.observeAddLiquidity( + chainId = chainId, + tokenBase = tokenBase, + tokenTarget = tokenTarget, + amountBase = amountBase, + amountTarget = amountTarget, + enabled = pairEnabled, + presented = true, + slippageTolerance = _stateSlippage.value, + ) + } catch (t: Throwable) { + coroutinesStore.uiScope.launch { + internalPoolsRouter.openErrorsScreen(message = t.message.orEmpty()) + } + } + + if (result.isNotEmpty()) { + coroutinesStore.uiScope.launch { +// internalPoolsRouter.popupToScreen(LiquidityPoolsNavGraphRoute.PoolDetailsScreen) + internalPoolsRouter.back() + internalPoolsRouter.back() + internalPoolsRouter.back() + internalPoolsRouter.openSuccessScreen(result, chainId, resourceManager.getString(R.string.lp_liquidity_add_complete_text)) + } + } + }.invokeOnCompletion { + coroutinesStore.ioScope.launch { + delay(UPDATE_POOL_DELAY) + poolsInteractor.updateAccountPools() + } + + coroutinesStore.uiScope.launch { + delay(DEBOUNCE_300) + setButtonLoading(false) + } + } + } + + override fun onAddItemClick(itemId: Int) { + internalPoolsRouter.openInfoScreen(itemId) + } + + private fun setButtonLoading(loading: Boolean) { + stateFlow.value = stateFlow.value.copy( + buttonLoading = loading + ) + } + + companion object { + private const val UPDATE_POOL_DELAY = 700L + private const val DEBOUNCE_300 = 300L + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmScreen.kt new file mode 100644 index 0000000000..8816e05d53 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityaddconfirm/LiquidityAddConfirmScreen.kt @@ -0,0 +1,221 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityaddconfirm + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import coil.compose.SubcomposeAsyncImage +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.B1 +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.compose.component.InfoTableItem +import jp.co.soramitsu.common.compose.component.InfoTableItemAsset +import jp.co.soramitsu.common.compose.component.MarginHorizontal +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.Shimmer +import jp.co.soramitsu.common.compose.component.TitleIconValueState +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.component.getImageRequest +import jp.co.soramitsu.common.compose.theme.backgroundBlack +import jp.co.soramitsu.common.compose.theme.customColors +import jp.co.soramitsu.common.compose.theme.customTypography +import jp.co.soramitsu.common.compose.theme.white50 +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_APY_ID +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_FEE_ID + +data class LiquidityAddConfirmState( + val assetBase: Asset? = null, + val assetTarget: Asset? = null, + val baseAmount: String = "", + val targetAmount: String = "", + val slippage: String = "0.5%", + val apy: String? = null, + val feeInfo: FeeInfoViewState = FeeInfoViewState.default, + val buttonEnabled: Boolean = false, + val buttonLoading: Boolean = false +) + +interface LiquidityAddConfirmCallbacks { + + fun onConfirmClick() + fun onAddItemClick(itemId: Int) +} + +@Composable +fun LiquidityAddConfirmScreen(state: LiquidityAddConfirmState, callbacks: LiquidityAddConfirmCallbacks) { + Column( + modifier = Modifier + .background(backgroundBlack) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MarginVertical(margin = 16.dp) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + SubcomposeAsyncImage( + modifier = Modifier + .size(64.dp) + .offset(x = 14.dp) + .zIndex(1f), + model = getImageRequest(LocalContext.current, state.assetBase?.iconUrl.orEmpty()), + contentDescription = null, + loading = { Shimmer(Modifier.size(64.dp)) } + ) + SubcomposeAsyncImage( + modifier = Modifier + .size(64.dp) + .offset(x = (-14).dp) + .zIndex(0f), + model = getImageRequest(LocalContext.current, state.assetTarget?.iconUrl.orEmpty()), + contentDescription = null, + loading = { Shimmer(Modifier.size(64.dp)) } + ) + } + + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = state.baseAmount, + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End + ) + + MarginHorizontal(margin = 8.dp) + + Icon( + painter = painterResource(jp.co.soramitsu.common.R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + + MarginHorizontal(margin = 8.dp) + + Text( + text = state.targetAmount, + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start + ) + } + + MarginVertical(margin = 8.dp) + B1( + modifier = Modifier + .padding(horizontal = 7.dp) + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.lp_confirm_liquidity_warning_text), + textAlign = TextAlign.Center, + color = white50 + ) + MarginVertical(margin = 8.dp) + + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + ) { + Column { + MarginVertical(margin = 6.dp) + InfoTableItem(TitleValueViewState(stringResource(id = R.string.lp_slippage_title), state.slippage)) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.lp_apy_title), + value = state.apy, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_APY_ID) + ), + onClick = { callbacks.onAddItemClick(ITEM_APY_ID) } + ) + InfoTableItemAsset( + TitleIconValueState( + title = stringResource(id = R.string.lp_reward_token_title), + iconUrl = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/icons/tokens/coloured/PSWAP.svg", + value = "PSWAP" + ) + ) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.common_network_fee), + value = state.feeInfo.feeAmount, + additionalValue = state.feeInfo.feeAmountFiat, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_FEE_ID) + ), + onClick = { callbacks.onAddItemClick(ITEM_FEE_ID) } + ) + MarginVertical(margin = 8.dp) + } + } + + MarginVertical(margin = 24.dp) + } + + AccentButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(id = R.string.common_confirm), + enabled = state.buttonEnabled, + loading = state.buttonLoading, + onClick = callbacks::onConfirmClick + ) + + MarginVertical(margin = 8.dp) + } +} + +@Preview +@Composable +private fun PreviewLiquidityAddConfirmScreen() { + LiquidityAddConfirmScreen( + state = LiquidityAddConfirmState( + baseAmount = "100.003 XOR", + targetAmount = "12340443,24312 PSWAP", + slippage = "0.5%", + apy = "23.3%", + feeInfo = FeeInfoViewState.default, + ), + callbacks = object : LiquidityAddConfirmCallbacks { + override fun onConfirmClick() {} + override fun onAddItemClick(itemId: Int) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemovePresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemovePresenter.kt new file mode 100644 index 0000000000..a835dd58ad --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemovePresenter.kt @@ -0,0 +1,556 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove + +import jp.co.soramitsu.androidfoundation.format.isZero +import jp.co.soramitsu.common.base.errors.ValidationException +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.MAX_DECIMALS_8 +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.flowOf +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatCryptoDetail +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.common.utils.moreThanZero +import jp.co.soramitsu.common.utils.orZero +import jp.co.soramitsu.common.utils.requireValue +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.utils.utilityAsset +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.domain.interfaces.DemeterFarmingInteractor +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.domain.model.CommonUserPoolData +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.impl.usecase.ValidateRemoveLiquidityUseCase +import jp.co.soramitsu.liquiditypools.impl.util.PolkaswapFormulas +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.wallet.api.domain.fromValidationResult +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject +import kotlin.math.min + +@OptIn(FlowPreview::class) +@Suppress("LargeClass") +class LiquidityRemovePresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val poolsInteractor: PoolsInteractor, + private val demeterFarmingInteractor: DemeterFarmingInteractor, + private val resourceManager: ResourceManager, + private val validateRemoveLiquidityUseCase: ValidateRemoveLiquidityUseCase, +) : LiquidityRemoveCallbacks { + private val enteredBaseAmountFlow = MutableStateFlow(BigDecimal.ZERO) + private val enteredTargetAmountFlow = MutableStateFlow(BigDecimal.ZERO) + + private var amountBase: BigDecimal = BigDecimal.ZERO + private var amountTarget: BigDecimal = BigDecimal.ZERO + + private val isBaseAmountFocused = MutableStateFlow(false) + private val isTargetAmountFocused = MutableStateFlow(false) + + private var poolInFarming = false + private var poolDataUsable: CommonUserPoolData? = null + private var poolDataReal: CommonUserPoolData? = null + private var percent: Double = 0.0 + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .onEach { + resetState() + } + .shareIn(coroutinesStore.uiScope, SharingStarted.Eagerly, 1) + + private val baseToTargetTokensFlow = screenArgsFlow.mapNotNull { screenArgs -> + val currencyIds = screenArgs.ids + val chainId = poolsInteractor.poolsChainId + + val chain = chainsRepository.getChain(chainId) + val first = chain.assets.find { it.currencyId == currencyIds.first } ?: return@mapNotNull null + val second = chain.assets.find { it.currencyId == currencyIds.second } ?: return@mapNotNull null + + coroutineScope { + val baseTokenDeferred = async { walletInteractor.getToken(first) } + val targetTokenDeferred = async { walletInteractor.getToken(second) } + + baseTokenDeferred.await() to targetTokenDeferred.await() + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + val poolDataFlow = screenArgsFlow.flatMapLatest { + poolsInteractor.getPoolData( + baseTokenId = it.ids.first, + targetTokenId = it.ids.second + ) + } + + private val networkFeeFlow = baseToTargetTokensFlow.map { (baseToken, targetToken) -> + getRemoveLiquidityNetworkFee( + tokenBase = baseToken.configuration, + tokenTarget = targetToken.configuration, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val utilityAssetFlow = flowOf { + requireNotNull(chainsRepository.getChain(poolsInteractor.poolsChainId).utilityAsset?.id) + }.flatMapLatest { utilityAssetId -> + walletInteractor.assetFlow(poolsInteractor.poolsChainId, utilityAssetId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val feeInfoViewStateFlow: Flow = + flowOf { + requireNotNull(chainsRepository.getChain(poolsInteractor.poolsChainId).utilityAsset?.id) + }.flatMapLatest { utilityAssetId -> + combine( + networkFeeFlow, + walletInteractor.assetFlow(poolsInteractor.poolsChainId, utilityAssetId) + ) { networkFee, utilityAsset -> + val tokenSymbol = utilityAsset.token.configuration.symbol + val tokenFiatRate = utilityAsset.token.fiatRate + val tokenFiatSymbol = utilityAsset.token.fiatSymbol + + FeeInfoViewState( + feeAmount = networkFee.formatCryptoDetail(tokenSymbol), + feeAmountFiat = networkFee.applyFiatRate(tokenFiatRate)?.formatFiat(tokenFiatSymbol), + ) + } + } + + private val stateFlow = MutableStateFlow(LiquidityRemoveState()) + + init { + coroutinesStore.ioScope.launch { + poolDataFlow.map { data -> + data.user?.let { + CommonUserPoolData( + data.basic, + data.user!!, + ) + } + } + .catch { showError(it) } + .distinctUntilChanged() + .debounce(DEBOUNCE_500) + .map { poolDataLocal -> + poolDataReal = poolDataLocal + poolInFarming = false + + val ids = screenArgsFlow.replayCache.firstOrNull()?.ids ?: return@map null + val (token1Id, token2Id) = ids + + val chainId = poolsInteractor.poolsChainId + val result = if (poolDataLocal != null) { + val maxPercent = demeterFarmingInteractor.getFarmedPools(chainId)?.filter { pool -> + pool.tokenBase.token.configuration.currencyId == token1Id && + pool.tokenTarget.token.configuration.currencyId == token2Id + }?.maxOfOrNull { + PolkaswapFormulas.calculateShareOfPoolFromAmount( + it.amount, + poolDataLocal.user.poolProvidersBalance, + ) + } + + if (maxPercent != null && !maxPercent.isNaN()) { + poolInFarming = true + val usablePercent = 100 - maxPercent + poolDataLocal.copy( + user = poolDataLocal.user.copy( + basePooled = PolkaswapFormulas.calculateAmountByPercentage( + poolDataLocal.user.basePooled, + usablePercent, + poolDataLocal.basic.baseToken.precision, + ), + targetPooled = PolkaswapFormulas.calculateAmountByPercentage( + poolDataLocal.user.targetPooled, + usablePercent, + poolDataLocal.basic.baseToken.precision, + ), + poolProvidersBalance = PolkaswapFormulas.calculateAmountByPercentage( + poolDataLocal.user.poolProvidersBalance, + usablePercent, + poolDataLocal.basic.baseToken.precision, + ), + ) + ) + } else { + poolDataLocal + } + } else { + null + } + result + } + .collectLatest { poolDataLocal -> + poolDataUsable = poolDataLocal + amountBase = + if (poolDataLocal != null) { + PolkaswapFormulas.calculateAmountByPercentage( + poolDataLocal.user.basePooled, + percent, + poolDataLocal.basic.baseToken.precision, + ) + } else { + BigDecimal.ZERO + } + amountTarget = + if (poolDataLocal != null) { + PolkaswapFormulas.calculateAmountByPercentage( + poolDataLocal.user.targetPooled, + percent, + poolDataLocal.basic.targetToken?.precision!!, + ) + } else { + BigDecimal.ZERO + } + + coroutinesStore.ioScope.launch { + updateAmounts() + } + } + } + } + + private fun resetState() { + amountTarget = BigDecimal.ZERO + amountBase = BigDecimal.ZERO + stateFlow.value = stateFlow.value.copy( + baseAmountInputViewState = stateFlow.value.baseAmountInputViewState.copy( + tokenAmount = BigDecimal.ZERO, + fiatAmount = null + ), + targetAmountInputViewState = stateFlow.value.targetAmountInputViewState.copy( + tokenAmount = BigDecimal.ZERO, + fiatAmount = null + ) + ) + } + + @OptIn(FlowPreview::class) + private fun subscribeState(coroutineScope: CoroutineScope) { + poolDataFlow.onEach { pool -> + val (baseToken, targetToken) = baseToTargetTokensFlow.first() + + val pooledBaseCrypto = pool.user?.basePooled?.formatCrypto(baseToken.configuration.symbol).orEmpty() + val pooledBaseFiat = pool.user?.basePooled?.applyFiatRate(baseToken.fiatRate)?.formatFiat(baseToken.fiatSymbol) + val argsBase = pooledBaseCrypto + pooledBaseFiat?.let { " ($it)" }.orEmpty() + val pooledBaseBalance = resourceManager.getString(R.string.common_available_format, argsBase) + + val pooledTargetCrypto = pool.user?.targetPooled?.formatCrypto(targetToken.configuration.symbol).orEmpty() + val pooledTargetFiat = pool.user?.targetPooled?.applyFiatRate(targetToken.fiatRate)?.formatFiat(targetToken.fiatSymbol) + val argsTarget = pooledTargetCrypto + pooledTargetFiat?.let { " ($it)" }.orEmpty() + val pooledTargetBalance = resourceManager.getString(R.string.common_available_format, argsTarget) + + stateFlow.value = stateFlow.value.copy( + baseAmountInputViewState = stateFlow.value.baseAmountInputViewState.copy( + tokenName = baseToken.configuration.symbol, + tokenImage = baseToken.configuration.iconUrl, + totalBalance = pooledBaseBalance, + ), + targetAmountInputViewState = stateFlow.value.targetAmountInputViewState.copy( + tokenName = targetToken.configuration.symbol, + tokenImage = targetToken.configuration.iconUrl, + totalBalance = pooledTargetBalance, + ) + ) + }.launchIn(coroutineScope) + + utilityAssetFlow.onEach { + stateFlow.value = stateFlow.value.copy( + transferableAmount = it.transferable.formatCrypto(it.token.configuration.symbol), + transferableFiat = it.transferable.applyFiatRate(it.token.fiatRate)?.formatFiat(it.token.fiatSymbol) + ) + }.launchIn(coroutineScope) + + enteredBaseAmountFlow.onEach { + val (baseToken, _) = baseToTargetTokensFlow.first() + + stateFlow.value = stateFlow.value.copy( + baseAmountInputViewState = stateFlow.value.baseAmountInputViewState.copy( + fiatAmount = it.applyFiatRate(baseToken.fiatRate)?.formatFiat(baseToken.fiatSymbol), + tokenAmount = it, + ) + ) + } + .debounce(INPUT_DEBOUNCE) + .onEach { amount -> + poolDataUsable?.let { + amountBase = if (amount <= it.user.basePooled) amount else it.user.basePooled + + val precisionTarget = poolDataFlow.firstOrNull()?.basic?.targetToken?.precision + amountTarget = PolkaswapFormulas.calculateOneAmountFromAnother( + amountBase, + it.user.basePooled, + it.user.targetPooled, + precisionTarget + ) + percent = PolkaswapFormulas.calculateShareOfPoolFromAmount( + amountBase, + it.user.basePooled, + ) + } + + coroutinesStore.uiScope.launch { + updateAmounts() + } + }.launchIn(coroutineScope) + + enteredTargetAmountFlow.onEach { + val (_, targetToken) = baseToTargetTokensFlow.first() + + stateFlow.value = stateFlow.value.copy( + targetAmountInputViewState = stateFlow.value.targetAmountInputViewState.copy( + fiatAmount = it.applyFiatRate(targetToken.fiatRate)?.formatFiat(targetToken.fiatSymbol), + tokenAmount = it + ) + ) + } + .debounce(INPUT_DEBOUNCE) + .onEach { amount -> + poolDataUsable?.let { + amountTarget = if (amount <= it.user.targetPooled) amount else it.user.targetPooled + + val precisionBase = poolDataFlow.firstOrNull()?.basic?.baseToken?.precision + amountBase = PolkaswapFormulas.calculateOneAmountFromAnother( + amountTarget, + it.user.targetPooled, + it.user.basePooled, + precisionBase + ) + percent = PolkaswapFormulas.calculateShareOfPoolFromAmount( + amountBase, + it.user.basePooled, + ) + } + + coroutinesStore.uiScope.launch { + updateAmounts() + } + }.launchIn(coroutineScope) + + isBaseAmountFocused.onEach { + stateFlow.value = stateFlow.value.copy( + baseAmountInputViewState = stateFlow.value.baseAmountInputViewState.copy( + isFocused = it + ), + ) + }.launchIn(coroutineScope) + + isTargetAmountFocused.onEach { + stateFlow.value = stateFlow.value.copy( + targetAmountInputViewState = stateFlow.value.targetAmountInputViewState.copy( + isFocused = it + ), + ) + }.launchIn(coroutineScope) + + feeInfoViewStateFlow.onEach { + stateFlow.value = stateFlow.value.copy( + feeInfo = it + ) + updateButtonState() + }.launchIn(coroutineScope) + } + + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + return stateFlow + } + + @Suppress("NestedBlockDepth") + private suspend fun updateAmounts() { + baseToTargetTokensFlow.firstOrNull()?.let { (tokenBase, tokenTarget) -> + if (amountBase.compareTo(stateFlow.value.baseAmountInputViewState.tokenAmount) != 0) { + val scaledAmountBase = if (amountBase.isZero()) { + BigDecimal.ZERO + } else { + amountBase.setScale( + min(MAX_DECIMALS_8, amountBase.scale()), + RoundingMode.DOWN + ) + } + + stateFlow.value = stateFlow.value.copy( + baseAmountInputViewState = stateFlow.value.baseAmountInputViewState.copy( + tokenAmount = scaledAmountBase, + fiatAmount = amountBase.applyFiatRate(tokenBase.fiatRate)?.formatFiat(tokenTarget.fiatSymbol), + ) + ) + } + if (amountTarget.compareTo(stateFlow.value.targetAmountInputViewState.tokenAmount) != 0) { + val scaledAmountTarget = if (amountTarget.isZero()) { + BigDecimal.ZERO + } else { + amountTarget.setScale( + min(MAX_DECIMALS_8, amountTarget.scale()), + RoundingMode.DOWN + ) + } + stateFlow.value = stateFlow.value.copy( + targetAmountInputViewState = stateFlow.value.targetAmountInputViewState.copy( + tokenAmount = scaledAmountTarget, + fiatAmount = amountTarget.applyFiatRate(tokenTarget.fiatRate)?.formatFiat(tokenTarget.fiatSymbol), + ) + ) + } + } + + updateButtonState() + } + + private fun updateButtonState() { + val isButtonEnabled = amountTarget.moreThanZero() && amountTarget.moreThanZero() && stateFlow.value.feeInfo.feeAmount != null + stateFlow.value = stateFlow.value.copy( + buttonEnabled = isButtonEnabled + ) + } + + private suspend fun getRemoveLiquidityNetworkFee(tokenBase: Asset, tokenTarget: Asset): BigDecimal { + val result = poolsInteractor.calcRemoveLiquidityNetworkFee( + tokenBase, + tokenTarget, + ) + return result ?: BigDecimal.ZERO + } + + private fun setButtonLoading(loading: Boolean) { + stateFlow.value = stateFlow.value.copy( + buttonLoading = loading + ) + } + + override fun onRemoveReviewClick() { + setButtonLoading(true) + + coroutinesStore.uiScope.launch { + val utilityAmount = utilityAssetFlow.firstOrNull()?.transferable ?: return@launch + val feeAmount = networkFeeFlow.firstOrNull().orZero() + + val poolAssets = baseToTargetTokensFlow.first() + + val userBasePooled = poolDataReal?.user?.basePooled ?: return@launch + val userTargetPooled = poolDataReal?.user?.targetPooled ?: return@launch + + val validationResult = validateRemoveLiquidityUseCase( + utilityAmount = utilityAmount.orZero(), + userBasePooled = userBasePooled, + userTargetPooled = userTargetPooled, + amountBase = amountBase, + amountTarget = amountTarget, + feeAmount = feeAmount, + ) + + validationResult.exceptionOrNull()?.let { + showError(it) + return@launch + } + + val validationValue = validationResult.requireValue() + ValidationException.fromValidationResult(validationValue, resourceManager)?.let { + showError(it) + return@launch + } + + val slippage = DEFAULT_SLIPPAGE + + val firstAmountMin = + PolkaswapFormulas.calculateMinAmount( + amountBase, + slippage + ) + val secondAmountMin = + PolkaswapFormulas.calculateMinAmount( + amountTarget, + slippage + ) + val desired = + if (percent == 100.0) { + poolDataUsable?.user?.poolProvidersBalance.orZero() + } else { + PolkaswapFormulas.calculateAmountByPercentage( + poolDataUsable?.user?.poolProvidersBalance.orZero(), + percent, + poolAssets.first.configuration.precision + ) + } + + val ids = screenArgsFlow.replayCache.lastOrNull()?.ids ?: return@launch + + internalPoolsRouter.openRemoveLiquidityConfirmScreen(ids, amountBase, amountTarget, firstAmountMin, secondAmountMin, desired) + }.invokeOnCompletion { + coroutinesStore.uiScope.launch { + delay(DEBOUNCE_300) + setButtonLoading(false) + } + } + } + + override fun onRemoveBaseAmountChange(amount: BigDecimal) { + enteredBaseAmountFlow.value = amount + updateButtonState() + } + + override fun onRemoveTargetAmountChange(amount: BigDecimal) { + enteredTargetAmountFlow.value = amount + updateButtonState() + } + + override fun onRemoveBaseAmountFocusChange(isFocused: Boolean) { + isBaseAmountFocused.value = isFocused + } + + override fun onRemoveTargetAmountFocusChange(isFocused: Boolean) { + isTargetAmountFocused.value = isFocused + } + + override fun onRemoveItemClick(itemId: Int) { + internalPoolsRouter.openInfoScreen(itemId) + } + + private fun showError(throwable: Throwable) { + if (throwable is ValidationException) { + val (title, text) = throwable + internalPoolsRouter.openErrorsScreen(title, text) + } else { + throwable.message?.let { internalPoolsRouter.openErrorsScreen(message = it) } + } + } + + companion object { + private const val INPUT_DEBOUNCE = 900L + private const val DEBOUNCE_300 = 300L + private const val DEBOUNCE_500 = 500L + private const val DEFAULT_SLIPPAGE = 0.5 + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemoveScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemoveScreen.kt new file mode 100644 index 0000000000..af8c86785b --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremove/LiquidityRemoveScreen.kt @@ -0,0 +1,203 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremove + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.AmountInput +import jp.co.soramitsu.common.compose.component.AmountInputViewState +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.compose.component.InfoTableItem +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.Notification +import jp.co.soramitsu.common.compose.component.NotificationState +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.theme.backgroundBlack +import jp.co.soramitsu.common.compose.theme.colorAccentDark +import jp.co.soramitsu.common.compose.theme.grayButtonBackground +import jp.co.soramitsu.common.compose.theme.warningOrange +import jp.co.soramitsu.common.compose.theme.white +import jp.co.soramitsu.common.compose.theme.white08 +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_FEE_ID +import java.math.BigDecimal + +data class LiquidityRemoveState( + val baseAmountInputViewState: AmountInputViewState = AmountInputViewState.defaultObj, + val targetAmountInputViewState: AmountInputViewState = AmountInputViewState.defaultObj, + val transferableAmount: String? = null, + val transferableFiat: String? = null, + val feeInfo: FeeInfoViewState = FeeInfoViewState.default, + val buttonEnabled: Boolean = false, + val buttonLoading: Boolean = false +) + +interface LiquidityRemoveCallbacks { + + fun onRemoveReviewClick() + + fun onRemoveBaseAmountChange(amount: BigDecimal) + + fun onRemoveBaseAmountFocusChange(isFocused: Boolean) + + fun onRemoveTargetAmountChange(amount: BigDecimal) + + fun onRemoveTargetAmountFocusChange(isFocused: Boolean) + + fun onRemoveItemClick(itemId: Int) +} + +@Composable +fun LiquidityRemoveScreen(state: LiquidityRemoveState, callbacks: LiquidityRemoveCallbacks) { + val keyboardController = LocalSoftwareKeyboardController.current + val runCallback: (() -> Unit) -> Unit = { block -> + keyboardController?.hide() + block() + } + + Column( + modifier = Modifier + .background(backgroundBlack) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MarginVertical(margin = 16.dp) + + Box( + modifier = Modifier + .padding(top = 8.dp), + contentAlignment = Alignment.Center + ) { + Column { + AmountInput( + state = state.baseAmountInputViewState, + borderColorFocused = colorAccentDark, + onInput = callbacks::onRemoveBaseAmountChange, + onInputFocusChange = callbacks::onRemoveBaseAmountFocusChange, + onKeyboardDone = { keyboardController?.hide() } + ) + + MarginVertical(margin = 8.dp) + + AmountInput( + state = state.targetAmountInputViewState, + borderColorFocused = colorAccentDark, + onInput = callbacks::onRemoveTargetAmountChange, + onInputFocusChange = callbacks::onRemoveTargetAmountFocusChange, + onKeyboardDone = { keyboardController?.hide() } + ) + } + + Icon( + modifier = Modifier + .clip(CircleShape) + .background(grayButtonBackground) + .border(width = 1.dp, color = white08, shape = CircleShape) + .padding(8.dp), + painter = painterResource(R.drawable.ic_plus_white_24), + contentDescription = null, + tint = white + ) + } + + MarginVertical(margin = 24.dp) + + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + ) { + Column { + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.assetdetails_balance_transferable), + value = state.transferableAmount, + additionalValue = state.transferableFiat + ) + ) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.common_network_fee), + value = state.feeInfo.feeAmount, + additionalValue = state.feeInfo.feeAmountFiat, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_FEE_ID) + ), + onClick = { callbacks.onRemoveItemClick(ITEM_FEE_ID) } + ) + } + } + + MarginVertical(margin = 24.dp) + } + + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + Notification( + state = NotificationState( + iconRes = R.drawable.ic_warning_filled, + title = stringResource(id = R.string.lp_pool_remove_warning_title).uppercase(), + value = stringResource(id = R.string.lp_pool_remove_warning_text), + color = warningOrange + ) + ) + } + MarginVertical(margin = 16.dp) + + AccentButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(id = R.string.common_preview), + enabled = state.buttonEnabled, + loading = state.buttonLoading, + onClick = { runCallback(callbacks::onRemoveReviewClick) } + ) + + MarginVertical(margin = 8.dp) + } +} + +@Preview +@Composable +private fun PreviewLiquidityRemoveScreen() { + LiquidityRemoveScreen( + state = LiquidityRemoveState( + baseAmountInputViewState = AmountInputViewState.defaultObj, + targetAmountInputViewState = AmountInputViewState.defaultObj, + feeInfo = FeeInfoViewState.default, + ), + callbacks = object : LiquidityRemoveCallbacks { + override fun onRemoveReviewClick() {} + override fun onRemoveBaseAmountChange(amount: BigDecimal) {} + override fun onRemoveBaseAmountFocusChange(isFocused: Boolean) {} + override fun onRemoveTargetAmountChange(amount: BigDecimal) {} + override fun onRemoveTargetAmountFocusChange(isFocused: Boolean) {} + override fun onRemoveItemClick(itemId: Int) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmPresenter.kt new file mode 100644 index 0000000000..0527f1e7bb --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmPresenter.kt @@ -0,0 +1,206 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm + +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.resources.ResourceManager +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.flowOf +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatCryptoDetail +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.utils.utilityAsset +import jp.co.soramitsu.feature_liquiditypools_impl.R +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +class LiquidityRemoveConfirmPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val poolsInteractor: PoolsInteractor, + private val resourceManager: ResourceManager, +) : LiquidityRemoveConfirmCallbacks { + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .shareIn(coroutinesStore.uiScope, SharingStarted.Eagerly, 1) + + val assetsInPoolFlow = screenArgsFlow.flatMapLatest { screenArgs -> + val ids = screenArgs.ids + val chainId = poolsInteractor.poolsChainId + val assetsFlow = walletInteractor.assetsFlow().mapNotNull { + val firstInPair = it.firstOrNull { + it.asset.token.configuration.currencyId == ids.first && + it.asset.token.configuration.chainId == chainId + } + val secondInPair = it.firstOrNull { + it.asset.token.configuration.currencyId == ids.second && + it.asset.token.configuration.chainId == chainId + } + if (firstInPair == null || secondInPair == null) { + return@mapNotNull null + } else { + firstInPair to secondInPair + } + } + assetsFlow + } + + private val tokensInPoolFlow = assetsInPoolFlow.map { + it.first.asset.token to it.second.asset.token + }.distinctUntilChanged() + + private val networkFeeFlow = tokensInPoolFlow.map { (baseToken, targetToken) -> + getRemoveLiquidityNetworkFee( + tokenBase = baseToken.configuration, + tokenTarget = targetToken.configuration, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val feeInfoViewStateFlow: Flow = + flowOf { + requireNotNull(chainsRepository.getChain(poolsInteractor.poolsChainId).utilityAsset?.id) + }.flatMapLatest { utilityAssetId -> + combine( + networkFeeFlow, + walletInteractor.assetFlow(poolsInteractor.poolsChainId, utilityAssetId) + ) { networkFee, utilityAsset -> + val tokenSymbol = utilityAsset.token.configuration.symbol + val tokenFiatRate = utilityAsset.token.fiatRate + val tokenFiatSymbol = utilityAsset.token.fiatSymbol + + FeeInfoViewState( + feeAmount = networkFee.formatCryptoDetail(tokenSymbol), + feeAmountFiat = networkFee.applyFiatRate(tokenFiatRate)?.formatFiat(tokenFiatSymbol), + ) + } + } + + private val stateFlow = MutableStateFlow(LiquidityRemoveConfirmState()) + + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + return stateFlow + } + + private fun subscribeState(coroutineScope: CoroutineScope) { + combine(screenArgsFlow, tokensInPoolFlow) { screenArgs, (assetBase, assetTarget) -> + stateFlow.value = stateFlow.value.copy( + assetBaseIconUrl = assetBase.configuration.iconUrl, + assetTargetIconUrl = assetTarget.configuration.iconUrl, + baseAmount = screenArgs.amountBase.formatCrypto(assetBase.configuration.symbol), + baseFiat = screenArgs.amountBase.applyFiatRate(assetBase.fiatRate)?.formatFiat(assetBase.fiatSymbol).orEmpty(), + targetAmount = screenArgs.amountTarget.formatCrypto(assetTarget.configuration.symbol), + targetFiat = screenArgs.amountTarget.applyFiatRate(assetTarget.fiatRate)?.formatFiat(assetTarget.fiatSymbol).orEmpty(), + ) + }.launchIn(coroutineScope) + + feeInfoViewStateFlow.onEach { + stateFlow.value = stateFlow.value.copy( + feeInfo = it, + buttonEnabled = it.feeAmount.isNullOrEmpty().not() + ) + }.launchIn(coroutineScope) + } + + private suspend fun getRemoveLiquidityNetworkFee(tokenBase: Asset, tokenTarget: Asset): BigDecimal { + val result = poolsInteractor.calcRemoveLiquidityNetworkFee( + tokenBase, + tokenTarget, + ) + return result ?: BigDecimal.ZERO + } + + override fun onRemoveConfirmClick() { + setButtonLoading(true) + coroutinesStore.ioScope.launch { + val firstAmountMin = screenArgsFlow.replayCache.firstOrNull()?.firstAmountMin ?: return@launch + val secondAmountMin = screenArgsFlow.replayCache.firstOrNull()?.secondAmountMin ?: return@launch + val desired = screenArgsFlow.replayCache.firstOrNull()?.desired ?: return@launch + val networkFee = networkFeeFlow.firstOrNull() ?: return@launch + + val chainId = poolsInteractor.poolsChainId + val tokenBase = tokensInPoolFlow.firstOrNull()?.first?.configuration ?: return@launch + val tokenTarget = tokensInPoolFlow.firstOrNull()?.second?.configuration ?: return@launch + + var result = "" + + try { + result = poolsInteractor.observeRemoveLiquidity( + chainId = chainId, + tokenBase = tokenBase, + tokenTarget = tokenTarget, + markerAssetDesired = desired, + firstAmountMin = firstAmountMin, + secondAmountMin = secondAmountMin, + networkFee = networkFee + ) + } catch (t: Throwable) { + coroutinesStore.uiScope.launch { + internalPoolsRouter.openErrorsScreen(message = t.message.orEmpty()) + } + } + + if (result.isNotEmpty()) { + coroutinesStore.uiScope.launch { +// internalPoolsRouter.popupToScreen(LiquidityPoolsNavGraphRoute.PoolDetailsScreen) + internalPoolsRouter.back() + internalPoolsRouter.back() + internalPoolsRouter.back() + internalPoolsRouter.openSuccessScreen(result, chainId, resourceManager.getString(R.string.lp_liquidity_add_complete_text)) + } + } + }.invokeOnCompletion { + coroutinesStore.ioScope.launch { + delay(UPDATE_POOL_DELAY) + poolsInteractor.updateAccountPools() + } + + coroutinesStore.uiScope.launch { + delay(DEBOUNCE_300) + setButtonLoading(false) + } + } + } + + override fun onRemoveConfirmItemClick(itemId: Int) { + internalPoolsRouter.openInfoScreen(itemId) + } + + private fun setButtonLoading(loading: Boolean) { + stateFlow.value = stateFlow.value.copy( + buttonLoading = loading + ) + } + + companion object { + private const val UPDATE_POOL_DELAY = 700L + private const val DEBOUNCE_300 = 300L + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmScreen.kt new file mode 100644 index 0000000000..ac691d77c9 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/liquidityremoveconfirm/LiquidityRemoveConfirmScreen.kt @@ -0,0 +1,181 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.liquidityremoveconfirm + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import coil.compose.AsyncImage +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.FeeInfoViewState +import jp.co.soramitsu.common.compose.component.InfoTableItem +import jp.co.soramitsu.common.compose.component.MarginHorizontal +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.component.getImageRequest +import jp.co.soramitsu.common.compose.theme.backgroundBlack +import jp.co.soramitsu.common.compose.theme.customColors +import jp.co.soramitsu.common.compose.theme.customTypography +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_FEE_ID + +data class LiquidityRemoveConfirmState( + val assetBaseIconUrl: String? = null, + val assetTargetIconUrl: String? = null, + val baseAmount: String = "", + val baseFiat: String = "", + val targetAmount: String = "", + val targetFiat: String = "", + val feeInfo: FeeInfoViewState = FeeInfoViewState.default, + val buttonEnabled: Boolean = false, + val buttonLoading: Boolean = false +) + +interface LiquidityRemoveConfirmCallbacks { + + fun onRemoveConfirmClick() + fun onRemoveConfirmItemClick(itemId: Int) +} + +@Composable +fun LiquidityRemoveConfirmScreen(state: LiquidityRemoveConfirmState, callbacks: LiquidityRemoveConfirmCallbacks) { + Column( + modifier = Modifier + .background(backgroundBlack) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + ) { + Column { + MarginVertical(margin = 16.dp) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + AsyncImage( + model = getImageRequest(LocalContext.current, state.assetBaseIconUrl.orEmpty()), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .offset(x = 14.dp) + .zIndex(1f) + ) + AsyncImage( + model = getImageRequest(LocalContext.current, state.assetTargetIconUrl.orEmpty()), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .offset(x = (-14).dp) + .zIndex(0f) + ) + } + + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = state.baseAmount, + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End + ) + + MarginHorizontal(margin = 8.dp) + + Icon( + painter = painterResource(jp.co.soramitsu.common.R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + + MarginHorizontal(margin = 8.dp) + + Text( + text = state.targetAmount, + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start + ) + } + + MarginVertical(margin = 8.dp) + + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.common_network_fee), + value = state.feeInfo.feeAmount, + additionalValue = state.feeInfo.feeAmountFiat, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_FEE_ID) + ), + onClick = { callbacks.onRemoveConfirmItemClick(ITEM_FEE_ID) } + ) + } + } + + MarginVertical(margin = 24.dp) + } + + AccentButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(id = R.string.common_confirm), + enabled = state.buttonEnabled, + loading = state.buttonLoading, + onClick = callbacks::onRemoveConfirmClick + ) + + MarginVertical(margin = 8.dp) + } +} + +@Preview +@Composable +private fun PreviewLiquidityRemoveConfirmScreen() { + LiquidityRemoveConfirmScreen( + state = LiquidityRemoveConfirmState( + feeInfo = FeeInfoViewState.default, + ), + callbacks = object : LiquidityRemoveConfirmCallbacks { + override fun onRemoveConfirmClick() {} + override fun onRemoveConfirmItemClick(itemId: Int) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt new file mode 100644 index 0000000000..e56bc8bc09 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt @@ -0,0 +1,124 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails + +import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.common.utils.formatPercent +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import jp.co.soramitsu.wallet.impl.domain.model.Token +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PoolDetailsPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val poolsInteractor: PoolsInteractor, + private val accountInteractor: AccountInteractor, +) : PoolDetailsCallbacks { + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .shareIn(coroutinesStore.uiScope, SharingStarted.Eagerly, 1) + + private val stateFlow = MutableStateFlow(PoolDetailsState()) + + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + return stateFlow + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun subscribeState(coroutineScope: CoroutineScope) { + screenArgsFlow.flatMapLatest { args -> + observePoolDetails(args.ids).onEach { pool -> + val token = walletInteractor.getToken(pool.basic.baseToken) + stateFlow.value = pool.mapToState(token) + } + }.onEach { + val apy = poolsInteractor.getSbApy(it.basic.reserveAccount) + stateFlow.update { prevState -> + prevState.copy(apy = "${apy?.toBigDecimal()?.formatPercent()}%") + } + }.launchIn(coroutineScope) + } + + override fun onSupplyLiquidityClick() { + coroutinesStore.ioScope.launch { + val ids = screenArgsFlow.replayCache.firstOrNull()?.ids ?: return@launch + internalPoolsRouter.openAddLiquidityScreen(ids) + } + } + + override fun onRemoveLiquidityClick() { + coroutinesStore.ioScope.launch { + val ids = screenArgsFlow.replayCache.firstOrNull()?.ids ?: return@launch + internalPoolsRouter.openRemoveLiquidityScreen(ids) + } + } + + override fun onDetailItemClick(itemId: Int) { + internalPoolsRouter.openInfoScreen(itemId) + } + + suspend fun observePoolDetails(ids: StringPair): Flow { + val (baseTokenId, targetTokenId) = ids + return poolsInteractor.getPoolData(baseTokenId, targetTokenId) + } + + suspend fun requestPoolDetails(ids: StringPair): PoolDetailsState? { + val chainId = poolsInteractor.poolsChainId + + val soraChain = accountInteractor.getChain(chainId) + val address = accountInteractor.selectedMetaAccount().address(soraChain).orEmpty() + val baseAsset = soraChain.assets.firstOrNull { it.id == ids.first } + val targetAsset = soraChain.assets.firstOrNull { it.id == ids.second } + val baseTokenId = baseAsset?.currencyId ?: error("No currency for Asset ${baseAsset?.symbol}") + val targetTokenId = targetAsset?.currencyId ?: error("No currency for Asset ${targetAsset?.symbol}") + + val result = poolsInteractor.getUserPoolData(chainId, address, baseTokenId, targetTokenId.fromHex())?.let { + PoolDetailsState( + assetBase = baseAsset, + assetTarget = targetAsset, + tvl = null, + apy = null + ) + } + return result + } +} + +private fun CommonPoolData.mapToState(token: Token): PoolDetailsState { + val tvl = basic.getTvl(token.fiatRate) + return PoolDetailsState( + assetBase = basic.baseToken, + assetTarget = basic.targetToken, + pooledBaseAmount = user?.basePooled?.formatCrypto(basic.baseToken.symbol).orEmpty(), + pooledBaseFiat = user?.basePooled?.applyFiatRate(token.fiatRate)?.formatFiat(token.fiatSymbol).orEmpty(), + pooledTargetAmount = user?.targetPooled?.formatCrypto(basic.targetToken?.symbol).orEmpty(), + pooledTargetFiat = user?.targetPooled?.applyFiatRate(token.fiatRate)?.formatFiat(token.fiatSymbol).orEmpty(), + tvl = tvl?.formatFiat(token.fiatSymbol), + apy = null + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsScreen.kt new file mode 100644 index 0000000000..21d4816e74 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsScreen.kt @@ -0,0 +1,218 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.pooldetails + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import coil.compose.SubcomposeAsyncImage +import jp.co.soramitsu.common.compose.component.AccentButton +import jp.co.soramitsu.common.compose.component.BackgroundCorneredWithBorder +import jp.co.soramitsu.common.compose.component.GrayButton +import jp.co.soramitsu.common.compose.component.InfoTableItem +import jp.co.soramitsu.common.compose.component.InfoTableItemAsset +import jp.co.soramitsu.common.compose.component.MarginHorizontal +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.Shimmer +import jp.co.soramitsu.common.compose.component.TitleIconValueState +import jp.co.soramitsu.common.compose.component.TitleValueViewState +import jp.co.soramitsu.common.compose.component.getImageRequest +import jp.co.soramitsu.common.compose.theme.customColors +import jp.co.soramitsu.common.compose.theme.customTypography +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.PoolsFlowViewModel.Companion.ITEM_APY_ID + +data class PoolDetailsState( + val assetBase: Asset? = null, + val assetTarget: Asset? = null, + val pooledBaseAmount: String = "", + val pooledBaseFiat: String = "", + val pooledTargetAmount: String = "", + val pooledTargetFiat: String = "", + val tvl: String? = null, + val apy: String? = null +) + +interface PoolDetailsCallbacks { + fun onSupplyLiquidityClick() + fun onRemoveLiquidityClick() + fun onDetailItemClick(itemId: Int) +} + +@Composable +fun PoolDetailsScreen(state: PoolDetailsState, callbacks: PoolDetailsCallbacks) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MarginVertical(margin = 16.dp) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + SubcomposeAsyncImage( + model = getImageRequest(LocalContext.current, state.assetBase?.iconUrl.orEmpty()), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .offset(x = 14.dp) + .zIndex(1f), + loading = { Shimmer(Modifier.size(64.dp)) } + ) + SubcomposeAsyncImage( + model = getImageRequest(LocalContext.current, state.assetTarget?.iconUrl.orEmpty()), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .offset(x = (-14).dp) + .zIndex(0f), + loading = { Shimmer(Modifier.size(64.dp)) } + ) + } + + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = state.assetBase?.symbol?.uppercase().orEmpty(), + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End + ) + + MarginHorizontal(margin = 8.dp) + + Icon( + painter = painterResource(jp.co.soramitsu.common.R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = MaterialTheme.customColors.white + ) + + MarginHorizontal(margin = 8.dp) + + Text( + text = state.assetTarget?.symbol?.uppercase().orEmpty(), + style = MaterialTheme.customTypography.header3, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start + ) + } + + MarginVertical(margin = 24.dp) + BackgroundCorneredWithBorder( + modifier = Modifier + .fillMaxWidth() + ) { + Column { + InfoTableItem(TitleValueViewState("TVL", state.tvl)) + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.lp_apy_title), + value = state.apy, + clickState = TitleValueViewState.ClickState.Title(R.drawable.ic_info_14, ITEM_APY_ID) + ), + onClick = { callbacks.onDetailItemClick(ITEM_APY_ID) } + ) + InfoTableItemAsset( + TitleIconValueState( + title = stringResource(id = R.string.lp_reward_token_title), + iconUrl = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/icons/tokens/coloured/PSWAP.svg", + value = "PSWAP" + ) + ) + if (state.pooledBaseAmount.isNotEmpty()) { + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.lp_your_pooled_format, state.assetBase?.symbol?.uppercase().orEmpty()), + value = state.pooledBaseAmount, + additionalValue = state.pooledBaseFiat + ) + ) + } + if (state.pooledTargetAmount.isNotEmpty()) { + InfoTableItem( + TitleValueViewState( + title = stringResource(id = R.string.lp_your_pooled_format, state.assetTarget?.symbol?.uppercase().orEmpty()), + value = state.pooledTargetAmount, + additionalValue = state.pooledTargetFiat + ) + ) + } + } + } + + MarginVertical(margin = 24.dp) + + AccentButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(id = R.string.lp_supply_button_title), + onClick = callbacks::onSupplyLiquidityClick + ) + MarginVertical(margin = 8.dp) + + if (state.pooledBaseAmount.isNotEmpty()) { + GrayButton( + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(id = R.string.lp_remove_button_title), + onClick = callbacks::onRemoveLiquidityClick + ) + MarginVertical(margin = 8.dp) + } + } + } +} + +@Preview +@Composable +private fun PreviewPoolDetailsScreen() { + PoolDetailsScreen( + state = PoolDetailsState( + apy = "23.3%", + tvl = "$34.999 TVL", + ), + callbacks = object : PoolDetailsCallbacks { + override fun onSupplyLiquidityClick() {} + override fun onRemoveLiquidityClick() {} + override fun onDetailItemClick(itemId: Int) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListPresenter.kt new file mode 100644 index 0000000000..1de568cf4b --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListPresenter.kt @@ -0,0 +1,145 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.poollist + +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.androidfoundation.format.compareNullDesc +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.utils.applyFiatRate +import jp.co.soramitsu.common.utils.formatCrypto +import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor +import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData +import jp.co.soramitsu.liquiditypools.domain.model.isFilterMatch +import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore +import jp.co.soramitsu.liquiditypools.impl.presentation.toListItemState +import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter +import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute +import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +class PoolListPresenter @Inject constructor( + private val coroutinesStore: CoroutinesStore, + private val internalPoolsRouter: InternalPoolsRouter, + private val walletInteractor: WalletInteractor, + private val poolsInteractor: PoolsInteractor +) : PoolListScreenInterface { + + private val enteredAssetQueryFlow = MutableStateFlow("") + + private val screenArgsFlow = internalPoolsRouter.createNavGraphRoutesFlow() + .filterIsInstance() + .onEach { + stateFlow.value = stateFlow.value.copy(isLoading = true) + } + .shareIn(coroutinesStore.uiScope, SharingStarted.Lazily, 1) + + @OptIn(ExperimentalCoroutinesApi::class) + private val pools = screenArgsFlow.flatMapLatest { screenArgs -> + poolsInteractor.subscribePoolsCacheCurrentAccount().map { pools -> + pools.asSequence().filter { + if (screenArgs.isUserPools) { + it.user != null + } else { + true + } + }.toList() + } + } + + private val poolsStates = combine( + pools, + enteredAssetQueryFlow + ) { pools, query -> + coroutineScope { + val tokensDeferred = + pools.map { async { walletInteractor.getToken(it.basic.baseToken) } } + val tokensMap = tokensDeferred.awaitAll().associateBy { it.configuration.id } + + pools.filter { + it.basic.isFilterMatch(query) + }.sortedWith { current, next -> + val currentTokenFiatRate = tokensMap[current.basic.baseToken.id]?.fiatRate + val nextTokenFiatRate = tokensMap[next.basic.baseToken.id]?.fiatRate + val userPoolData = current.user + val userPoolNextData = next.user + + if (userPoolData != null && userPoolNextData != null) { + val currentPooled = userPoolData.basePooled.applyFiatRate(currentTokenFiatRate) + val nextPooled = userPoolNextData.basePooled.applyFiatRate(nextTokenFiatRate) + compareNullDesc(currentPooled, nextPooled) + } else { + val currentTvl = current.basic.getTvl(currentTokenFiatRate) + val nextTvl = next.basic.getTvl(nextTokenFiatRate) + compareNullDesc(currentTvl, nextTvl) + } + }.mapNotNull { it.toListItemState(tokensMap[it.basic.baseToken.id]) } + } + } + + private val stateFlow = MutableStateFlow(PoolListState()) + + fun createScreenStateFlow(coroutineScope: CoroutineScope): StateFlow { + subscribeState(coroutineScope) + return stateFlow + } + + private fun subscribeState(coroutineScope: CoroutineScope) { + poolsStates.onEach { + stateFlow.value = stateFlow.value.copy(pools = it, isLoading = false) + }.launchIn(coroutineScope) + + enteredAssetQueryFlow.onEach { + stateFlow.value = stateFlow.value.copy(searchQuery = it) + }.launchIn(coroutineScope) + + pools.onEach { commonPoolData: List -> + val apyMap = coroutineScope.async { + commonPoolData.mapNotNull { pool -> + val baseTokenId = pool.basic.baseToken.currencyId ?: return@mapNotNull null + val targetTokenId = pool.basic.targetToken?.currencyId ?: return@mapNotNull null + val id = StringPair(baseTokenId, targetTokenId) + val sbApy = poolsInteractor.getSbApy(pool.basic.reserveAccount) + id to sbApy + }.toMap() + } + apyMap.join() + stateFlow.update { prevState -> + val newPools = prevState.pools.map { pool -> + apyMap.await()[pool.ids]?.let { sbApy -> + pool.copy( + apy = LoadingState.Loaded( + sbApy.let { apy -> + "%s%%".format(apy.toBigDecimal().formatCrypto()) + } + ) + ) + } ?: pool + } + + prevState.copy(pools = newPools) + } + }.launchIn(coroutineScope) + } + + override fun onPoolClicked(pair: StringPair) { + internalPoolsRouter.openDetailsPoolScreen(pair) + } + + override fun onAssetSearchEntered(value: String) { + enteredAssetQueryFlow.value = value + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt new file mode 100644 index 0000000000..a79a9a1405 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt @@ -0,0 +1,120 @@ +package jp.co.soramitsu.liquiditypools.impl.presentation.poollist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import jp.co.soramitsu.androidfoundation.format.StringPair +import jp.co.soramitsu.common.compose.component.CorneredInput +import jp.co.soramitsu.common.compose.component.EmptyMessage +import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.models.TextModel +import jp.co.soramitsu.common.compose.theme.white04 +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.BasicPoolListItem +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.BasicPoolListItemState +import jp.co.soramitsu.liquiditypools.impl.presentation.allpools.ShimmerPoolList +import jp.co.soramitsu.ui_core.resources.Dimens + +private const val SHIMMERS_SIZE = 20 + +data class PoolListState( + val pools: List = listOf(), + val searchQuery: String? = null, + val isLoading: Boolean = true +) + +interface PoolListScreenInterface { + fun onPoolClicked(pair: StringPair) + fun onAssetSearchEntered(value: String) +} + +@Composable +fun PoolListScreen(state: PoolListState, callback: PoolListScreenInterface) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + MarginVertical(margin = 16.dp) + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + CorneredInput( + state = state.searchQuery, + borderColor = white04, + hintLabel = stringResource(id = R.string.manage_assets_search_hint), + onInput = callback::onAssetSearchEntered + ) + } + + if (state.isLoading) { + ShimmerPoolList(SHIMMERS_SIZE) + } else { + if (state.pools.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyMessage(message = R.string.common_search_network_and_assets_alert_description) + } + } else { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = Modifier + .wrapContentHeight() + ) { + items(state.pools) { pool -> + BasicPoolListItem( + modifier = Modifier.padding(vertical = Dimens.x1), + state = pool, + onPoolClick = callback::onPoolClicked, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun PreviewPoolListScreen() { + val itemState = BasicPoolListItemState( + ids = "0" to "1", + token1Icon = "DEFAULT_ICON_URI", + token2Icon = "DEFAULT_ICON_URI", + text1 = "XOR-VAL", + text2 = "123.4M", + apy = LoadingState.Loaded("1234.3%"), + text4 = TextModel.SimpleString("Earn SWAP"), + ) + + val items = listOf( + itemState, + itemState.copy(text1 = "TEXT1", text2 = "TEXT2", apy = LoadingState.Loaded("TEXT3"), text4 = TextModel.SimpleString("TEXT4")), + itemState.copy(text1 = "text1", text2 = "text2", apy = LoadingState.Loaded("text3"), text4 = TextModel.SimpleString("text4")), + ) + PoolListScreen( + state = PoolListState( + pools = emptyList(), + isLoading = false + ), + callback = object : PoolListScreenInterface { + override fun onPoolClicked(pair: StringPair) {} + override fun onAssetSearchEntered(value: String) {} + }, + ) +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateAddLiquidityUseCase.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateAddLiquidityUseCase.kt new file mode 100644 index 0000000000..6d41427b48 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateAddLiquidityUseCase.kt @@ -0,0 +1,51 @@ +package jp.co.soramitsu.liquiditypools.impl.usecase + +import jp.co.soramitsu.common.utils.orZero +import jp.co.soramitsu.wallet.api.domain.TransferValidationResult +import jp.co.soramitsu.wallet.impl.domain.model.Asset +import java.math.BigDecimal +import javax.inject.Inject + +class ValidateAddLiquidityUseCase @Inject constructor() { + + operator fun invoke( + assetBase: Asset, + assetTarget: Asset, + utilityAssetId: String, + utilityAmount: BigDecimal, + amountBase: BigDecimal, + amountTarget: BigDecimal, + feeAmount: BigDecimal + ): Result { + return runCatching { + val isEnoughAmountBase = amountBase + feeAmount.takeIf { + assetBase.token.configuration.id == utilityAssetId + }.orZero() < assetBase.total.orZero() + + val isEnoughAmountTarget = amountTarget + feeAmount.takeIf { + assetTarget.token.configuration.id == utilityAssetId + }.orZero() < assetTarget.total.orZero() + + val isEnoughAmountFee = if (utilityAssetId in listOf(assetBase.token.configuration.id, assetTarget.token.configuration.id)) { + true + } else { + feeAmount < utilityAmount + } + + val validationChecks = mapOf( + TransferValidationResult.InsufficientBalance to (!isEnoughAmountBase || !isEnoughAmountTarget), + TransferValidationResult.InsufficientUtilityAssetBalance to !isEnoughAmountFee + ) + + val result = performChecks(validationChecks) + return Result.success(result) + } + } + + private fun performChecks(checks: Map): TransferValidationResult { + checks.forEach { (result, condition) -> + if (condition) return result + } + return TransferValidationResult.Valid + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateRemoveLiquidityUseCase.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateRemoveLiquidityUseCase.kt new file mode 100644 index 0000000000..2d0e62a65a --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/usecase/ValidateRemoveLiquidityUseCase.kt @@ -0,0 +1,40 @@ +package jp.co.soramitsu.liquiditypools.impl.usecase + +import jp.co.soramitsu.wallet.api.domain.TransferValidationResult +import java.math.BigDecimal +import javax.inject.Inject + +class ValidateRemoveLiquidityUseCase @Inject constructor() { + + operator fun invoke( + utilityAmount: BigDecimal, + userBasePooled: BigDecimal, + userTargetPooled: BigDecimal, + amountBase: BigDecimal, + amountTarget: BigDecimal, + feeAmount: BigDecimal + ): Result { + return runCatching { + val isEnoughAmountBase = amountBase <= userBasePooled + + val isEnoughAmountTarget = amountTarget <= userTargetPooled + + val isEnoughAmountFee = feeAmount < utilityAmount + + val validationChecks = mapOf( + TransferValidationResult.InsufficientBalance to (!isEnoughAmountBase || !isEnoughAmountTarget), + TransferValidationResult.InsufficientUtilityAssetBalance to !isEnoughAmountFee + ) + + val result = performChecks(validationChecks) + return Result.success(result) + } + } + + private fun performChecks(checks: Map): TransferValidationResult { + checks.forEach { (result, condition) -> + if (condition) return result + } + return TransferValidationResult.Valid + } +} diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/util/PolkaswapFormulas.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/util/PolkaswapFormulas.kt new file mode 100644 index 0000000000..f9a1d186d7 --- /dev/null +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/util/PolkaswapFormulas.kt @@ -0,0 +1,100 @@ +package jp.co.soramitsu.liquiditypools.impl.util + +import jp.co.soramitsu.androidfoundation.format.Big100 +import jp.co.soramitsu.androidfoundation.format.divideBy +import jp.co.soramitsu.androidfoundation.format.equalTo +import jp.co.soramitsu.androidfoundation.format.safeDivide +import jp.co.soramitsu.polkaswap.api.models.WithDesired +import java.math.BigDecimal + +object PolkaswapFormulas { + + fun calculatePooledValue( + reserves: BigDecimal, + poolProvidersBalance: BigDecimal, + totalIssuance: BigDecimal, + precision: Int? = 18 // OptionsProvider.defaultScale, + ): BigDecimal = + reserves.multiply(poolProvidersBalance).divideBy(totalIssuance, precision) + + private fun calculateShareOfPool(poolProvidersBalance: BigDecimal, totalIssuance: BigDecimal): BigDecimal = + poolProvidersBalance.divideBy(totalIssuance).multiply(Big100) + + fun calculateShareOfPoolFromAmount(amount: BigDecimal, amountPooled: BigDecimal): Double = + if (amount.equalTo(amountPooled)) { + 100.0 + } else { + calculateShareOfPool(amount, amountPooled).toDouble() + } + + fun calculateAddLiquidityAmount( + baseAmount: BigDecimal, + reservesFirst: BigDecimal, + reservesSecond: BigDecimal, + precisionFirst: Int, + precisionSecond: Int, + desired: WithDesired, + ): BigDecimal { + return if (desired == WithDesired.INPUT) { + baseAmount.multiply(reservesSecond).safeDivide(reservesFirst, precisionSecond) + } else { + baseAmount.multiply(reservesFirst).safeDivide(reservesSecond, precisionFirst) + } + } + + fun estimateAddingShareOfPool( + amount: BigDecimal, + pooled: BigDecimal, + reserves: BigDecimal + ): BigDecimal { + return pooled + .plus(amount) + .multiply(Big100) + .safeDivide(amount.plus(reserves)) + } + + fun estimateRemovingShareOfPool( + amount: BigDecimal, + pooled: BigDecimal, + reserves: BigDecimal + ): BigDecimal = pooled + .minus(amount) + .multiply(Big100) + .safeDivide(reserves.minus(amount)) + + fun calculateMinAmount(amount: BigDecimal, slippageTolerance: Double): BigDecimal { + return amount.minus(amount.multiply(BigDecimal.valueOf(slippageTolerance / 100))) + } + + fun calculateTokenPerTokenRate(amount1: BigDecimal, amount2: BigDecimal): BigDecimal { + return amount1.safeDivide(amount2) + } + + fun calculateMarkerAssetDesired( + fromAmount: BigDecimal, + firstReserves: BigDecimal, + totalIssuance: BigDecimal, + ): BigDecimal = fromAmount.safeDivide(firstReserves).multiply(totalIssuance) + + fun calculateStrategicBonusAPY(strategicBonusApy: Double?): Double? { + return strategicBonusApy?.times(100) + } + + fun calculateAmountByPercentage( + amount: BigDecimal, + percentage: Double, + precision: Int, + ): BigDecimal = if (percentage == 100.0) { + amount + } else { + amount.multiply(percentage.toBigDecimal()) + .safeDivide(Big100, precision) + } + + fun calculateOneAmountFromAnother( + amount: BigDecimal, + amountPooled: BigDecimal, + otherPooled: BigDecimal, + precision: Int? = 18 // OptionsProvider.defaultScale, + ): BigDecimal = amount.multiply(otherPooled).safeDivide(amountPooled, precision) +} diff --git a/feature-nft-api/build.gradle.kts b/feature-nft-api/build.gradle.kts index 36d9ad3a6c..400f5532c1 100644 --- a/feature-nft-api/build.gradle.kts +++ b/feature-nft-api/build.gradle.kts @@ -30,5 +30,7 @@ dependencies { implementation("javax.inject:javax.inject:1") implementation(libs.bundles.coroutines) - implementation(libs.sharedFeaturesCoreDep) + implementation(libs.sharedFeaturesCoreDep) { + exclude(module = "android-foundation") + } } \ No newline at end of file diff --git a/feature-nft-impl/build.gradle.kts b/feature-nft-impl/build.gradle.kts index 6e6bd6c84a..72114dcc2d 100644 --- a/feature-nft-impl/build.gradle.kts +++ b/feature-nft-impl/build.gradle.kts @@ -51,7 +51,9 @@ dependencies { implementation(libs.bundles.compose) implementation(libs.fragmentKtx) implementation(libs.material) - implementation(libs.sharedFeaturesCoreDep) + implementation(libs.sharedFeaturesCoreDep) { + exclude(module = "android-foundation") + } implementation(libs.retrofit) implementation(libs.gson) implementation(libs.web3jDep) { diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/NFTFlowFragment.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/NFTFlowFragment.kt index 77a5e3c1fc..48c0d2aa15 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/NFTFlowFragment.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/NFTFlowFragment.kt @@ -198,7 +198,7 @@ class NFTFlowFragment : BaseComposeBottomSheetDialogFragment() is LoadingState.Loading> -> MainToolbarShimmer( - homeIconState = ToolbarHomeIconState() + homeIconState = ToolbarHomeIconState.Navigation(jp.co.soramitsu.feature_wallet_impl.R.drawable.ic_arrow_back_24dp) ) } diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/collection/CollectionNFTsScreen.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/collection/CollectionNFTsScreen.kt index d89097676a..3469143d42 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/collection/CollectionNFTsScreen.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/collection/CollectionNFTsScreen.kt @@ -174,7 +174,7 @@ private fun CollectionNFTsScreen( LaunchedEffect(firstVisibleItemOffsetAsState) { snapshotFlow { firstVisibleItemOffsetAsState.value } - .onEach { offset -> savableOffset.value = offset } + .onEach { offset -> savableOffset.intValue = offset } .flowOn(Dispatchers.Default) .launchIn(this) } @@ -222,7 +222,7 @@ private fun CollectionNFTsScreen( return@LaunchedEffect } - lazyGridState.scrollToItem(index, savableOffset.value) + lazyGridState.scrollToItem(index, savableOffset.intValue) } } diff --git a/feature-onboarding-api/build.gradle b/feature-onboarding-api/build.gradle index 36d098486e..784aff9b28 100644 --- a/feature-onboarding-api/build.gradle +++ b/feature-onboarding-api/build.gradle @@ -22,6 +22,7 @@ android { jvmTarget = '17' } + namespace 'jp.co.soramitsu.feature_onboarding_api' } diff --git a/feature-onboarding-impl/build.gradle b/feature-onboarding-impl/build.gradle index b4d6ce7984..80ad8391aa 100644 --- a/feature-onboarding-impl/build.gradle +++ b/feature-onboarding-impl/build.gradle @@ -22,7 +22,7 @@ android { } release { - buildConfigField "String", "ONBOARDING_CONFIG", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/develop-free/appConfigs/onboarding/mobile%20v2.json\"" + buildConfigField "String", "ONBOARDING_CONFIG", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/appConfigs/onboarding/mobile%20v2.json\"" } } @@ -45,6 +45,7 @@ android { composeOptions { kotlinCompilerExtensionVersion composeCompilerVersion } + namespace 'jp.co.soramitsu.feature_onboarding_impl' } @@ -82,5 +83,5 @@ dependencies { implementation libs.zxing.core implementation libs.zxing.embedded - implementation libs.sharedFeaturesBackupDep + implementation libs.sharedFeaturesBackupDep, withoutAndroidFoundation } diff --git a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/PagerIndicator.kt b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/PagerIndicator.kt index d87bd6c0c9..88c612479d 100644 --- a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/PagerIndicator.kt +++ b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/PagerIndicator.kt @@ -17,7 +17,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -146,7 +146,7 @@ fun lerp(start: Float, stop: Float, fraction: Float): Float { @Composable private fun PreviewSlidingPagerIndicator() { val ci = remember { - mutableStateOf(0) + mutableIntStateOf(0) } val d: List = ArrayDeque() diff --git a/feature-polkaswap-api/build.gradle.kts b/feature-polkaswap-api/build.gradle.kts index 0fd6ae5bfe..2e616cd668 100644 --- a/feature-polkaswap-api/build.gradle.kts +++ b/feature-polkaswap-api/build.gradle.kts @@ -30,6 +30,13 @@ dependencies { implementation(projects.runtime) implementation(projects.featureWalletApi) implementation(project(mapOf("path" to ":common"))) + + implementation("javax.inject:javax.inject:1") + + implementation(libs.xnetworking.basic) + implementation(libs.xnetworking.sorawallet) { + exclude(group = "jp.co.soramitsu.xnetworking", module = "basic") + } } diff --git a/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/data/PolkaswapRepository.kt b/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/data/PolkaswapRepository.kt index 13222967a3..86b301f3f4 100644 --- a/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/data/PolkaswapRepository.kt +++ b/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/data/PolkaswapRepository.kt @@ -1,11 +1,11 @@ package jp.co.soramitsu.polkaswap.api.data +import java.math.BigInteger import jp.co.soramitsu.core.runtime.models.responses.QuoteResponse import jp.co.soramitsu.polkaswap.api.models.Market import jp.co.soramitsu.polkaswap.api.models.WithDesired import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import kotlinx.coroutines.flow.Flow -import java.math.BigInteger interface PolkaswapRepository { suspend fun getAvailableDexes(chainId: ChainId): List @@ -53,4 +53,5 @@ interface PolkaswapRepository { markets: List, desired: WithDesired ): Result + } diff --git a/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/presentation/PolkaswapRouter.kt b/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/presentation/PolkaswapRouter.kt index 82a431a068..66a5fe4665 100644 --- a/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/presentation/PolkaswapRouter.kt +++ b/feature-polkaswap-api/src/main/kotlin/jp/co/soramitsu/polkaswap/api/presentation/PolkaswapRouter.kt @@ -38,4 +38,6 @@ interface PolkaswapRouter { fun openPolkaswapDisclaimerFromMainScreen() fun openWebViewer(title: String, url: String) + + fun openPools() } diff --git a/feature-polkaswap-impl/build.gradle.kts b/feature-polkaswap-impl/build.gradle.kts index 9814fbb69b..b892a6ca99 100644 --- a/feature-polkaswap-impl/build.gradle.kts +++ b/feature-polkaswap-impl/build.gradle.kts @@ -30,6 +30,7 @@ android { kotlinOptions { jvmTarget = "17" } + namespace = "jp.co.soramitsu.feature_polkaswap_impl" } @@ -39,7 +40,19 @@ dependencies { implementation(libs.bundles.compose) implementation(libs.fragmentKtx) implementation(libs.material) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.xnetworking.basic) + implementation(libs.xnetworking.sorawallet) { + exclude(group = "jp.co.soramitsu.xnetworking", module = "basic") + } + +// api(libs.sharedFeaturesPoolsDep) { +// exclude(module = "android-foundation") +// } + implementation(projects.androidFoundation) + implementation(projects.coreDb) implementation(projects.common) implementation(projects.runtime) implementation(projects.featurePolkaswapApi) diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt index 48c9e70271..aa51cb7730 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt @@ -10,7 +10,6 @@ import jp.co.soramitsu.common.utils.poolTBC import jp.co.soramitsu.common.utils.poolXYK import jp.co.soramitsu.common.utils.u32ArgumentFromStorageKey import jp.co.soramitsu.core.extrinsic.ExtrinsicService -import jp.co.soramitsu.core.rpc.RpcCalls import jp.co.soramitsu.core.runtime.models.responses.QuoteResponse import jp.co.soramitsu.polkaswap.api.data.PolkaswapRepository import jp.co.soramitsu.polkaswap.api.models.Market @@ -43,8 +42,7 @@ class PolkaswapRepositoryImpl @Inject constructor( private val remoteStorage: StorageDataSource, private val extrinsicService: ExtrinsicService, private val chainRegistry: ChainRegistry, - private val rpcCalls: RpcCalls, - private val accountRepository: AccountRepository + private val accountRepository: AccountRepository, ) : PolkaswapRepository { override suspend fun getAvailableDexes(chainId: ChainId): List { @@ -69,35 +67,39 @@ class PolkaswapRepositoryImpl @Inject constructor( } override fun observePoolXYKReserves(chainId: ChainId, fromTokenId: String, toTokenId: String): Flow { - return flow { emit(waitForChain(chainId)) }.flatMapLatest { remoteStorage.observe( - chainId = chainId, - keyBuilder = { - val from = Struct.Instance( - mapOf("code" to fromTokenId.fromHex().toList().map { it.toInt().toBigInteger() }) - ) - val to = Struct.Instance( - mapOf("code" to toTokenId.fromHex().toList().map { it.toInt().toBigInteger() }) - ) - it.metadata.poolXYK()?.storage("Reserves")?.storageKey(it, from, to) + return flow { emit(waitForChain(chainId)) }.flatMapLatest { + remoteStorage.observe( + chainId = chainId, + keyBuilder = { + val from = Struct.Instance( + mapOf("code" to fromTokenId.fromHex().toList().map { it.toInt().toBigInteger() }) + ) + val to = Struct.Instance( + mapOf("code" to toTokenId.fromHex().toList().map { it.toInt().toBigInteger() }) + ) + it.metadata.poolXYK()?.storage("Reserves")?.storageKey(it, from, to) + } + ) { scale, _ -> + scale.orEmpty() } - ) { scale, _ -> - scale.orEmpty() - }} + } } override fun observePoolTBCReserves(chainId: ChainId, tokenId: String): Flow { - return flow { emit(waitForChain(chainId)) }.flatMapLatest { remoteStorage.observe( - chainId = chainId, - keyBuilder = { - val token = Struct.Instance( - mapOf("code" to tokenId.fromHex().toList().map { it.toInt().toBigInteger() }) - ) - it.metadata.poolTBC()?.storage("CollateralReserves")?.storageKey(it, token) + return flow { emit(waitForChain(chainId)) }.flatMapLatest { + remoteStorage.observe( + chainId = chainId, + keyBuilder = { + val token = Struct.Instance( + mapOf("code" to tokenId.fromHex().toList().map { it.toInt().toBigInteger() }) + ) + it.metadata.poolTBC()?.storage("CollateralReserves")?.storageKey(it, token) + } + ) { scale, _ -> + scale.orEmpty() } - ) { scale, _ -> - scale.orEmpty() } - }} + } // Because if we get chain from the ChainRegistry, it will emit a chain // only after runtime for this chain will be ready @@ -163,6 +165,7 @@ class PolkaswapRepositoryImpl @Inject constructor( markets: List, desired: WithDesired ): BigInteger { + val chain = chainRegistry.getChain(chainId) return extrinsicService.estimateFee(chain) { swap(dexId, inputAssetId, outputAssetId, amount, limit, filter, markets, desired) diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt index 34796577d8..46b7f16664 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt @@ -1,11 +1,11 @@ package jp.co.soramitsu.polkaswap.impl.data.network.blockchain +import java.math.BigInteger import jp.co.soramitsu.polkaswap.api.models.WithDesired import jp.co.soramitsu.shared_utils.extensions.fromHex import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import java.math.BigInteger fun ExtrinsicBuilder.swap( dexId: Int, diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/di/PolkaswapFeatureBindModule.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/di/PolkaswapFeatureBindModule.kt index af6ad86882..4c2d4cfd4c 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/di/PolkaswapFeatureBindModule.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/di/PolkaswapFeatureBindModule.kt @@ -1,15 +1,19 @@ package jp.co.soramitsu.polkaswap.impl.di -import dagger.Binds +import android.content.Context import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Named import javax.inject.Singleton import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.common.data.network.OptionsProvider import jp.co.soramitsu.common.data.network.config.RemoteConfigFetcher import jp.co.soramitsu.common.data.storage.Preferences +import jp.co.soramitsu.common.domain.NetworkStateService +import jp.co.soramitsu.common.mixin.api.UpdatesMixin import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.polkaswap.api.data.PolkaswapRepository import jp.co.soramitsu.polkaswap.api.domain.PolkaswapInteractor @@ -17,10 +21,22 @@ import jp.co.soramitsu.polkaswap.impl.data.PolkaswapRepositoryImpl import jp.co.soramitsu.polkaswap.impl.domain.PolkaswapInteractorImpl import jp.co.soramitsu.runtime.di.REMOTE_STORAGE_SOURCE import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.core.rpc.RpcCalls +import jp.co.soramitsu.core.runtime.IChainRegistry +import jp.co.soramitsu.coredb.dao.AssetDao +import jp.co.soramitsu.coredb.dao.ChainDao +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainSyncService import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.runtime.multiNetwork.connection.ConnectionPool +import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumConnectionPool +import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeProviderPool +import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSubscriptionPool +import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSyncService import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository +import jp.co.soramitsu.xnetworking.basic.networkclient.SoramitsuNetworkClient +import jp.co.soramitsu.xnetworking.sorawallet.blockexplorerinfo.SoraWalletBlockExplorerInfo +import jp.co.soramitsu.xnetworking.sorawallet.mainconfig.SoraRemoteConfigBuilder +import jp.co.soramitsu.xnetworking.sorawallet.mainconfig.SoraRemoteConfigProvider @InstallIn(SingletonComponent::class) @Module @@ -32,10 +48,15 @@ class PolkaswapFeatureModule { @Named(REMOTE_STORAGE_SOURCE) remoteSource: StorageDataSource, extrinsicService: ExtrinsicService, chainRegistry: ChainRegistry, - rpcCalls: RpcCalls, - accountRepository: AccountRepository + accountRepository: AccountRepository, ): PolkaswapRepository { - return PolkaswapRepositoryImpl(remoteConfigFetcher, remoteSource, extrinsicService, chainRegistry, rpcCalls, accountRepository) + return PolkaswapRepositoryImpl( + remoteConfigFetcher, + remoteSource, + extrinsicService, + chainRegistry, + accountRepository, + ) } @Provides @@ -57,4 +78,59 @@ class PolkaswapFeatureModule { chainsRepository ) } + + @Provides + @Singleton + fun provideChainRegistry( + runtimeProviderPool: RuntimeProviderPool, + chainConnectionPool: ConnectionPool, + runtimeSubscriptionPool: RuntimeSubscriptionPool, + chainDao: ChainDao, + chainSyncService: ChainSyncService, + runtimeSyncService: RuntimeSyncService, + updatesMixin: UpdatesMixin, + networkStateService: NetworkStateService, + ethereumConnectionPool: EthereumConnectionPool, + assetReadOnlyCache: AssetDao, + chainsRepository: ChainsRepository, + ): IChainRegistry = ChainRegistry( + runtimeProviderPool, + chainConnectionPool, + runtimeSubscriptionPool, + chainDao, + chainSyncService, + runtimeSyncService, + updatesMixin, + networkStateService, + ethereumConnectionPool, + assetReadOnlyCache, + chainsRepository + ) + + @Singleton + @Provides + fun provideSoraWalletBlockExplorerInfo( + client: SoramitsuNetworkClient, + soraRemoteConfigBuilder: SoraRemoteConfigBuilder, + ): SoraWalletBlockExplorerInfo { + return SoraWalletBlockExplorerInfo( + networkClient = client, + soraRemoteConfigBuilder = soraRemoteConfigBuilder, + ) + } + + @Singleton + @Provides + fun provideSoraRemoteConfigBuilder( + client: SoramitsuNetworkClient, + @ApplicationContext context: Context, + ): SoraRemoteConfigBuilder { + return SoraRemoteConfigProvider( + context = context, + client = client, + commonUrl = OptionsProvider.soraConfigCommon, + mobileUrl = OptionsProvider.soraConfigMobile, + ).provide() + } + } diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensContent.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensContent.kt index 43df745c14..22be114118 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensContent.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensContent.kt @@ -1,5 +1,8 @@ package jp.co.soramitsu.polkaswap.impl.presentation.swap_tokens +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -12,6 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -20,9 +25,9 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester @@ -30,12 +35,14 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import java.math.BigDecimal import jp.co.soramitsu.common.compose.component.AccentButton import jp.co.soramitsu.common.compose.component.AmountInput import jp.co.soramitsu.common.compose.component.AmountInputViewState +import jp.co.soramitsu.common.compose.component.BannerDemeter +import jp.co.soramitsu.common.compose.component.BannerLiquidityPools +import jp.co.soramitsu.common.compose.component.BannerPageIndicator import jp.co.soramitsu.common.compose.component.FeeInfo import jp.co.soramitsu.common.compose.component.FeeInfoViewState import jp.co.soramitsu.common.compose.component.FullScreenLoading @@ -59,6 +66,7 @@ import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.feature_polkaswap_impl.R import jp.co.soramitsu.polkaswap.api.models.Market import jp.co.soramitsu.polkaswap.api.presentation.models.SwapDetailsViewState +import kotlinx.coroutines.delay data class SwapTokensContentViewState( val fromAmountInputViewState: AmountInputViewState, @@ -67,6 +75,7 @@ data class SwapTokensContentViewState( val swapDetailsViewState: SwapDetailsViewState?, val isLoading: Boolean, val networkFeeViewState: LoadingState, + val showLiquidityBanner: Boolean, val hasReadDisclaimer: Boolean, val isSoftKeyboardOpen: Boolean ) { @@ -74,12 +83,13 @@ data class SwapTokensContentViewState( fun default(resourceManager: ResourceManager): SwapTokensContentViewState { return SwapTokensContentViewState( - fromAmountInputViewState = AmountInputViewState.default(resourceManager, R.string.common_available_format), - toAmountInputViewState = AmountInputViewState.default(resourceManager), + fromAmountInputViewState = AmountInputViewState.defaultObj.copy(totalBalance = resourceManager.getString(R.string.common_available_format, "0")), + toAmountInputViewState = AmountInputViewState.defaultObj.copy(totalBalance = resourceManager.getString(R.string.common_balance_format, "0")), selectedMarket = Market.SMART, swapDetailsViewState = null, isLoading = false, networkFeeViewState = LoadingState.Loaded(null), + showLiquidityBanner = true, hasReadDisclaimer = false, isSoftKeyboardOpen = false ) @@ -116,9 +126,12 @@ interface SwapTokensCallbacks { fun onQuickAmountInput(value: Double) fun onDisclaimerClick() + + fun onPoolsClick() + + fun onLiquidityBannerClose() } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SwapTokensContent( state: SwapTokensContentViewState, @@ -236,7 +249,15 @@ fun SwapTokensContent( callbacks = callbacks ) } - } + if (state.showLiquidityBanner && state.isSoftKeyboardOpen.not()) { + Spacer(modifier = Modifier.weight(1f)) + + Banners( + showLiquidity = state.showLiquidityBanner, + showDemeter = false, + callback = callbacks + ) + } } if (state.hasReadDisclaimer.not()) { Box(modifier = modifier.padding(horizontal = 16.dp)) { Notification( @@ -266,7 +287,7 @@ fun SwapTokensContent( if (showQuickInput) { QuickInput( - values = QuickAmountInput.values(), + values = QuickAmountInput.entries.toTypedArray(), onQuickAmountInput = { keyboardController?.hide() callbacks.onQuickAmountInput(it) @@ -389,6 +410,74 @@ private fun MarketLabel( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Banners( + showLiquidity: Boolean, + showDemeter: Boolean, + callback: SwapTokensCallbacks, + autoPlay: Boolean = true +) { + val bannerLiquidityPools: @Composable (() -> Unit)? = if (showLiquidity) { + { + BannerLiquidityPools( + onShowMoreClick = callback::onPoolsClick, + onCloseClick = callback::onLiquidityBannerClose + ) + } + } else null + + val bannerDemeter: @Composable (() -> Unit)? = if (showDemeter) { + { + BannerDemeter( + onShowMoreClick = {}, + onCloseClick = {} + ) + } + } else null + + val banners = listOfNotNull(bannerLiquidityPools, bannerDemeter) + val bannersCount = banners.size + val pagerState = rememberPagerState { bannersCount } + + if (bannersCount > 1) { + // Auto play + LaunchedEffect(key1 = autoPlay) { + if (autoPlay) { + while (true) { + delay(5000L) + with(pagerState) { + animateScrollToPage( + page = (currentPage + 1) % bannersCount, + animationSpec = tween( + durationMillis = 500, + easing = FastOutSlowInEasing + ) + ) + } + } + } + } + } + HorizontalPager( + modifier = Modifier.fillMaxWidth(), + state = pagerState, + pageSpacing = 8.dp, + pageContent = { page -> + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + banners[page].invoke() + } + } + ) + MarginVertical(margin = 8.dp) + + if (bannersCount > 1) { + BannerPageIndicator(bannersCount, pagerState) + MarginVertical(margin = 8.dp) + } + MarginVertical(margin = 8.dp) +} + @Preview @Composable fun SwapTokensContentPreview() { @@ -407,6 +496,7 @@ fun SwapTokensContentPreview() { swapDetailsViewState = null, isLoading = false, networkFeeViewState = LoadingState.Loading(), + showLiquidityBanner = true, hasReadDisclaimer = false, isSoftKeyboardOpen = false ) @@ -425,6 +515,8 @@ fun SwapTokensContentPreview() { override fun networkFeeTooltipClick() {} override fun onQuickAmountInput(value: Double) {} override fun onDisclaimerClick() {} + override fun onPoolsClick() {} + override fun onLiquidityBannerClose() {} } SwapTokensContent( diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensViewModel.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensViewModel.kt index fad0fe8d3b..be2a9f295c 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensViewModel.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/presentation/swap_tokens/SwapTokensViewModel.kt @@ -100,13 +100,14 @@ class SwapTokensViewModel @Inject constructor( savedStateHandle.get(SwapTokensFragment.KEY_SELECTED_CHAIN_ID) private val fromAmountInputViewState = MutableStateFlow( - AmountInputViewState.default( - resourceManager, - R.string.common_available_format + AmountInputViewState.defaultObj.copy( + totalBalance = resourceManager.getString(R.string.common_available_format, "0") ) ) - private val toAmountInputViewState = - MutableStateFlow(AmountInputViewState.default(resourceManager)) + private val toAmountInputViewState = MutableStateFlow( + AmountInputViewState.defaultObj.copy( + totalBalance = resourceManager.getString(R.string.common_balance_format, "0")) + ) private var selectedMarket = MutableStateFlow(Market.SMART) private var slippageTolerance = MutableStateFlow(0.5) @@ -133,6 +134,7 @@ class SwapTokensViewModel @Inject constructor( ) private val isLoading = MutableStateFlow(false) + private val isShowBannerLiquidity = MutableStateFlow(true) private var initialFee = BigDecimal.ZERO private val availableDexPathsFlow: MutableStateFlow?> = MutableStateFlow(null) @@ -279,9 +281,10 @@ class SwapTokensViewModel @Inject constructor( swapDetailsViewState, networkFeeViewStateFlow, isLoading, + isShowBannerLiquidity, polkaswapInteractor.observeHasReadDisclaimer(), isSoftKeyboardOpenFlow - ) { fromAmountInput, toAmountInput, selectedMarket, swapDetails, networkFeeState, isLoading, hasReadDisclaimer, isSoftKeyboardOpen -> + ) { fromAmountInput, toAmountInput, selectedMarket, swapDetails, networkFeeState, isLoading, isShowBannerLiquidity, hasReadDisclaimer, isSoftKeyboardOpen -> SwapTokensContentViewState( fromAmountInputViewState = fromAmountInput, toAmountInputViewState = toAmountInput, @@ -289,6 +292,7 @@ class SwapTokensViewModel @Inject constructor( swapDetailsViewState = swapDetails, networkFeeViewState = networkFeeState, isLoading = isLoading, + showLiquidityBanner = isShowBannerLiquidity, hasReadDisclaimer = hasReadDisclaimer, isSoftKeyboardOpen = isSoftKeyboardOpen ) @@ -725,4 +729,12 @@ class SwapTokensViewModel @Inject constructor( fun setSoftKeyboardOpen(isOpen: Boolean) { isSoftKeyboardOpenFlow.value = isOpen } + + override fun onPoolsClick() { + polkaswapRouter.openPools() + } + + override fun onLiquidityBannerClose() { + isShowBannerLiquidity.value = false + } } diff --git a/feature-soracard-impl/build.gradle.kts b/feature-soracard-impl/build.gradle.kts index c0f1572e11..2c8267b8bb 100644 --- a/feature-soracard-impl/build.gradle.kts +++ b/feature-soracard-impl/build.gradle.kts @@ -33,6 +33,7 @@ android { kotlinOptions { jvmTarget = "17" } + namespace = "jp.co.soramitsu.feature_soracard_impl" } diff --git a/feature-splash/build.gradle b/feature-splash/build.gradle index ddfbcaf7bc..8e90fe0a34 100644 --- a/feature-splash/build.gradle +++ b/feature-splash/build.gradle @@ -28,6 +28,7 @@ android { jvmTarget = '17' } + namespace 'jp.co.soramitsu.splash' } diff --git a/feature-staking-api/build.gradle b/feature-staking-api/build.gradle index c1d3f689a8..f22d4ef9d5 100644 --- a/feature-staking-api/build.gradle +++ b/feature-staking-api/build.gradle @@ -23,6 +23,7 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + namespace 'jp.co.soramitsu.feature_staking_api' } diff --git a/feature-staking-impl/build.gradle b/feature-staking-impl/build.gradle index 1969bd1767..87064197fb 100644 --- a/feature-staking-impl/build.gradle +++ b/feature-staking-impl/build.gradle @@ -35,6 +35,7 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + namespace 'jp.co.soramitsu.feature_staking_impl' } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt index e5ba140b21..c960db0bbf 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt @@ -33,12 +33,14 @@ import jp.co.soramitsu.staking.impl.data.mappers.mapAccountToStakingAccount import jp.co.soramitsu.staking.impl.data.repository.StakingRewardsRepository import jp.co.soramitsu.staking.impl.domain.validations.setup.SetupStakingFeeValidation import jp.co.soramitsu.staking.impl.domain.validations.setup.SetupStakingValidationFailure +import jp.co.soramitsu.staking.impl.presentation.validators.parcel.ValidatorDetailsParcelModel import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository import jp.co.soramitsu.wallet.impl.domain.model.ControllerDeprecationWarning import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks import jp.co.soramitsu.wallet.impl.domain.validation.EnoughToPayFeesValidation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first @@ -58,6 +60,8 @@ class StakingInteractor( private val addressIconGenerator: AddressIconGenerator, private val walletRepository: WalletRepository ) { + val validatorDetailsCache = MutableStateFlow>(emptyMap()) + suspend fun getCurrentMetaAccount() = accountRepository.getSelectedMetaAccount() fun selectedMetaAccountFlow() = accountRepository.selectedMetaAccountFlow() suspend fun getMetaAccount(metaId: Long) = accountRepository.getMetaAccount(metaId) diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/StakingRouter.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/StakingRouter.kt index ace3c7d14c..45873426ee 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/StakingRouter.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/StakingRouter.kt @@ -18,7 +18,6 @@ import jp.co.soramitsu.staking.impl.presentation.staking.rewardDestination.confi import jp.co.soramitsu.staking.impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload import jp.co.soramitsu.staking.impl.presentation.staking.unbond.select.SelectUnbondPayload import jp.co.soramitsu.staking.impl.presentation.validators.parcel.CollatorDetailsParcelModel -import jp.co.soramitsu.staking.impl.presentation.validators.parcel.ValidatorDetailsParcelModel import kotlinx.coroutines.flow.Flow interface StakingRouter { @@ -49,7 +48,7 @@ interface StakingRouter { fun openReviewCustomValidators() - fun openValidatorDetails(validatorDetails: ValidatorDetailsParcelModel) + fun openValidatorDetails(validatorIdHex: String) fun openSelectedValidators() @@ -111,7 +110,7 @@ interface StakingRouter { fun openConfirmJoinPool() - fun openPoolInfo(poolInfo: PoolInfo) + fun openPoolInfo(poolId: Int) fun openManagePoolStake() diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedStateProvider.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedStateProvider.kt index e0eaf0958b..f830461d62 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedStateProvider.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedStateProvider.kt @@ -1,5 +1,8 @@ package jp.co.soramitsu.staking.impl.presentation.common +import jp.co.soramitsu.staking.api.domain.model.PoolInfo +import kotlinx.coroutines.flow.MutableStateFlow + class StakingPoolSharedStateProvider { val mainState by lazy { StakingPoolSharedState() } val joinFlowState by lazy { StakingPoolSharedState() } @@ -8,6 +11,7 @@ class StakingPoolSharedStateProvider { val selectValidatorsState by lazy { StakingPoolSharedState() } val selectedValidatorsState by lazy { StakingPoolSharedState() } val editPoolState by lazy { StakingPoolSharedState() } + val poolsCache: MutableStateFlow> = MutableStateFlow(emptyMap()) val requireMainState: StakingPoolState get() = requireNotNull(mainState.get()) diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/nominations/ConfirmNominationsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/nominations/ConfirmNominationsViewModel.kt index 5c2930fe5e..03df357aa0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/nominations/ConfirmNominationsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/nominations/ConfirmNominationsViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.liveData import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import javax.inject.Named import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.resources.ResourceManager @@ -23,7 +21,10 @@ import jp.co.soramitsu.staking.impl.presentation.validators.findSelectedValidato import jp.co.soramitsu.wallet.impl.domain.TokenUseCase import jp.co.soramitsu.wallet.impl.domain.model.Token import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named @HiltViewModel class ConfirmNominationsViewModel @Inject constructor( @@ -54,7 +55,10 @@ class ConfirmNominationsViewModel @Inject constructor( fun validatorInfoClicked(validatorModel: ValidatorModel) { viewModelScope.launch { validators.findSelectedValidator(validatorModel.accountIdHex)?.let { - router.openValidatorDetails(mapValidatorToValidatorDetailsParcelModel(it)) + interactor.validatorDetailsCache.update { prev -> + prev + (it.accountIdHex to mapValidatorToValidatorDetailsParcelModel(it)) + } + router.openValidatorDetails(it.accountIdHex) } } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoFragment.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoFragment.kt index c2ea0ac6aa..4cff4bd502 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoFragment.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoFragment.kt @@ -10,7 +10,6 @@ import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import dagger.hilt.android.AndroidEntryPoint import jp.co.soramitsu.common.base.BaseComposeBottomSheetDialogFragment -import jp.co.soramitsu.staking.api.domain.model.PoolInfo import jp.co.soramitsu.staking.impl.presentation.pools.compose.PoolInfoScreen @AndroidEntryPoint @@ -18,8 +17,8 @@ class PoolInfoFragment : BaseComposeBottomSheetDialogFragment companion object { const val POOL_INFO_KEY = "poolInfo" - fun getBundle(poolInfo: PoolInfo) = Bundle().apply { - putParcelable(POOL_INFO_KEY, poolInfo) + fun getBundle(poolId: Int) = Bundle().apply { + putInt(POOL_INFO_KEY, poolId) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt index 62bfcedb13..7566d6853f 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt @@ -3,7 +3,6 @@ package jp.co.soramitsu.staking.impl.presentation.pools import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.component.DropDownViewState import jp.co.soramitsu.common.compose.component.TitleValueViewState @@ -35,6 +34,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject @HiltViewModel class PoolInfoViewModel @Inject constructor( @@ -59,7 +59,8 @@ class PoolInfoViewModel @Inject constructor( chain = mainState.requireChain asset = mainState.requireAsset currentUserAccountId = chain.accountIdOf(mainState.requireAddress) - poolInfo = requireNotNull(savedStateHandle.get(PoolInfoFragment.POOL_INFO_KEY)) + val poolId = savedStateHandle.get(PoolInfoFragment.POOL_INFO_KEY) + poolInfo = requireNotNull(stakingPoolSharedStateProvider.poolsCache.value[poolId]) canChangeRoles = poolInfo.root.contentEquals(currentUserAccountId) val stakedAmount = asset.token.amountFromPlanks(poolInfo.stakedInPlanks) diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/SelectPoolViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/SelectPoolViewModel.kt index 64cf74b7ee..38495d2025 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/SelectPoolViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/SelectPoolViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.theme.black1 import jp.co.soramitsu.common.compose.theme.greenText @@ -32,6 +31,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import javax.inject.Inject @HiltViewModel class SelectPoolViewModel @Inject constructor( @@ -113,7 +114,11 @@ class SelectPoolViewModel @Inject constructor( val selectedPoolId = requireNotNull(item.id) val pools = (poolsFlow.value as? LoadingState.Loaded)?.data val pool = requireNotNull(pools?.find { it.poolId == selectedPoolId.toBigInteger() }) - router.openPoolInfo(pool) + + stakingPoolSharedStateProvider.poolsCache.update { prevState -> + prevState + (pool.poolId.toInt() to pool) + } + router.openPoolInfo(pool.poolId.toInt()) } fun onNextClick() { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/StartStakingPoolViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/StartStakingPoolViewModel.kt index 28dbf59591..b15d226287 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/StartStakingPoolViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/StartStakingPoolViewModel.kt @@ -2,17 +2,12 @@ package jp.co.soramitsu.staking.impl.presentation.setup.pool import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigDecimal -import javax.inject.Inject import jp.co.soramitsu.common.AlertViewState import jp.co.soramitsu.common.BuildConfig import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.component.ToolbarViewState import jp.co.soramitsu.common.resources.ResourceManager -import jp.co.soramitsu.common.utils.flowOf -import jp.co.soramitsu.common.utils.inBackground import jp.co.soramitsu.feature_staking_impl.R -import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.domain.rewards.RewardCalculatorFactory @@ -25,12 +20,13 @@ import jp.co.soramitsu.staking.impl.presentation.staking.main.scenarios.PERIOD_Y import jp.co.soramitsu.staking.impl.scenarios.StakingPoolInteractor import jp.co.soramitsu.staking.impl.scenarios.relaychain.HOURS_IN_DAY import jp.co.soramitsu.staking.impl.scenarios.relaychain.StakingRelayChainScenarioInteractor -import jp.co.soramitsu.wallet.impl.domain.model.Asset -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject @HiltViewModel class StartStakingPoolViewModel @Inject constructor( @@ -43,84 +39,93 @@ class StartStakingPoolViewModel @Inject constructor( private val flowStateProvider: StakingPoolSharedStateProvider ) : BaseViewModel() { - val chain: Chain - val asset: Asset + private val assetDeferred = viewModelScope.async { stakingSharedState.currentAssetFlow().first() } + private val chainDeferred = viewModelScope.async { stakingSharedState.assetWithChain.first().chain } init { - val mainState = requireNotNull(flowStateProvider.mainState.get()) - chain = requireNotNull(mainState.chain) - asset = requireNotNull(mainState.asset) - } - - private val poolsLimitHasReached = flowOf { - val possiblePools = stakingPoolInteractor.getPossiblePools(chain.id) - val poolsCount = stakingPoolInteractor.getPoolsCount(chain.id) - return@flowOf poolsCount >= possiblePools - }.inBackground().stateIn(viewModelScope, SharingStarted.Eagerly, null) - - private val yearlyReturnsFlow = flowOf { - val asset = stakingSharedState.currentAssetFlow().first() - // todo hardcoded returns for demo - val kusamaOnTestNodeChainId = "51cdb4b3101904a9d234d126656d33cd17518249819b510a03d6c90d0a019611" - val polkadotOnTestNodeChainId = "4f77f65b21b1f396c1555850be6f21e2b1f36c26b94dbcbfec976901c9f08bf3" - val chainId = if (asset.token.configuration.chainId == kusamaOnTestNodeChainId || asset.token.configuration.chainId == polkadotOnTestNodeChainId) { - polkadotChainId - } else { - asset.token.configuration.chainId - } - val rewardCalculator = rewardCalculatorFactory.create(asset.token.configuration) - val yearly = rewardCalculator.calculateReturns(BigDecimal.ONE, PERIOD_YEAR, true, chainId) - - mapPeriodReturnsToRewardEstimation(yearly, asset.token, resourceManager) - } + viewModelScope.launch { + val rewardsPayoutDelayDeferred = async { + val hours = relayChainScenarioInteractor.stakePeriodInHours() + resourceManager.getQuantityString(R.plurals.common_hours_format, hours, hours) + } + val unstakingPeriodDeferred = async { + val lockupPeriodInHours = relayChainScenarioInteractor.unstakingPeriod() + if (lockupPeriodInHours > HOURS_IN_DAY) { + val inDays = lockupPeriodInHours / HOURS_IN_DAY + resourceManager.getQuantityString(R.plurals.common_days_format, inDays, inDays) + } else { + resourceManager.getQuantityString( + R.plurals.common_hours_format, + lockupPeriodInHours, + lockupPeriodInHours + ) + } + } + val asset = assetDeferred.await() + val chain = chainDeferred.await() + val yearlyReturnsDeferred = async { + // todo hardcoded returns for demo + val kusamaOnTestNodeChainId = + "51cdb4b3101904a9d234d126656d33cd17518249819b510a03d6c90d0a019611" + val polkadotOnTestNodeChainId = + "4f77f65b21b1f396c1555850be6f21e2b1f36c26b94dbcbfec976901c9f08bf3" + val chainId = + if (chain.id == kusamaOnTestNodeChainId || chain.id == polkadotOnTestNodeChainId) { + polkadotChainId + } else { + chain.id + } + val rewardCalculator = rewardCalculatorFactory.create(asset.token.configuration) + val yearly = + rewardCalculator.calculateReturns(BigDecimal.ONE, PERIOD_YEAR, true, chainId) - private val unstakingPeriodFlow = flowOf { - val lockupPeriodInHours = relayChainScenarioInteractor.unstakingPeriod() - if (lockupPeriodInHours > HOURS_IN_DAY) { - val inDays = lockupPeriodInHours / HOURS_IN_DAY - resourceManager.getQuantityString(R.plurals.common_days_format, inDays, inDays) - } else { - resourceManager.getQuantityString(R.plurals.common_hours_format, lockupPeriodInHours, lockupPeriodInHours) + mapPeriodReturnsToRewardEstimation( + yearly, + asset.token, + resourceManager + ) + } + state.update { prevState -> + prevState.copy( + assetName = asset.token.configuration.symbol, + rewardsPayoutDelay = rewardsPayoutDelayDeferred.await(), + unstakingPeriod = unstakingPeriodDeferred.await(), + yearlyEstimatedEarnings = yearlyReturnsDeferred.await().gain + ) + } } } - private val rewardsPayoutDelayFlow = flowOf { - val hours = relayChainScenarioInteractor.stakePeriodInHours() - resourceManager.getQuantityString(R.plurals.common_hours_format, hours, hours) + private val poolsLimitHasReachedDeferred = viewModelScope.async { + val chain = chainDeferred.await() + val possiblePools = stakingPoolInteractor.getPossiblePools(chain.id) + val poolsCount = stakingPoolInteractor.getPoolsCount(chain.id) + poolsCount >= possiblePools } - val state = combine(rewardsPayoutDelayFlow, unstakingPeriodFlow, yearlyReturnsFlow) { rewardsPayoutDelay, unstakingPeriod, yearlyReturns -> - SetupStakingPoolViewState( - ToolbarViewState( - resourceManager.getString(R.string.pool_staking_title), - R.drawable.ic_arrow_back_24dp - ), - asset.token.configuration.symbol, - rewardsPayoutDelay, - yearlyReturns.gain, - unstakingPeriod - ) - }.stateIn( - this.viewModelScope, - SharingStarted.Eagerly, - SetupStakingPoolViewState( - ToolbarViewState( - resourceManager.getString(R.string.pool_staking_title), - R.drawable.ic_arrow_back_24dp - ), - "...", - "...", - "...", - "..." + val state: MutableStateFlow = + MutableStateFlow( + SetupStakingPoolViewState( + ToolbarViewState( + resourceManager.getString(R.string.pool_staking_title), + R.drawable.ic_arrow_back_24dp + ), + "...", + "...", + "...", + "..." + ) ) - ) fun onBackClick() { router.back() } fun onInstructionsClick() { - router.openWebViewer(resourceManager.getString(R.string.pool_staking_title), BuildConfig.STAKING_POOL_WIKI) + router.openWebViewer( + resourceManager.getString(R.string.pool_staking_title), + BuildConfig.STAKING_POOL_WIKI + ) } fun onJoinPool() { @@ -132,26 +137,24 @@ class StartStakingPoolViewModel @Inject constructor( } fun onCreatePool() { - val limitHasReached = poolsLimitHasReached.value - if (limitHasReached == null) { - showError("Error poolsLimitHasReached value not provided") - return - } - if (limitHasReached) { - router.openAlert( - AlertViewState( - title = resourceManager.getString(R.string.pools_limit_has_reached_error_title), - message = resourceManager.getString(R.string.pools_limit_has_reached_error_message), - buttonText = resourceManager.getString(R.string.common_got_it), - iconRes = R.drawable.ic_status_warning_16 + viewModelScope.launch { + val limitHasReached = poolsLimitHasReachedDeferred.await() + if (limitHasReached) { + router.openAlert( + AlertViewState( + title = resourceManager.getString(R.string.pools_limit_has_reached_error_title), + message = resourceManager.getString(R.string.pools_limit_has_reached_error_message), + buttonText = resourceManager.getString(R.string.common_got_it), + iconRes = R.drawable.ic_status_warning_16 + ) ) - ) - return - } - val setupState = flowStateProvider.createFlowState - if (setupState.get() == null) { - setupState.set(StakingPoolCreateFlowState()) + return@launch + } + val setupState = flowStateProvider.createFlowState + if (setupState.get() == null) { + setupState.set(StakingPoolCreateFlowState()) + } + router.openCreatePoolSetup() } - router.openCreatePoolSetup() } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/ManagePoolStakeViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/ManagePoolStakeViewModel.kt index e24212f79a..7785870f83 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/ManagePoolStakeViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/ManagePoolStakeViewModel.kt @@ -2,8 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.staking.balance import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigInteger -import javax.inject.Inject import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.component.NotificationState import jp.co.soramitsu.common.compose.component.TitleValueViewState @@ -36,7 +34,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigInteger +import javax.inject.Inject @HiltViewModel class ManagePoolStakeViewModel @Inject constructor( @@ -62,7 +63,7 @@ class ManagePoolStakeViewModel @Inject constructor( userRole = pool?.getUserRole(accountId) ) } - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) private val validatorIdsFlow = poolStateFlow.filterNotNull() .map { pool -> stakingPoolInteractor.getValidatorsIds(chain, pool.poolId) } @@ -225,7 +226,10 @@ class ManagePoolStakeViewModel @Inject constructor( private fun onPoolInfoClick() { viewModelScope.launch { val pool = requireNotNull(poolStateFlow.first { it != null }) - router.openPoolInfo(pool.toPoolInfo()) + stakingPoolSharedStateProvider.poolsCache.update { prevState -> + prevState + (pool.poolId.toInt() to pool.toPoolInfo()) + } + router.openPoolInfo(pool.poolId.toInt()) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt index 428879075f..8b2b4f9102 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt @@ -4,9 +4,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigDecimal -import javax.inject.Inject -import javax.inject.Named import jp.co.soramitsu.account.api.domain.model.address import jp.co.soramitsu.common.address.AddressModel import jp.co.soramitsu.common.base.BaseViewModel @@ -22,12 +19,10 @@ import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.Event import jp.co.soramitsu.common.utils.childScope import jp.co.soramitsu.common.utils.formatAsPercentage -import jp.co.soramitsu.common.utils.formatCrypto import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.withLoading import jp.co.soramitsu.common.validation.ValidationExecutor import jp.co.soramitsu.core.updater.UpdateSystem -import jp.co.soramitsu.core.utils.amountFromPlanks import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.staking.api.data.StakingAssetSelection import jp.co.soramitsu.staking.api.data.StakingSharedState @@ -64,7 +59,6 @@ import jp.co.soramitsu.staking.impl.scenarios.StakingPoolInteractor import jp.co.soramitsu.staking.impl.scenarios.parachain.StakingParachainScenarioInteractor import jp.co.soramitsu.staking.impl.scenarios.relaychain.StakingRelayChainScenarioInteractor import jp.co.soramitsu.wallet.impl.domain.interfaces.QuickInputsUseCase -import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount import jp.co.soramitsu.wallet.impl.presentation.model.ControllerDeprecationWarningModel import jp.co.soramitsu.wallet.impl.presentation.model.toModel import kotlinx.coroutines.CoroutineScope @@ -88,6 +82,9 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject +import javax.inject.Named private const val CURRENT_ICON_SIZE = 40 @@ -225,6 +222,8 @@ class StakingViewModel @Inject constructor( stakingSharedState.selectionItem.distinctUntilChanged().onEach { setupStakingSharedState.set(SetupStakingProcess.Initial(it.type)) stakingStateScope.coroutineContext.cancelChildren() + + stakingPoolSharedStateProvider.poolsCache.update { emptyMap() } }.launchIn(viewModelScope) interactor.selectionStateFlow().onEach { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt index a962011bd0..ddec49d7f3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt @@ -2,8 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.change.custom.revie import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import javax.inject.Named import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.resources.ResourceManager @@ -29,7 +27,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named @HiltViewModel class ReviewCustomValidatorsViewModel @Inject constructor( @@ -114,7 +115,10 @@ class ReviewCustomValidatorsViewModel @Inject constructor( } fun validatorInfoClicked(validatorModel: ValidatorModel) { - router.openValidatorDetails(mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + interactor.validatorDetailsCache.update { prev -> + prev + (validatorModel.accountIdHex to mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + } + router.openValidatorDetails(validatorModel.accountIdHex) } fun nextClicked() { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt index 6e95b62baf..2f4bd03911 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt @@ -2,9 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.change.custom.searc import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.Locale -import javax.inject.Inject -import javax.inject.Named import jp.co.soramitsu.common.address.AddressIconGenerator.Companion.SIZE_MEDIUM import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.presentation.LoadingState @@ -16,6 +13,7 @@ import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.shared_utils.extensions.fromHex import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.validators.current.search.BlockedValidatorException import jp.co.soramitsu.staking.impl.domain.validators.current.search.SearchCustomBlockProducerInteractor import jp.co.soramitsu.staking.impl.presentation.StakingRouter @@ -33,7 +31,11 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named sealed class SearchBlockProducersState { object NoInput : SearchBlockProducersState() @@ -51,7 +53,8 @@ class SearchCustomValidatorsViewModel @Inject constructor( private val resourceManager: ResourceManager, private val sharedStateSetup: SetupStakingSharedState, @Named("StakingTokenUseCase") tokenUseCase: TokenUseCase, - private val searchCustomBlockProducerInteractor: SearchCustomBlockProducerInteractor + private val searchCustomBlockProducerInteractor: SearchCustomBlockProducerInteractor, + private val interactor: StakingInteractor ) : BaseViewModel() { private val confirmSetupState = sharedStateSetup.setupStakingProcess @@ -167,7 +170,10 @@ class SearchCustomValidatorsViewModel @Inject constructor( ) }, { - router.openValidatorDetails(mapValidatorToValidatorDetailsParcelModel(it)) + interactor.validatorDetailsCache.update { prev -> + prev + (it.accountIdHex to mapValidatorToValidatorDetailsParcelModel(it)) + } + router.openValidatorDetails(it.accountIdHex) } ) } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt index 77a1e9084c..30768bb0c8 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt @@ -2,9 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.change.custom.selec import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigInteger -import javax.inject.Inject -import javax.inject.Named import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.address.AddressModel import jp.co.soramitsu.common.address.createAddressModel @@ -45,7 +42,11 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named @HiltViewModel class SelectCustomValidatorsViewModel @Inject constructor( @@ -180,7 +181,10 @@ class SelectCustomValidatorsViewModel @Inject constructor( } fun validatorInfoClicked(validatorModel: ValidatorModel) { - router.openValidatorDetails(mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + interactor.validatorDetailsCache.update { prev -> + prev + (validatorModel.accountIdHex to mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + } + router.openValidatorDetails(validatorModel.accountIdHex) } fun validatorClicked(validatorModel: ValidatorModel) { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/compose/SelectCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/compose/SelectCustomValidatorsViewModel.kt index 59a186e9d4..d2667465d5 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/compose/SelectCustomValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/select/compose/SelectCustomValidatorsViewModel.kt @@ -3,8 +3,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.change.custom.selec import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigInteger -import javax.inject.Inject import jp.co.soramitsu.common.AlertViewState import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.presentation.LoadingState @@ -49,6 +47,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigInteger +import javax.inject.Inject private val filtersSet = setOf(Filters.HavingOnChainIdentity, Filters.NotSlashedFilter, Filters.NotOverSubscribed) @@ -222,14 +222,12 @@ class SelectCustomValidatorsViewModel @Inject constructor( override fun onInfoClick(item: SelectableListItemState) { val validator = recommendedValidators.value.dataOrNull()?.find { it.accountIdHex == item.id } - - router.openValidatorDetails( - mapValidatorToValidatorDetailsParcelModel( - requireNotNull( - validator - ) - ) - ) + validator?.let { + interactor.validatorDetailsCache.update { prev -> + prev + (it.accountIdHex to mapValidatorToValidatorDetailsParcelModel(it)) + } + router.openValidatorDetails(it.accountIdHex) + } } override fun onChooseClick() { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt index 15f95a000b..400e223977 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt @@ -2,7 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.change.recommended import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.resources.ResourceManager @@ -29,7 +28,9 @@ import jp.co.soramitsu.wallet.impl.domain.model.Token import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject import javax.inject.Named @HiltViewModel @@ -73,7 +74,10 @@ class RecommendedValidatorsViewModel @Inject constructor( } fun validatorInfoClicked(validatorModel: ValidatorModel) { - router.openValidatorDetails(mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + interactor.validatorDetailsCache.update { prev -> + prev + (validatorModel.validator.accountIdHex to mapValidatorToValidatorDetailsParcelModel(validatorModel.validator)) + } + router.openValidatorDetails(validatorModel.validator.accountIdHex) } fun nextClicked() { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt index d4ed1d8742..ee1844e71e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt @@ -2,8 +2,6 @@ package jp.co.soramitsu.staking.impl.presentation.validators.compose import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigInteger -import javax.inject.Inject import jp.co.soramitsu.common.AlertViewState import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.presentation.LoadingState @@ -18,6 +16,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.shared_utils.extensions.fromHex import jp.co.soramitsu.shared_utils.extensions.toHexString import jp.co.soramitsu.staking.api.domain.model.Validator +import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.recommendations.ValidatorRecommendatorFactory import jp.co.soramitsu.staking.impl.domain.recommendations.settings.RecommendationSettings import jp.co.soramitsu.staking.impl.domain.recommendations.settings.RecommendationSettingsProvider @@ -43,6 +42,8 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigInteger +import javax.inject.Inject private val filtersSet = setOf(Filters.HavingOnChainIdentity, Filters.NotSlashedFilter, Filters.NotOverSubscribed) @@ -56,7 +57,8 @@ class SelectValidatorsViewModel @Inject constructor( private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, private val resourceManager: ResourceManager, private val stakingPoolSharedStateProvider: StakingPoolSharedStateProvider, - private val settingsStorage: SettingsStorage + private val settingsStorage: SettingsStorage, + private val interactor: StakingInteractor ) : BaseViewModel(), SelectValidatorsScreenInterface { private val asset: Asset @@ -195,13 +197,12 @@ class SelectValidatorsViewModel @Inject constructor( override fun onInfoClick(item: SelectableListItemState) { val validator = recommendedValidators.value.dataOrNull()?.find { it.accountIdHex == item.id } - router.openValidatorDetails( - mapValidatorToValidatorDetailsParcelModel( - requireNotNull( - validator - ) - ) - ) + validator?.let { + interactor.validatorDetailsCache.update { prev -> + prev + (it.accountIdHex to mapValidatorToValidatorDetailsParcelModel(it)) + } + router.openValidatorDetails(it.accountIdHex) + } } override fun onChooseClick() { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt index 345713698b..563271d701 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt @@ -11,12 +11,14 @@ import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.shared_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.NominatedValidator +import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.recommendations.settings.sortings.BlockProducersSorting import jp.co.soramitsu.staking.impl.presentation.StakingRouter import jp.co.soramitsu.staking.impl.presentation.common.SelectValidatorFlowState import jp.co.soramitsu.staking.impl.presentation.common.StakingPoolSharedStateProvider import jp.co.soramitsu.staking.impl.presentation.mappers.mapValidatorToValidatorDetailsWithStakeFlagParcelModel import jp.co.soramitsu.staking.impl.presentation.pools.compose.SelectableListItemState +import jp.co.soramitsu.staking.impl.presentation.validators.toModel import jp.co.soramitsu.staking.impl.scenarios.StakingPoolInteractor import jp.co.soramitsu.wallet.impl.domain.model.Asset import kotlinx.coroutines.Dispatchers @@ -26,18 +28,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigInteger import javax.inject.Inject -import jp.co.soramitsu.staking.impl.presentation.validators.toModel @HiltViewModel class SelectedValidatorsViewModel @Inject constructor( private val poolSharedStateProvider: StakingPoolSharedStateProvider, private val poolInteractor: StakingPoolInteractor, private val resourceManager: ResourceManager, - private val router: StakingRouter + private val router: StakingRouter, + private val stakingInteractor: StakingInteractor ) : BaseViewModel(), SelectedValidatorsInterface { private val validatorsToShow: List = poolSharedStateProvider.requireSelectedValidatorsState.selectedValidators @@ -102,7 +105,10 @@ class SelectedValidatorsViewModel @Inject constructor( mapValidatorToValidatorDetailsWithStakeFlagParcelModel(requireNotNull(nominatedValidator)) } - router.openValidatorDetails(payload) + stakingInteractor.validatorDetailsCache.update { prev -> + prev + (payload.accountIdHex to payload) + } + router.openValidatorDetails(payload.accountIdHex) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt index defe322154..5d713813ae 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -187,6 +188,9 @@ class CurrentValidatorsViewModel @Inject constructor( mapValidatorToValidatorDetailsWithStakeFlagParcelModel(nominatedValidator) } - router.openValidatorDetails(payload) + stakingInteractor.validatorDetailsCache.update { prev -> + prev + (payload.accountIdHex to payload) + } + router.openValidatorDetails(payload.accountIdHex) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/ValidatorDetailsFragment.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/ValidatorDetailsFragment.kt index 47bbaaa683..0b4616784d 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/ValidatorDetailsFragment.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/ValidatorDetailsFragment.kt @@ -12,7 +12,6 @@ import jp.co.soramitsu.common.utils.makeGone import jp.co.soramitsu.common.utils.makeVisible import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.feature_staking_impl.databinding.FragmentValidatorDetailsBinding -import jp.co.soramitsu.staking.impl.presentation.validators.parcel.ValidatorDetailsParcelModel @AndroidEntryPoint class ValidatorDetailsFragment : BaseBottomSheetDialogFragment(R.layout.fragment_validator_details) { @@ -20,9 +19,9 @@ class ValidatorDetailsFragment : BaseBottomSheetDialogFragment(KEY_VALIDATOR)!! + private val validatorIdHex = savedStateHandle.get(KEY_VALIDATOR)!! + private val validator = requireNotNull(interactor.validatorDetailsCache.value[validatorIdHex]) private val assetFlow = interactor.currentAssetFlow() .share() diff --git a/feature-success-impl/build.gradle.kts b/feature-success-impl/build.gradle.kts index 91c9df2f50..66a882b68e 100644 --- a/feature-success-impl/build.gradle.kts +++ b/feature-success-impl/build.gradle.kts @@ -30,6 +30,7 @@ android { kotlinOptions { jvmTarget = "17" } + namespace = "jp.co.soramitsu.feature_success_impl" } diff --git a/feature-success-impl/src/main/kotlin/jp/co/soramitsu/success/presentation/SuccessViewModel.kt b/feature-success-impl/src/main/kotlin/jp/co/soramitsu/success/presentation/SuccessViewModel.kt index 42931011f5..451c630dd7 100644 --- a/feature-success-impl/src/main/kotlin/jp/co/soramitsu/success/presentation/SuccessViewModel.kt +++ b/feature-success-impl/src/main/kotlin/jp/co/soramitsu/success/presentation/SuccessViewModel.kt @@ -69,6 +69,7 @@ class SuccessViewModel @Inject constructor( } Chain.Explorer.Type.OKLINK, Chain.Explorer.Type.ETHERSCAN, + Chain.Explorer.Type.KLAYTN, Chain.Explorer.Type.ZETA -> { BlockExplorerUrlBuilder(explorerItem.url, explorerItem.types).build(BlockExplorerUrlBuilder.Type.TX, operationHash) } diff --git a/feature-wallet-api/build.gradle b/feature-wallet-api/build.gradle index cbfc62e2bb..945bea7627 100644 --- a/feature-wallet-api/build.gradle +++ b/feature-wallet-api/build.gradle @@ -26,6 +26,7 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + namespace 'jp.co.soramitsu.feature_wallet_api' } diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/presentation/mixin/fee/FeeLoaderProvider.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/presentation/mixin/fee/FeeLoaderProvider.kt index d58c5c4c32..22aa03fe7b 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/presentation/mixin/fee/FeeLoaderProvider.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/presentation/mixin/fee/FeeLoaderProvider.kt @@ -66,7 +66,7 @@ class FeeLoaderProvider( FeeStatus.Error } value?.let { - feeLiveData.postValue(value) + feeLiveData.postValue(it) } withContext(Dispatchers.Main) { onComplete?.invoke(value) diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt index c5f4120868..b80f1fab36 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt @@ -1,8 +1,5 @@ package jp.co.soramitsu.wallet.impl.domain.interfaces -import java.io.File -import java.math.BigDecimal -import java.math.BigInteger import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.common.data.model.CursorPage @@ -26,10 +23,14 @@ import jp.co.soramitsu.wallet.impl.domain.model.OperationsPageChange import jp.co.soramitsu.wallet.impl.domain.model.PhishingModel import jp.co.soramitsu.wallet.impl.domain.model.QrContentCBDC import jp.co.soramitsu.wallet.impl.domain.model.QrContentSora +import jp.co.soramitsu.wallet.impl.domain.model.Token import jp.co.soramitsu.wallet.impl.domain.model.Transfer import jp.co.soramitsu.wallet.impl.domain.model.TransferValidityStatus import jp.co.soramitsu.wallet.impl.domain.model.WalletAccount import kotlinx.coroutines.flow.Flow +import java.io.File +import java.math.BigDecimal +import java.math.BigInteger import jp.co.soramitsu.core.models.Asset as CoreAsset class NotValidTransferStatus(val status: TransferValidityStatus) : Exception() @@ -163,4 +164,6 @@ interface WalletInteractor { suspend fun retryChainSync(chainId: ChainId): Result fun observeCurrentAccountChainsPerAsset(assetId: String): Flow> + suspend fun getOperationAddressWithChainId(chainId: ChainId, limit: Int?): Set + suspend fun getToken(chainAsset: jp.co.soramitsu.core.models.Asset): Token } diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt index 6cef5f3930..2e45012666 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt @@ -96,7 +96,7 @@ interface WalletRepository { suspend fun getStashAccount(chainId: ChainId, accountId: AccountId): AccountId? suspend fun getTotalBalance( - chainAsset: jp.co.soramitsu.core.models.Asset, + chainAsset: CoreAsset, chain: Chain, accountId: ByteArray ): BigInteger diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index f7c00c4f73..67ac0c5e9c 100644 --- a/feature-wallet-impl/build.gradle +++ b/feature-wallet-impl/build.gradle @@ -55,6 +55,7 @@ android { composeOptions { kotlinCompilerExtensionVersion composeCompilerVersion } + namespace 'jp.co.soramitsu.feature_wallet_impl' } @@ -133,8 +134,9 @@ dependencies { implementation libs.navigation.fragment.ktx implementation libs.navigation.ui.ktx - implementation libs.sharedFeaturesXcmDep - implementation libs.sharedFeaturesBackupDep +// implementation libs.sharedFeaturesCoreDep, withoutAndroidFoundation + implementation libs.sharedFeaturesXcmDep, withoutAndroidFoundation + implementation libs.sharedFeaturesBackupDep, withoutAndroidFoundation implementation libs.web3jDep } \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt new file mode 100644 index 0000000000..887bd92977 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt @@ -0,0 +1,87 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import android.os.Build +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Locale +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class AtletaHistorySource( + private val walletOperationsApi: OperationsHistoryApi, + private val historyUrl: String +) : HistorySource { + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + val page = cursor?.toInt() ?: 1 + val responseResult = + runCatching { + val urlBuilder = StringBuilder(historyUrl).append("addresses/").append(accountAddress).append("/transactions") + walletOperationsApi.getAtletaOperationsHistory( + url = urlBuilder.toString() + ) + } + + return responseResult.fold(onSuccess = { + val operations = it.items.map { element -> + val status = when (element.result) { + "success" -> Operation.Status.COMPLETED + "error" -> Operation.Status.FAILED + else -> Operation.Status.COMPLETED + } + Operation( + id = element.hash, + address = accountAddress, + time = parseTimeToMillis(element.timestamp), + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = element.hash, + myAddress = accountAddress, + amount = element.value, + receiver = element.to.hash, + sender = element.from.hash, + status = status, + fee = element.fee?.value + ) + ) + } + + val nextCursor = (page + 1).toString() + CursorPage(nextCursor, operations) + }, onFailure = { + CursorPage(null, emptyList()) + }) + + } + + private val blockScanDateFormat by lazy { + SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSX", + Locale.getDefault() + ) + } + + private fun parseTimeToMillis(timestamp: String): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Instant.parse(timestamp).toEpochMilli() + } else { + try { + blockScanDateFormat.parse(timestamp)?.time ?: 0 + } catch (e: Exception) { + 0 + } + } + } +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZetaHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt similarity index 94% rename from feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZetaHistorySource.kt rename to feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt index abbca42e22..fd2a5ca6a2 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZetaHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt @@ -15,7 +15,7 @@ import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount -class ZetaHistorySource( +class BlockscoutHistorySource( private val walletOperationsApi: OperationsHistoryApi, private val historyUrl: String ) : HistorySource { @@ -30,7 +30,7 @@ class ZetaHistorySource( ): CursorPage { val responseResult = runCatching { - val zetaUrl = StringBuilder(historyUrl).append(accountId.toHexString(true)).apply { + val urlBuilder = StringBuilder(historyUrl).append("addresses/").append(accountId.toHexString(true)).apply { when (chainAsset.ethereumType) { Asset.EthereumType.NORMAL -> { this.append("/transactions").toString() @@ -42,7 +42,7 @@ class ZetaHistorySource( } walletOperationsApi.getZetaOperationsHistory( - url = zetaUrl.toString() + url = urlBuilder.toString() ) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt new file mode 100644 index 0000000000..17b04cc7d5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt @@ -0,0 +1,85 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import android.os.Build +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Locale +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class FireHistorySource( + private val walletOperationsApi: OperationsHistoryApi, + private val historyUrl: String +) : HistorySource { + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + val page = cursor?.toInt() ?: 1 + val responseResult = + runCatching { + val urlBuilder = StringBuilder(historyUrl).append("transactions/address/").append(accountAddress) + walletOperationsApi.getFireOperationsHistory( + url = urlBuilder.toString(), + page = page, + limit = pageSize + ) + } + + return responseResult.fold(onSuccess = { + val operations = it.data.transactions.mapNotNull { element -> + if (element.toAddress.isNullOrEmpty()) return@mapNotNull null + val status = if (element.status == 1) Operation.Status.COMPLETED else Operation.Status.FAILED + Operation( + id = element.hash, + address = accountAddress, + time = parseTimeToMillis(element.createdAt), + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = element.hash, + myAddress = accountAddress, + amount = element.value, + receiver = element.toAddress.lowercase(), + sender = element.fromAddress.lowercase(), + status = status, + fee = element.gasUsed + ) + ) + } + + val nextCursor = (page + 1).toString() + CursorPage(nextCursor, operations) + }, onFailure = { + CursorPage(null, emptyList()) + }) + } + + private val blockScanDateFormat by lazy { + SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSX", + Locale.getDefault() + ) + } + + private fun parseTimeToMillis(timestamp: String): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Instant.parse(timestamp).toEpochMilli() + } else { + try { + blockScanDateFormat.parse(timestamp)?.time ?: 0 + } catch (e: Exception) { + 0 + } + } + } +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt index 95abfdc8f0..fa5bcc55d9 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt @@ -20,8 +20,12 @@ class HistorySourceProvider( Chain.ExternalApi.Section.Type.GIANTSQUID -> GiantsquidHistorySource(walletOperationsApi, historyUrl) Chain.ExternalApi.Section.Type.ETHERSCAN -> EtherscanHistorySource(walletOperationsApi, historyUrl) Chain.ExternalApi.Section.Type.OKLINK -> OkLinkHistorySource(walletOperationsApi, historyUrl) - Chain.ExternalApi.Section.Type.ZETA -> ZetaHistorySource(walletOperationsApi, historyUrl) + Chain.ExternalApi.Section.Type.BLOCKSCOUT -> BlockscoutHistorySource(walletOperationsApi, historyUrl) Chain.ExternalApi.Section.Type.REEF -> ReefHistorySource(walletOperationsApi, historyUrl) + Chain.ExternalApi.Section.Type.KLAYTN -> KlaytnHistorySource(walletOperationsApi, historyUrl) + Chain.ExternalApi.Section.Type.FIRE -> FireHistorySource(walletOperationsApi, historyUrl) + Chain.ExternalApi.Section.Type.VICSCAN -> VicscanHistorySource(walletOperationsApi, historyUrl) + Chain.ExternalApi.Section.Type.ZCHAINS -> ZchainsHistorySource(walletOperationsApi, historyUrl) else -> null } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt new file mode 100644 index 0000000000..43867990ac --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt @@ -0,0 +1,64 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class KlaytnHistorySource( + private val walletOperationsApi: OperationsHistoryApi, + private val historyUrl: String +) : HistorySource { + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + val page = cursor?.toInt() ?: 1 + val responseResult = + runCatching { + val urlBuilder = StringBuilder(historyUrl) + .append("accounts/") + .append(accountAddress) + .append("/txs") + + walletOperationsApi.getKlaytnOperationsHistory( + url = urlBuilder.toString(), + page = page + ) + } + + return responseResult.fold(onSuccess = { + val operations = it.result.map { element -> + val status = if (element.txStatus == 1) Operation.Status.COMPLETED else Operation.Status.FAILED + Operation( + id = element.txHash, + address = accountAddress, + time = element.createdAt, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = element.txHash, + myAddress = accountAddress, + amount = element.amount, + receiver = element.toAddress.lowercase(), + sender = element.fromAddress.lowercase(), + status = status, + fee = element.txFee + ) + ) + } + + val nextCursor = (page + 1).toString() + CursorPage(nextCursor, operations) + }, onFailure = { + CursorPage(null, emptyList()) + }) + } +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt new file mode 100644 index 0000000000..ac772f5b6d --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt @@ -0,0 +1,67 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class VicscanHistorySource( + private val walletOperationsApi: OperationsHistoryApi, + private val historyUrl: String +) : HistorySource { + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + val page = cursor?.toInt() ?: 1 + val urlBuilder = StringBuilder(historyUrl).append("transaction/list") + + val responseResult = + runCatching { + walletOperationsApi.getVicscanOperationsHistory( + url = urlBuilder.toString(), + account = accountAddress, + limit = pageSize, + offset = (page - 1) * pageSize + ) + } + + return responseResult.fold(onSuccess = { + val operations = it.data.map { element -> + val status = when (element.status) { + "success" -> Operation.Status.COMPLETED + "error" -> Operation.Status.FAILED + else -> Operation.Status.COMPLETED + } + Operation( + id = element.transactionIndex.toString(), + address = accountAddress, + time = element.timestamp, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = element.hash, + myAddress = accountAddress, + amount = element.value, + receiver = element.to.lowercase(), + sender = element.from.lowercase(), + status = status, + fee = element.gasUsed + ) + ) + } + + val nextCursor = (page + 1).toString() + CursorPage(nextCursor, operations) + }, onFailure = { + CursorPage(null, emptyList()) + }) + } +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt new file mode 100644 index 0000000000..24484a34f5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt @@ -0,0 +1,64 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class ZchainsHistorySource( + private val walletOperationsApi: OperationsHistoryApi, + private val historyUrl: String +) : HistorySource { + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + val page = cursor?.toInt() ?: 1 + val urlBuilder = StringBuilder(historyUrl).append("transaction") + + val responseResult = + runCatching { + walletOperationsApi.getZchainOperationsHistory( + url = urlBuilder.toString(), + address = accountAddress, + pageSize = pageSize, + page = page + ) + } + + return responseResult.fold(onSuccess = { + val operations = it.data.map { element -> + val status = if (element.success) Operation.Status.COMPLETED else Operation.Status.FAILED + Operation( + id = element.hash, + address = accountAddress, + time = element.timestamp, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = element.hash, + myAddress = accountAddress, + amount = element.value, + receiver = element.to.address, + sender = element.from.address, + status = status, + fee = element.gasUsed + ) + ) + } + + val nextCursor = (page + 1).toString() + CursorPage(nextCursor, operations) + }, onFailure = { + CursorPage(null, emptyList()) + }) + + } +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalanceUpdateTrigger.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalanceUpdateTrigger.kt index 9b0cf241e0..11e1091d29 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalanceUpdateTrigger.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalanceUpdateTrigger.kt @@ -5,9 +5,11 @@ import jp.co.soramitsu.core.models.ChainId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +const val UPDATE_TIMEOUT = 30_000L + object BalanceUpdateTrigger { - private var lastUpdateTime: Long? = null + private var lastUpdateTimeMap = mutableMapOf() private val flow = MutableSharedFlow() @@ -17,8 +19,14 @@ object BalanceUpdateTrigger { suspend operator fun invoke(chainId: ChainId? = null, force: Boolean = false) { val currentTime = getTimeMillis() - if (force.not() && lastUpdateTime != null && currentTime - lastUpdateTime!! <= 30_000L) return + val chainLastUpdateTime = chainId?.let { lastUpdateTimeMap[chainId] } + + if (force.not() && chainLastUpdateTime != null && currentTime - chainLastUpdateTime <= UPDATE_TIMEOUT) { + return + } flow.emit(chainId) - lastUpdateTime = getTimeMillis() + chainId?.let { + lastUpdateTimeMap[chainId] = getTimeMillis() + } } } \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt index d01d3e322b..1b84a95906 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt @@ -41,6 +41,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -51,6 +53,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -108,7 +111,7 @@ class BalancesUpdateSystem( chain: Chain, metaAccount: MetaAccount ): Flow> { - return trigger.map { triggeredChainId -> + return trigger.onStart { emit(null) }.map { triggeredChainId -> val specificChainTriggered = triggeredChainId != null val currentChainTriggered = triggeredChainId == chain.id @@ -218,27 +221,42 @@ class BalancesUpdateSystem( chain: Chain, accounts: List ) { - accounts.forEach { account -> - val address = account.address(chain) ?: return@forEach - val accountId = account.accountId(chain) ?: return@forEach - chain.assets.forEach asset@{ asset -> - val balance = - kotlin.runCatching { ethereumRemoteSource.fetchEthBalance(asset, address) } - .onFailure { - Log.d( - TAG, - "fetchEthBalance error ${it.message} ${it.localizedMessage} $it" + coroutineScope { + + val accountsDeferred = accounts.map { account -> + async { + val address = account.address(chain) ?: return@async + val accountId = account.accountId(chain) ?: return@async + val accountBalancesDeferred = chain.assets.map { asset -> + async assets@{ + val balance = + kotlin.runCatching { + ethereumRemoteSource.fetchEthBalance( + asset, + address + ) + } + .onFailure { + Log.d( + TAG, + "fetchEthBalance error ${it.message} ${it.localizedMessage} $it" + ) + } + .getOrNull() ?: return@assets + val balanceData = SimpleBalanceData(balance) + assetCache.updateAsset( + metaId = account.id, + accountId = accountId, + asset = asset, + balanceData = balanceData ) } - .getOrNull() ?: return@asset - val balanceData = SimpleBalanceData(balance) - assetCache.updateAsset( - metaId = account.id, - accountId = accountId, - asset = asset, - balanceData = balanceData - ) + } + accountBalancesDeferred.awaitAll() + } } + + accountsDeferred.awaitAll() } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/AtletaHistoryResponse.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/AtletaHistoryResponse.kt new file mode 100644 index 0000000000..98866289e5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/AtletaHistoryResponse.kt @@ -0,0 +1,29 @@ +package jp.co.soramitsu.wallet.impl.data.network.model.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +data class AtletaHistoryResponse( + val items: List, + @SerializedName("next_page_params") + val nextPageParams: NextPageParams? = null +) + +data class AtletaHistoryItem( + val timestamp: String, + val fee: AtletaFee? = null, + val result: String, + val to: AtletaAddress, + val from: AtletaAddress, + val value: BigInteger, + val hash: String +) + +data class AtletaAddress( + val hash: String +) + +data class AtletaFee( + val type: String, + val value: BigInteger +) \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/FireHistoryResponse.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/FireHistoryResponse.kt new file mode 100644 index 0000000000..10877900a3 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/FireHistoryResponse.kt @@ -0,0 +1,37 @@ +package jp.co.soramitsu.wallet.impl.data.network.model.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.runtime.binding.BlockNumber + +data class FireHistoryResponse( + val message: String, + val data: FireHistoryData, + val page: Int, + val total: Int +) + +data class FireHistoryData( + val count: Int, + val transactions: List +) + +data class FireHistoryItem( + val hash: String, + @SerializedName("receipt_status") + val status: Int, + @SerializedName("created_at") + val createdAt: String, + @SerializedName("block_number") + val blockNumber: BlockNumber, + @SerializedName("from_address") + val fromAddress: String, + @SerializedName("to_address") + val toAddress: String?, + val value: BigInteger, + val gas: BigInteger, + @SerializedName("receipt_cumulative_gas_used") + val gasUsed: BigInteger, + @SerializedName("gas_price") + val gasPrice: BigInteger +) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/KlaytnHistoryResponse.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/KlaytnHistoryResponse.kt new file mode 100644 index 0000000000..efd92c55c6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/KlaytnHistoryResponse.kt @@ -0,0 +1,25 @@ +package jp.co.soramitsu.wallet.impl.data.network.model.response + +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.runtime.binding.BlockNumber + +data class KlaytnHistoryItem( + val createdAt: Long, + val txHash: String, + val blockNumber: BlockNumber, + val fromAddress: String, + val toAddress: String, + val amount: BigInteger, + val txFee: BigInteger, + val gasLimit: BigInteger, + val gasUsed: BigInteger, + val gasPrice: BigInteger, + val txStatus: Int, +) + +data class KlaytnHistoryResponse( + val success: Boolean, + val result: List, + val page: Int, + val total: Int +) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/VicscanHistoryResponse.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/VicscanHistoryResponse.kt new file mode 100644 index 0000000000..a0ae7fb5ca --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/VicscanHistoryResponse.kt @@ -0,0 +1,25 @@ +package jp.co.soramitsu.wallet.impl.data.network.model.response + +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.runtime.binding.BlockNumber + +data class VicscanHistoryResponse( + val data: List, + val total: Int +) + +data class VicscanHistoryItem( + val transactionIndex: BigInteger, + val status: String, + val hash: String, + val timestamp: Long, + val blockNumber: BlockNumber, + val from: String, + val to: String, + val value: BigInteger, + val gas: BigInteger, + val fee: BigInteger, + val gasUsed: BigInteger, + val gasPrice: BigInteger +) + diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/ZchainHistoryResponse.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/ZchainHistoryResponse.kt new file mode 100644 index 0000000000..ba84e0cf6d --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/model/response/ZchainHistoryResponse.kt @@ -0,0 +1,40 @@ +package jp.co.soramitsu.wallet.impl.data.network.model.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.runtime.binding.BlockNumber + +data class ZchainHistoryResponse( + val data: List, + val total: Int +) + +data class ZchainHistoryItem( + @SerializedName("s") + val success: Boolean, + @SerializedName("h") + val hash: String, + @SerializedName("ti") + val timestamp: Long, + @SerializedName("bn") + val blockNumber: BlockNumber, + @SerializedName("f") + val from: ZchainHistoryAddress, + @SerializedName("f") + val to: ZchainHistoryAddress, + @SerializedName("v") + val value: BigInteger, + @SerializedName("gl") + val gas: BigInteger, + @SerializedName("tf") + val fee: BigInteger, + @SerializedName("gu") + val gasUsed: BigInteger, + @SerializedName("gp") + val gasPrice: BigInteger +) + +data class ZchainHistoryAddress( + @SerializedName("a") + val address: String +) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/subquery/OperationsHistoryApi.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/subquery/OperationsHistoryApi.kt index fada86b5dd..9ae8483987 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/subquery/OperationsHistoryApi.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/subquery/OperationsHistoryApi.kt @@ -8,12 +8,17 @@ import jp.co.soramitsu.wallet.impl.data.network.model.request.GiantsquidHistoryR import jp.co.soramitsu.wallet.impl.data.network.model.request.ReefHistoryRequest import jp.co.soramitsu.wallet.impl.data.network.model.request.SubqueryHistoryRequest import jp.co.soramitsu.wallet.impl.data.network.model.request.SubsquidHistoryRequest +import jp.co.soramitsu.wallet.impl.data.network.model.response.AtletaHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.EtherscanHistoryResponse +import jp.co.soramitsu.wallet.impl.data.network.model.response.FireHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.GiantsquidHistoryResponse +import jp.co.soramitsu.wallet.impl.data.network.model.response.KlaytnHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.OkLinkHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.ReefHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.SubqueryHistoryElementResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.SubsquidHistoryElementsConnectionResponse +import jp.co.soramitsu.wallet.impl.data.network.model.response.VicscanHistoryResponse +import jp.co.soramitsu.wallet.impl.data.network.model.response.ZchainHistoryResponse import jp.co.soramitsu.wallet.impl.data.network.model.response.ZetaHistoryResponse import retrofit2.http.Body import retrofit2.http.GET @@ -68,6 +73,40 @@ interface OperationsHistoryApi { @Url url: String ): ZetaHistoryResponse + @GET + suspend fun getAtletaOperationsHistory( + @Url url: String + ): AtletaHistoryResponse + + @GET + suspend fun getKlaytnOperationsHistory( + @Url url: String, + @Query("page") page: Int + ): KlaytnHistoryResponse + + @GET + suspend fun getFireOperationsHistory( + @Url url: String, + @Query("page") page: Int, + @Query("limit") limit: Int + ): FireHistoryResponse + + @GET + suspend fun getZchainOperationsHistory( + @Url url: String, + @Query("a") address: String, + @Query("page") page: Int, + @Query("page_size") pageSize: Int, + ): ZchainHistoryResponse + + @GET + suspend fun getVicscanOperationsHistory( + @Url url: String, + @Query("account") account: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int + ): VicscanHistoryResponse + @POST suspend fun getReefOperationsHistory( @Url url: String, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt index 3a5f7c87b4..bbf2a11093 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.wallet.impl.data.repository -import android.util.Log import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.common.utils.mapList import jp.co.soramitsu.core.models.Asset @@ -19,6 +18,7 @@ import jp.co.soramitsu.wallet.impl.data.storage.TransferCursorStorage import jp.co.soramitsu.wallet.impl.domain.CurrentAccountAddressUseCase import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation +import kotlin.coroutines.coroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -100,11 +100,11 @@ class HistoryRepository( getOperations(pageSize, cursor = nextCursor, filters, accountId, chain, chainAsset) }.getOrDefault(CursorPage(null, emptyList())) nextCursor = page.nextCursor - hasNextPage = nextCursor != null + hasNextPage = nextCursor != null && page.items.isNotEmpty() elements.addAll(page.map { mapOperationToOperationLocalDb( it, - OperationLocal.Source.SUBQUERY + OperationLocal.Source.BLOCKCHAIN ) }) } @@ -141,4 +141,11 @@ class HistoryRepository( addresses.toSet() } } + + suspend fun getOperationAddressWithChainId(chainId: ChainId, limit: Int?): Set = + withContext(coroutineContext) { + val address = currentAccountAddress.invoke(chainId) ?: return@withContext emptySet() + val operations = operationDao.getOperationAddresses(chainId, address, limit ?: NO_LIMIT) + return@withContext operations.toSet() + } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt index af82143e3f..0f5638439d 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt @@ -26,7 +26,6 @@ import jp.co.soramitsu.common.utils.requireValue import jp.co.soramitsu.common.utils.tokens import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.core.models.Asset.PriceProvider -import jp.co.soramitsu.core.models.Asset.PriceProviderType.Chainlink import jp.co.soramitsu.core.models.IChain import jp.co.soramitsu.core.runtime.storage.returnType import jp.co.soramitsu.core.utils.utilityAsset @@ -423,7 +422,7 @@ class WalletRepositoryImpl( } override suspend fun getTotalBalance( - chainAsset: jp.co.soramitsu.core.models.Asset, + chainAsset: CoreAsset, chain: Chain, accountId: ByteArray ): BigInteger { @@ -482,7 +481,7 @@ class WalletRepositoryImpl( senderAddress: String, fee: BigDecimal, source: OperationLocal.Source, - utilityAsset: jp.co.soramitsu.core.models.Asset? + utilityAsset: CoreAsset? ) = OperationLocal.manualTransfer( hash = hash, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt index 9553b77470..cbfe74da31 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt @@ -8,8 +8,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import javax.inject.Named -import javax.inject.Singleton import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.impl.domain.WalletSyncService @@ -20,6 +18,7 @@ import jp.co.soramitsu.common.data.network.HttpExceptionHandler import jp.co.soramitsu.common.data.network.NetworkApiCreator import jp.co.soramitsu.common.data.network.coingecko.CoingeckoApi import jp.co.soramitsu.common.data.network.config.RemoteConfigFetcher +import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.domain.GetAvailableFiatCurrencies import jp.co.soramitsu.common.domain.NetworkStateService @@ -35,6 +34,7 @@ import jp.co.soramitsu.coredb.dao.AddressBookDao import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.ChainDao import jp.co.soramitsu.coredb.dao.MetaAccountDao +import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.dao.OperationDao import jp.co.soramitsu.coredb.dao.PhishingDao import jp.co.soramitsu.coredb.dao.TokenPriceDao @@ -95,6 +95,8 @@ import jp.co.soramitsu.xcm.XcmService import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher import jp.co.soramitsu.xnetworking.basic.networkclient.SoramitsuNetworkClient import jp.co.soramitsu.xnetworking.fearlesswallet.txhistory.client.TxHistoryClientForFearlessWalletFactory +import javax.inject.Named +import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module @@ -206,13 +208,17 @@ class WalletFeatureModule { chainRegistry: ChainRegistry, remoteStorageSource: RemoteStorageSource, assetDao: AssetDao, + nomisApi: NomisApi, + nomisScoresDao: NomisScoresDao ): WalletSyncService { return WalletSyncService( metaAccountDao, chainsRepository, chainRegistry, remoteStorageSource, - assetDao + assetDao, + nomisApi, + nomisScoresDao ) } @@ -257,7 +263,8 @@ class WalletFeatureModule { updatesMixin: UpdatesMixin, xcmEntitiesFetcher: XcmEntitiesFetcher, chainsRepository: ChainsRepository, - networkStateService: NetworkStateService + networkStateService: NetworkStateService, + tokenRepository: TokenRepository ): WalletInteractor = WalletInteractorImpl( walletRepository, addressBookRepository, @@ -270,7 +277,8 @@ class WalletFeatureModule { updatesMixin, xcmEntitiesFetcher, chainsRepository, - networkStateService + networkStateService, + tokenRepository ) @Provides @@ -317,8 +325,8 @@ class WalletFeatureModule { @Provides @Singleton - fun provideXcmService(): XcmService { - return XcmService() + fun provideXcmService(chainRegistry: ChainRegistry): XcmService { + return XcmService(chainRegistry) } @Provides diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt index 6b328d11ee..5827d959eb 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt @@ -1,8 +1,5 @@ package jp.co.soramitsu.wallet.impl.domain -import java.math.BigDecimal -import java.math.BigInteger -import java.math.RoundingMode import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.model.accountId import jp.co.soramitsu.account.api.domain.model.address @@ -20,11 +17,14 @@ import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository import jp.co.soramitsu.wallet.impl.domain.model.Transfer import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode +import kotlin.coroutines.CoroutineContext private const val CROSS_CHAIN_ED_SAFE_TRANSFER_MULTIPLIER = 1.1 @@ -162,7 +162,10 @@ class QuickInputsUseCaseImpl( originNetworkId = originChainId, destinationNetworkId = destinationChainId, asset = asset.token.configuration, - amount = transferable * input.toBigDecimal() + destinationFee + amount = (transferable * input.toBigDecimal()).setScale( + chainAsset.precision, + RoundingMode.HALF_DOWN + ) + destinationFee ).takeIf { chainAsset.isUtility } ?: BigDecimal.ZERO val quickAmountWithoutExtraPays = diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt index e3ae018735..cbb4172bed 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt @@ -4,26 +4,21 @@ import android.net.Uri import android.util.Log import com.mastercard.mpqr.pushpayment.model.PushPaymentData import com.mastercard.mpqr.pushpayment.parser.Parser -import java.math.BigDecimal -import java.math.BigInteger -import java.math.RoundingMode -import java.net.URLDecoder import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.accountId import jp.co.soramitsu.account.api.domain.model.address -import jp.co.soramitsu.common.compose.component.QuickAmountInput import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.common.data.network.runtime.binding.EqAccountInfo import jp.co.soramitsu.common.data.network.runtime.binding.EqOraclePricePoint import jp.co.soramitsu.common.data.storage.Preferences +import jp.co.soramitsu.common.domain.NetworkStateService import jp.co.soramitsu.common.domain.SelectedFiat +import jp.co.soramitsu.common.domain.model.NetworkIssueType import jp.co.soramitsu.common.interfaces.FileProvider import jp.co.soramitsu.common.mixin.api.UpdatesMixin import jp.co.soramitsu.common.mixin.api.UpdatesProviderUi -import jp.co.soramitsu.common.domain.NetworkStateService -import jp.co.soramitsu.common.domain.model.NetworkIssueType import jp.co.soramitsu.common.model.AssetBooleanState import jp.co.soramitsu.common.utils.Modules import jp.co.soramitsu.common.utils.mapList @@ -32,7 +27,6 @@ import jp.co.soramitsu.common.utils.requireValue import jp.co.soramitsu.core.models.Asset.StakingType import jp.co.soramitsu.core.models.ChainId import jp.co.soramitsu.core.utils.isValidAddress -import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.coredb.model.AssetUpdateItem import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository @@ -49,6 +43,7 @@ import jp.co.soramitsu.wallet.impl.data.repository.HistoryRepository import jp.co.soramitsu.wallet.impl.data.repository.isSupported import jp.co.soramitsu.wallet.impl.domain.interfaces.AddressBookRepository import jp.co.soramitsu.wallet.impl.domain.interfaces.AssetSorting +import jp.co.soramitsu.wallet.impl.domain.interfaces.TokenRepository import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository @@ -63,28 +58,25 @@ import jp.co.soramitsu.wallet.impl.domain.model.QrContentCBDC import jp.co.soramitsu.wallet.impl.domain.model.QrContentSora import jp.co.soramitsu.wallet.impl.domain.model.Transfer import jp.co.soramitsu.wallet.impl.domain.model.WalletAccount -import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks import jp.co.soramitsu.wallet.impl.domain.model.toPhishingModel -import jp.co.soramitsu.wallet.impl.presentation.send.setup.SendSetupViewModel import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigDecimal +import java.math.BigInteger +import java.net.URLDecoder +import kotlin.coroutines.CoroutineContext import jp.co.soramitsu.core.models.Asset as CoreAsset private const val QR_PREFIX_SUBSTRATE = "substrate" @@ -111,6 +103,7 @@ class WalletInteractorImpl( private val xcmEntitiesFetcher: XcmEntitiesFetcher, private val chainsRepository: ChainsRepository, private val networkStateService: NetworkStateService, + private val tokenRepository: TokenRepository, private val coroutineContext: CoroutineContext = Dispatchers.Default ) : WalletInteractor, UpdatesProviderUi by updatesMixin { @@ -484,15 +477,22 @@ class WalletInteractorImpl( chainId: ChainId, limit: Int? ): Flow> = - historyRepository.getOperationAddressWithChainIdFlow(chainId, limit) + historyRepository.getOperationAddressWithChainIdFlow(chainId, limit).flowOn(coroutineContext) + + override suspend fun getOperationAddressWithChainId( + chainId: ChainId, + limit: Int? + ): Set = + withContext(coroutineContext){ historyRepository.getOperationAddressWithChainId(chainId, limit) } - override suspend fun saveAddress(name: String, address: String, selectedChainId: String) { + override suspend fun saveAddress(name: String, address: String, selectedChainId: String) = withContext(coroutineContext) { addressBookRepository.saveAddress(name, address, selectedChainId) } override fun observeAddressBook(chainId: ChainId) = addressBookRepository.observeAddressBook(chainId) .mapList { it.copy(address = it.address.trim()) } + .flowOn(coroutineContext) override fun saveChainId(walletId: Long, chainId: ChainId?) { preferences.putString(PREFS_WALLET_SELECTED_CHAIN_ID + walletId, chainId) @@ -699,4 +699,8 @@ class WalletInteractorImpl( } } } + + override suspend fun getToken(chainAsset: jp.co.soramitsu.core.models.Asset) = withContext(coroutineContext) { + tokenRepository.getToken(chainAsset) + } } \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/WalletRouter.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/WalletRouter.kt index e4ac7a2664..9a400e3339 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/WalletRouter.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/WalletRouter.kt @@ -81,6 +81,7 @@ interface WalletRouter : SecureRouter, WalletRouterApi { fun openFilter() fun openOperationSuccess(operationHash: String?, chainId: ChainId?) + fun openOperationSuccess(operationHash: String?, chainId: ChainId?, customMessage: String?) fun openSendConfirm(transferDraft: TransferDraft, phishingType: PhishingType?, overrides: Map = emptyMap(), transferComment: String? = null, skipEdValidation: Boolean = false) @@ -195,4 +196,6 @@ interface WalletRouter : SecureRouter, WalletRouterApi { fun openManageAssets() fun openServiceScreen() + + fun openScoreDetailsScreen(metaId: Long) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsScreen.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsScreen.kt index fdf748f1c3..d13c7e3600 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsScreen.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsScreen.kt @@ -68,7 +68,7 @@ fun AssetDetailsToolbar( when(state) { is LoadingState.Loading -> { MainToolbarShimmer( - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_arrow_back_24dp), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_arrow_back_24dp), ) } is LoadingState.Loaded -> { @@ -84,7 +84,7 @@ fun AssetDetailsToolbar( .align(Alignment.CenterStart) ) { ToolbarHomeIcon( - state = ToolbarHomeIconState(navigationIcon = state.data.homeIconState.navigationIcon), + state = state.data.homeIconState, onClick = callback::onNavigationBack ) } @@ -121,7 +121,7 @@ private fun AssetDetailsToolbarPreview() { state = LoadingState.Loaded( MainToolbarViewState( title = "MyWallet", - homeIconState = ToolbarHomeIconState( + homeIconState = ToolbarHomeIconState.Navigation( navigationIcon = R.drawable.ic_arrow_back_24dp ), selectorViewState = ChainSelectorViewState( diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsViewModel.kt index f97f509872..5c6667d876 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetDetails/AssetDetailsViewModel.kt @@ -18,7 +18,6 @@ import jp.co.soramitsu.common.compose.component.MainToolbarViewState import jp.co.soramitsu.common.compose.component.MultiToggleButtonState import jp.co.soramitsu.common.compose.component.NetworkIssueType import jp.co.soramitsu.common.compose.component.ToolbarHomeIconState -import jp.co.soramitsu.common.domain.model.NetworkIssue import jp.co.soramitsu.common.presentation.LoadingState import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.applyFiatRate @@ -139,7 +138,7 @@ class AssetDetailsViewModel @Inject constructor( LoadingState.Loaded( MainToolbarViewState( title = selectedMetaAccount.name, - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_arrow_back_24dp), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_arrow_back_24dp), selectorViewState = ChainSelectorViewState() ) ) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetselector/AssetSelectContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetselector/AssetSelectContent.kt index d4267b9400..7782fcbd67 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetselector/AssetSelectContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/assetselector/AssetSelectContent.kt @@ -110,7 +110,7 @@ fun EmptyResultContent() { ) H3(text = stringResource(id = R.string.common_search_assets_alert_title)) B0( - text = stringResource(id = R.string.common_search_assets_alert_description), + text = stringResource(id = R.string.common_search_network_and_assets_alert_description), color = gray2 ) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/chainselector/ChainSelectContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/chainselector/ChainSelectContent.kt index f861ffadc3..315021fc24 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/chainselector/ChainSelectContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/chainselector/ChainSelectContent.kt @@ -159,7 +159,7 @@ fun EmptyResultContent() { MarginVertical(margin = 16.dp) B0( - text = stringResource(id = jp.co.soramitsu.common.R.string.common_search_assets_alert_description), + text = stringResource(id = jp.co.soramitsu.common.R.string.common_search_network_and_assets_alert_description), color = white50 ) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailFragment.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailFragment.kt index 9894078303..b7713d3bad 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailFragment.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailFragment.kt @@ -105,7 +105,7 @@ class BalanceDetailFragment : BaseComposeFragment() { when (toolbarState) { is LoadingState.Loading -> { MainToolbarShimmer( - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_arrow_back_24dp), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_arrow_back_24dp), menuItems = listOf( MenuIconItem(icon = R.drawable.ic_dots_horizontal_24, {}) ) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailViewModel.kt index 6e92472fe4..320e427cab 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/BalanceDetailViewModel.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.wallet.impl.presentation.balance.detail -import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData @@ -180,7 +179,7 @@ class BalanceDetailViewModel @Inject constructor( LoadingState.Loaded( MainToolbarViewState( title = interactor.getSelectedMetaAccount().name, - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_arrow_back_24dp), + homeIconState = ToolbarHomeIconState.Navigation(navigationIcon = R.drawable.ic_arrow_back_24dp), selectorViewState = ChainSelectorViewState(selectedChain?.title, selectedChain?.id) ) ) @@ -205,6 +204,10 @@ class BalanceDetailViewModel @Inject constructor( chainId?.let { selectedChainId.value = chainId } }.launchIn(viewModelScope) + selectedChainId.onEach { chainId -> + BalanceUpdateTrigger.invoke(chainId = chainId) + }.launchIn(viewModelScope) + transactionHistoryProvider.sideEffects().onEach { when (it) { is TransactionHistoryUi.SideEffect.Error -> showError( diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListFragment.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListFragment.kt index 751798c976..faa575fb80 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListFragment.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListFragment.kt @@ -28,13 +28,9 @@ import jp.co.soramitsu.common.PLAY_MARKET_APP_URI import jp.co.soramitsu.common.PLAY_MARKET_BROWSER_URI import jp.co.soramitsu.common.base.BaseComposeFragment import jp.co.soramitsu.common.compose.component.MainToolbar -import jp.co.soramitsu.common.compose.component.MainToolbarShimmer -import jp.co.soramitsu.common.compose.component.MainToolbarViewStateWithFilters import jp.co.soramitsu.common.compose.component.MenuIconItem -import jp.co.soramitsu.common.compose.component.ToolbarHomeIconState import jp.co.soramitsu.common.data.network.coingecko.FiatCurrency import jp.co.soramitsu.common.presentation.FiatCurrenciesChooserBottomSheetDialog -import jp.co.soramitsu.common.presentation.LoadingState import jp.co.soramitsu.common.presentation.askPermissionsSafely import jp.co.soramitsu.common.scan.ScanTextContract import jp.co.soramitsu.common.scan.ScannerActivity @@ -88,35 +84,22 @@ class BalanceListFragment : BaseComposeFragment() { Modifier } Column(modifier = toolbarModifier) { - when (toolbarState) { - is LoadingState.Loading -> { - MainToolbarShimmer( - homeIconState = ToolbarHomeIconState(navigationIcon = R.drawable.ic_wallet), - menuItems = listOf( - MenuIconItem(icon = R.drawable.ic_scan) {}, - MenuIconItem(icon = R.drawable.ic_search) {} - ) + MainToolbar( + state = toolbarState, + menuItems = listOf( + MenuIconItem( + icon = R.drawable.ic_scan, + onClick = ::requestCameraPermission + ), + MenuIconItem( + icon = R.drawable.ic_search, + onClick = viewModel::openSearchAssets ) - } - - is LoadingState.Loaded -> { - MainToolbar( - state = (toolbarState as LoadingState.Loaded).data, - menuItems = listOf( - MenuIconItem( - icon = R.drawable.ic_scan, - onClick = ::requestCameraPermission - ), - MenuIconItem( - icon = R.drawable.ic_search, - onClick = viewModel::openSearchAssets - ) - ), - onChangeChainClick = viewModel::openSelectChain, - onNavigationClick = viewModel::openWalletSelector - ) - } - } + ), + onChangeChainClick = viewModel::openSelectChain, + onNavigationClick = viewModel::openWalletSelector, + onScoreClick = viewModel::onScoreClick + ) } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt index a29e3e7e1f..52ca43f263 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt @@ -10,12 +10,9 @@ import androidx.lifecycle.viewModelScope import co.jp.soramitsu.walletconnect.domain.WalletConnectInteractor import com.walletconnect.android.internal.common.exception.MalformedWalletConnectUri import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigDecimal -import java.math.BigInteger -import java.util.concurrent.atomic.AtomicBoolean -import javax.inject.Inject import jp.co.soramitsu.account.api.domain.PendulumPreInstalledAccountsScenario import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor import jp.co.soramitsu.account.api.domain.interfaces.TotalBalanceUseCase import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.common.BuildConfig @@ -43,7 +40,6 @@ import jp.co.soramitsu.common.domain.SelectedFiat import jp.co.soramitsu.common.domain.model.NetworkIssueType import jp.co.soramitsu.common.mixin.api.UpdatesMixin import jp.co.soramitsu.common.mixin.api.UpdatesProviderUi -import jp.co.soramitsu.common.presentation.LoadingState import jp.co.soramitsu.common.resources.ClipboardManager import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.Event @@ -118,6 +114,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.math.BigDecimal +import java.math.BigInteger +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject private const val CURRENT_ICON_SIZE = 40 @@ -130,6 +130,7 @@ class BalanceListViewModel @Inject constructor( private val getAvailableFiatCurrencies: GetAvailableFiatCurrencies, private val selectedFiat: SelectedFiat, private val accountInteractor: AccountInteractor, + private val nomisScoreInteractor: NomisScoreInteractor, private val updatesMixin: UpdatesMixin, private val resourceManager: ResourceManager, private val clipboardManager: ClipboardManager, @@ -328,9 +329,10 @@ class BalanceListViewModel @Inject constructor( val screenModel = if (successfulCollections.isEmpty() && chainsWithFailedRequests.isNotEmpty()) { - withContext(Dispatchers.Main.immediate) { - showError(resourceManager.getString(R.string.nft_load_error)) - } + // todo move error state to the ndt list screen +// withContext(Dispatchers.Main.immediate) { +// showError(resourceManager.getString(R.string.nft_load_error)) +// } ScreenModel.ReadyToRender( result = successfulCollections, @@ -338,11 +340,12 @@ class BalanceListViewModel @Inject constructor( onItemClick = {} ) } else { - if (successfulCollections.isNotEmpty() && chainsWithFailedRequests.isNotEmpty()) { - withContext(Dispatchers.Main.immediate) { - showError("${resourceManager.getString(R.string.nft_load_error)} (${chainsWithFailedRequests.joinToString(", ")})") - } - } + // todo move error state to the ndt list screen +// if (successfulCollections.isNotEmpty() && chainsWithFailedRequests.isNotEmpty()) { +// withContext(Dispatchers.Main.immediate) { +// showError("${resourceManager.getString(R.string.nft_load_error)} (${chainsWithFailedRequests.joinToString(", ")})") +// } +// } ScreenModel.ReadyToRender( result = successfulCollections, @@ -416,6 +419,50 @@ class BalanceListViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = null) } + private fun observeToolbarStates() { + currentAddressModelFlow().onEach { addressModel -> + toolbarState.update { prevState -> + val newWalletIconState = when(prevState.homeIconState) { + is ToolbarHomeIconState.Navigation -> ToolbarHomeIconState.Wallet(walletIcon = addressModel.image) + is ToolbarHomeIconState.Wallet -> (prevState.homeIconState as ToolbarHomeIconState.Wallet).copy(walletIcon = addressModel.image) + } + prevState.copy( + title = addressModel.nameOrAddress, + homeIconState = newWalletIconState, + ) + } + }.launchIn(viewModelScope) + + combine( + interactor.observeSelectedAccountChainSelectFilter(), + selectedChainItemFlow + ) { filter, chain -> + toolbarState.update { prevState -> + prevState.copy( + selectorViewState = ChainSelectorViewStateWithFilters( + selectedChainName = chain?.title, + selectedChainId = chain?.id, + selectedChainImageUrl = chain?.imageUrl, + filterApplied = ChainSelectorViewStateWithFilters.Filter.entries.find { + it.name == filter + } ?: ChainSelectorViewStateWithFilters.Filter.All + ) + ) + } + }.launchIn(viewModelScope) + + nomisScoreInteractor.observeCurrentAccountScore() + .onEach { score -> + toolbarState.update { prevState -> + val newWalletIconState = (prevState.homeIconState as? ToolbarHomeIconState.Wallet)?.copy(score = score?.score) + newWalletIconState?.let { + prevState.copy(homeIconState = newWalletIconState) + } ?: prevState + } + } + .launchIn(viewModelScope) + } + private fun observeNetworkIssues() { combine( currentAssetsFlow, @@ -557,33 +604,11 @@ class BalanceListViewModel @Inject constructor( }.launchIn(this) } - val toolbarState = combine( - currentAddressModelFlow(), - interactor.observeSelectedAccountChainSelectFilter(), - selectedChainItemFlow - ) { addressModel, filter, chain -> - LoadingState.Loaded( - MainToolbarViewStateWithFilters( - title = addressModel.nameOrAddress, - homeIconState = ToolbarHomeIconState(walletIcon = addressModel.image), - selectorViewState = ChainSelectorViewStateWithFilters( - selectedChainName = chain?.title, - selectedChainId = chain?.id, - selectedChainImageUrl = chain?.imageUrl, - filterApplied = ChainSelectorViewStateWithFilters.Filter.entries.find { - it.name == filter - } ?: ChainSelectorViewStateWithFilters.Filter.All - ) - ) - ) - }.stateIn( - scope = this, - started = SharingStarted.Eagerly, - initialValue = LoadingState.Loading() - ) + val toolbarState: MutableStateFlow = MutableStateFlow(MainToolbarViewStateWithFilters(title = null, selectorViewState = null)) init { subscribeScreenState() + observeToolbarStates() observeNetworkIssues() observeFiatSymbolChange() sync() @@ -594,6 +619,10 @@ class BalanceListViewModel @Inject constructor( selectedChainId.value = chainId }.launchIn(this) + selectedChainId.onEach { chainId -> + BalanceUpdateTrigger.invoke(chainId = chainId) + }.launchIn(this) + interactor.selectedLightMetaAccountFlow().map { wallet -> if (pendulumPreInstalledAccountsScenario.isPendulumMode(wallet.id)) { selectedChainId.value = pendulumChainId @@ -608,7 +637,7 @@ class BalanceListViewModel @Inject constructor( } override fun onRefresh() { - refresh() + sync() viewModelScope.launch { BalanceUpdateTrigger.invoke() } @@ -630,10 +659,6 @@ class BalanceListViewModel @Inject constructor( } } - private fun refresh() { - sync() - } - fun onResume() { viewModelScope.launch { interactor.selectedMetaAccountFlow().collect { @@ -674,7 +699,6 @@ class BalanceListViewModel @Inject constructor( interactor.syncAssetsRates().onFailure { withContext(Dispatchers.Main) { selectedFiat.notifySyncFailed() - showError(it) } } } @@ -939,4 +963,11 @@ class BalanceListViewModel @Inject constructor( fun onServiceButtonClick() { router.openServiceScreen() } + + fun onScoreClick() { + viewModelScope.launch { + val currentAccount = currentMetaAccountFlow.first() + router.openScoreDetailsScreen(currentAccount.id) + } + } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/WalletScreen.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/WalletScreen.kt index a1125654ba..f8db508fb7 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/WalletScreen.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/WalletScreen.kt @@ -36,6 +36,7 @@ import jp.co.soramitsu.common.compose.component.AssetBalance import jp.co.soramitsu.common.compose.component.AssetBalanceViewState import jp.co.soramitsu.common.compose.component.BannerBackup import jp.co.soramitsu.common.compose.component.BannerBuyXor +import jp.co.soramitsu.common.compose.component.BannerPageIndicator import jp.co.soramitsu.common.compose.component.ChangeBalanceViewState import jp.co.soramitsu.common.compose.component.GrayButton import jp.co.soramitsu.common.compose.component.MarginVertical @@ -44,8 +45,6 @@ import jp.co.soramitsu.common.compose.component.MultiToggleButtonState import jp.co.soramitsu.common.compose.component.NetworkIssuesBadge import jp.co.soramitsu.common.compose.component.SwipeState import jp.co.soramitsu.common.compose.theme.FearlessAppTheme -import jp.co.soramitsu.common.compose.theme.white16 -import jp.co.soramitsu.common.compose.theme.white50 import jp.co.soramitsu.common.compose.viewstate.AssetListItemViewState import jp.co.soramitsu.common.utils.rememberForeverLazyListState import jp.co.soramitsu.feature_wallet_impl.R @@ -209,31 +208,6 @@ private fun Banners(data: WalletState, callback: WalletScreenInterface) { } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun BannerPageIndicator( - bannersCount: Int, - pagerState: PagerState -) { - Row( - modifier = Modifier - .wrapContentHeight() - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - repeat(bannersCount) { iteration -> - val color = if (pagerState.currentPage == iteration) white16 else white50 - Box( - modifier = Modifier - .padding(horizontal = 3.dp) - .clip(CircleShape) - .background(color) - .size(8.dp) - ) - } - } -} - @Composable fun WalletScreenWithRefresh( data: WalletState, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/nft/list/NftList.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/nft/list/NftList.kt index ab738ba3f8..430c3626d7 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/nft/list/NftList.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/nft/list/NftList.kt @@ -1,6 +1,5 @@ package jp.co.soramitsu.wallet.impl.presentation.balance.nft.list -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -24,7 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -53,7 +51,6 @@ import jp.co.soramitsu.common.compose.theme.black50 import jp.co.soramitsu.common.compose.theme.warningOrange import jp.co.soramitsu.common.compose.theme.white import jp.co.soramitsu.common.compose.theme.white50 -import jp.co.soramitsu.common.utils.castOrNull import jp.co.soramitsu.common.utils.clickableSingle import jp.co.soramitsu.common.compose.utils.PageScrollingCallback import jp.co.soramitsu.common.compose.utils.nestedScrollConnectionForPageScrolling diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletContent.kt index 0c56e96a2f..60c98d6813 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletContent.kt @@ -28,7 +28,8 @@ import jp.co.soramitsu.common.compose.theme.grayButtonBackground import jp.co.soramitsu.feature_wallet_impl.R data class OptionsWalletScreenViewState( - val isSelected: Boolean + val isSelected: Boolean, + val showScoreButton: Boolean ) interface OptionsWalletCallback { @@ -42,6 +43,8 @@ interface OptionsWalletCallback { fun onDeleteWalletClick() fun onCloseClick() + + fun onShowWalletScoreClick() } @Composable @@ -104,6 +107,16 @@ fun OptionsWalletContent( text = stringResource(id = R.string.change_wallet_name), onClick = callback::onChangeWalletNameClick ) + if (state.showScoreButton) { + MarginVertical(margin = 12.dp) + GrayButton( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + text = stringResource(id = R.string.account_stats_wallet_option_title), + onClick = callback::onShowWalletScoreClick + ) + } if (!state.isSelected) { MarginVertical(margin = 12.dp) TextButton( @@ -126,7 +139,8 @@ private fun OptionsWalletScreenPreview() { FearlessAppTheme() { OptionsWalletContent( state = OptionsWalletScreenViewState( - isSelected = false + isSelected = false, + showScoreButton = true ), callback = object : OptionsWalletCallback { override fun onChangeWalletNameClick() {} @@ -134,6 +148,7 @@ private fun OptionsWalletScreenPreview() { override fun onBackupWalletClick() {} override fun onDeleteWalletClick() {} override fun onCloseClick() {} + override fun onShowWalletScoreClick() {} } ) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletViewModel.kt index a681bbf14b..da4347b5f3 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/optionswallet/OptionsWalletViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.utils.Event @@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class OptionsWalletViewModel @Inject constructor( @@ -39,11 +39,12 @@ class OptionsWalletViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, null) val state: StateFlow = selectedWallet.map { - OptionsWalletScreenViewState(it.id == walletId) + + OptionsWalletScreenViewState(it.id == walletId, it.ethereumAddress != null) }.stateIn( viewModelScope, SharingStarted.Eagerly, - OptionsWalletScreenViewState(true) + OptionsWalletScreenViewState(isSelected = true, showScoreButton = false) ) override fun onChangeWalletNameClick() { @@ -76,4 +77,8 @@ class OptionsWalletViewModel @Inject constructor( override fun onBackupWalletClick() { router.openBackupWalletScreen(walletId) } + + override fun onShowWalletScoreClick() { + router.openScoreDetailsScreen(walletId) + } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/searchAssets/SearchAssetsScreen.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/searchAssets/SearchAssetsScreen.kt index f9efb2ce39..95357a6416 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/searchAssets/SearchAssetsScreen.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/searchAssets/SearchAssetsScreen.kt @@ -88,7 +88,7 @@ fun SearchAssetsScreen( data?.assets == null -> {} data.assets.isEmpty() -> { MarginVertical(margin = 16.dp) - EmptyMessage(message = R.string.common_search_assets_alert_description) + EmptyMessage(message = R.string.common_search_network_and_assets_alert_description) } else -> { diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletContent.kt index 736af1c919..a772dc24f8 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletContent.kt @@ -35,7 +35,8 @@ fun SelectWalletContent( onWalletOptionsClick: (WalletItemViewState) -> Unit, addNewWallet: () -> Unit, importWallet: () -> Unit, - onBackClicked: () -> Unit + onBackClicked: () -> Unit, + onScoreClick: (WalletItemViewState) -> Unit ) { BottomSheetScreen { Column( @@ -61,7 +62,8 @@ fun SelectWalletContent( WalletItem( state = walletItemState, onOptionsClick = onWalletOptionsClick, - onSelected = onWalletSelected + onSelected = onWalletSelected, + onScoreClick = onScoreClick ) } item { @@ -126,7 +128,8 @@ private fun SelectWalletScreenPreview() { addNewWallet = {}, importWallet = {}, onBackClicked = {}, - onWalletOptionsClick = {} + onWalletOptionsClick = {}, + onScoreClick = {} ) } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletFragment.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletFragment.kt index 8be99911f4..bcb5055e0d 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletFragment.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/SelectWalletFragment.kt @@ -54,7 +54,8 @@ class SelectWalletFragment : BaseComposeBottomSheetDialogFragment walletItemsFlow.update { @@ -69,11 +72,19 @@ class SelectWalletViewModel @Inject constructor( } } } - .onEach { - walletItemsFlow.update { prevList -> - prevList.map { prevState -> - val balanceModel = getTotalBalance(prevState.id) - prevState.copy( + .onEach { accounts -> + accounts.forEach { observeTotalBalance(it.id) } + observeScores() + } + .launchIn(viewModelScope) + } + + private fun observeTotalBalance(metaId: Long) { + getTotalBalance.observe(metaId).onEach { balanceModel -> + walletItemsFlow.update { + it.map { state -> + if(state.id == metaId) { + state.copy( balance = balanceModel.balance.formatFiat(balanceModel.fiatSymbol), changeBalanceViewState = ChangeBalanceViewState( percentChange = balanceModel.rateChange?.formatAsChange().orEmpty(), @@ -81,6 +92,21 @@ class SelectWalletViewModel @Inject constructor( .formatFiat(balanceModel.fiatSymbol) ) ) + } else { + state + } + } + } + }.launchIn(viewModelScope) + } + + private fun observeScores() { + nomisScoreInteractor.observeNomisScores() + .onEach { scores -> + walletItemsFlow.update { oldStates -> + oldStates.map { state -> + val score = scores.find { it.metaId == state.id } + score?.let { state.copy(score = score.score) } ?: state } } } @@ -196,4 +222,8 @@ class SelectWalletViewModel @Inject constructor( } } } + + fun onScoreClick(state: WalletItemViewState) { + router.openScoreDetailsScreen(state.id) + } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/light/WalletSelectorViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/light/WalletSelectorViewModel.kt index 32c9046c44..0099920c14 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/light/WalletSelectorViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/walletselector/light/WalletSelectorViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor import jp.co.soramitsu.account.api.domain.interfaces.TotalBalanceUseCase import jp.co.soramitsu.account.impl.presentation.account.mixin.api.AccountListingMixin import jp.co.soramitsu.common.address.AddressIconGenerator @@ -11,8 +12,6 @@ import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.compose.component.ChangeBalanceViewState import jp.co.soramitsu.common.compose.component.WalletItemViewState import jp.co.soramitsu.common.compose.component.WalletSelectorViewState -import jp.co.soramitsu.common.mixin.api.UpdatesMixin -import jp.co.soramitsu.common.mixin.api.UpdatesProviderUi import jp.co.soramitsu.common.navigation.payload.WalletSelectorPayload import jp.co.soramitsu.common.utils.formatAsChange import jp.co.soramitsu.common.utils.formatFiat @@ -21,6 +20,7 @@ import jp.co.soramitsu.wallet.impl.presentation.WalletRouter import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -32,10 +32,10 @@ import kotlinx.coroutines.launch class WalletSelectorViewModel @Inject constructor( accountListingMixin: AccountListingMixin, private val router: WalletRouter, - private val updatesMixin: UpdatesMixin, private val totalBalanceUseCase: TotalBalanceUseCase, + private val nomisScoreInteractor: NomisScoreInteractor, savedStateHandle: SavedStateHandle -) : BaseViewModel(), UpdatesProviderUi by updatesMixin { +) : BaseViewModel() { private val tag = savedStateHandle.get(WalletSelectorFragment.TAG_ARGUMENT_KEY)!! private val selectedWalletId = @@ -47,9 +47,10 @@ class WalletSelectorViewModel @Inject constructor( init { accountListingMixin.accountsFlow(AddressIconGenerator.SIZE_BIG) + .distinctUntilChanged() .inBackground() .onEach { newList -> - walletItemsFlow.value = + walletItemsFlow.update { newList.map { WalletItemViewState( id = it.id, @@ -60,13 +61,21 @@ class WalletSelectorViewModel @Inject constructor( changeBalanceViewState = null ) } - + } + } + .onEach { accounts -> + accounts.forEach { observeTotalBalance(it.id) } + observeScores() } - .onEach { - walletItemsFlow.update { prevList -> - prevList.map { prevState -> - val balanceModel = totalBalanceUseCase(prevState.id) - prevState.copy( + .launchIn(viewModelScope) + } + + private fun observeTotalBalance(metaId: Long) { + totalBalanceUseCase.observe(metaId).onEach { balanceModel -> + walletItemsFlow.update { + it.map { state -> + if(state.id == metaId) { + state.copy( balance = balanceModel.balance.formatFiat(balanceModel.fiatSymbol), changeBalanceViewState = ChangeBalanceViewState( percentChange = balanceModel.rateChange?.formatAsChange().orEmpty(), @@ -74,6 +83,21 @@ class WalletSelectorViewModel @Inject constructor( .formatFiat(balanceModel.fiatSymbol) ) ) + } else { + state + } + } + } + }.launchIn(viewModelScope) + } + + private fun observeScores() { + nomisScoreInteractor.observeNomisScores() + .onEach { scores -> + walletItemsFlow.update { oldStates -> + oldStates.map { state -> + val score = scores.find { it.metaId == state.id } + score?.let { state.copy(score = score.score) } ?: state } } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupContent.kt index 6659ca112a..f5ef5a44e1 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupContent.kt @@ -77,7 +77,6 @@ interface CrossChainSetupScreenInterface { fun onWarningInfoClick() } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun CrossChainSetupContent( state: CrossChainSetupViewState, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryContent.kt index ada9b93113..c94930fa37 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryContent.kt @@ -35,6 +35,7 @@ import jp.co.soramitsu.common.compose.component.FearlessProgress import jp.co.soramitsu.common.compose.component.H5 import jp.co.soramitsu.common.compose.component.MarginHorizontal import jp.co.soramitsu.common.compose.component.MarginVertical +import jp.co.soramitsu.common.compose.component.ScoreStar import jp.co.soramitsu.common.compose.component.ToolbarBottomSheet import jp.co.soramitsu.common.compose.theme.FearlessTheme import jp.co.soramitsu.common.compose.theme.black05 @@ -51,7 +52,8 @@ data class Address( val address: String, val image: Any, val chainId: ChainId, - val isSavedToContacts: Boolean + val isSavedToContacts: Boolean, + val score: Int? ) data class AddressHistoryViewState( @@ -208,18 +210,24 @@ fun AddressItem( val name = address.name.ifEmpty { stringResource(id = R.string.common_unknown) } - H5( - text = name.withNoFontPadding(), - color = black2, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(modifier = Modifier.fillMaxWidth()) { + H5( + text = name.withNoFontPadding(), + color = black2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, false) + ) + MarginHorizontal(margin = 8.dp) + address.score?.let { ScoreStar(score = it) } + } MarginVertical(margin = 4.dp) B1( text = address.address.shortenAddress(), color = Color.White ) } + MarginHorizontal(margin = 8.dp) if (!address.isSavedToContacts) { BackgroundCorneredWithBorder( backgroundColor = black05, @@ -246,8 +254,14 @@ fun AddressItem( @Preview fun PreviewAddressHistoryContent() { val addressSet = setOf( - Address("Address 1 name of a very long text to show how it looks in UI", "address1qasd32dqa32e32r3qqed", R.drawable.ic_plus_circle, "", true), - Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false) + Address("Address 1 name of a very long text to show how it looks in UI", "address1qasd32dqa32e32r3qqed", R.drawable.ic_plus_circle, "", true, 99), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, 10), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, 60), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, 90), + Address("Address 1 name of a very long text to show how it looks in UI", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, 90), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, -1), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, -2), + Address("" ?: "John Mir", "32dfs4323AE3asdqa32e32r3qqed", R.drawable.ic_plus_circle, "", false, null) ) val addressBookAddresses = mapOf>("J" to addressSet.toList().subList(0, 1)) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryViewModel.kt index bca1356ad4..f6f4ad8e1e 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/history/AddressHistoryViewModel.kt @@ -1,28 +1,44 @@ package jp.co.soramitsu.wallet.impl.presentation.history +import android.graphics.drawable.PictureDrawable import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor +import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.address.createAddressIcon import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.presentation.dataOrNull import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.feature_wallet_impl.R +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import jp.co.soramitsu.wallet.impl.presentation.WalletRouter import jp.co.soramitsu.wallet.impl.presentation.send.SendSharedState +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch @HiltViewModel class AddressHistoryViewModel @Inject constructor( val savedStateHandle: SavedStateHandle, val sharedState: SendSharedState, private val walletInteractor: WalletInteractor, + private val chainsRepository: ChainsRepository, + private val nomisScoreInteractor: NomisScoreInteractor, private val resourceManager: ResourceManager, private val addressIconGenerator: AddressIconGenerator, private val router: WalletRouter @@ -32,52 +48,204 @@ class AddressHistoryViewModel @Inject constructor( private const val RECENT_SIZE = 10 } - val chainId: ChainId = savedStateHandle[AddressHistoryFragment.KEY_PAYLOAD] ?: error("ChainId not specified") + val chainId: ChainId = + savedStateHandle[AddressHistoryFragment.KEY_PAYLOAD] ?: error("ChainId not specified") + val chain = viewModelScope.async { chainsRepository.getChain(chainId) } - val state: StateFlow> = combine( - walletInteractor.getOperationAddressWithChainIdFlow(chainId, RECENT_SIZE), - walletInteractor.observeAddressBook(chainId) - ) { recentAddressesInfo, addressBook -> - val recentAddresses: Set
= recentAddressesInfo.map { address -> - val placeholder = resourceManager.getDrawable(R.drawable.ic_wallet) - val chain = walletInteractor.getChain(chainId) - val accountImage = address.ifEmpty { null }?.let { - addressIconGenerator.createAddressIcon(chain.isEthereumBased, address, AddressIconGenerator.SIZE_BIG) + val state: MutableStateFlow> = + MutableStateFlow(LoadingState.Loading()) + + private val addressBookFlow = + walletInteractor.observeAddressBook(chainId).map { LoadingState.Loaded(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, LoadingState.Loading()) + + private val recentAddressesDeferred = + async { walletInteractor.getOperationAddressWithChainId(chainId, RECENT_SIZE) } + + private val allAddressesFlow = addressBookFlow.map { + val data = it.dataOrNull()?.map { contact -> contact.address } ?: return@map emptySet() + val recentAddresses = recentAddressesDeferred.await() + (data + recentAddresses).toSet() + }.distinctUntilChanged() + + private val imagesFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + + init { + observeAddressBook() + observeImages() + observeScore() + } + + private fun observeAddressBook() { + val placeholder = resourceManager.getDrawable(R.drawable.ic_wallet) + addressBookFlow + .mapNotNull { it.dataOrNull() } + .onEach { addressBook -> + state.update { prevState -> + when (prevState) { + is LoadingState.Loading -> { + val newContacts = addressBook.map { contact -> + Address( + name = contact.name.orEmpty(), + address = contact.address.trim(), + image = imagesFlow.value[contact.address] ?: placeholder, + chainId = contact.chainId, + isSavedToContacts = true, + getCachedNomisScore(contact.address) + ) + }.groupBy { + it.name.firstOrNull()?.uppercase() + } + LoadingState.Loaded(AddressHistoryViewState(emptySet(), newContacts)) + } + + is LoadingState.Loaded -> { + val allPrevAddresses = + prevState.dataOrNull()?.addressBookAddresses?.values?.flatten() + ?: return@update prevState + val newContacts = addressBook.map { contact -> + val existingContact = + allPrevAddresses.find { it.address == contact.address } + + existingContact?.copy( + name = contact.name.orEmpty(), + address = contact.address.trim(), + image = imagesFlow.value[contact.address] ?: placeholder, + chainId = contact.chainId, + isSavedToContacts = true, + ) ?: Address( + name = contact.name.orEmpty(), + address = contact.address.trim(), + image = imagesFlow.value[contact.address] ?: placeholder, + chainId = contact.chainId, + isSavedToContacts = true, + prevState.data.recentAddresses.find { it.address == contact.address }?.score ?: getCachedNomisScore(contact.address) + ) + }.groupBy { + it.name.firstOrNull()?.uppercase() + } + + LoadingState.Loaded(prevState.data.copy(addressBookAddresses = newContacts)) + } + + else -> prevState + } + } } + .onEach { addressBook -> + val recentAddressesInfo = recentAddressesDeferred.await() + state.update { prevState -> + val data = prevState.dataOrNull() ?: return@update prevState + val newRecentAddressModels = recentAddressesInfo.map { address -> + val existing = data.recentAddresses.find { it.address == address } + existing?.copy(name = addressBook.firstOrNull { it.address == address }?.name.orEmpty(), + address = address.trim(), + chainId = chainId, + isSavedToContacts = address in addressBook.map { it.address }) + ?: Address( + name = addressBook.firstOrNull { it.address == address }?.name.orEmpty(), + address = address.trim(), + image = imagesFlow.value[address] ?: placeholder, + chainId = chainId, + isSavedToContacts = address in addressBook.map { it.address }, + score = getCachedNomisScore(address) + ) + }.toSet() + LoadingState.Loaded(data.copy(recentAddresses = newRecentAddressModels)) + } + } + .launchIn(viewModelScope) + } + + private suspend fun getCachedNomisScore(address: String): Int? { + return if(chain.await().let { it.isEthereumChain || it.isEthereumBased }) { + nomisScoreInteractor.getNomisScoreFromMemoryCache(address)?.score ?: NomisScoreData.LOADING_CODE + } else { + null + } + } - Address( - name = addressBook.firstOrNull { it.address == address }?.name.orEmpty(), - address = address.trim(), - image = accountImage ?: placeholder, - chainId = chainId, - isSavedToContacts = address in addressBook.map { it.address } - ) - }.toSet() + private fun observeImages() { + allAddressesFlow.onEach { allAddresses -> + val chain = chain.await() + coroutineScope { + allAddresses.forEach { address -> + viewModelScope.launch { + val accountImage = address.ifEmpty { null }?.let { + addressIconGenerator.createAddressIcon( + chain.isEthereumBased, + address, + AddressIconGenerator.SIZE_BIG + ) + } + imagesFlow.update { prevState -> + val newMap = prevState.toMutableMap() + newMap[address] = accountImage + newMap + } + } + } + } + }.launchIn(viewModelScope) - val addressBookAddresses = addressBook.map { contact -> + imagesFlow.onEach { images -> val placeholder = resourceManager.getDrawable(R.drawable.ic_wallet) - val chain = walletInteractor.getChain(contact.chainId) - val accountImage = contact.address.ifEmpty { null }?.let { - addressIconGenerator.createAddressIcon(chain.isEthereumBased, contact.address, AddressIconGenerator.SIZE_BIG) + state.update { prevState -> + val data = prevState.dataOrNull()?: return@update prevState + val addressBook = data.addressBookAddresses.mapValues { group -> + group.value.map { + it.copy(image = images[it.address] ?: placeholder) + } + } + val recentAddresses = data.recentAddresses.map { addressModel -> + addressModel.copy(image = images[addressModel.address] ?: placeholder) + }.toSet() + + LoadingState.Loaded(data.copy(recentAddresses = recentAddresses, addressBookAddresses = addressBook)) } - Address( - name = contact.name.orEmpty(), - address = contact.address.trim(), - image = accountImage ?: placeholder, - chainId = contact.chainId, - isSavedToContacts = true - ) - }.groupBy { - it.name.firstOrNull()?.uppercase() - } + }.launchIn(viewModelScope) + } - LoadingState.Loaded( - AddressHistoryViewState( - recentAddresses = recentAddresses, - addressBookAddresses = addressBookAddresses - ) - ) - }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = LoadingState.Loading()) + private fun observeScore() { + allAddressesFlow.onEach { allAddresses -> + if(!chain.await().let { it.isEthereumChain || it.isEthereumBased }) return@onEach + coroutineScope { + allAddresses.forEach { address -> + val score = nomisScoreInteractor.getNomisScore(address)?.score ?: NomisScoreData.ERROR_CODE + state.update { prevState -> + if (prevState is LoadingState.Loaded) { + val addressBookWithScores = + prevState.data.addressBookAddresses.mapValues { group -> + group.value.map { + if (it.address == address) { + it.copy(score = score) + } else { + it + } + } + } + val recentAddressesWithScores = prevState.data.recentAddresses.map { + if (it.address == address) { + it.copy(score = score) + } else { + it + } + }.toSet() + + LoadingState.Loaded( + prevState.data.copy( + addressBookAddresses = addressBookWithScores, + recentAddresses = recentAddressesWithScores + ) + ) + } else { + prevState + } + } + } + } + }.launchIn(viewModelScope) + } override fun onAddressClick(address: Address) { sharedState.updateAddress(address.address) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/manageassets/ManageAssetsViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/manageassets/ManageAssetsViewModel.kt index 5405cf6049..0965726d74 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/manageassets/ManageAssetsViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/manageassets/ManageAssetsViewModel.kt @@ -15,6 +15,7 @@ import jp.co.soramitsu.common.utils.formatCrypto import jp.co.soramitsu.common.utils.formatFiat import jp.co.soramitsu.common.utils.isZero import jp.co.soramitsu.common.utils.orZero +import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.ChainId import jp.co.soramitsu.coredb.dao.emptyAccountIdValue import jp.co.soramitsu.feature_wallet_impl.R @@ -128,7 +129,7 @@ class ManageAssetsViewModel @Inject constructor( filteredChainAssets.map { chainAsset -> val asset = assets.find { chainAsset.id == it.asset.token.configuration.id && chainAsset.chainId == it.asset.token.configuration.chainId } chainAsset to asset - }.sortedWith(compareBy> { + }.sortedWith(compareBy> { it.second == null }.thenBy { it.second?.asset?.enabled == false diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupContent.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupContent.kt index cfd4f42542..bc8819cc50 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupContent.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupContent.kt @@ -38,8 +38,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import java.math.BigDecimal import jp.co.soramitsu.common.compose.component.AccentDarkDisabledButton -import jp.co.soramitsu.common.compose.component.AddressInput -import jp.co.soramitsu.common.compose.component.AddressInputState +import jp.co.soramitsu.common.compose.component.AddressInputWithScore import jp.co.soramitsu.common.compose.component.AmountInput import jp.co.soramitsu.common.compose.component.AmountInputViewState import jp.co.soramitsu.common.compose.component.B1 @@ -73,7 +72,7 @@ import jp.co.soramitsu.feature_wallet_impl.R data class SendSetupViewState( val toolbarState: ToolbarViewState, - val addressInputState: AddressInputState, + val addressInputState: AddressInputWithScore, val amountInputState: AmountInputViewState, val chainSelectorState: SelectorState, val feeInfoState: FeeInfoViewState, @@ -81,7 +80,7 @@ data class SendSetupViewState( val buttonState: ButtonViewState, val isSoftKeyboardOpen: Boolean, val isInputLocked: Boolean, - val quickAmountInputValues: List = QuickAmountInput.values().asList(), + val quickAmountInputValues: List = QuickAmountInput.entries, val isHistoryAvailable: Boolean, val sendAllChecked: Boolean, val sendAllAllowed: Boolean @@ -152,10 +151,9 @@ fun SendSetupContent( onNavigationClick = callback::onNavigationClick ) MarginVertical(margin = 16.dp) - AddressInput( + AddressInputWithScore( state = state.addressInputState, - onInput = callback::onAddressInput, - onInputClear = callback::onAddressInputClear, + onClear = callback::onAddressInputClear, onPaste = callback::onPasteClick ) @@ -297,7 +295,7 @@ private fun Badge( private fun SendSetupPreview() { val state = SendSetupViewState( toolbarState = ToolbarViewState("Send Fund", R.drawable.ic_arrow_left_24), - addressInputState = AddressInputState("Send to", "", ""), + addressInputState = AddressInputWithScore.Filled("Send to...", "0x23j2rf3bh8384j938", "", 100), amountInputState = AmountInputViewState( "KSM", "", diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupViewModel.kt index f42796e7d0..4d633f3898 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/send/setup/SendSetupViewModel.kt @@ -5,16 +5,14 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.math.BigDecimal -import java.math.BigInteger -import java.math.RoundingMode -import javax.inject.Inject +import jp.co.soramitsu.account.api.domain.interfaces.NomisScoreInteractor +import jp.co.soramitsu.account.api.domain.model.NomisScoreData import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.address.createAddressIcon import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.base.errors.ValidationException import jp.co.soramitsu.common.base.errors.ValidationWarning -import jp.co.soramitsu.common.compose.component.AddressInputState +import jp.co.soramitsu.common.compose.component.AddressInputWithScore import jp.co.soramitsu.common.compose.component.AmountInputViewState import jp.co.soramitsu.common.compose.component.ButtonViewState import jp.co.soramitsu.common.compose.component.FeeInfoViewState @@ -22,7 +20,10 @@ import jp.co.soramitsu.common.compose.component.QuickAmountInput import jp.co.soramitsu.common.compose.component.SelectorState import jp.co.soramitsu.common.compose.component.ToolbarViewState import jp.co.soramitsu.common.compose.component.WarningInfoState +import jp.co.soramitsu.common.compose.theme.warningOrange import jp.co.soramitsu.common.data.network.runtime.binding.cast +import jp.co.soramitsu.common.presentation.LoadingState +import jp.co.soramitsu.common.presentation.dataOrNull import jp.co.soramitsu.common.resources.ClipboardManager import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.Event @@ -31,6 +32,7 @@ import jp.co.soramitsu.common.utils.combine import jp.co.soramitsu.common.utils.formatCrypto import jp.co.soramitsu.common.utils.formatCryptoDetail import jp.co.soramitsu.common.utils.formatFiat +import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.common.utils.greaterThen import jp.co.soramitsu.common.utils.isNotZero import jp.co.soramitsu.common.utils.isZero @@ -75,7 +77,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest @@ -86,8 +87,13 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode +import javax.inject.Inject @HiltViewModel class SendSetupViewModel @Inject constructor( @@ -95,6 +101,7 @@ class SendSetupViewModel @Inject constructor( val savedStateHandle: SavedStateHandle, private val resourceManager: ResourceManager, private val walletInteractor: WalletInteractor, + private val nomisScoreInteractor: NomisScoreInteractor, private val polkaswapInteractor: PolkaswapInteractor, private val existentialDepositUseCase: ExistentialDepositUseCase, private val walletConstants: WalletConstants, @@ -155,12 +162,7 @@ class SendSetupViewModel @Inject constructor( } } - private val defaultAddressInputState = AddressInputState( - title = resourceManager.getString(R.string.send_to), - input = "", - image = R.drawable.ic_address_placeholder, - editable = false - ) + private val defaultAddressInputState = AddressInputWithScore.Empty(resourceManager.getString(R.string.send_to), resourceManager.getString(R.string.search_textfield_placeholder)) private val defaultAmountInputState = AmountInputViewState( tokenName = "...", @@ -353,10 +355,20 @@ class SendSetupViewModel @Inject constructor( private val phishingModelFlow = addressInputTrimmedFlow.map { walletInteractor.getPhishingInfo(it) } + private val nomisScore = addressInputTrimmedFlow.transform { + if(it.isEmpty()) { + return@transform + } + emit(LoadingState.Loading()) + val score = nomisScoreInteractor.getNomisScore(it) + emit(LoadingState.Loaded(score)) + }.stateIn(viewModelScope, SharingStarted.Eagerly, LoadingState.Loading()) + private val warningInfoStateFlow = combine( phishingModelFlow, - isWarningExpanded - ) { phishing, isExpanded -> + isWarningExpanded, + nomisScore + ) { phishing, isExpanded, nomisScoreLoadingState -> phishing?.let { WarningInfoState( message = getPhishingMessage(phishing.type), @@ -368,6 +380,17 @@ class SendSetupViewModel @Inject constructor( isExpanded = isExpanded, color = phishing.color ) + } ?: nomisScoreLoadingState.dataOrNull()?.takeIf { it.score in 0..25 }?.let { + WarningInfoState( + message = resourceManager.getString(R.string.scam_description_lowscore_text), + extras = listOf( + resourceManager.getString(R.string.username_setup_choose_title) to resourceManager.getString(R.string.scam_info_nomis_name), + resourceManager.getString(R.string.reason) to resourceManager.getString(R.string.scam_info_nomis_reason_text), + resourceManager.getString(R.string.scam_additional_stub) to resourceManager.getString(R.string.scam_info_nomis_subtype_text) + ), + isExpanded = isExpanded, + color = warningOrange + ) } } @@ -454,8 +477,7 @@ class SendSetupViewModel @Inject constructor( lockInputFlow.onEach { isInputLocked -> state.value = state.value.copy( - isInputLocked = isInputLocked, - addressInputState = state.value.addressInputState.copy(showClear = isInputLocked.not()) + isInputLocked = isInputLocked ) }.launchIn(this) @@ -493,12 +515,22 @@ class SendSetupViewModel @Inject constructor( AddressIconGenerator.SIZE_BIG ) } + val addressState = if(address.isNotEmpty()) { + (state.value.addressInputState as? AddressInputWithScore.Filled)?.copy( + address = address.shortenAddress(), + image = image + ) ?: AddressInputWithScore.Filled( + defaultAddressInputState.title, + address.shortenAddress(), + image, + nomisScore.value.dataOrNull()?.score ?: nomisScoreInteractor.getNomisScoreFromMemoryCache(address)?.score ?: NomisScoreData.LOADING_CODE + ) + } else { + defaultAddressInputState + } state.value = state.value.copy( - addressInputState = state.value.addressInputState.copy( - input = address, - image = image - ), + addressInputState = addressState, isHistoryAvailable = chain?.externalApi?.history != null ) }.launchIn(this) @@ -510,6 +542,21 @@ class SendSetupViewModel @Inject constructor( val quickInputs = quickInputsUseCase.calculateTransfersQuickInputs(chainId, asset.token.configuration.id) quickInputsStateFlow.update { quickInputs } }.launchIn(this) + + nomisScore.onEach { + val score = if(it is LoadingState.Loading) { + NomisScoreData.LOADING_CODE + } else { + it.dataOrNull()?.score + } + state.update { prevState -> + if(prevState.addressInputState is AddressInputWithScore.Filled) { + prevState.copy( + addressInputState = prevState.addressInputState.copy(score = score) + ) + } else prevState + } + }.launchIn(viewModelScope) } private fun observeExistentialDeposit(showMaxInput: Boolean) { @@ -522,6 +569,9 @@ class SendSetupViewModel @Inject constructor( isInputAddressValidFlow, addressInputTrimmedFlow ) { asset, amount, fee, isAddressValid, address -> + if (asset.token.configuration.ethereumType != null) { + return@combine Result.success(TransferValidationResult.Valid) + } if (amount.isZero()) { sendAllToggleState.value = ToggleState.INITIAL diff --git a/feature-walletconnect-impl/build.gradle.kts b/feature-walletconnect-impl/build.gradle.kts index 534e1b6b4e..c79b43d820 100644 --- a/feature-walletconnect-impl/build.gradle.kts +++ b/feature-walletconnect-impl/build.gradle.kts @@ -38,7 +38,9 @@ dependencies { implementation(libs.bundles.compose) implementation(libs.fragmentKtx) implementation(libs.material) - implementation(libs.sharedFeaturesCoreDep) + implementation(libs.sharedFeaturesCoreDep) { + exclude(module = "android-foundation") + } implementation(libs.web3jDep) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a785d2fc6..71c5ac81d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistVersion = "0.34.0" activityCompose = "1.8.2" -android_plugin = "8.3.2" +android_plugin = "8.5.2" appcompat = "1.6.1" architectureComponentVersion = "2.7.0" beaconVersion = "3.2.4" @@ -10,7 +10,7 @@ bouncyCastleVersion = "1.78" cardViewVersion = "1.0.0" coilVersion = "2.6.0" compose = "1.6.5" -composeCompiler = "1.5.11" +composeCompiler = "1.5.14" composeShimmer = "1.0.4" composeThemeAdapter = "1.2.1" constraintlayoutComposeVersion = "1.0.1" @@ -30,7 +30,7 @@ insetterVersion = "0.5.0" jna = "5.14.0" junit = "4.13.2" junitVersion = "1.1.5" -kotlin = "1.9.23" +kotlin = "1.9.24" kotlinxSerializationjson = "1.6.3" legacySupportV4 = "1.0.0" material = "1.11.0" @@ -49,10 +49,10 @@ retrofit = "2.9.0" roomVersion = "2.6.1" rules = "1.5.0" runner = "1.5.2" -sharedFeaturesVersion = "1.1.1.35-FLW" +sharedFeaturesVersion = "1.1.1.36-FLW" shimmerVersion = "0.5.0" sonarqubeGradlePlugin = "3.3" -soraUiCore = "0.2.22" +soraUiCore = "0.2.32" storiesVersion = "3.0.1" walletconnectBom = "1.31.4" web3j = "4.8.8-android" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a781b73d47..8bede77e6f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Aug 04 19:19:31 YEKT 2022 +#Tue Aug 13 16:04:52 GMT+07:00 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/runtime/build.gradle b/runtime/build.gradle index 4ce13e2b8c..091e5742dd 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -18,11 +18,11 @@ android { buildTypes { debug { - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/develop-free/chains/v10/chains_dev.json\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/develop-free/chains/v11/chains_dev.json\"" } release { - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/v10/chains.json\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/v11/chains.json\"" } } @@ -71,7 +71,7 @@ dependencies { androidTestImplementation libs.rules androidTestImplementation libs.ext.junit - api libs.sharedFeaturesCoreDep + api libs.sharedFeaturesCoreDep, withoutAndroidFoundation implementation libs.web3jDep } \ No newline at end of file diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt index 537f0351ca..4e0c60f94c 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt @@ -32,8 +32,12 @@ private fun mapSectionTypeRemoteToSectionType(section: String) = when (section) "sora" -> Chain.ExternalApi.Section.Type.SORA "etherscan" -> Chain.ExternalApi.Section.Type.ETHERSCAN "oklink" -> Chain.ExternalApi.Section.Type.OKLINK - "zeta" -> Chain.ExternalApi.Section.Type.ZETA + "blockscout" -> Chain.ExternalApi.Section.Type.BLOCKSCOUT "reef" -> Chain.ExternalApi.Section.Type.REEF + "klaytn" -> Chain.ExternalApi.Section.Type.KLAYTN + "fire" -> Chain.ExternalApi.Section.Type.FIRE + "vicscan" -> Chain.ExternalApi.Section.Type.VICSCAN + "zchain" -> Chain.ExternalApi.Section.Type.ZCHAINS else -> Chain.ExternalApi.Section.Type.UNKNOWN } @@ -45,6 +49,7 @@ private fun mapExplorerTypeRemoteToExplorerType(explorer: String) = when (explor "okx explorer" -> Chain.Explorer.Type.OKLINK "zeta" -> Chain.Explorer.Type.ZETA "reef" -> Chain.Explorer.Type.REEF + "klaytn" -> Chain.Explorer.Type.KLAYTN else -> Chain.Explorer.Type.UNKNOWN } diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt index 3a4d021b03..2943d98167 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt @@ -67,16 +67,16 @@ data class Chain( ) { data class Section(val type: Type, val url: String) { enum class Type { - SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, ZETA, REEF, UNKNOWN, GITHUB; + SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS, UNKNOWN, GITHUB; - fun isHistory() = this in listOf(SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, ZETA, REEF) + fun isHistory() = this in listOf(SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS) } } } data class Explorer(val type: Type, val types: List, val url: String) { enum class Type { - POLKASCAN, SUBSCAN, ETHERSCAN, OKLINK, ZETA, REEF, UNKNOWN; + POLKASCAN, SUBSCAN, ETHERSCAN, OKLINK, ZETA, REEF, KLAYTN, UNKNOWN; val capitalizedName: String get() = if (this == OKLINK) { diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt index 0fdbd7427e..125b1e0e81 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -1,6 +1,8 @@ package jp.co.soramitsu.runtime.multiNetwork.chain +import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.ChainDao +import jp.co.soramitsu.coredb.dao.MetaAccountDao import jp.co.soramitsu.coredb.model.chain.ChainLocal import jp.co.soramitsu.coredb.model.chain.JoinedChainInfo import jp.co.soramitsu.runtime.multiNetwork.chain.remote.ChainFetcher @@ -65,6 +67,12 @@ class ChainSyncServiceTest { @Mock lateinit var dao: ChainDao + @Mock + lateinit var metaAccountDao: MetaAccountDao + + @Mock + lateinit var assetsDao: AssetDao + @Mock lateinit var chainFetcher: ChainFetcher @@ -72,7 +80,7 @@ class ChainSyncServiceTest { @Before fun setup() { - chainSyncService = ChainSyncService(dao, chainFetcher) + chainSyncService = ChainSyncService(dao, chainFetcher, metaAccountDao, assetsDao) } @Test diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt index b7024d4789..612aa153cf 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.runtime.multiNetwork.runtime -import jp.co.soramitsu.common.mixin.api.networkStateService +import jp.co.soramitsu.common.domain.NetworkStateService import jp.co.soramitsu.core.runtime.ConstructedRuntime import jp.co.soramitsu.core.runtime.RuntimeFactory import jp.co.soramitsu.coredb.dao.ChainDao @@ -50,7 +50,7 @@ class RuntimeProviderTest { lateinit var chainDao: ChainDao @Mock - lateinit var networkStateService: networkStateService + lateinit var networkStateService: NetworkStateService lateinit var runtimeProvider: RuntimeProvider diff --git a/settings.gradle b/settings.gradle index 9c52657797..59423edbc4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -28,3 +28,5 @@ include ':feature-walletconnect-api' include ':feature-walletconnect-impl' include ':feature-nft-api' include ':feature-nft-impl' +include ':feature-liquiditypools-api' +include ':feature-liquiditypools-impl'