Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-26 16:34:17 +03:00
commit 6920ad3b6d
356 changed files with 7941 additions and 1194 deletions

View file

@ -23,7 +23,6 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.SaveWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase
import com.tangem.features.details.impl.R
@ -38,7 +37,7 @@ import kotlin.coroutines.resume
internal class UserWalletSaver @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val messageSender: UiMessageSender,
@ -56,17 +55,20 @@ internal class UserWalletSaver @Inject constructor(
}
},
ifRight = { scanResponse ->
recover(block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
}, recover = {
recover(
block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
},
recover = {
val message = it.message
if (!message.isNullOrEmpty()) {
messageSender.send(SnackbarMessage(message))
}
},)
},
)
},
)
}
@ -110,7 +112,7 @@ internal class UserWalletSaver @Inject constructor(
}
private suspend fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
val userWallet = UserWalletBuilder(response, generateWalletNameUseCase).build()
val userWallet = userWalletBuilderFactory.create(scanResponse = response).build()
return ensureNotNull(userWallet) { Error.Unknown }
}

View file

@ -134,7 +134,7 @@ internal class MarketsTokenItemConverter(
TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
val scaled = percent.setScale(4, RoundingMode.HALF_UP)
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP

View file

@ -26,6 +26,9 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain modules */
implementation(projects.domain.nft.models)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)

View file

@ -4,4 +4,5 @@ interface OnboardingV2FeatureToggles {
val isOnboardingV2Enabled: Boolean
val isVisaOnboardingEnabled: Boolean
val isNoteRefactoringEnabled: Boolean
val isTwinRefactoringEnabled: Boolean
}

View file

@ -43,6 +43,9 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.domain.onboarding)
implementation(projects.domain.visa)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.onramp)
/** Tangem libraries */
implementation(projects.libs.tangemSdkApi)
@ -63,6 +66,7 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.compose.shimmer)
implementation(deps.lottie.compose)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)

View file

@ -12,4 +12,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor(
get() = featureTogglesManager.isFeatureEnabled("VISA_ONBOARDING_ENABLED")
override val isNoteRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NOTE_REFACTORING_ENABLED")
override val isTwinRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("TWIN_REFACTORING_ENABLED")
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.analytics
package com.tangem.features.onboarding.v2.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.tokens.model.CryptoCurrency
sealed class OnboardingEvent(
category: String,
@ -38,6 +40,21 @@ sealed class OnboardingEvent(
}
}
sealed class Topup(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingEvent("Onboarding / Top Up", event, params) {
object ScreenOpened : Topup("Activation Screen Opened")
object ButtonShowWalletAddress : Topup("Button - Show the Wallet Address")
class ButtonBuyCrypto(currency: CryptoCurrency) : Topup(
event = "Button - Buy Crypto",
params = mapOf(AnalyticsParam.CURRENCY to currency.symbol),
)
}
sealed class Backup(
event: String,
params: Map<String, String> = mapOf(),

View file

@ -0,0 +1,25 @@
package com.tangem.features.onboarding.v2.common.ui
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.features.onboarding.v2.impl.R
internal fun exitOnboardingDialog(onConfirm: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.onboarding_exit_alert_title),
message = resourceReference(R.string.onboarding_exit_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = onConfirm,
)
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
)
},
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui
package com.tangem.features.onboarding.v2.common.ui
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference

View file

@ -0,0 +1,51 @@
package com.tangem.features.onboarding.v2.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onboarding.v2.impl.R
@Composable
internal fun RefreshButton(isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size48)
.shadow(elevation = 2.dp, shape = CircleShape)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = !isRefreshing,
onClick = onRefreshBalanceClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
),
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
} else {
Icon(
painter = painterResource(id = R.drawable.ic_refresh_24),
contentDescription = null,
tint = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}
}

View file

@ -5,7 +5,14 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface OnboardingDoneComponent : ComposableContentComponent {
data class Params(val onDone: () -> Unit)
data class Params(
val mode: Mode,
val onDone: () -> Unit,
)
enum class Mode {
WalletCreated, GoodToGo
}
interface Factory : ComponentFactory<Params, OnboardingDoneComponent>
}

View file

@ -1,7 +1,6 @@
package com.tangem.features.onboarding.v2.done.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
@ -18,7 +17,8 @@ internal class DefaultOnboardingDoneComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
OnboardingDone(
onContinueClick = remember(this) { params.onDone },
mode = params.mode,
onContinueClick = params.onDone,
)
}

View file

@ -20,10 +20,16 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.impl.R
@Suppress("LongMethod")
@Composable
internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Modifier) {
internal fun OnboardingDone(
mode: OnboardingDoneComponent.Mode,
onContinueClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti))
val progress by animateLottieCompositionAsState(composition)
var showConfetti by remember { mutableStateOf(false) }
@ -57,7 +63,12 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo
SpacerH12()
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding),
text = when (mode) {
OnboardingDoneComponent.Mode.WalletCreated ->
stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding)
OnboardingDoneComponent.Mode.GoodToGo ->
stringResourceSafe(R.string.visa_onboarding_success_screen_description)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -100,6 +111,9 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo
@Composable
private fun Preview() {
TangemThemePreview {
OnboardingDone(onContinueClick = {})
OnboardingDone(
mode = OnboardingDoneComponent.Mode.WalletCreated,
onContinueClick = {},
)
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.BiometryFeatureToggles
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEvent
import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute
@ -105,7 +106,12 @@ internal class OnboardingEntryModel @Inject constructor(
private fun onMultiWalletOnboardingDone(userWallet: UserWallet) {
when {
params.multiWalletMode == OnboardingEntryComponent.MultiWalletMode.AddBackup -> {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = OnboardingDoneComponent.Mode.WalletCreated,
onDone = ::navigateToWalletScreen,
),
)
}
userWallet.scanResponse.cardTypesResolver.isMultiwalletAllowed() -> {
stackNavigation.replaceAll(OnboardingRoute.ManageTokens(userWallet))
@ -117,34 +123,58 @@ internal class OnboardingEntryModel @Inject constructor(
}
private fun onVisaOnboardingDone() {
navigateToFinalScreenFlow()
navigateToFinalScreenFlow(doneMode = OnboardingDoneComponent.Mode.GoodToGo)
}
private fun navigateToFinalScreenFlow() {
private fun navigateToFinalScreenFlow(
doneMode: OnboardingDoneComponent.Mode = OnboardingDoneComponent.Mode.WalletCreated,
) {
if (askBiometryFeatureToggles.isAskForBiometryEnabled) {
modelScope.launch {
if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) {
stackNavigation.replaceAll(
OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks()),
OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)),
)
} else {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::navigateToWalletScreen,
),
)
}
}
} else {
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::navigateToWalletScreen,
),
)
}
}
inner class AskBiometryModelCallbacks : AskBiometryComponent.ModelCallbacks {
inner class AskBiometryModelCallbacks(
private val doneMode: OnboardingDoneComponent.Mode,
) : AskBiometryComponent.ModelCallbacks {
override fun onAllowed() {
analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.On))
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::navigateToWalletScreen,
),
)
}
override fun onDenied() {
analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.Off))
stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen))
stackNavigation.replaceAll(
OnboardingRoute.Done(
mode = doneMode,
onDone = ::navigateToWalletScreen,
),
)
}
}

View file

@ -48,6 +48,7 @@ internal class OnboardingChildFactory @Inject constructor(
is OnboardingRoute.Done -> onboardingDoneComponentFactory.create(
context = childContext,
params = OnboardingDoneComponent.Params(
mode = route.mode,
onDone = route.onDone,
),
)

View file

@ -5,6 +5,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
sealed class OnboardingRoute : Route {
@ -40,6 +41,7 @@ sealed class OnboardingRoute : Route {
) : OnboardingRoute()
data class Done(
val mode: OnboardingDoneComponent.Mode,
val onDone: () -> Unit,
) : OnboardingRoute()
}

View file

@ -26,7 +26,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation
import com.tangem.core.decompose.navigation.inner.InnerNavigationState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent

View file

@ -6,7 +6,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.ui.state.MultiWalletAccessCodeUM
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState

View file

@ -1,11 +1,12 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model
import androidx.compose.runtime.Stable
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -19,8 +20,8 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ProductType
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.ui.backupCardAttestationFailedDialog
@ -48,6 +49,7 @@ class MultiWalletBackupModel @Inject constructor(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val tangemSdkManager: TangemSdkManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val uiMessageSender: UiMessageSender,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardRepository: CardRepository,
@ -241,7 +243,7 @@ class MultiWalletBackupModel @Inject constructor(
)
}
}
else -> FirebaseCrashlytics.getInstance().recordException(result.error)
else -> analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(result.error))
}
}
}

View file

@ -10,9 +10,8 @@ import com.tangem.domain.common.TapWorkarounds.canSkipBackup
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
@ -25,7 +24,7 @@ import javax.inject.Inject
internal class Wallet1ChooseOptionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
private val analyticsHandler: AnalyticsEventHandler,
@ -67,7 +66,7 @@ internal class Wallet1ChooseOptionModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -15,10 +15,9 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state.MultiWalletCreateWalletUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog
@ -43,7 +42,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private val getCardInfoUseCase: GetCardInfoUseCase,
private val cardRepository: CardRepository,
private val analyticsHandler: AnalyticsEventHandler,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
) : Model() {
@ -146,7 +145,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -1,7 +1,7 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class MultiWalletCreateWalletUM(
val title: TextReference,

View file

@ -20,7 +20,6 @@ import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
@ -49,7 +48,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
@ -279,7 +278,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase)
value = userWalletBuilderFactory.create(scanResponse = scanResponse)
.backupCardsIds(backupCardIds.toSet())
.hasBackupError(walletHasBackupError)
.build(),

View file

@ -1,6 +1,6 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class MultiWalletFinalizeUM(
val step: Step = Step.Primary,

View file

@ -13,13 +13,13 @@ import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.ImportSeedPhraseUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.SeedPhraseCheckUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -1,6 +1,7 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.impl.R
internal fun resetCardDialog(onConfirm: () -> Unit, dismiss: () -> Unit, onDismissButtonClick: () -> Unit) =

View file

@ -10,7 +10,7 @@ import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.interruptBackupDialog
import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage
@ -18,21 +18,25 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMul
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class OnboardingMultiWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
analyticsHandler: AnalyticsEventHandler,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analyticsHandler: AnalyticsEventHandler,
private val backupServiceHolder: BackupServiceHolder,
private val onboardingRepository: OnboardingRepository,
private val getCardImageUseCase: GetCardImageUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingMultiWalletComponent.Params>()
private val getCardImageUseCase = GetCardImageUseCase()
private val _uiState = MutableStateFlow(OnboardingMultiWalletUM())
val state = MutableStateFlow(

View file

@ -1,6 +1,6 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state
import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
internal data class OnboardingMultiWalletUM(
val artwork1Url: String? = null,

View file

@ -25,15 +25,16 @@ import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent
import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent
import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel
import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonState
import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT
import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@ -47,6 +48,7 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
}
private val childParams = ChildParams(
commonState = model.commonUiState,
onBack = ::onChildBack,
parentBackEvent = currentChildBackEventHandle,
)
@ -70,15 +72,13 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
override val state: StateFlow<InnerNavigationState> = model.innerNavigationState
override fun pop(onComplete: (Boolean) -> Unit) {
// TODO [REDACTED_TASK_KEY]
model.onChildBack(
currentRoute = childStack.value.active.configuration,
)
}
}
init {
componentScope.launch {
model.onDone.collect { params.onDone() }
}
// sets title and stepper value
childStack.subscribe(lifecycle) { stack ->
val currentRoute = stack.active.configuration
@ -96,14 +96,17 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
appComponentContext = factoryContext,
params = OnboardingNoteCreateWalletComponent.Params(
childParams = childParams,
onDone = { model.stackNavigation.push(OnboardingNoteRoute.TopUp) },
onWalletCreated = { userWallet ->
model.onWalletCreated(userWallet)
model.stackNavigation.push(OnboardingNoteRoute.TopUp)
},
),
)
OnboardingNoteRoute.TopUp -> OnboardingNoteTopUpComponent(
appComponentContext = factoryContext,
params = OnboardingNoteTopUpComponent.Params(
childParams = childParams,
onDone = { model.onDone.tryEmit(Unit) },
onDone = { params.onDone() },
),
)
}
@ -134,6 +137,7 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor(
}
data class ChildParams(
val commonState: MutableStateFlow<OnboardingNoteCommonState>,
val onBack: () -> Unit,
val parentBackEvent: SharedFlow<Unit>,
)

View file

@ -9,6 +9,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.onboarding.v2.note.impl.DefaultOnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.model.OnboardingNoteCreateWalletModel
import com.tangem.features.onboarding.v2.note.impl.child.create.ui.OnboardingNoteCreateWallet
@ -24,7 +25,7 @@ internal class OnboardingNoteCreateWalletComponent(
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = remember(this) { { params.childParams.onBack() } })
BackHandler(onBack = remember(this) { { params.childParams.onBack } })
OnboardingNoteCreateWallet(
modifier = modifier,
@ -34,6 +35,6 @@ internal class OnboardingNoteCreateWalletComponent(
data class Params(
val childParams: DefaultOnboardingNoteComponent.ChildParams,
val onDone: () -> Unit,
val onWalletCreated: (UserWallet) -> Unit,
)
}

View file

@ -1,26 +1,103 @@
package com.tangem.features.onboarding.v2.note.impl.child.create.model
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.Analytics
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.ui.state.OnboardingNoteCreateWalletUM
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("UnusedPrivateMember")
@ModelScoped
internal class OnboardingNoteCreateWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
private val cardRepository: CardRepository,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingNoteCreateWalletComponent.Params>()
private val _uiState = MutableStateFlow(
OnboardingNoteCreateWalletUM(),
OnboardingNoteCreateWalletUM(
onCreateClick = ::onCreateWalletClick,
),
)
init {
Analytics.send(OnboardingEvent.CreateWallet.ScreenOpened)
modelScope.launch {
val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch
if (!cardRepository.isActivationStarted(scanResponse.card.cardId)) {
Analytics.send(OnboardingEvent.Started)
}
}
observeArtwork()
}
val uiState: StateFlow<OnboardingNoteCreateWalletUM> = _uiState
private fun onCreateWalletClick() {
modelScope.launch {
_uiState.update {
it.copy(createWalletInProgress = true)
}
val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch
val result = tangemSdkManager.createProductWallet(scanResponse)
when (result) {
is CompletionResult.Success -> {
Analytics.send(OnboardingEvent.CreateWallet.WalletCreatedSuccessfully())
cardRepository.startCardActivation(scanResponse.card.cardId)
createWalletAndNavigateBackWithDone(scanResponse.copy(card = result.data.card))
}
is CompletionResult.Failure -> _uiState.update {
it.copy(createWalletInProgress = false)
}
}
}
}
private fun createWalletAndNavigateBackWithDone(scanResponse: ScanResponse) {
modelScope.launch {
val userWallet = createUserWallet(scanResponse)
saveWalletUseCase(userWallet, canOverride = true).onRight {
params.onWalletCreated(userWallet)
}
_uiState.update {
it.copy(createWalletInProgress = false)
}
}
}
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}
private fun observeArtwork() {
modelScope.launch {
params.childParams.commonState.collect {
_uiState.value = _uiState.value.copy(
artworkUrl = it.cardArtworkUrl,
)
}
}
}
}

View file

@ -1,24 +1,253 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.model
import com.tangem.core.analytics.Analytics
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent
import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state.OnboardingNoteTopUpUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import com.tangem.utils.extensions.isPositive
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("UnusedPrivateMember")
@Suppress("LongParameterList")
@ModelScoped
internal class OnboardingNoteTopUpModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase,
private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val getLegacyTopUpUrlUseCase: GetLegacyTopUpUrlUseCase,
private val urlOpener: UrlOpener,
private val clipboardManager: ClipboardManager,
private val shareManager: ShareManager,
private val rampStateManager: RampStateManager,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingNoteTopUpComponent.Params>()
private val commonState = params.childParams.commonState
private val scanResponse = params.childParams.commonState.value.scanResponse
private var userWallet = params.childParams.commonState.value.userWallet
private val _uiState = MutableStateFlow(
OnboardingNoteTopUpUM(),
OnboardingNoteTopUpUM(
onRefreshBalanceClick = ::refreshBalance,
onBuyCryptoClick = ::onBuyCryptoClick,
onShowWalletAddressClick = ::onShowWalletAddressClick,
onDismissBottomSheet = ::onDismissBottomSheet,
),
)
val uiState: StateFlow<OnboardingNoteTopUpUM> = _uiState
init {
Analytics.send(OnboardingEvent.Topup.ScreenOpened)
observeArtwork()
modelScope.launch {
createUserWalletIfNull()
observeCryptoCurrencyStatus()
refreshBalance()
}
}
private fun refreshBalance() {
modelScope.launch {
showBalanceLoadingProgress(true)
createUserWalletIfNull()
val userWalletId = requireNotNull(userWallet?.walletId)
fetchCurrencyStatusUseCase(
userWalletId = userWalletId,
refresh = true,
)
showBalanceLoadingProgress(false)
}
}
private fun onBuyCryptoClick() {
val cryptoCurrencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return
modelScope.launch {
getLegacyTopUpUrlUseCase(cryptoCurrencyStatus).onRight {
urlOpener.openUrl(it)
}
}
Analytics.send(OnboardingEvent.Topup.ButtonBuyCrypto(cryptoCurrencyStatus.currency))
}
private fun onShowWalletAddressClick() {
val currencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return
val networkAddress = currencyStatus.value.networkAddress ?: return
_uiState.update {
it.copy(addressBottomSheetConfig = createReceiveBS(currencyStatus, networkAddress))
}
Analytics.send(OnboardingEvent.Topup.ButtonShowWalletAddress)
}
private fun onDismissBottomSheet() {
_uiState.update {
it.copy(addressBottomSheetConfig = null)
}
}
private suspend fun createUserWalletIfNull() {
if (userWallet != null) {
return
}
val commonState = params.childParams.commonState.value
userWallet = commonState.userWallet ?: createAndSaveUserWallet(scanResponse)
}
private fun observeArtwork() {
modelScope.launch {
params.childParams.commonState.collect {
_uiState.value = _uiState.value.copy(
cardArtworkUrl = it.cardArtworkUrl,
)
}
}
}
private fun observeCryptoCurrencyStatus() {
val userWalletId = userWallet?.walletId ?: return
getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId).map { it.getOrNull() }.filterNotNull()
.onEach(::applyCryptoCurrencyStatusToState).launchIn(modelScope)
}
private fun applyCryptoCurrencyStatusToState(status: CryptoCurrencyStatus) {
if (commonState.value.cryptoCurrencyStatus == null) {
loadAvailableForBuy(status)
}
commonState.update {
it.copy(cryptoCurrencyStatus = status)
}
val amount = when (status.value) {
is CryptoCurrencyStatus.Loaded -> status.value.amount
is CryptoCurrencyStatus.NoAccount -> status.value.amount
is CryptoCurrencyStatus.NoQuote -> status.value.amount
else -> null
}
val hasCurrentNetworkTransactions = when (status.value) {
is CryptoCurrencyStatus.Loaded -> status.value.hasCurrentNetworkTransactions
is CryptoCurrencyStatus.NoAccount -> status.value.hasCurrentNetworkTransactions
else -> false
}
val amountToCreateAccount = (status.value as? CryptoCurrencyStatus.NoAccount)?.amountToCreateAccount
if (amount?.isPositive() == true || hasCurrentNetworkTransactions) {
modelScope.launch {
cardRepository.finishCardActivation(scanResponse.card.cardId)
params.onDone()
}
}
_uiState.update {
it.copy(
amountToCreateAccount = amountToCreateAccount
?.format(
{
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
),
balance = amount?.format(
{
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
).orEmpty(),
isTopUpDataLoading = status.value.networkAddress == null,
)
}
}
private fun showBalanceLoadingProgress(value: Boolean) {
_uiState.update {
it.copy(isRefreshing = value)
}
}
private fun loadAvailableForBuy(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val userWalletId = userWallet?.walletId ?: return
modelScope.launch {
val availableForBuy = rampStateManager.availableForBuy(
scanResponse = scanResponse,
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
)
_uiState.update {
it.copy(
availableForBuy = availableForBuy,
availableForBuyLoading = false,
)
}
}
}
private fun createReceiveBS(currencyStatus: CryptoCurrencyStatus, networkAddress: NetworkAddress) =
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = uiState.value.onDismissBottomSheet,
content = TokenReceiveBottomSheetConfig(
name = currencyStatus.currency.name,
symbol = currencyStatus.currency.symbol,
network = currencyStatus.currency.network.name,
addresses = networkAddress.availableAddresses
.mapToAddressModels(currencyStatus.currency)
.toImmutableList(),
showMemoDisclaimer =
currencyStatus.currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currencyStatus.currency.symbol))
clipboardManager.setText(text = it, isSensitive = true)
},
onShareClick = {
Analytics.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currencyStatus.currency.symbol))
shareManager.shareText(text = it)
},
),
)
private suspend fun createAndSaveUserWallet(scanResponse: ScanResponse): UserWallet {
val wallet = requireNotNull(
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
saveWalletUseCase(wallet, false)
return wallet
}
}

View file

@ -1,46 +1,44 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalTangemShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.common.ui.RefreshButton
import com.tangem.features.onboarding.v2.common.ui.WalletCard
import com.tangem.features.onboarding.v2.impl.R
import com.valentinilk.shimmer.shimmer
@Composable
fun OnboardinNoteTopUpHeader(
balance: String?,
fun OnboardingNoteTopUpHeader(
balance: String,
cardArtworkUrl: String?,
isRefreshing: Boolean,
onRefreshBalanceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.heightIn(min = 228.dp)
.widthIn(max = 450.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.padding(vertical = 24.dp, horizontal = 32.dp)
.padding(vertical = 24.dp)
.fillMaxSize()
.background(
TangemTheme.colors.button.secondary,
@ -64,10 +62,17 @@ fun OnboardinNoteTopUpHeader(
)
SpacerH8()
Text(
text = balance.orEmpty(),
modifier = if (balance.isEmpty()) {
Modifier
.width(120.dp)
.clip(RoundedCornerShape(size = TangemTheme.dimens.radius3))
.shimmer(LocalTangemShimmer.current)
} else {
Modifier
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
text = balance,
)
SpacerHMax()
RefreshButton(
@ -78,44 +83,11 @@ fun OnboardinNoteTopUpHeader(
}
}
@Composable
private fun RefreshButton(isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size48)
.shadow(elevation = 2.dp, shape = CircleShape)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = !isRefreshing,
onClick = onRefreshBalanceClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
),
) {
if (isRefreshing) {
Box {
CircularProgressIndicator(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
}
} else {
Icon(
painter = painterResource(id = R.drawable.ic_refresh_24),
contentDescription = null,
tint = TangemTheme.colors.text.disabled,
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardinNoteTopUpHeaderPreview() {
TangemThemePreview {
OnboardinNoteTopUpHeader(
OnboardingNoteTopUpHeader(
balance = "0.00000001 BTC",
cardArtworkUrl = "",
onRefreshBalanceClick = {},

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -10,14 +11,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.note.impl.ALL_STEPS_TOP_CONTAINER_WEIGHT
import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state.OnboardingNoteTopUpUM
@Composable
@ -27,23 +27,25 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif
.fillMaxSize()
.navigationBarsPadding(),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally,
) {
OnboardinNoteTopUpHeader(
OnboardingNoteTopUpHeader(
balance = state.balance,
cardArtworkUrl = state.cardArtworkUrl,
onRefreshBalanceClick = state.onRefreshBalanceClick,
isRefreshing = state.isRefreshing,
modifier = Modifier
.padding(horizontal = 34.dp)
.padding(top = 20.dp)
.weight(ALL_STEPS_TOP_CONTAINER_WEIGHT)
.padding(top = 16.dp)
.height(228.dp)
.fillMaxWidth(),
)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(32.dp)
.padding(horizontal = 32.dp)
.padding(top = 16.dp)
.weight(1.0f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -55,8 +57,16 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif
modifier = Modifier.padding(top = 16.dp),
)
val text = if (state.amountToCreateAccount != null) {
stringResourceSafe(
R.string.onboarding_top_up_min_create_account_amount,
state.amountToCreateAccount,
)
} else {
stringResourceSafe(R.string.onboarding_top_up_body)
}
Text(
text = stringResourceSafe(R.string.onboarding_top_up_body),
text = text,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -65,38 +75,43 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif
}
BottomButtons(state)
state.addressBottomSheetConfig?.let { config ->
TokenReceiveBottomSheet(config = config)
}
}
}
@Composable
private fun BottomButtons(state: OnboardingNoteTopUpUM) {
if (state.availableForBuy) {
PrimaryButtonIconEnd(
PrimaryButton(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth(),
iconResId = R.drawable.ic_tangem_24,
text = stringResourceSafe(R.string.onboarding_top_up_button_but_crypto),
onClick = state.onBuyCryptoClick,
)
SpacerH8()
SecondaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address),
onClick = state.onShowWalletAddressClick,
)
} else {
PrimaryButtonIconEnd(
PrimaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
iconResId = R.drawable.ic_tangem_24,
text = stringResourceSafe(R.string.onboarding_button_receive_crypto),
onClick = state.onShowWalletAddressClick,
)
}
AnimatedVisibility(visible = !state.availableForBuyLoading) {
if (state.availableForBuy) {
SecondaryButton(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address),
onClick = state.onShowWalletAddressClick,
)
}
}
}
@Preview(showBackground = true)

View file

@ -1,12 +1,18 @@
package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
data class OnboardingNoteTopUpUM(
val cardArtworkUrl: String? = null,
val availableForBuy: Boolean = true,
val balance: String? = null,
val availableForBuy: Boolean = false,
val availableForBuyLoading: Boolean = true,
val balance: String = "",
val isRefreshing: Boolean = false,
val isTopUpDataLoading: Boolean = true,
val amountToCreateAccount: String? = null,
val addressBottomSheetConfig: TangemBottomSheetConfig? = null,
val onBuyCryptoClick: () -> Unit = {},
val onShowWalletAddressClick: () -> Unit = {},
val onRefreshBalanceClick: () -> Unit = {},
val onDismissBottomSheet: () -> Unit = {},
)

View file

@ -0,0 +1,12 @@
package com.tangem.features.onboarding.v2.note.impl.model
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
internal data class OnboardingNoteCommonState(
val scanResponse: ScanResponse,
val userWallet: UserWallet? = null,
val cardArtworkUrl: String? = null,
val cryptoCurrencyStatus: CryptoCurrencyStatus? = null,
)

View file

@ -4,46 +4,54 @@ import com.arkivanov.decompose.router.stack.StackNavigation
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog
import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState
import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute
import com.tangem.features.onboarding.v2.note.impl.route.stepNum
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
internal class OnboardingNoteModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val messageSender: UiMessageSender,
private val getCardImageUseCase: GetCardImageUseCase,
) : Model() {
@Suppress("UnusedPrivateMember")
private val params = paramsContainer.require<OnboardingNoteComponent.Params>()
private val _currentScanResponse = MutableStateFlow(params.scanResponse)
val initialRoute = OnboardingNoteRoute.CreateWallet // TODO [REDACTED_TASK_KEY]
val initialRoute = initializeRoute()
private val _innerNavigationState =
MutableStateFlow(OnboardingNoteInnerNavigationState(stackSize = initialRoute.stepNum()))
val currentScanResponse = _currentScanResponse.asStateFlow()
val innerNavigationState = _innerNavigationState.asStateFlow()
val onDone = MutableSharedFlow<Unit>()
val stackNavigation = StackNavigation<OnboardingNoteRoute>()
private val _uiState = MutableStateFlow(
OnboardingNoteState(),
OnboardingNoteCommonState(
scanResponse = params.scanResponse,
),
)
val uiState: StateFlow<OnboardingNoteState> = _uiState
val commonUiState: MutableStateFlow<OnboardingNoteCommonState> = _uiState
init {
loadArtworkUrl()
}
fun updateStepForNewRoute(route: OnboardingNoteRoute) {
_innerNavigationState.value = innerNavigationState.value.copy(
@ -53,6 +61,40 @@ internal class OnboardingNoteModel @Inject constructor(
@Suppress("UnusedPrivateMember")
fun onChildBack(currentRoute: OnboardingNoteRoute) {
// TODO [REDACTED_TASK_KEY]
messageSender.send(
exitOnboardingDialog(
onConfirm = {
router.pop()
},
),
)
}
fun onWalletCreated(userWallet: UserWallet) {
commonUiState.update {
it.copy(userWallet = userWallet)
}
}
private fun initializeRoute(): OnboardingNoteRoute {
val card = params.scanResponse.card
return if (card.wallets.isEmpty()) {
OnboardingNoteRoute.CreateWallet
} else {
OnboardingNoteRoute.TopUp
}
}
private fun loadArtworkUrl() {
modelScope.launch {
val cardInfo = params.scanResponse.card
val url = getCardImageUseCase(
cardId = cardInfo.cardId,
cardPublicKey = cardInfo.cardPublicKey,
)
_uiState.update {
it.copy(cardArtworkUrl = url)
}
}
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.features.onboarding.v2.note.impl.model
data class OnboardingNoteState(
val cardArtworkUrl: String? = null,
)

View file

@ -0,0 +1,26 @@
package com.tangem.features.onboarding.v2.twin.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.features.onboarding.v2.TitleProvider
interface OnboardingTwinComponent : ComposableContentComponent {
data class Params(
val titleProvider: TitleProvider,
val scanResponse: ScanResponse,
val modelCallbacks: ModelCallbacks,
val mode: Mode,
) {
enum class Mode {
CreateWallet, RecreateWallet
}
}
interface ModelCallbacks {
fun onDone()
}
interface Factory : ComponentFactory<Params, OnboardingTwinComponent>
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.onboarding.v2.twin.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnboardingTwinComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: OnboardingTwinComponent.Params,
) : OnboardingTwinComponent, AppComponentContext by appComponentContext {
private val model: OnboardingTwinModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
TODO("Not yet implemented")
}
@AssistedFactory
interface Factory : OnboardingTwinComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnboardingTwinComponent.Params,
): DefaultOnboardingTwinComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.onboarding.v2.twin.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.features.onboarding.v2.twin.impl.DefaultOnboardingTwinComponent
import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindComponent(factory: DefaultOnboardingTwinComponent.Factory): OnboardingTwinComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(OnboardingTwinModel::class)
fun provideModel(model: OnboardingTwinModel): Model
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.onboarding.v2.twin.impl.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
@ModelScoped
internal class OnboardingTwinModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
val params = paramsContainer.require<OnboardingTwinComponent.Params>()
// TODO
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.onboarding.v2.twin.impl.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemThemePreview
@Suppress("UnusedPrivateMember")
@Composable
fun OnboardingTwin(modifier: Modifier = Modifier) {
Column {
Box {
// TODO
}
}
}
@Preview
@Composable
private fun Preview() {
TangemThemePreview {
OnboardingTwin()
}
}

View file

@ -0,0 +1,378 @@
package com.tangem.features.onboarding.v2.twin.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.zIndex
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.wallets.models.Artwork
import com.tangem.features.onboarding.v2.common.ui.RefreshButton
import com.tangem.features.onboarding.v2.common.ui.WalletCard
import com.tangem.features.onboarding.v2.impl.R
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
internal sealed class TwinWalletArtworkState {
data object Spread : TwinWalletArtworkState()
data class Leapfrog(
val step: Step,
) : TwinWalletArtworkState() {
enum class Step {
FirstCard, SecondCard
}
}
data object TopUp : TwinWalletArtworkState()
}
private data class CardsTransitionState(
val walletCard1: WalletCardTransitionState,
val walletCard2: WalletCardTransitionState,
)
private data class WalletCardTransitionState(
val alpha: Float = 1f,
val xTranslation: Float = 0f,
val yTranslation: Float = 0f,
val xScale: Float = 1f,
val yScale: Float = 1f,
val rotation: Float = 0f,
val zIndex: Float = 0f,
)
@Suppress("LongMethod")
@Composable
internal fun TwinWalletArtworks(
state: TwinWalletArtworkState,
balance: String,
isRefreshing: Boolean,
onRefreshBalanceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier
.heightIn(min = 180.dp)
.widthIn(max = 450.dp),
) {
val animationWidth = maxWidth
val density = LocalDensity.current.density
var animationState by remember {
mutableStateOf(
state.toTransitionSetState(
maxWidthDp = animationWidth.value,
density = density,
maxHeightDp = maxHeight.value,
).first(),
)
}
val transition1 = updateTransition(animationState.walletCard1, label = "transition")
val transition2 = updateTransition(animationState.walletCard2, label = "transition")
LaunchedEffect(state) {
val animationSetState = state.toTransitionSetState(
maxWidthDp = animationWidth.value,
density = density,
maxHeightDp = maxHeight.value,
)
animationSetState.fastForEach {
animationState = it
val maxTime = maxOf(
transition1.totalDurationNanos,
transition2.totalDurationNanos,
)
delay(TimeUnit.NANOSECONDS.toMillis(maxTime))
}
}
AnimatedVisibility(
visible = state == TwinWalletArtworkState.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.padding(vertical = 24.dp, horizontal = 32.dp)
.fillMaxSize()
.background(
TangemTheme.colors.button.secondary,
shape = TangemTheme.shapes.roundedCornersMedium,
),
)
}
AnimatedTwinCards(
transition1 = transition1,
transition2 = transition2,
modifier = Modifier
.widthIn(max = 450.dp)
.matchParentSize(),
)
AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = state == TwinWalletArtworkState.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerHMax()
Text(
text = stringResourceSafe(R.string.common_balance_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH8()
Text(
text = balance.orEmpty(),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerHMax()
}
}
AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomCenter),
visible = state == TwinWalletArtworkState.TopUp,
enter = fadeIn(),
exit = fadeOut(),
) {
RefreshButton(
isRefreshing = isRefreshing,
onRefreshBalanceClick = onRefreshBalanceClick,
)
}
}
}
@Composable
private fun AnimatedTwinCards(
transition1: Transition<WalletCardTransitionState>,
transition2: Transition<WalletCardTransitionState>,
modifier: Modifier = Modifier,
) {
val alpha1 by transition1.animateFloat(label = "alpha1") { it.alpha }
val translationX1 by transition1.animateFloat(label = "translationX1") { it.xTranslation }
val translationY1 by transition1.animateFloat(label = "translationY1") { it.yTranslation }
val rotation1 by transition1.animateFloat(label = "rotation1") { it.rotation }
val scaleX1 by transition1.animateFloat(label = "scaleX1") { it.xScale }
val scaleY1 by transition1.animateFloat(label = "scaleY1") { it.yScale }
val zIndex1 = transition1.currentState.zIndex
val alpha2 by transition2.animateFloat(label = "alpha2") { it.alpha }
val translationX2 by transition2.animateFloat(label = "translationX2") { it.xTranslation }
val translationY2 by transition2.animateFloat(label = "translationY2") { it.yTranslation }
val rotation2 by transition2.animateFloat(label = "rotation2") { it.rotation }
val scaleX2 by transition2.animateFloat(label = "scaleX2") { it.xScale }
val scaleY2 by transition2.animateFloat(label = "scaleY2") { it.yScale }
val zIndex2 = transition2.currentState.zIndex
Box(modifier) {
WalletCard(
modifier = Modifier
.zIndex(zIndex2)
.matchParentSize()
.graphicsLayer {
translationX = translationX2
translationY = translationY2
}
.graphicsLayer {
alpha = alpha2
rotationZ = rotation2
scaleX = scaleX2
scaleY = scaleY2
},
url = Artwork.TWIN_CARD_2_URL,
)
WalletCard(
modifier = Modifier
.zIndex(zIndex1)
.matchParentSize()
.graphicsLayer {
scaleX = scaleX1
scaleY = scaleY1
}
.graphicsLayer {
alpha = alpha1
translationX = translationX1
translationY = translationY1
rotationZ = rotation1
},
url = Artwork.TWIN_CARD_1_URL,
)
}
}
@Suppress("MagicNumber", "LongMethod", "UnnecessaryParentheses")
private fun TwinWalletArtworkState.toTransitionSetState(
maxWidthDp: Float,
maxHeightDp: Float,
density: Float,
): List<CardsTransitionState> = when (this) {
TwinWalletArtworkState.Spread -> {
val scale = 0.8f
listOf(
CardsTransitionState(
walletCard1 = WalletCardTransitionState(
xTranslation = maxWidthDp * -0.15f * density,
yTranslation = -65 * density,
rotation = -5f,
xScale = scale,
yScale = scale,
zIndex = 2f,
),
walletCard2 = WalletCardTransitionState(
xTranslation = maxWidthDp * 0.15f * density,
yTranslation = 65 * density,
rotation = -5f,
xScale = scale,
yScale = scale,
zIndex = 1f,
),
),
)
}
is TwinWalletArtworkState.Leapfrog -> {
val translationY = -(maxHeightDp / 2) * density
val secondCardPlace = WalletCardTransitionState().copy(
xTranslation = 0f,
yTranslation = ((maxHeightDp * (1 - 0.83f)) / 4 + 12) * density,
rotation = 0f,
xScale = 0.83f,
yScale = 0.83f,
)
if (step == TwinWalletArtworkState.Leapfrog.Step.FirstCard) {
listOf(
CardsTransitionState(
walletCard1 = WalletCardTransitionState(
yTranslation = translationY,
zIndex = 2f,
),
walletCard2 = WalletCardTransitionState(),
),
CardsTransitionState(
walletCard1 = WalletCardTransitionState(
zIndex = 2f,
),
walletCard2 = secondCardPlace,
),
)
} else {
listOf(
CardsTransitionState(
walletCard1 = WalletCardTransitionState(),
walletCard2 = WalletCardTransitionState(
yTranslation = translationY,
zIndex = 2f,
),
),
CardsTransitionState(
walletCard1 = secondCardPlace,
walletCard2 = WalletCardTransitionState(
zIndex = 2f,
),
),
)
}
}
TwinWalletArtworkState.TopUp -> {
val scale = 0.4f
val yTranslation = -maxHeightDp * density - 24 * density
listOf(
CardsTransitionState(
walletCard1 = WalletCardTransitionState(
yTranslation = yTranslation,
xScale = scale,
yScale = scale,
zIndex = 2f,
),
walletCard2 = WalletCardTransitionState(
yTranslation = yTranslation * 0.35f,
xScale = scale * 0.8f,
yScale = scale * 0.8f,
zIndex = 1f,
),
),
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
var state: TwinWalletArtworkState by remember { mutableStateOf(TwinWalletArtworkState.TopUp) }
TwinWalletArtworks(
state = state,
modifier = Modifier
.padding(top = 250.dp)
.fillMaxWidth(),
balance = "1 USD",
isRefreshing = false,
onRefreshBalanceClick = {},
)
var index by remember { mutableIntStateOf(0) }
Button(
modifier = Modifier
.zIndex(5f)
.align(Alignment.TopStart),
onClick = {
val list = listOf(
TwinWalletArtworkState.Spread,
TwinWalletArtworkState.Leapfrog(step = TwinWalletArtworkState.Leapfrog.Step.FirstCard),
TwinWalletArtworkState.Leapfrog(step = TwinWalletArtworkState.Leapfrog.Step.SecondCard),
TwinWalletArtworkState.Leapfrog(step = TwinWalletArtworkState.Leapfrog.Step.FirstCard),
TwinWalletArtworkState.Leapfrog(step = TwinWalletArtworkState.Leapfrog.Step.SecondCard),
TwinWalletArtworkState.TopUp,
)
state = list[index % list.size]
index++
},
) {
Text("Animate $state")
}
}
}
}

View file

@ -161,7 +161,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaOtherWalletComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.push(OnboardingVisaRoute.PinCode(activationOrderInfo = it))
model.stackNavigation.pushNew(OnboardingVisaRoute.PinCode(activationOrderInfo = it))
},
),
)
@ -174,7 +174,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaPinCodeComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.push(
model.stackNavigation.pushNew(
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode),
)
},
@ -190,7 +190,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
params = OnboardingVisaApproveComponent.Params(
childParams = childParams,
onDone = {
model.stackNavigation.push(
model.stackNavigation.pushNew(
OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve),
)
},

View file

@ -23,6 +23,7 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -87,9 +88,9 @@ private fun Content(
modifier = Modifier
.padding(horizontal = 32.dp),
text = if (reEnterAccessCodeState) {
"Re-enter your access code" // TODO
stringResourceSafe(R.string.onboarding_access_code_repeat_code_title)
} else {
"Create access code" // TODO
stringResourceSafe(R.string.onboarding_access_code_intro_title)
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
@ -98,11 +99,10 @@ private fun Content(
SpacerH16()
// TODO
Text(
modifier = Modifier
.padding(horizontal = 32.dp),
text = "The access code will be used manage your payment account and protect it from unauthorized access",
text = stringResourceSafe(R.string.visa_onboarding_access_code_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,

View file

@ -14,7 +14,8 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
@ -43,7 +44,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod
SpacerH(86.dp)
Text(
text = "Prepare Tangem Wallet",
text = stringResourceSafe(R.string.visa_onboarding_tangem_approve_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
@ -52,7 +53,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod
SpacerH(12.dp)
Text(
text = "Prepare the Tangem card\nand tap to approve",
text = stringResourceSafe(R.string.visa_onboarding_tangem_approve_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -65,7 +66,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
primaryButton = NavigationButton(
textReference = TextReference.Str("Approve"),
textReference = resourceReference(R.string.common_approve),
iconRes = R.drawable.ic_tangem_24,
isIconVisible = true,
showProgress = state.approveButtonLoading,

View file

@ -3,7 +3,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.OnboardingVisaChooseWalletComponent.Params.Event
import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.OnboardingVisaChooseWalletUM
@ -32,7 +32,7 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor(
val selectedOption = SelectableChainRowUM(
event = Event.TangemWallet,
icon = R.drawable.ic_tangem_24,
text = TextReference.Str("Tangem Wallet"),
text = resourceReference(R.string.common_tangem_wallet),
)
return OnboardingVisaChooseWalletUM(
@ -41,7 +41,7 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor(
SelectableChainRowUM(
event = Event.OtherWallet,
icon = R.drawable.ic_wallet_filled_24,
text = TextReference.Str("Other Wallet"),
text = resourceReference(R.string.visa_onboarding_other_wallet),
),
),
selectedOption = selectedOption,

View file

@ -21,6 +21,8 @@ import com.tangem.core.ui.components.rows.RowContentContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.outsetBorder
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
@ -45,10 +47,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod
) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Str(
"Please choose the wallet you started the registration" +
" process with to sign the transaction for creating your account on the Blockchain.",
),
subtitle = resourceReference(R.string.visa_onboarding_approve_wallet_selector_notification_message),
iconResId = R.drawable.ic_alert_circle_24,
),
iconTint = TangemTheme.colors.icon.accent,
@ -62,7 +61,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod
start = 12.dp,
end = 12.dp,
),
text = "Choose wallet",
text = stringResourceSafe(R.string.visa_onboarding_wallet_list_header),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
@ -79,7 +78,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod
.imePadding()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = "Continue",
text = stringResourceSafe(R.string.common_continue),
onClick = state.onContinueClick,
)
}

View file

@ -14,7 +14,6 @@ import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params
import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute
@ -35,7 +34,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
private val visaAuthRepository: VisaAuthRepository,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaOTPStorage,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
) : Model() {
@ -110,9 +109,8 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
val newActivationStatus = VisaCardActivationStatus.Activated(visaAuthTokens = authTokens)
requireNotNull(
value = UserWalletBuilder(
scanResponse.copy(visaCardActivationStatus = newActivationStatus),
generateWalletNameUseCase,
value = userWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
).build(),
lazyMessage = { "User wallet not created" },
)

View file

@ -68,10 +68,10 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
}
private fun onShareClicked() {
shareManager.shareText("https://tangem.com/")
shareManager.shareText("https://tangem.com/") // TODO
}
private fun onOpenInBrowserClicked() {
urlOpener.openUrl("https://tangem.com/")
urlOpener.openUrl("https://tangem.com/") // TODO
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
@ -44,7 +45,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif
SpacerH12()
Text(
text = "Go to Website",
text = stringResourceSafe(R.string.visa_onboarding_wallet_connect_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
@ -53,9 +54,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif
SpacerH12()
Text(
text = "You will be able to complete your connection \n" +
"on the third-party web-site \n" +
"and back to the Tangem app",
text = stringResourceSafe(R.string.visa_onboarding_wallet_connect_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -68,7 +67,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
text = "Open in Browser",
text = stringResourceSafe(R.string.common_open_in_browser),
onClick = state.onOpenInBrowserClick,
)
@ -78,7 +77,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = "Share Link",
text = stringResourceSafe(R.string.common_share_link),
onClick = state.onOpenInBrowserClick,
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.SetVisaPinCodeUseCase
@ -13,7 +14,7 @@ import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -31,7 +32,7 @@ import javax.inject.Inject
internal class OnboardingVisaPinCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val authTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaAuthTokenStorage,
@ -57,24 +58,32 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
}
private fun onPinCodeChange(pin: String) {
if (pin.all { it.isDigit() }) {
if (PinCodeValidation.validateAllDigits(pin)) {
val isError = PinCodeValidation.validateLength(pin) && PinCodeValidation.validate(pin).not()
_uiState.update {
it.copy(
pinCode = pin,
submitButtonEnabled = checkPinCode(pin),
submitButtonEnabled = PinCodeValidation.validate(pin),
error = if (isError) {
resourceReference(R.string.visa_onboarding_pin_validation_error_message)
} else {
null
},
)
}
}
}
private fun onSubmitClick() {
if (checkPinCode(_uiState.value.pinCode).not()) return
val pinCode = _uiState.value.pinCode
if (PinCodeValidation.validate(pinCode).not()) return
modelScope.launch {
loading(true)
setVisaPinCodeUseCase(
pinCode = _uiState.value.pinCode,
pinCode = pinCode,
visaCardId = visaCardId,
activationOrderId = params.activationOrderInfo.orderId,
).onLeft {
@ -88,10 +97,6 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
}
}
private fun checkPinCode(pin: String): Boolean {
return pin.length == PIN_CODE_LENGTH
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(submitButtonLoading = state) }
}
@ -111,15 +116,10 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
)
requireNotNull(
value = UserWalletBuilder(
scanResponse.copy(visaCardActivationStatus = newActivationStatus),
generateWalletNameUseCase,
value = userWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
).build(),
lazyMessage = { "User wallet not created" },
)
}
private companion object {
const val PIN_CODE_LENGTH = 4
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model
internal object PinCodeValidation {
private const val PIN_CODE_LENGTH = 4
fun validate(pinCode: String): Boolean {
return sequenceOf(
::validateAllDigits,
::validateLength,
::validateNoRepeatedDigits,
::validateNoConsecutiveDigits,
).all { it(pinCode) }
}
fun validateAllDigits(pinCode: String): Boolean {
return pinCode.all { it.isDigit() }
}
fun validateLength(pinCode: String): Boolean {
return pinCode.length == PIN_CODE_LENGTH
}
private fun validateNoRepeatedDigits(pinCode: String): Boolean {
return pinCode.toSet().size == PIN_CODE_LENGTH
}
private fun validateNoConsecutiveDigits(pinCode: String): Boolean {
return pinCode.windowed(2).all { it[0] + 1 == it[1] }.not()
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -31,9 +32,14 @@ import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
import kotlinx.coroutines.delay
@ -53,7 +59,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Create\nPIN Code",
text = stringResourceSafe(R.string.visa_onboarding_pin_code_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
@ -62,7 +68,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
SpacerH16()
Text(
text = "Set up a 4-digit code.\nIt will be used for payments.",
text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -79,7 +85,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
primaryButton = NavigationButton(
textReference = TextReference.Str("Submit"),
textReference = resourceReference(R.string.common_submit),
onClick = state.onSubmitClick,
showProgress = state.submitButtonLoading,
isEnabled = state.submitButtonEnabled,
@ -91,13 +97,29 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
@Composable
private fun PinCodeSection(state: OnboardingVisaPinCodeUM, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PinCode(
modifier = modifier,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
)
PinCode(
modifier = modifier,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
)
AnimatedVisibility(
visible = state.error != null,
) {
val error = remember(this) { requireNotNull(state.error) }
Column {
SpacerH4()
Text(
text = error.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
textAlign = TextAlign.Center,
)
}
}
}
LaunchedEffect(Unit) {
delay(timeMillis = 300)
@ -134,9 +156,11 @@ private fun PinCode(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
},),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
},
),
modifier = Modifier
.alpha(alpha = 0.01f)
.focusRequester(focusRequester),
@ -194,6 +218,7 @@ private fun Preview() {
OnboardingVisaPinCode(
state = OnboardingVisaPinCodeUM(
pinCode = "1234",
error = stringReference("PIN Code error"),
),
)
}

View file

@ -1,7 +1,10 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class OnboardingVisaPinCodeUM(
val pinCode: String = "",
val error: TextReference? = null,
val onPinCodeChange: (String) -> Unit = {},
val submitButtonLoading: Boolean = false,
val submitButtonEnabled: Boolean = true,

View file

@ -16,6 +16,7 @@ import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.impl.R
@ -47,8 +48,10 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod
Text(
text = when (state.mode) {
OnboardingVisaWelcomeUM.Mode.Hello -> "Hello, ${state.userName}!"
OnboardingVisaWelcomeUM.Mode.WelcomeBack -> "Welcome back, ${state.userName}!"
OnboardingVisaWelcomeUM.Mode.Hello ->
stringResourceSafe(R.string.visa_onboarding_welcome_title)
OnboardingVisaWelcomeUM.Mode.WelcomeBack ->
stringResourceSafe(R.string.visa_onboarding_welcome_back_title)
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
@ -58,8 +61,10 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod
Text(
text = when (state.mode) {
OnboardingVisaWelcomeUM.Mode.Hello -> "bla bla bla //TODO"
OnboardingVisaWelcomeUM.Mode.WelcomeBack -> "bla bla bla? //TODO"
OnboardingVisaWelcomeUM.Mode.Hello ->
stringResourceSafe(R.string.visa_onboarding_welcome_description)
OnboardingVisaWelcomeUM.Mode.WelcomeBack ->
stringResourceSafe(R.string.visa_onboarding_welcome_back_description)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
@ -72,7 +77,7 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = "Start Activation",
text = stringResourceSafe(R.string.visa_onboarding_welcome_button_title),
onClick = state.onContinueClick,
)
}
@ -81,7 +86,7 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = "Continue Activation",
text = stringResourceSafe(R.string.visa_onboarding_welcome_back_button_title),
showProgress = state.continueButtonLoading,
iconResId = R.drawable.ic_tangem_24,
onClick = state.onContinueClick,

View file

@ -2,7 +2,6 @@ package com.tangem.features.onboarding.v2.visa.impl.child.welcome.ui.state
internal data class OnboardingVisaWelcomeUM(
val mode: Mode = Mode.Hello,
val userName: String = "",
val continueButtonLoading: Boolean = false,
val onContinueClick: () -> Unit = {},
) {

View file

@ -1,16 +1,22 @@
package com.tangem.features.onboarding.v2.visa.impl.route
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.onboarding.v2.impl.R
// TODO add translations [REDACTED_TASK_KEY]
internal fun OnboardingVisaRoute.screenTitle(): TextReference = when (this) {
OnboardingVisaRoute.AccessCode -> TextReference.Str("Access code")
is OnboardingVisaRoute.ChooseWallet -> TextReference.Str("Account activation")
is OnboardingVisaRoute.InProgress -> TextReference.Str("In progress")
is OnboardingVisaRoute.OtherWalletApproveOption -> TextReference.Str("Wallet connection")
is OnboardingVisaRoute.PinCode -> TextReference.Str("PIN code")
is OnboardingVisaRoute.TangemWalletApproveOption -> TextReference.Str("Wallet connection")
OnboardingVisaRoute.AccessCode ->
resourceReference(R.string.visa_onboarding_access_code_navigation_title)
is OnboardingVisaRoute.ChooseWallet ->
resourceReference(R.string.visa_onboarding_account_activation_navigation_title)
is OnboardingVisaRoute.InProgress ->
resourceReference(R.string.common_in_progress)
is OnboardingVisaRoute.OtherWalletApproveOption,
is OnboardingVisaRoute.TangemWalletApproveOption,
-> resourceReference(R.string.visa_onboarding_wallet_connection_navigation_title)
is OnboardingVisaRoute.PinCode ->
resourceReference(R.string.visa_onboarding_pin_code_navigation_title)
is OnboardingVisaRoute.Welcome,
is OnboardingVisaRoute.WelcomeBack,
-> TextReference.Str("Getting started")
-> resourceReference(R.string.onboarding_getting_started)
}

View file

@ -1,9 +0,0 @@
package com.tangem.features.onboarding.v2.visa.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
internal fun OnboardingVisa(modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit) {
childContent(modifier)
}

View file

@ -19,6 +19,7 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.onramp.component.OnrampSuccessComponent
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.success.entity.OnrampSuccessClickIntents
@ -42,6 +43,7 @@ internal class OnrampSuccessComponentModel @Inject constructor(
private val getOnrampStatusUseCase: GetOnrampStatusUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val onrampRemoveTransactionUseCase: OnrampRemoveTransactionUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val messageSender: UiMessageSender,
private val router: Router,
@ -79,17 +81,21 @@ internal class OnrampSuccessComponentModel @Inject constructor(
}
private suspend fun loadTransactionStatus(transaction: OnrampTransaction) {
val multiCryptoCurrency = getCryptoCurrencyUseCase(
transaction.userWalletId,
transaction.toCurrencyId,
).getOrNull()
val cryptoCurrency = multiCryptoCurrency
?: getCryptoCurrencyUseCase.invoke(transaction.userWalletId).getOrElse {
Timber.e("Crypto currency not found")
showErrorAlert(OnrampError.DomainError(null))
return
}
val userWallet = getUserWalletUseCase(transaction.userWalletId).getOrNull()
if (userWallet == null) {
Timber.e("UserWallet found")
// this case should never happened
showErrorAlert(OnrampError.DomainError("UserWallet not found"))
return
}
val cryptoCurrency = getCryptoCurrencyUseCase(
userWallet = userWallet,
cryptoCurrencyId = transaction.toCurrencyId,
).getOrElse {
Timber.e("Crypto currency not found")
showErrorAlert(OnrampError.DomainError(null))
return
}
getOnrampStatusUseCase(txId = transaction.txId)
.fold(

1
features/send-v2/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,19 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.send.v2.api"
}
dependencies {
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Domain models */
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface SendComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val transactionId: String? = null,
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
)
interface Factory : ComponentFactory<Params, SendComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.send.v2.api
interface SendFeatureToggles {
val isSendV2Enabled: Boolean
}

1
features/send-v2/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,70 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.send.v2.impl"
}
dependencies {
/** Api */
implementation(projects.features.sendV2.api)
implementation(projects.features.txhistory.api)
/** Libs */
implementation(projects.libs.crypto)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.analytics)
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
/** Tangem SDK */
implementation(tangemDeps.blockchain)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.legacy)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.settings)
implementation(projects.domain.feedback)
implementation(projects.domain.txhistory)
/** Compose libraries */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
/** Other dependencies */
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.send.v2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.api.SendFeatureToggles
internal class DefaultSendFeatureToggles(
private val featureToggles: FeatureTogglesManager,
) : SendFeatureToggles {
override val isSendV2Enabled: Boolean
get() = featureToggles.isFeatureEnabled("SEND_V2_ENABLED")
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.v2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.DefaultSendFeatureToggles
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.send.DefaultSendComponent
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal object SendFeatureModule {
@Provides
fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles {
return DefaultSendFeatureToggles(featureTogglesManager)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface SendFeatureModuleBinds {
@Binds
@Singleton
fun provideSendComponentFactory(impl: DefaultSendComponent.Factory): SendComponent.Factory
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.v2.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface SendModelModule {
@Binds
@IntoMap
@ClassKey(SendModel::class)
fun provideSendModel(model: SendModel): Model
@Binds
@IntoMap
@ClassKey(SendAmountModel::class)
fun provideSendAmountModel(model: SendAmountModel): Model
@Binds
@IntoMap
@ClassKey(SendDestinationModel::class)
fun provideSendDestinationModel(model: SendDestinationModel): Model
}

View file

@ -0,0 +1,120 @@
package com.tangem.features.send.v2.send
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendRoute>()
private val innerRouter = InnerRouter<SendRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val initialRoute = SendRoute.Empty
private val currentRoute = MutableStateFlow<SendRoute>(initialRoute)
private val model: SendModel = getOrCreateModel(params = params, router = innerRouter)
private val childStack = childStack(
key = "sendInnerStack",
source = stackNavigation,
serializer = null,
initialConfiguration = initialRoute,
handleBackButton = true,
childFactory = { configuration, factoryContext ->
createChild(
configuration,
childByContext(
componentContext = factoryContext,
router = innerRouter,
),
)
},
)
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = ::onChildBack)
}
private fun createChild(route: SendRoute, factoryContext: AppComponentContext) = when (route) {
SendRoute.Empty -> getStubComponent()
is SendRoute.Destination -> getDestinationComponent(factoryContext, route)
is SendRoute.Amount -> getAmountComponent(factoryContext, route)
is SendRoute.Fee -> getFeeComponent()
SendRoute.Confirm -> getConfirmComponent()
}
private fun getDestinationComponent(factoryContext: AppComponentContext, route: SendRoute) =
SendDestinationComponent(
appComponentContext = factoryContext,
params = SendDestinationComponent.Params(
state = model.uiState.value.destinationUM,
currentRoute = currentRoute.filterIsInstance<SendRoute.Destination>(),
analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY,
userWallet = model.userWallet,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
callback = model,
isEditMode = route.isEditMode,
),
)
private fun getAmountComponent(factoryContext: AppComponentContext, route: SendRoute) = SendAmountComponent(
appComponentContext = factoryContext,
params = SendAmountComponentParams.AmountParams(
state = model.uiState.value.amountUM,
currentRoute = currentRoute.filterIsInstance<SendRoute.Amount>(),
analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY,
userWallet = model.userWallet,
appCurrency = model.appCurrency,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
callback = model,
isEditMode = route.isEditMode,
predefinedAmountValue = model.predefinedAmountValue,
),
)
private fun getFeeComponent() = getStubComponent() // todo
private fun getConfirmComponent() = getStubComponent() // todo
private fun getStubComponent() = ComposableContentComponent { }
private fun onChildBack() {
if (childStack.value.active.configuration == SendRoute.Empty || childStack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : SendComponent.Factory {
override fun create(context: AppComponentContext, params: SendComponent.Params): DefaultSendComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.send.v2.send
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal sealed class SendRoute : Route {
abstract val isEditMode: Boolean
@Serializable
data object Empty : SendRoute() {
override val isEditMode = false
}
@Serializable
data object Confirm : SendRoute() {
override val isEditMode: Boolean = false
}
@Serializable
data class Destination(
override val isEditMode: Boolean,
) : SendRoute()
@Serializable
data class Amount(
override val isEditMode: Boolean,
) : SendRoute()
@Serializable
data class Fee(
override val isEditMode: Boolean = true,
) : SendRoute()
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.send.v2.send.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
/**
* Send screen analytics
*/
internal sealed class SendAnalyticEvents(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = SEND_CATEGORY, event = event, params = params) {
/** Recipient address screen opened */
data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
/** Amount screen opened */
data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
/** Fee screen opened */
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Confirmation screen opened */
data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** If transaction delays notification is present */
data class NoticeTransactionDelays(
val token: String,
) : SendAnalyticEvents(
event = "Notice - Transaction Delays Are Possible",
params = mapOf(TOKEN_PARAM to token),
)
/** If error occurs during send transactions */
data class TransactionError(val token: String) : SendAnalyticEvents(
event = "Error - Transaction Rejected",
params = mapOf(TOKEN_PARAM to token),
)
companion object {
const val SEND_CATEGORY = "Token / Send"
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.send.v2.send.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import kotlin.properties.Delegates
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class SendModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
) : Model(),
SendDestinationComponent.ModelCallback,
SendAmountComponent.ModelCallback {
private val _uiState = MutableStateFlow(initialState())
val uiState = _uiState.asStateFlow()
var userWallet: UserWallet by Delegates.notNull()
var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
var appCurrency: AppCurrency = AppCurrency.Default
var predefinedAmountValue: String? = null
override fun onDestinationResult(destinationUM: DestinationUM) {
_uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onAmountResult(state: AmountState) {
_uiState.update { it.copy(amountUM = state) }
}
private fun initialState(): SendUM = SendUM(
amountUM = AmountState.Empty(),
destinationUM = DestinationUM.Empty(),
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
internal data class SendUM(
val amountUM: AmountState,
val destinationUM: DestinationUM,
)

View file

@ -0,0 +1,46 @@
package com.tangem.features.send.v2.subcomponents.amount
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountBlockParams
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
internal class SendAmountBlockComponent(
appComponentContext: AppComponentContext,
private val params: AmountBlockParams,
val onResult: (AmountState) -> Unit,
val onClick: () -> Unit,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendAmountModel = getOrCreateModel(params = params, router = router)
init {
model.uiState.onEach {
onResult(it)
}.launchIn(componentScope)
}
fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val isClickEnabled = params.blockClickEnableFlow.collectAsStateWithLifecycle()
val isEditingDisabled = params.blockEditDisabledFlow.collectAsStateWithLifecycle()
AmountBlock(
amountState = state.value,
isClickDisabled = !isClickEnabled.value,
isEditingDisabled = isEditingDisabled.value,
onClick = onClick,
)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.send.v2.subcomponents.amount
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountParams
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
internal class SendAmountComponent(
appComponentContext: AppComponentContext,
private val params: AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendAmountModel = getOrCreateModel(params = params)
fun updateState(amountUM: AmountState) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
AmountScreenContent(
amountState = state,
isBalanceHidden = false,
clickIntents = model,
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
)
}
interface ModelCallback {
fun onAmountResult(amountUM: AmountState)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.send.v2.subcomponents.amount
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent.ModelCallback
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal sealed class SendAmountComponentParams {
abstract val state: AmountState
abstract val analyticsCategoryName: String
abstract val userWallet: UserWallet
abstract val appCurrency: AppCurrency
abstract val cryptoCurrencyStatus: CryptoCurrencyStatus
data class AmountParams(
override val state: AmountState,
override val analyticsCategoryName: String,
override val userWallet: UserWallet,
override val appCurrency: AppCurrency,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
val isEditMode: Boolean,
val callback: ModelCallback,
val currentRoute: Flow<SendRoute.Amount>,
val predefinedAmountValue: String?,
) : SendAmountComponentParams()
data class AmountBlockParams(
override val state: AmountState,
override val analyticsCategoryName: String,
override val userWallet: UserWallet,
override val appCurrency: AppCurrency,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
val blockEditDisabledFlow: StateFlow<Boolean>,
val blockClickEnableFlow: StateFlow<Boolean>,
) : SendAmountComponentParams()
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.send.v2.subcomponents.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
/**
* Trigger for reducing amount from another component
*/
interface SendAmountReduceTrigger {
suspend fun triggerReduceBy(reduceBy: ReduceByData)
suspend fun triggerReduceTo(reduceTo: BigDecimal)
}
/**
* Trigger for reducing amount from another component
*/
interface SendAmountReduceListener {
val reduceToTriggerFlow: Flow<BigDecimal>
val reduceByTriggerFlow: Flow<ReduceByData>
}
@Singleton
internal class DefaultSendAmountReduceTrigger @Inject constructor() :
SendAmountReduceTrigger,
SendAmountReduceListener {
override val reduceToTriggerFlow = MutableSharedFlow<BigDecimal>()
override val reduceByTriggerFlow = MutableSharedFlow<ReduceByData>()
override suspend fun triggerReduceBy(reduceBy: ReduceByData) {
reduceByTriggerFlow.emit(reduceBy)
}
override suspend fun triggerReduceTo(reduceTo: BigDecimal) {
reduceToTriggerFlow.emit(reduceTo)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.send.v2.subcomponents.amount.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
internal sealed class SendAmountAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = category, event = event, params = params) {
/** Selected currency */
data class SelectedCurrency(
val categoryName: String,
val type: SelectedCurrencyType,
) : SendAmountAnalyticEvents(
category = categoryName,
event = "Selected Currency",
params = mapOf(TYPE to type.value),
)
/** Max amount button clicked */
data class MaxAmountButtonClicked(
val categoryName: String,
) : SendAmountAnalyticEvents(category = categoryName, event = "Max Amount Taped")
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.send.v2.subcomponents.amount.di
import com.tangem.features.send.v2.subcomponents.amount.DefaultSendAmountReduceTrigger
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal object SendAmountModule {
@Provides
@Singleton
fun provideSendAmountReduceTrigger(): SendAmountReduceTrigger {
return DefaultSendAmountReduceTrigger()
}
@Provides
@Singleton
fun provideSendAmountReduceListener(): SendAmountReduceListener {
return DefaultSendAmountReduceTrigger()
}
}

View file

@ -0,0 +1,239 @@
package com.tangem.features.send.v2.subcomponents.amount.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.*
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class SendAmountModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase,
private val sendAmountReduceListener: SendAmountReduceListener,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), AmountScreenClickIntents {
private val params: SendAmountComponentParams = paramsContainer.require()
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private val analyticsCategoryName = params.analyticsCategoryName
private val userWallet = params.userWallet
private val cryptoCurrencyStatus = params.cryptoCurrencyStatus
private var minAmountBoundary: EnterAmountBoundary? = null
private var maxAmountBoundary: EnterAmountBoundary = MaxEnterAmountConverter().convert(cryptoCurrencyStatus)
init {
configAmountNavigation()
initMinBoundary()
subscribeOnAmountReduceByTriggerUpdates()
subscribeOnAmountReduceToTriggerUpdates()
}
private fun initMinBoundary() {
modelScope.launch {
minAmountBoundary = getMinimumTransactionAmountSyncUseCase(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
).getOrNull()?.let {
EnterAmountBoundary(
amount = it,
fiatRate = cryptoCurrencyStatus.value.fiatRate.orZero(),
)
}
initialState()
}
}
private fun initialState() {
val predefinedAmountValue = (params as? SendAmountComponentParams.AmountParams)?.predefinedAmountValue
if (uiState.value is AmountState.Empty) {
_uiState.update {
AmountStateConverterV2(
clickIntents = this,
appCurrency = params.appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxEnterAmount = maxAmountBoundary,
iconStateConverter = CryptoCurrencyToIconStateConverter(),
).convert(
AmountParameters(
title = stringReference(userWallet.name),
value = "",
),
)
}
if (predefinedAmountValue != null) {
onAmountValueChange(predefinedAmountValue)
}
}
}
fun updateState(amountUM: AmountState) {
if (amountUM !is AmountState.Empty) {
_uiState.value = amountUM
}
}
override fun onCurrencyChangeClick(isFiat: Boolean) {
_uiState.update(AmountCurrencyTransformer(cryptoCurrencyStatus, isFiat))
}
override fun onAmountValueChange(value: String) {
_uiState.update(
AmountFieldChangeTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxEnterAmount = maxAmountBoundary,
minimumTransactionAmount = minAmountBoundary,
value = value,
),
)
}
override fun onMaxValueClick() {
val decimalCryptoValue = cryptoCurrencyStatus.value.amount
if (decimalCryptoValue.isNullOrZero()) return
_uiState.update(
AmountFieldSetMaxAmountTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = maxAmountBoundary,
minAmount = minAmountBoundary,
),
)
analyticsEventHandler.send(
SendAmountAnalyticEvents.MaxAmountButtonClicked(categoryName = analyticsCategoryName),
)
}
override fun onAmountPasteTriggerDismiss() {
_uiState.update(AmountPastedTriggerDismissTransformer)
}
override fun onAmountNext() {
(uiState.value as? AmountState.Data)?.amountTextField?.isFiatValue?.let { isFiatSelected ->
analyticsEventHandler.send(
SendAmountAnalyticEvents.SelectedCurrency(
categoryName = analyticsCategoryName,
type = if (isFiatSelected) {
SelectedCurrencyType.AppCurrency
} else {
SelectedCurrencyType.Token
},
),
)
}
saveResult()
if ((params as? SendAmountComponentParams.AmountParams)?.isEditMode == true) {
router.pop()
} else {
router.push(SendRoute.Confirm)
}
}
private fun subscribeOnAmountReduceToTriggerUpdates() {
sendAmountReduceListener.reduceToTriggerFlow
.onEach { reduceTo ->
_uiState.update(
AmountReduceToTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumTransactionAmount = minAmountBoundary,
value = reduceTo,
),
)
}
.launchIn(modelScope)
}
private fun subscribeOnAmountReduceByTriggerUpdates() {
sendAmountReduceListener.reduceByTriggerFlow
.onEach { reduceByData ->
_uiState.update(
AmountReduceByTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumTransactionAmount = minAmountBoundary,
value = reduceByData,
),
)
}
.launchIn(modelScope)
}
private fun saveResult() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
params.callback.onAmountResult(uiState.value)
}
private fun configAmountNavigation() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach { (_, _) ->
// todo
// params.callback.onNavigationResult(
// NavigationUM.Content(
// title = resourceReference(R.string.send_amount_label),
// subtitle = null,
// backIconRes = R.drawable.ic_back_24,
// backIconClick = {
// if (route.isEditMode) {
// saveResult()
// }
// router.pop()
// },
// primaryButton = ButtonsUM.PrimaryButtonUM(
// text = if (route.isEditMode) {
// resourceReference(R.string.common_continue)
// } else {
// resourceReference(R.string.common_next)
// },
// isEnabled = state.isPrimaryButtonEnabled,
// onClick = ::onAmountNext,
// ),
// prevButton = ButtonsUM.PrimaryButtonUM(
// text = TextReference.EMPTY,
// iconResId = R.drawable.ic_back_24,
// isEnabled = true,
// onClick = {
// saveResult()
// router.pop()
// },
// ).takeIf { route.isEditMode.not() },
// secondaryPairButtonsUM = null,
// ),
// )
}.launchIn(modelScope)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.send.v2.subcomponents.destination
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel
import com.tangem.features.send.v2.subcomponents.destination.ui.SendDestinationContent
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import kotlinx.coroutines.flow.Flow
internal class SendDestinationComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendDestinationModel = getOrCreateModel(params = params, router = router)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
SendDestinationContent(state = state.value, model, isBalanceHidden = false)
}
data class Params(
val state: DestinationUM,
val analyticsCategoryName: String,
val userWallet: UserWallet,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val callback: ModelCallback,
val currentRoute: Flow<SendRoute.Destination>,
val isEditMode: Boolean,
)
interface ModelCallback {
fun onDestinationResult(destinationUM: DestinationUM)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.analytics
internal enum class EnterAddressSource {
QRCode,
PasteButton,
RecentAddress,
InputField,
;
val isPasted: Boolean
get() = this != InputField
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.send.v2.subcomponents.destination.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION
internal sealed class SendDestinationAnalyticEvents(
category: String,
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = category, event = event, params = params) {
abstract val categoryName: String
/** Address to send entered */
data class AddressEntered(
override val categoryName: String,
val source: EnterAddressSource,
val isValid: Boolean,
) : SendDestinationAnalyticEvents(
category = categoryName,
event = "Address Entered",
params = mapOf(
SOURCE to source.name,
VALIDATION to if (isValid) "Success" else "Fail",
),
)
/** Qr Code button clicked */
data class QrCodeButtonClicked(
override val categoryName: String,
) :
SendDestinationAnalyticEvents(
category = categoryName,
event = "Button - QR Code",
)
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.model
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
internal interface SendDestinationClickIntents {
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource)
fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean = false)
fun onQrCodeScanClick()
}

View file

@ -0,0 +1,306 @@
package com.tangem.features.send.v2.subcomponents.destination.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.IsUtxoConsolidationAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.v2.send.SendRoute
import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.*
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class SendDestinationModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
private val validateWalletMemoUseCase: ValidateWalletMemoUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val isUtxoConsolidationAvailableUseCase: IsUtxoConsolidationAvailableUseCase,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val parseQrCodeUseCase: ParseQrCodeUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), SendDestinationClickIntents {
private val params: SendDestinationComponent.Params = paramsContainer.require()
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
private val analyticsCategoryName = params.analyticsCategoryName
private val userWallet = params.userWallet
private val cryptoCurrencyStatus = params.cryptoCurrencyStatus
private val cryptoCurrency = cryptoCurrencyStatus.currency
private var validationJobHolder = JobHolder()
init {
configDestinationNavigation()
initialState()
getWalletsAndRecent()
subscribeOnQRScannerResult()
}
private fun initialState() {
if (uiState.value is DestinationUM.Empty) {
_uiState.update(
SendDestinationInitialStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
onAddressChange = ::onRecipientAddressValueChange,
onMemoChange = ::onRecipientMemoValueChange,
),
)
}
}
fun updateState(state: DestinationUM) {
if (state !is DestinationUM.Empty) {
_uiState.value = state
}
}
override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource) {
_uiState.update(
SendDestinationAddressTransformer(
address = value,
isPasted = type.isPasted,
),
)
val memo = (uiState.value as? DestinationUM.Content)?.memoTextField?.value
validate(address = value, memo = memo, type)
}
override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) {
_uiState.update(
SendDestinationMemoTransformer(
memo = value,
isPasted = isValuePasted,
),
)
val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.value.orEmpty()
validate(address = address, memo = value)
}
override fun onQrCodeScanClick() {
analyticsEventHandler.send(SendDestinationAnalyticEvents.QrCodeButtonClicked(analyticsCategoryName))
router.push(
AppRoute.QrScanning(
source = AppRoute.QrScanning.Source.SEND,
networkName = cryptoCurrency.network.name,
),
)
}
private fun subscribeOnQRScannerResult() {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
.onEach(::onQrCodeScanned)
.launchIn(modelScope)
}
private fun onQrCodeScanned(address: String) {
parseQrCodeUseCase(address, cryptoCurrency).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {
onRecipientAddressValueChange(address, EnterAddressSource.QRCode)
},
)
}
private fun getWalletsAndRecent() {
combine(
flow = getWalletsUseCase().conflate().map {
it.toAvailableWallets()
},
flow2 = getFixedTxHistoryItemsUseCase(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
pageSize = RECENT_TX_SIZE,
).getOrElse { flowOf(emptyList()) }.conflate(),
) { destinationWalletList, txHistoryList ->
val isUtxoConsolidationAvailable = isUtxoConsolidationAvailableUseCase.invokeSync(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
)
_uiState.update(
SendDestinationRecentListTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
isUtxoConsolidationAvailable = isUtxoConsolidationAvailable,
destinationWalletList = destinationWalletList,
txHistoryList,
),
)
}.launchIn(modelScope)
}
private suspend fun List<UserWallet>.toAvailableWallets(): List<DestinationWalletUM> {
return coroutineScope {
val cryptoCurrencyNetwork = cryptoCurrency.network
return@coroutineScope filterNot { it.isLocked }
.map { wallet ->
async {
val addresses = if (!wallet.isMultiCurrency) {
getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let {
if (it.network.id == cryptoCurrencyNetwork.id) {
getNetworkAddressesUseCase.invokeSync(wallet.walletId, it.network)
} else {
null
}
}
} else {
getNetworkAddressesUseCase.invokeSync(wallet.walletId, cryptoCurrencyNetwork)
}
wallet to addresses
}
}.awaitAll()
.asSequence()
.mapNotNull { (wallet, addresses) ->
addresses?.map { (cryptoCurrency, address) ->
DestinationWalletUM(
name = wallet.name,
address = address,
cryptoCurrency = cryptoCurrency,
userWalletId = wallet.walletId,
)
}
}.flatten()
.toList()
}
}
private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) {
modelScope.launch {
_uiState.update(SendDestinationValidationStartedTransformer)
val addressValidationResult = validateWalletAddressUseCase(
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
address = address,
currencyAddress = cryptoCurrencyStatus.value.networkAddress?.availableAddresses,
)
val memoValidationResult = validateWalletMemoUseCase(
memo = memo.orEmpty(),
network = cryptoCurrency.network,
)
type?.let {
analyticsEventHandler.send(
SendDestinationAnalyticEvents.AddressEntered(
categoryName = analyticsCategoryName,
source = it,
isValid = addressValidationResult.isRight(),
),
)
}
_uiState.update(SendDestinationValidationResultTransformer(addressValidationResult, memoValidationResult))
autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight())
}.saveIn(validationJobHolder)
}
private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) {
val isRecent = type == EnterAddressSource.RecentAddress
if (isRecent && isValidAddress && isValidMemo) onNextClick()
}
private fun saveResult() {
params.callback.onDestinationResult(uiState.value)
}
private fun onNextClick() {
saveResult()
if (params.isEditMode) {
router.pop()
} else {
router.push(SendRoute.Amount(isEditMode = false))
}
}
private fun configDestinationNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach {
// todo
// params.callback.onNavigationResult(
// NavigationUM.Content(
// title = resourceReference(R.string.send_recipient_label),
// subtitle = null,
// backIconRes = if (route.isEditMode) {
// R.drawable.ic_back_24
// } else {
// R.drawable.ic_close_24
// },
// backIconClick = {
// if (route.isEditMode) {
// router.pop()
// } else {
// appRouter.pop()
// }
// },
// additionalIconRes = R.drawable.ic_qrcode_scan_24,
// additionalIconClick = {
// router.push(
// AppRoute.QrScanning(
// source = AppRoute.QrScanning.Source.SEND,
// networkName = cryptoCurrency.network.name,
// ),
// )
// },
// primaryButton = ButtonsUM.PrimaryButtonUM(
// text = if (route.isEditMode) {
// resourceReference(R.string.common_continue)
// } else {
// resourceReference(R.string.common_next)
// },
// isEnabled = state.isPrimaryButtonEnabled,
// onClick = ::onNextClick,
// ),
// prevButton = null,
// secondaryPairButtonsUM = null,
// ),
// )
}.launchIn(modelScope)
}
private companion object {
const val RECENT_TX_SIZE = 100
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.send.v2.subcomponents.destination.model.converters
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_DEFAULT_COUNT
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_KEY_TAG
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientHistoryListConverter(
private val cryptoCurrency: CryptoCurrency,
) : Converter<List<TxHistoryItem>, ImmutableList<DestinationRecipientListUM>> {
override fun convert(value: List<TxHistoryItem>): ImmutableList<DestinationRecipientListUM> {
return value.filterRecipients(cryptoCurrency).ifEmpty {
emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT)
}
}
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
val notZero = !item.amount.isZero()
isTransfer && isSingleAddress && isNotContract && item.isOutgoing && notZero
}
.take(RECENT_LIST_SIZE)
.mapIndexed { index, tx ->
DestinationRecipientListUM(
id = "${RECENT_KEY_TAG}$index",
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.toPersistentList()
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> resourceReference(
R.string.transaction_history_multiple_addresses,
)
is TxHistoryItem.DestinationType.Single -> stringReference(destination.addressType.address)
}
} else {
when (val source = sourceType) {
is TxHistoryItem.SourceType.Multiple -> resourceReference(R.string.transaction_history_multiple_addresses)
is TxHistoryItem.SourceType.Single -> stringReference(source.address)
}
}
private fun TxHistoryItem.extractIconRes() = if (isOutgoing) {
R.drawable.ic_arrow_up_24
} else {
R.drawable.ic_arrow_down_24
}
private fun TxHistoryItem.getAmount(cryptoCurrency: CryptoCurrency): String {
return amount.format { crypto(cryptoCurrency) }
}
private fun TxHistoryItem.extractTimestamp(): TextReference {
val date = timestampInMillis.toDateFormatWithTodayYesterday(
formatter = DateTimeFormatters.dateDDMMYYYY,
)
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
companion object {
private const val RECENT_LIST_SIZE = 10
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.send.v2.subcomponents.destination.model.converters
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_KEY_TAG
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class SendRecipientWalletListConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val isUtxoConsolidationAvailable: Boolean,
) :
Converter<List<DestinationWalletUM?>, PersistentList<DestinationRecipientListUM>> {
override fun convert(value: List<DestinationWalletUM?>): PersistentList<DestinationRecipientListUM> {
return value.filterWallets().ifEmpty {
emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT)
}
}
private fun List<DestinationWalletUM?>.filterWallets(): PersistentList<DestinationRecipientListUM> {
var walletsCounter = 0
val currentAddress: String = runCatching {
cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
}.getOrNull().orEmpty()
return this.filterNotNull()
.filter {
val isCoin = it.cryptoCurrency is CryptoCurrency.Coin
val isNotSameAddress = it.address != currentAddress
val isNotBlankAddress = it.address.isNotBlank()
isNotBlankAddress && isCoin && (isNotSameAddress || isUtxoConsolidationAvailable)
}
.groupBy { item -> item.name }
.values.map { wallets ->
val groupedByWallet = wallets.groupBy { it.userWalletId }
var i = 0
groupedByWallet
.flatMap { item ->
item.value.map { wallet ->
val name = if (groupedByWallet.size > 1) {
"${wallet.name} ${++i}"
} else {
wallet.name
}
DestinationRecipientListUM(
id = "${WALLET_KEY_TAG}${walletsCounter++}",
title = stringReference(wallet.address),
subtitle = stringReference(name),
address = wallet.address,
userWalletId = wallet.userWalletId,
network = wallet.cryptoCurrency.network,
)
}
}
}
.flatten()
.toPersistentList()
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import kotlinx.collections.immutable.toPersistentList
internal const val WALLET_DEFAULT_COUNT = 1
internal const val RECENT_DEFAULT_COUNT = 3
internal const val WALLET_KEY_TAG = "wallet"
internal const val RECENT_KEY_TAG = "recent"
internal fun loadingListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
DestinationRecipientListUM(
id = "$tag$it",
isLoading = true,
),
)
}
}.toPersistentList()
internal fun emptyListState(tag: String, count: Int) = buildList {
repeat(count) {
add(
DestinationRecipientListUM(
id = "$tag$it",
isLoading = false,
isVisible = false,
),
)
}
}.toPersistentList()

View file

@ -0,0 +1,18 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationAddressTransformer(
private val address: String,
private val isPasted: Boolean,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
addressTextField = state.addressTextField.copy(value = address, isValuePasted = isPasted),
)
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationInitialStateTransformer(
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val onAddressChange: (String, EnterAddressSource) -> Unit,
val onMemoChange: (String, Boolean) -> Unit,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val memoType = when (cryptoCurrencyStatus.currency.network.transactionExtrasType) {
Network.TransactionExtrasType.NONE -> null
Network.TransactionExtrasType.MEMO -> R.string.send_extras_hint_memo
Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field
}
return DestinationUM.Content(
isPrimaryButtonEnabled = false,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = "",
onValueChange = { onAddressChange(it, EnterAddressSource.InputField) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Text,
),
error = null,
placeholder = resourceReference(R.string.send_enter_address_field),
label = resourceReference(R.string.send_recipient),
isValuePasted = false,
),
memoTextField = memoType?.let {
DestinationTextFieldUM.RecipientMemo(
value = "",
onValueChange = { onMemoChange(it, false) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text,
),
placeholder = resourceReference(R.string.send_optional_field),
label = resourceReference(memoType),
error = resourceReference(R.string.send_memo_destination_tag_error),
disabledText = resourceReference(R.string.send_additional_field_already_included),
isEnabled = true,
isValuePasted = false,
)
},
wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT),
recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT),
networkName = cryptoCurrencyStatus.currency.network.name,
isValidating = false,
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationMemoTransformer(
private val memo: String,
private val isPasted: Boolean,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
memoTextField = state.memoTextField?.copy(value = memo, isValuePasted = isPasted),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientHistoryListConverter
import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientWalletListConverter
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationRecentListTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val isUtxoConsolidationAvailable: Boolean,
private val destinationWalletList: List<DestinationWalletUM>,
private val txHistoryList: List<TxHistoryItem>,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(
wallets = SendRecipientWalletListConverter(
cryptoCurrencyStatus,
isUtxoConsolidationAvailable,
).convert(destinationWalletList),
recent = SendRecipientHistoryListConverter(
cryptoCurrency = cryptoCurrencyStatus.currency,
).convert(txHistoryList),
)
}
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import arrow.core.Either
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.error.ValidateMemoError
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal class SendDestinationValidationResultTransformer(
private val addressValidationResult: AddressValidationResult,
private val memoValidationResult: Either<ValidateMemoError, Unit>,
) : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
val isValidAddress = addressValidationResult.isRight()
val isValidMemo = memoValidationResult.isRight()
val addressErrorText = addressValidationResult.mapLeft {
when (it) {
is AddressValidation.Error.DataError,
AddressValidation.Error.InvalidAddress,
-> R.string.send_recipient_address_error
AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet
}
}.leftOrNull()
return state.copy(
isValidating = false,
isPrimaryButtonEnabled = isValidAddress && isValidMemo,
addressTextField = state.addressTextField.copy(
error = addressErrorText?.let(::resourceReference),
isError = state.addressTextField.value.isNotEmpty() && !isValidAddress,
),
memoTextField = state.memoTextField?.copy(
isError = state.memoTextField.value.isNotEmpty() && !isValidMemo,
isEnabled = !shouldDisableMemo(),
),
)
}
/** Ripple X-Address contains memo, so memo field is unnecessary */
private fun shouldDisableMemo(): Boolean {
return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress }
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.subcomponents.destination.model.transformers
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import com.tangem.utils.transformer.Transformer
internal object SendDestinationValidationStartedTransformer : Transformer<DestinationUM> {
override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(isValidating = true)
}
}

View file

@ -0,0 +1,245 @@
package com.tangem.features.send.v2.subcomponents.destination.ui
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.v2.impl.R
/**
* Row item with title and subtitle
*
* @param title title
* @param subtitle subtitle
* @param onClick click listener
* @param modifier modifier
* @param info info
* @param subtitleEndOffset offset for subtitle ellipsis
* @param subtitleIconRes icon
*/
@Composable
fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
isLoading: Boolean = false,
) {
AnimatedContent(
targetState = isLoading,
label = "Recent List Content Animation",
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { isLoadingState ->
if (isLoadingState) {
ListItemLoading(modifier = modifier)
} else {
ListItemWithIcon(
title = title,
subtitle = subtitle,
onClick = onClick,
info = info,
subtitleEndOffset = subtitleEndOffset,
subtitleIconRes = subtitleIconRes,
modifier = modifier,
)
}
}
}
@Composable
private fun ListItemWithIcon(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
info: String? = null,
subtitleEndOffset: Int = 0,
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable { hapticFeedback() }
.padding(horizontal = 12.dp),
) {
IdentIcon(
address = title,
modifier = Modifier
.padding(vertical = 8.dp)
.size(40.dp)
.clip(RoundedCornerShape(20.dp)),
)
Column(
modifier = Modifier
.height(36.dp)
.padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
modifier = Modifier,
)
Row {
if (subtitleIconRes != null) {
Icon(
painter = painterResource(id = subtitleIconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = 2.dp)
.size(16.dp)
.background(TangemTheme.colors.background.tertiary, CircleShape)
.padding(2.dp),
)
}
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
)
}
}
}
}
@Composable
private fun ListItemLoading(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
) {
CircleShimmer(
modifier = Modifier
.padding(vertical = 8.dp)
.size(40.dp),
)
Column(
modifier = Modifier
.height(36.dp)
.padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
RectangleShimmer(
radius = 3.dp,
modifier = Modifier.size(
width = 70.dp,
height = 12.dp,
),
)
RectangleShimmer(
radius = 3.dp,
modifier = Modifier.size(
width = 52.dp,
height = 12.dp,
),
)
}
}
}
// region preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ListItemWithIconPreview(
@PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig,
) {
TangemThemePreview {
ListItemWithIcon(
title = config.title,
subtitle = config.subtitle,
subtitleEndOffset = config.subtitleEndOffset,
subtitleIconRes = config.iconRes,
onClick = {},
isLoading = config.isLoading,
)
}
}
private data class ListItemWithIconPreviewConfig(
val title: String,
val subtitle: String,
val info: String? = null,
val subtitleEndOffset: Int = 0,
val iconRes: Int? = null,
val isLoading: Boolean = false,
)
private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvider<ListItemWithIconPreviewConfig>(
collection = listOf(
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "1 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "Wallet",
),
ListItemWithIconPreviewConfig(
title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
subtitle = "0.000000000000000000000000000000 BTC",
info = "0.0.0000 at 00:00",
subtitleEndOffset = "BTC".length,
iconRes = R.drawable.ic_arrow_down_24,
isLoading = true,
),
),
)
//endregion

View file

@ -0,0 +1,248 @@
package com.tangem.features.send.v2.subcomponents.destination.ui
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationClickIntents
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM
import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM
import kotlinx.collections.immutable.ImmutableList
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
@Composable
internal fun SendDestinationContent(
state: DestinationUM,
clickIntents: SendDestinationClickIntents,
isBalanceHidden: Boolean,
) {
if (state !is DestinationUM.Content) return
val recipients = state.recent
val wallets = state.wallets
val memoField = state.memoTextField
val address = state.addressTextField
val isValidating by remember(state.isValidating) { derivedStateOf { state.isValidating } }
val isError by remember(address.isError) { derivedStateOf { address.isError } }
LazyColumn(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
addressItem(
address = address,
networkName = state.networkName,
isError = isError,
isValidating = isValidating,
onAddressChange = clickIntents::onRecipientAddressValueChange,
)
memoField(
memoField = memoField,
onMemoChange = clickIntents::onRecipientMemoValueChange,
)
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
isVisible = wallets.isNotEmpty() && wallets.first().isVisible,
isFirst = true,
)
listItem(
list = wallets,
clickIntents = clickIntents,
isLast = recipients.any { !it.isVisible },
isBalanceHidden = isBalanceHidden,
)
listHeaderItem(
titleRes = R.string.send_recent_transactions,
isVisible = recipients.isNotEmpty() && recipients.first().isVisible,
isFirst = wallets.any { !it.isVisible },
)
listItem(
list = recipients,
clickIntents = clickIntents,
isLast = true,
isBalanceHidden = isBalanceHidden,
)
}
}
private fun LazyListScope.addressItem(
address: DestinationTextFieldUM.RecipientAddress,
networkName: String,
isError: Boolean,
isValidating: Boolean,
onAddressChange: (String, EnterAddressSource) -> Unit,
) {
item(key = ADDRESS_FIELD_KEY) {
FooterContainer(
footer = resourceReference(R.string.send_recipient_address_footer, wrappedList(networkName)),
) {
InputRowRecipient(
value = address.value,
title = address.label,
placeholder = address.placeholder,
onValueChange = { onAddressChange(it, EnterAddressSource.InputField) },
onPasteClick = { onAddressChange(it, EnterAddressSource.PasteButton) },
isError = isError,
isLoading = isValidating,
error = address.error,
isValuePasted = address.isValuePasted,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
}
private fun LazyListScope.memoField(
memoField: DestinationTextFieldUM.RecipientMemo?,
onMemoChange: (String, Boolean) -> Unit,
) {
if (memoField != null) {
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
value = memoField.value,
label = memoField.label,
placeholder = placeholder,
footer = resourceReference(R.string.send_recipient_memo_footer),
onValueChange = { onMemoChange(it, false) },
onPasteClick = { onMemoChange(it, true) },
modifier = Modifier.padding(top = 20.dp),
labelStyle = TangemTheme.typography.subtitle2,
isError = memoField.isError,
error = memoField.error,
isReadOnly = !memoField.isEnabled,
isValuePasted = memoField.isValuePasted,
)
}
}
}
private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) {
item(key = titleRes) {
AnimateRecentAppearance(isVisible) {
val (topPadding, paddingFromTop) = if (isFirst) {
20.dp to 12.dp
} else {
0.dp to 8.dp
}
val topRadius = if (isFirst) 16.dp else 0.dp
Text(
text = stringResourceSafe(titleRes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.clip(
RoundedCornerShape(
topEnd = topRadius,
topStart = topRadius,
),
)
.background(TangemTheme.colors.background.action)
.padding(
top = paddingFromTop,
bottom = 12.dp,
start = 12.dp,
end = 12.dp,
),
)
}
}
}
private fun LazyListScope.listItem(
list: ImmutableList<DestinationRecipientListUM>,
clickIntents: SendDestinationClickIntents,
isLast: Boolean,
isBalanceHidden: Boolean,
) {
items(
count = list.size,
key = { list[it].id },
contentType = { list[it]::class.java },
) { index ->
val item = list[index]
val title = item.title.resolveReference()
if (item.isVisible) {
ListItemWithIcon(
title = title,
subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
onClick = {
clickIntents.onRecipientAddressValueChange(
title,
EnterAddressSource.RecentAddress,
)
},
isLoading = item.isLoading,
modifier = Modifier
.animateItem()
.then(
if (isLast && index == list.lastIndex) {
Modifier
.clip(
shape = RoundedCornerShape(
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
)
}
}
}
@Composable
private fun AnimateRecentAppearance(isVisible: Boolean, content: @Composable () -> Unit) {
AnimatedContent(
targetState = isVisible,
label = "Item Appearance Animation",
transitionSpec = {
(slideInHorizontally() + fadeIn())
.togetherWith(slideOutVertically() + fadeOut())
},
) {
if (it) {
content()
} else {
Box(modifier = Modifier.fillMaxWidth())
}
}
}

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