Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-15 16:11:50 +04:00
commit dbe235560b
428 changed files with 10165 additions and 3907 deletions

View file

@ -25,7 +25,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TestTags
import com.tangem.core.ui.test.DetailsScreenTestTags
import com.tangem.features.details.component.preview.PreviewDetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
@ -67,7 +67,7 @@ private fun Content(
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.testTag(TestTags.DETAILS_SCREEN),
modifier = modifier.testTag(DetailsScreenTestTags.SCREEN_CONTAINER),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
contentPadding = PaddingValues(
top = TangemTheme.dimens.spacing12,
@ -120,7 +120,7 @@ private fun Block(
) {
val itemModifier = Modifier
.fillMaxWidth()
.testTag(TestTags.DETAILS_SCREEN_ITEM)
.testTag(DetailsScreenTestTags.SCREEN_ITEM)
when (model) {
is DetailsItemUM.Basic -> {

View file

@ -5,10 +5,8 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.block.model.BlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.impl.BuildConfig
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -76,17 +74,6 @@ internal class ItemsBuilder @Inject constructor(private val router: Router) {
onClick = { router.push(AppRoute.AppSettings) },
),
).let(::add)
if (BuildConfig.TESTER_MENU_ENABLED) {
DetailsItemUM.Basic.Item(
id = "tester_menu",
block = BlockUM(
text = stringReference(value = "Tester menu"),
iconRes = R.drawable.ic_alert_24,
onClick = { router.push(AppRoute.TesterMenu) },
),
).let(::add)
}
}.toImmutableList(),
)

View file

@ -32,8 +32,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TestTags.DISCLAIMER_SCREEN_ACCEPT_BUTTON
import com.tangem.core.ui.test.TestTags.DISCLAIMER_SCREEN_CONTAINER
import com.tangem.core.ui.test.DisclaimerScreenTestTags
import com.tangem.core.ui.webview.applySafeSettings
import com.tangem.features.disclaimer.impl.R
import com.tangem.features.disclaimer.impl.entity.DisclaimerUM
@ -58,7 +57,7 @@ internal fun DisclaimerScreen(state: DisclaimerUM) {
modifier = Modifier
.background(backgroundColor)
.statusBarsPadding()
.testTag(DISCLAIMER_SCREEN_CONTAINER),
.testTag(DisclaimerScreenTestTags.SCREEN_CONTAINER),
) {
Column(
modifier = Modifier
@ -183,7 +182,7 @@ private fun BoxScope.DisclaimerButton(onAccept: (Boolean) -> Unit) {
disabledContentColor = TangemColorPalette.Dark6,
),
modifier = Modifier
.testTag(DISCLAIMER_SCREEN_ACCEPT_BUTTON)
.testTag(DisclaimerScreenTestTags.ACCEPT_BUTTON)
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(

View file

@ -26,6 +26,10 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
@ -36,6 +40,8 @@ dependencies {
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
implementation(tangemDeps.hot.core)
implementation(tangemDeps.hot.android)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)

View file

@ -1,18 +1,29 @@
package com.tangem.features.hotwallet.createmobilewallet
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.hotwallet.createmobilewallet.entity.CreateMobileWalletUM
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
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
@ModelScoped
internal class CreateMobileWalletModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveUserWalletUseCase: SaveWalletUseCase,
private val router: Router,
private val tangemHotSdk: TangemHotSdk,
) : Model() {
internal val uiState: StateFlow<CreateMobileWalletUM>
@ -20,10 +31,28 @@ internal class CreateMobileWalletModel @Inject constructor(
CreateMobileWalletUM(
onBackClick = { router.pop() },
onCreateClick = ::onCreateClick,
createButtonLoading = false,
),
)
private fun onCreateClick() {
// TODO create a wallet
modelScope.launch {
uiState.update {
it.copy(createButtonLoading = true)
}
runCatching {
val hotWalletId = tangemHotSdk.generateWallet(HotAuth.NoAuth, mnemonicType = MnemonicType.Words12)
val hotUserWalletBuilder = hotUserWalletBuilderFactory.create(hotWalletId)
saveUserWalletUseCase(
hotUserWalletBuilder.build(),
)
router.push(AppRoute.Wallet)
}.onFailure {
uiState.update {
it.copy(createButtonLoading = false)
}
}
}
}
}

View file

@ -12,10 +12,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultCreateMobileWalletComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Unit,
) : CreateMobileWalletComponent, AppComponentContext by context {
private val model: CreateMobileWalletModel = getOrCreateModel(params)
@Composable

View file

@ -7,24 +7,30 @@ import com.tangem.features.hotwallet.createmobilewallet.DefaultCreateMobileWalle
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object CreateMobileWalletModule
@Module
@InstallIn(SingletonComponent::class)
internal interface CreateMobileWalletModuleBinds {
@InstallIn(ActivityComponent::class)
internal interface CreateMobileWalletModuleActivityBinds {
@Binds
@Singleton
@ActivityScoped
fun bindCreateMobileWalletComponentFactory(
impl: DefaultCreateMobileWalletComponent.Factory,
): CreateMobileWalletComponent.Factory
}
@Module
@InstallIn(SingletonComponent::class)
internal interface CreateMobileWalletModuleBinds {
@Binds
@IntoMap

View file

@ -1,6 +1,7 @@
package com.tangem.features.hotwallet.createmobilewallet.entity
internal data class CreateMobileWalletUM(
val createButtonLoading: Boolean,
val onBackClick: () -> Unit,
val onCreateClick: () -> Unit,
)

View file

@ -84,8 +84,8 @@ internal fun CreateMobileWalletContent(state: CreateMobileWalletUM, modifier: Mo
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = "Create",
showProgress = false,
text = stringResourceSafe(R.string.common_create),
showProgress = state.createButtonLoading,
enabled = true,
onClick = state.onCreateClick,
)
@ -133,6 +133,7 @@ private fun PreviewCreateWalletContent() {
CreateMobileWalletContent(
state = CreateMobileWalletUM(
onBackClick = {},
createButtonLoading = false,
onCreateClick = {},
),
)

View file

@ -0,0 +1,39 @@
package com.tangem.features.hotwallet.manualbackup.check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.crypto.bip39.Mnemonic
import com.tangem.features.hotwallet.manualbackup.check.model.ManualBackupCheckModel
import com.tangem.features.hotwallet.manualbackup.check.ui.ManualBackupCheckContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class ManualBackupCheckComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: ManualBackupCheckModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ManualBackupCheckContent(
state = state,
modifier = modifier,
)
}
interface ModelCallbacks {
fun onCompleteClick()
}
data class Params(
val generatedWords: Mnemonic,
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.hotwallet.manualbackup.check.entity
import androidx.compose.ui.text.input.TextFieldValue
import kotlinx.collections.immutable.ImmutableList
internal data class ManualBackupCheckUM(
val onCompleteButtonClick: () -> Unit,
val wordFields: ImmutableList<WordField>,
val completeButtonEnabled: Boolean,
val completeButtonProgress: Boolean,
) {
data class WordField(
val index: Int,
val word: TextFieldValue,
val error: Boolean,
val onChange: (TextFieldValue) -> Unit,
)
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.hotwallet.manualbackup.check.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
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.hotwallet.manualbackup.check.ManualBackupCheckComponent
import com.tangem.features.hotwallet.manualbackup.check.entity.ManualBackupCheckUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import kotlin.Boolean
import kotlin.Int
import kotlin.String
import kotlin.Suppress
import kotlin.collections.List
import kotlin.collections.all
import kotlin.collections.map
import kotlin.error
@Stable
@ModelScoped
internal class ManualBackupCheckModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ManualBackupCheckComponent.Params>()
private val callbacks = params.callbacks
internal val uiState: StateFlow<ManualBackupCheckUM>
field = MutableStateFlow(getInitialUIState())
@Suppress("MagicNumber")
private fun getInitialUIState(): ManualBackupCheckUM {
val wordFields = List(WORD_FIELD_INDICES.size) { index ->
val shownIndex = WORD_FIELD_INDICES[index]
ManualBackupCheckUM.WordField(
index = shownIndex,
word = TextFieldValue(""),
error = false,
onChange = { textFieldValue ->
updateWordField(shownIndex, textFieldValue)
},
)
}.toImmutableList()
return ManualBackupCheckUM(
wordFields = wordFields,
completeButtonEnabled = false,
completeButtonProgress = false,
onCompleteButtonClick = {
val currentUIState = uiState.value
if (currentUIState.completeButtonEnabled) {
callbacks.onCompleteClick()
}
},
)
}
private fun updateWordField(shownIndex: Int, newText: TextFieldValue) {
uiState.update { currentState ->
val updatedFields = currentState.wordFields.map { wordField ->
if (wordField.index == shownIndex) {
val isCorrect = checkWordField(newText.text, shownIndex)
wordField.copy(
word = newText,
error = !isCorrect,
)
} else {
wordField
}
}.toImmutableList()
val allFieldsCorrect = updatedFields.all { field ->
checkWordField(field.word.text, field.index)
}
currentState.copy(
wordFields = updatedFields,
completeButtonEnabled = allFieldsCorrect,
)
}
}
private fun checkWordField(word: String, shownIndex: Int): Boolean {
val generatedWords = params.generatedWords
val wordList = generatedWords.mnemonicComponents
return if (shownIndex <= wordList.size) {
wordList[shownIndex - 1] == word
} else {
false
}
}
companion object {
private val WORD_FIELD_INDICES = listOf(2, 7, 11)
}
}

View file

@ -0,0 +1,147 @@
package com.tangem.features.hotwallet.manualbackup.check.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
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.fastForEachIndexed
import com.tangem.core.ui.components.OutlineTextField
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.fields.contextmenu.DisableContextMenu
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.hotwallet.impl.R
import com.tangem.features.hotwallet.manualbackup.check.entity.ManualBackupCheckUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun ManualBackupCheckContent(state: ManualBackupCheckUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.imePadding(),
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.weight(1f),
) {
Text(
text = stringResourceSafe(R.string.onboarding_seed_user_validation_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 48.dp, start = 36.dp, end = 36.dp, bottom = 16.dp)
.fillMaxWidth(),
)
Text(
text = stringResourceSafe(R.string.onboarding_seed_user_validation_message),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 36.dp)
.fillMaxWidth(),
)
Fields(
state = state,
modifier = Modifier
.padding(vertical = 30.dp, horizontal = 16.dp),
)
}
PrimaryButton(
modifier = Modifier
.padding(16.dp)
.imePadding()
.fillMaxWidth(),
text = stringResourceSafe(id = R.string.common_continue),
enabled = state.completeButtonEnabled,
showProgress = state.completeButtonProgress,
onClick = state.onCompleteButtonClick,
)
}
}
@Composable
private fun Fields(state: ManualBackupCheckUM, modifier: Modifier = Modifier) {
val focusManager = LocalFocusManager.current
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
DisableContextMenu {
state.wordFields.fastForEachIndexed { index, field ->
OutlineTextField(
value = field.word,
onValueChange = field.onChange,
label = field.index.toString(),
isError = field.error,
keyboardOptions = KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = if (index == state.wordFields.lastIndex) ImeAction.Done else ImeAction.Next,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
onNext = { focusManager.moveFocus(focusDirection = FocusDirection.Down) },
),
)
}
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
ManualBackupCheckContent(
state = ManualBackupCheckUM(
onCompleteButtonClick = {},
wordFields = persistentListOf(
ManualBackupCheckUM.WordField(
index = 2,
word = TextFieldValue(text = "word"),
onChange = {},
error = false,
),
ManualBackupCheckUM.WordField(
index = 7,
word = TextFieldValue(text = "wor"),
onChange = {},
error = true,
),
ManualBackupCheckUM.WordField(
index = 11,
word = TextFieldValue(text = "word"),
onChange = {},
error = false,
),
),
completeButtonEnabled = false,
completeButtonProgress = false,
),
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.hotwallet.manualbackup.completed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.features.hotwallet.manualbackup.completed.ui.ManualBackupCompletedContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class ManualBackupCompletedComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: ManualBackupCompletedModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ManualBackupCompletedContent(
state = state,
modifier = modifier,
)
}
interface ModelCallbacks {
fun onContinueClick()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.hotwallet.manualbackup.completed
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.hotwallet.manualbackup.completed.entity.ManualBackupCompletedUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class ManualBackupCompletedModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: ManualBackupCompletedComponent.Params = paramsContainer.require()
internal val uiState: StateFlow<ManualBackupCompletedUM>
field = MutableStateFlow(
ManualBackupCompletedUM(
onContinueClick = params.callbacks::onContinueClick,
),
)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.hotwallet.manualbackup.completed.entity
internal data class ManualBackupCompletedUM(
val onContinueClick: () -> Unit,
)

View file

@ -0,0 +1,86 @@
package com.tangem.features.hotwallet.manualbackup.completed.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.PrimaryButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.features.hotwallet.manualbackup.completed.entity.ManualBackupCompletedUM
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ManualBackupCompletedContent(state: ManualBackupCompletedUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(R.drawable.ic_success_blue_76),
contentDescription = null,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 48.dp,
top = 20.dp,
end = 48.dp,
),
text = stringResourceSafe(R.string.backup_complete_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 48.dp,
top = 12.dp,
end = 48.dp,
),
text = stringResourceSafe(R.string.backup_complete_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.weight(2f))
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
showProgress = false,
enabled = true,
onClick = state.onContinueClick,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewManualBackupCompletedContent() {
TangemThemePreview {
ManualBackupCompletedContent(
state = ManualBackupCompletedUM(
onContinueClick = {},
),
)
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.hotwallet.manualbackup.phrase
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.features.hotwallet.manualbackup.phrase.model.ManualBackupPhraseModel
import com.tangem.features.hotwallet.manualbackup.phrase.ui.ManualBackupPhraseContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class ManualBackupPhraseComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: ManualBackupPhraseModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ManualBackupPhraseContent(
state = state,
modifier = modifier,
)
}
interface ModelCallbacks {
fun onContinueClick()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.hotwallet.manualbackup.phrase.entity
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class ManualBackupPhraseUM(
val onContinueClick: () -> Unit,
val words: ImmutableList<MnemonicGridItem> = persistentListOf(),
) {
data class MnemonicGridItem(
val index: Int,
val mnemonic: String,
)
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.hotwallet.manualbackup.phrase.model
import androidx.compose.runtime.Stable
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.hotwallet.manualbackup.phrase.ManualBackupPhraseComponent
import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Stable
@ModelScoped
internal class ManualBackupPhraseModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ManualBackupPhraseComponent.Params>()
private val callbacks = params.callbacks
internal val uiState: StateFlow<ManualBackupPhraseUM>
field = MutableStateFlow(getInitialUIState())
private fun getInitialUIState(): ManualBackupPhraseUM {
return ManualBackupPhraseUM(
onContinueClick = callbacks::onContinueClick,
)
}
}

View file

@ -0,0 +1,181 @@
package com.tangem.features.hotwallet.manualbackup.phrase.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
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.hotwallet.impl.R
import com.tangem.features.hotwallet.manualbackup.phrase.entity.ManualBackupPhraseUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun ManualBackupPhraseContent(state: ManualBackupPhraseUM, modifier: Modifier = Modifier) {
Column(
modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize(),
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.imePadding()
.weight(1f),
) {
TitleBlock(
state = state,
modifier = Modifier.padding(top = 20.dp),
)
SeedPhraseGridBlock(
mnemonicGridItems = state.words,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp, bottom = 32.dp),
)
}
Text(
text = stringResourceSafe(R.string.backup_seed_responsibility),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
text = stringResourceSafe(id = R.string.common_continue),
onClick = state.onContinueClick,
)
}
}
@Composable
private fun TitleBlock(state: ManualBackupPhraseUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.size36)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResourceSafe(R.string.backup_seed_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = stringResourceSafe(
R.string.backup_seed_description,
state.words.size,
),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun SeedPhraseGridBlock(
mnemonicGridItems: ImmutableList<ManualBackupPhraseUM.MnemonicGridItem>,
modifier: Modifier = Modifier,
) {
VerticalGrid(
modifier = modifier,
items = mnemonicGridItems,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@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 {
ManualBackupPhraseContent(
state = ManualBackupPhraseUM(
onContinueClick = {},
words = List(12) {
ManualBackupPhraseUM.MnemonicGridItem(
index = it + 1,
mnemonic = "word${it + 1}",
)
}.toImmutableList(),
),
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.hotwallet.manualbackup.start
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.features.hotwallet.manualbackup.start.ui.ManualBackupStartContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class ManualBackupStartComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: ManualBackupStartModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ManualBackupStartContent(
state = state,
modifier = modifier,
)
}
interface ModelCallbacks {
fun onContinueClick()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.hotwallet.manualbackup.start
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.hotwallet.manualbackup.start.entity.ManualBackupStartUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class ManualBackupStartModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: ManualBackupStartComponent.Params = paramsContainer.require()
internal val uiState: StateFlow<ManualBackupStartUM>
field = MutableStateFlow(
ManualBackupStartUM(
onContinueClick = params.callbacks::onContinueClick,
),
)
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.hotwallet.manualbackup.start.entity
internal data class ManualBackupStartUM(
val seepPhraseLength: Int = 12,
val onContinueClick: () -> Unit,
)

View file

@ -0,0 +1,138 @@
package com.tangem.features.hotwallet.manualbackup.start.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.R
import com.tangem.core.ui.components.PrimaryButton
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.hotwallet.manualbackup.start.entity.ManualBackupStartUM
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ManualBackupStartContent(state: ManualBackupStartUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.padding(
start = 16.dp,
top = 24.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
text = stringResourceSafe(R.string.backup_info_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
text = stringResourceSafe(
R.string.backup_info_description,
state.seepPhraseLength.toString(),
),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_save_title),
description = stringResourceSafe(
R.string.backup_info_save_description,
state.seepPhraseLength.toString(),
),
iconRes = R.drawable.ic_lock_24,
)
FeatureBlock(
modifier = Modifier
.padding(top = 24.dp),
title = stringResourceSafe(R.string.backup_info_keep_title),
description = stringResourceSafe(R.string.backup_info_keep_description),
iconRes = R.drawable.ic_settings_24,
)
Spacer(modifier = Modifier.weight(1f))
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
text = stringResourceSafe(R.string.common_continue),
showProgress = false,
enabled = true,
onClick = state.onContinueClick,
)
}
}
@Composable
private fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier
.padding(horizontal = 12.dp),
painter = painterResource(iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewManualBackupStartContent() {
TangemThemePreview {
ManualBackupStartContent(
state = ManualBackupStartUM(
onContinueClick = {},
),
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.hotwallet.setupfinished
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.features.hotwallet.setupfinished.ui.MobileWalletSetupFinishedContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class MobileWalletSetupFinishedComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: MobileWalletSetupFinishedModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
MobileWalletSetupFinishedContent(
state = state,
modifier = modifier,
)
}
interface ModelCallbacks {
fun onContinueClick()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.hotwallet.setupfinished
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.hotwallet.setupfinished.entity.MobileWalletSetupFinishedUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class MobileWalletSetupFinishedModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: MobileWalletSetupFinishedComponent.Params = paramsContainer.require()
internal val uiState: StateFlow<MobileWalletSetupFinishedUM>
field = MutableStateFlow(
MobileWalletSetupFinishedUM(
onContinueClick = params.callbacks::onContinueClick,
),
)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.hotwallet.setupfinished.entity
internal data class MobileWalletSetupFinishedUM(
val onContinueClick: () -> Unit,
)

View file

@ -0,0 +1,119 @@
package com.tangem.features.hotwallet.setupfinished.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.FullScreen
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.features.hotwallet.setupfinished.entity.MobileWalletSetupFinishedUM
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MobileWalletSetupFinishedContent(state: MobileWalletSetupFinishedUM, 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) }
Column(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(R.drawable.ic_success_blue_76),
contentDescription = null,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 48.dp,
top = 20.dp,
end = 48.dp,
),
text = stringResourceSafe(R.string.onboarding_done_header),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 48.dp,
top = 12.dp,
end = 48.dp,
),
text = stringResourceSafe(R.string.backup_complete_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.weight(2f))
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
showProgress = false,
enabled = true,
onClick = state.onContinueClick,
)
}
if (showConfetti) {
FullScreen(notTouchable = true) {
LottieAnimation(
composition = composition,
progress = { progress },
)
}
}
LaunchedEffect(Unit) {
showConfetti = true
}
LaunchedEffect(progress == 1f) {
if (progress == 1f) {
showConfetti = false
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewMobileWalletSetupFinishedContent() {
TangemThemePreview {
MobileWalletSetupFinishedContent(
state = MobileWalletSetupFinishedUM(
onContinueClick = {},
),
)
}
}

View file

@ -23,7 +23,6 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
import com.tangem.features.markets.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -41,14 +40,10 @@ internal class TokenActionsHandler @AssistedInject constructor(
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val onrampFeatureToggles: OnrampFeatureToggles,
private val shareManager: ShareManager,
) {
private val disabledActionsInDemoMode = buildSet {
if (!onrampFeatureToggles.isFeatureEnabled) {
add(TokenActionsBSContentUM.Action.Buy)
}
add(TokenActionsBSContentUM.Action.Sell)
}
@ -132,12 +127,11 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Buy(
userWallet = cryptoCurrencyData.userWallet,
router.push(
AppRoute.Onramp(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
source = OnrampSource.MARKETS,
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
}

View file

@ -1,8 +1,6 @@
package com.tangem.features.nft
interface NFTFeatureToggles {
val isNFTEnabled: Boolean
val isNFTEVMEnabled: Boolean
val isNFTSolanaEnabled: Boolean
val isNFTMediaContentEnabled: Boolean
}

View file

@ -5,14 +5,6 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager
internal class DefaultNFTFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : NFTFeatureToggles {
override val isNFTEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_ENABLED")
override val isNFTEVMEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_EVM_ENABLED")
override val isNFTSolanaEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_SOLANA_ENABLED")
override val isNFTMediaContentEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NFT_MEDIA_CONTENT_ENABLED")

View file

@ -19,9 +19,11 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
@ -32,6 +34,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.isPositive
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ -49,6 +52,8 @@ internal class OnboardingNoteTopUpModel @Inject constructor(
private val rampStateManager: RampStateManager,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
private val walletBalanceFetcher: WalletBalanceFetcher,
private val tokensFeatureToggles: TokensFeatureToggles,
) : Model() {
private val params = paramsContainer.require<OnboardingNoteTopUpComponent.Params>()
@ -83,10 +88,12 @@ internal class OnboardingNoteTopUpModel @Inject constructor(
showBalanceLoadingProgress(true)
createUserWalletIfNull()
val userWalletId = requireNotNull(userWallet?.walletId)
fetchCurrencyStatusUseCase(
userWalletId = userWalletId,
refresh = true,
)
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWalletId))
.onLeft(Timber::e)
} else {
fetchCurrencyStatusUseCase(userWalletId = userWalletId, refresh = true)
}
showBalanceLoadingProgress(false)
}
}

View file

@ -36,8 +36,10 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
@ -85,6 +87,8 @@ internal class OnboardingTwinModel @Inject constructor(
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val clipboardManager: ClipboardManager,
private val shareManager: ShareManager,
private val tokensFeatureToggles: TokensFeatureToggles,
private val walletBalanceFetcher: WalletBalanceFetcher,
) : Model() {
private val params = paramsContainer.require<OnboardingTwinComponent.Params>()
@ -329,13 +333,15 @@ internal class OnboardingTwinModel @Inject constructor(
cardRepository.finishCardActivation(params.scanResponse.card.cardId)
fetchCurrencyStatusUseCase.invoke(
userWalletId = userWallet.walletId,
refresh = true,
).onLeft {
Timber.e("Unable to fetch currency status: $it")
setLoading(false)
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
} else {
fetchCurrencyStatusUseCase.invoke(userWalletId = userWallet.walletId, refresh = true)
}
.onLeft {
Timber.e("Unable to fetch currency status: $it")
setLoading(false)
}
val cryptoCurrencyStatus = getSingleCryptoCurrencyStatusUseCase.invokeSingleWallet(userWallet.walletId)
.firstOrNull()?.getOrNull()
@ -431,10 +437,12 @@ internal class OnboardingTwinModel @Inject constructor(
it.copy(isLoading = true)
}
modelScope.launch {
fetchCurrencyStatusUseCase(
userWalletId = userWallet.walletId,
refresh = true,
)
if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId))
.onLeft(Timber::e)
} else {
fetchCurrencyStatusUseCase(userWalletId = userWallet.walletId, refresh = true)
}
}
}

View file

@ -1,6 +0,0 @@
package com.tangem.features.onramp
interface OnrampFeatureToggles {
val isFeatureEnabled: Boolean
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.deeplink
import kotlinx.coroutines.CoroutineScope
interface BuyRedirectDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope): BuyRedirectDeepLinkHandler
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.onramp.deeplink
import com.tangem.core.deeplink.DeepLink
import kotlinx.coroutines.CoroutineScope
@Deprecated("Use OnrampDeepLinkHandler")
abstract class OnrampDeepLink : DeepLink() {
override val uri = "tangem://onramp"
interface Factory {
fun create(coroutineScope: CoroutineScope): OnrampDeepLink
}
}
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.onramp.deeplink
import kotlinx.coroutines.CoroutineScope
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, queryParams: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -1,11 +0,0 @@
package com.tangem.features.onramp
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
internal class DefaultOnrampFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : OnrampFeatureToggles {
override val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "ONRAMP_ENABLED")
}

View file

@ -1,19 +0,0 @@
package com.tangem.features.onramp
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object OnrampFeatureModule {
@Provides
@Singleton
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): OnrampFeatureToggles {
return DefaultOnrampFeatureToggles(featureTogglesManager)
}
}

View file

@ -1,49 +0,0 @@
package com.tangem.features.onramp.deeplink
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.onramp.OnrampFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class DefaultBuyRedirectDeepLinkHandler @AssistedInject constructor(
@Assisted scope: CoroutineScope,
onrampFeatureToggles: OnrampFeatureToggles,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
analyticsEventHandler: AnalyticsEventHandler,
) : BuyRedirectDeepLinkHandler {
init {
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase().fold(
ifLeft = {
Timber.e("Error on getting user wallet: $it")
},
ifRight = { userWallet ->
if (!onrampFeatureToggles.isFeatureEnabled && !userWallet.isMultiCurrency) {
scope.launch {
val cryptoCurrency = getCryptoCurrencyUseCase(userWallet.walletId).getOrElse {
Timber.e("Error on getting cryptoCurrency: $it")
return@launch
}
analyticsEventHandler.send(TokenScreenAnalyticsEvent.Bought(cryptoCurrency.symbol))
}
}
},
)
}
@AssistedFactory
interface Factory : BuyRedirectDeepLinkHandler.Factory {
override fun create(coroutineScope: CoroutineScope): DefaultBuyRedirectDeepLinkHandler
}
}

View file

@ -1,48 +0,0 @@
package com.tangem.features.onramp.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.onramp.success.OnrampSuccessScreenTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultOnrampDeepLink @AssistedInject constructor(
private val appRouter: AppRouter,
private val onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
@Assisted private val scope: CoroutineScope,
) : OnrampDeepLink() {
override fun onReceive(params: Map<String, String>) {
val txId = params[TX_ID_KEY]
val result = OnrampRedirectResult.getResult(params[RESULT_KEY])
when {
!txId.isNullOrEmpty() -> {
// finish current onramp flow and show onramp success screen
val replaceOnrampScreens = appRouter.stack
.filterNot { it is AppRoute.Onramp }
.toMutableList()
replaceOnrampScreens.add(AppRoute.OnrampSuccess(txId))
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
}
result != OnrampRedirectResult.Unknown -> {
scope.launch {
onrampSuccessScreenTrigger.triggerOnrampSuccess(result == OnrampRedirectResult.Success)
}
}
}
}
@AssistedFactory
interface Factory : OnrampDeepLink.Factory {
override fun create(coroutineScope: CoroutineScope): DefaultOnrampDeepLink
}
private companion object {
const val TX_ID_KEY = "tx_id"
const val RESULT_KEY = "result"
}
}

View file

@ -11,20 +11,10 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal interface OnrampDeeplinkModule {
@Binds
@Singleton
fun bindFactory(impl: DefaultOnrampDeepLink.Factory): OnrampDeepLink.Factory
@Binds
@Singleton
fun bindOnrampDeepLinkHandlerFactory(impl: DefaultOnrampDeepLinkHandler.Factory): OnrampDeepLinkHandler.Factory
@Binds
@Singleton
fun bindBuyRedirectDeepLinkHandler(
impl: DefaultBuyRedirectDeepLinkHandler.Factory,
): BuyRedirectDeepLinkHandler.Factory
@Binds
@Singleton
fun bindBuyDeepLinkHandler(impl: DefaultBuyDeepLinkHandler.Factory): BuyDeepLinkHandler.Factory

View file

@ -1,6 +1,7 @@
package com.tangem.features.onramp.selecttoken.model
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -23,7 +24,6 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.selecttoken.OnrampOperationComponent.Params
import com.tangem.features.onramp.selecttoken.entity.OnrampOperationUM
@ -46,7 +46,6 @@ internal class OnrampOperationModel @Inject constructor(
private val reduxStateHolder: ReduxStateHolder,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val onrampFeatureToggles: OnrampFeatureToggles,
private val rampStateManager: RampStateManager,
) : Model() {
@ -100,7 +99,7 @@ internal class OnrampOperationModel @Inject constructor(
}
private fun selectTokenIfDemoModeOff(status: CryptoCurrencyStatus) {
if (params is Params.Sell || !onrampFeatureToggles.isFeatureEnabled) {
if (params is Params.Sell) {
showErrorIfDemoModeOrElse { selectToken(status) }
} else {
selectToken(status)
@ -109,14 +108,25 @@ internal class OnrampOperationModel @Inject constructor(
private fun selectToken(status: CryptoCurrencyStatus) {
modelScope.launch {
val appCurrencyCode = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }.code
when (params) {
is Params.Buy -> {
router.push(
AppRoute.Onramp(
userWalletId = selectedUserWallet.walletId,
currency = status.currency,
source = OnrampSource.ACTION_BUTTONS,
),
)
}
is Params.Sell -> {
val appCurrencyCode = getSelectedAppCurrencyUseCase.invokeSync()
.getOrElse { AppCurrency.Default }.code
reduxStateHolder.dispatch(
action = when (params) {
is Params.Buy -> getBuyAction(status, appCurrencyCode)
is Params.Sell -> TradeCryptoAction.Sell(status, appCurrencyCode)
},
)
reduxStateHolder.dispatch(
action = TradeCryptoAction.Sell(status, appCurrencyCode),
)
}
}
}
}
@ -144,15 +154,6 @@ internal class OnrampOperationModel @Inject constructor(
router.pop()
}
private fun getBuyAction(status: CryptoCurrencyStatus, appCurrencyCode: String): TradeCryptoAction {
return TradeCryptoAction.Buy(
userWallet = selectedUserWallet,
cryptoCurrencyStatus = status,
source = OnrampSource.ACTION_BUTTONS,
appCurrencyCode = appCurrencyCode,
)
}
private fun showErrorIfDemoModeOrElse(action: () -> Unit) {
if (isDemoCardUseCase(cardId = selectedUserWallet.cardId)) {
val alertUM = AlertDemoModeUM(onConfirmClick = {})

View file

@ -18,6 +18,7 @@ import com.google.mlkit.vision.common.InputImage
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.model.QrScanningModel
import com.tangem.feature.qrscanning.presentation.QrScanningContent
@ -41,10 +42,10 @@ class DefaultQrScanningComponent @AssistedInject constructor(
// Camera requires its own analyzer instance due to flow of frames needed to be analyzed.
// Each new frame can cancel previous analysis e.i. image from the gallery can be skipped.
private val cameraAnalyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
MLKitBarcodeAnalyzer { qrCode -> model.onQrScanned(qrCode, QrResultSource.CAMERA) }
}
private val analyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
MLKitBarcodeAnalyzer { qrCode -> model.onQrScanned(qrCode, QrResultSource.GALLERY) }
}
init {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.qrscanning.model
import com.tangem.domain.qrscanning.models.QrResultSource
import kotlinx.coroutines.flow.SharedFlow
internal interface QrScanningClickIntents {
@ -8,7 +9,7 @@ internal interface QrScanningClickIntents {
fun onBackClick()
fun onQrScanned(qrCode: String)
fun onQrScanned(qrCode: String, source: QrResultSource)
fun onGalleryClicked()

View file

@ -10,6 +10,8 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.RawQrResult
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.QrScanningComponent
import com.tangem.feature.qrscanning.presentation.QrScanningState
@ -71,10 +73,11 @@ internal class QrScanningModel @Inject constructor(
override fun onBackClick() = appRouter.pop()
override fun onQrScanned(qrCode: String) {
override fun onQrScanned(qrCode: String, source: QrResultSource) {
if (qrCode.isNotBlank()) {
modelScope.launch(dispatchers.mainImmediate) {
emitQrScannedEventUseCase.invoke(params.source, qrCode)
val qrCode = RawQrResult(qrCode, source, params.source)
emitQrScannedEventUseCase.invoke(qrCode)
}
if (!isScanned) {
appRouter.pop()

View file

@ -65,8 +65,12 @@ internal fun QrScanningContent(
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
title = null,
startButton = TopAppBarButtonUM.Back(uiState.onBackClick),
title = uiState.topBarConfig.title?.resolveReference(),
startButton = TopAppBarButtonUM(
iconRes = uiState.topBarConfig.startIcon,
onIconClicked = uiState.onBackClick,
),
textColor = TangemTheme.colors.text.constantWhite,
iconTint = TangemColorPalette.White,
containerColor = Color.Transparent,
endContent = {

View file

@ -1,11 +1,13 @@
package com.tangem.feature.qrscanning.presentation
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class QrScanningState(
val topBarConfig: TopBarConfig,
val message: TextReference?,
val onQrScanned: (String) -> Unit,
val onBackClick: () -> Unit,
@ -14,6 +16,8 @@ internal data class QrScanningState(
val bottomSheetConfig: TangemBottomSheetConfig? = null,
)
internal data class TopBarConfig(val title: TextReference?, @DrawableRes val startIcon: Int)
@Immutable
internal sealed interface PasteAction {
data object None : PasteAction

View file

@ -26,6 +26,7 @@ internal class QrScanningStateController @Inject constructor() {
private fun getInitialState(): QrScanningState {
return QrScanningState(
topBarConfig = TopBarConfig(title = null, startIcon = 0),
message = null,
onBackClick = {},
onQrScanned = {},

View file

@ -4,13 +4,13 @@ import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.model.QrScanningClickIntents
import com.tangem.feature.qrscanning.presentation.PasteAction
import com.tangem.feature.qrscanning.presentation.QrScanningState
private const val WC_SCHEME = "wc"
import com.tangem.feature.qrscanning.presentation.TopBarConfig
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,
@ -26,24 +26,34 @@ internal class InitializeQrScanningStateTransformer(
}
return QrScanningState(
topBarConfig = constructTopBarConfig(),
message = message,
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onQrScanned = { qrCode -> clickIntents.onQrScanned(qrCode, QrResultSource.CAMERA) },
onGalleryClick = clickIntents::onGalleryClicked,
pasteAction = constructPasteAction(),
)
}
private fun constructTopBarConfig(): TopBarConfig {
return when (source) {
SourceType.SEND -> TopBarConfig(
title = resourceReference(R.string.common_send),
startIcon = R.drawable.ic_back_24,
)
SourceType.WALLET_CONNECT -> TopBarConfig(
title = resourceReference(R.string.wc_new_connection),
startIcon = R.drawable.ic_close_24,
)
}
}
private fun constructPasteAction(): PasteAction {
val uri = clipboardManager.getText()
return if (source == SourceType.WALLET_CONNECT && uri != null && isWalletConnectUri(uri)) {
PasteAction.Perform { clickIntents.onQrScanned(uri) }
return if (uri != null) {
PasteAction.Perform { clickIntents.onQrScanned(uri, QrResultSource.CLIPBOARD) }
} else {
PasteAction.None
}
}
private fun isWalletConnectUri(uri: String): Boolean {
return uri.lowercase().startsWith(WC_SCHEME)
}
}

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.features.send.v2.api"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.decompose)
@ -34,4 +38,14 @@ dependencies {
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.domain.staking.models)
// endregion
}

View file

@ -1,6 +1,6 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
@ -9,5 +9,11 @@ interface FeeSelectorBlockComponent : ComposableContentComponent {
fun updateState(feeSelectorUM: FeeSelectorUM)
interface Factory : ComponentFactory<FeeSelectorParams.FeeSelectorBlockParams, FeeSelectorBlockComponent>
interface Factory {
fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorBlockParams,
onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
): FeeSelectorBlockComponent
}
}

View file

@ -1,9 +1,15 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.send.v2.api.params.FeeSelectorParams
interface FeeSelectorComponent : ComposableBottomSheetComponent {
interface Factory : ComponentFactory<FeeSelectorParams.FeeSelectorDetailsParams, FeeSelectorComponent>
interface Factory {
fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorDetailsParams,
onDismiss: () -> Unit,
): FeeSelectorComponent
}
}

View file

@ -14,7 +14,12 @@ interface SendComponent : ComposableContentComponent {
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
val callback: ModelCallback? = null,
)
interface Factory : ComponentFactory<Params, SendComponent>
interface ModelCallback {
fun onConvertToAnotherToken(lastAmount: String)
}
}

View file

@ -3,4 +3,5 @@ package com.tangem.features.send.v2.api
interface SendFeatureToggles {
val isSendRedesignEnabled: Boolean
val isSendWithSwapEnabled: Boolean
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.send.v2.api.entity
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.error.GetFeeError
@ -17,13 +18,12 @@ sealed class FeeSelectorUM {
data class Error(val error: GetFeeError) : FeeSelectorUM()
data class Content(
val fees: TransactionFee,
val feeItems: ImmutableList<FeeItem>,
val selectedFeeItem: FeeItem,
val isFeeApproximate: Boolean,
val feeExtraInfo: FeeExtraInfo,
val feeFiatRateUM: FeeFiatRateUM?,
val displayNonceInput: Boolean,
val nonce: BigInteger?,
val onNonceChange: (String) -> Unit,
val feeNonce: FeeNonce,
) : FeeSelectorUM()
}
@ -33,11 +33,26 @@ data class FeeFiatRateUM(
val appCurrency: AppCurrency,
)
@Immutable
data class FeeExtraInfo(
val isFeeApproximate: Boolean,
val isFeeConvertibleToFiat: Boolean,
val isTronToken: Boolean,
)
sealed class FeeNonce {
data object None : FeeNonce()
data class Nonce(
val nonce: BigInteger?,
val onNonceChange: (String) -> Unit,
) : FeeNonce()
}
@Immutable
sealed class FeeItem {
abstract val fee: Fee
fun isSame(other: FeeItem): Boolean {
fun isSameClass(other: FeeItem): Boolean {
return this::class == other::class
}

View file

@ -13,27 +13,36 @@ sealed class FeeSelectorParams {
abstract val state: FeeSelectorUM
abstract val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>
abstract val cryptoCurrencyStatus: CryptoCurrencyStatus
abstract val callback: FeeSelectorModelCallback
abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus
abstract val suggestedFeeState: SuggestedFeeState
abstract val feeDisplaySource: FeeDisplaySource
data class FeeSelectorBlockParams(
override val state: FeeSelectorUM,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val callback: FeeSelectorModelCallback,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val suggestedFeeState: SuggestedFeeState,
override val feeDisplaySource: FeeDisplaySource,
) : FeeSelectorParams()
data class FeeSelectorDetailsParams(
override val state: FeeSelectorUM,
override val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
override val cryptoCurrencyStatus: CryptoCurrencyStatus,
override val callback: FeeSelectorModelCallback,
override val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
override val suggestedFeeState: SuggestedFeeState,
override val feeDisplaySource: FeeDisplaySource,
val callback: FeeSelectorModelCallback,
) : FeeSelectorParams()
sealed class SuggestedFeeState {
data object None : SuggestedFeeState()
data class Suggestion(val title: TextReference, val fee: Fee) : SuggestedFeeState()
}
enum class FeeDisplaySource {
Screen,
BottomSheet,
}
}

View file

@ -1,10 +1,19 @@
package com.tangem.features.send.v2.api.subcomponents.destination
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
interface SendDestinationBlockComponent : ComposableContentComponent {
interface Factory :
ComponentFactory<SendDestinationComponentParams.DestinationBlockParams, SendDestinationBlockComponent>
fun updateState(destinationUM: DestinationUM)
interface Factory {
fun create(
context: AppComponentContext,
params: SendDestinationComponentParams.DestinationBlockParams,
onClick: () -> Unit,
onResult: (DestinationUM) -> Unit,
): SendDestinationBlockComponent
}
}

View file

@ -7,6 +7,8 @@ import com.tangem.features.send.v2.api.subcomponents.destination.entity.Destinat
interface SendDestinationComponent : ComposableContentComponent {
fun updateState(destinationUM: DestinationUM)
interface ModelCallback : NavigationModelCallback {
fun onDestinationResult(destinationUM: DestinationUM)
}

View file

@ -0,0 +1,99 @@
package com.tangem.features.send.v2.api.subcomponents.feeSelector.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal
import java.math.RoundingMode
object FeeCalculationUtils {
private val FEE_MAX_DIFF = BigDecimal("5")
/**
* Check and calculates subtracted amount
*/
fun checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal,
): BigDecimal {
val balance = cryptoCurrencyStatus.value.amount ?: return amountValue
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
return if (isFeeCoverage) {
balance.minus(reduceAmountBy).minus(feeValue)
} else {
amountValue
}
}
/**
* Check if custom fee is too high
*/
fun checkIfCustomFeeTooHigh(feeSelectorUM: FeeSelectorUM.Content): Pair<Boolean, String> {
val defaultResult = false to ""
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return defaultResult
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return defaultResult
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return defaultResult
val highValue = multipleFees.priority.amount.value ?: return defaultResult
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = if (highValue > BigDecimal.ZERO) {
customValue / highValue
} else {
BigDecimal.ZERO
}
val isFeeTooHigh = diff > FEE_MAX_DIFF
return isFeeTooHigh to diff.parseBigDecimal(0, RoundingMode.HALF_UP)
}
/**
* Check if custom fee is too low
*/
fun checkIfCustomFeeTooLow(feeSelectorUM: FeeSelectorUM.Content): Boolean {
if (feeSelectorUM.selectedFeeItem !is FeeItem.Custom) return false
val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorUM.selectedFeeItem.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return minimumValue > customValue
}
/**
* Checks if sending amount with fee is greater than balance
*/
fun checkFeeCoverage(
isSubtractAvailable: Boolean,
balance: BigDecimal,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal?,
): Boolean {
if (!isSubtractAvailable) return false
val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO)
return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue
}
/**
* Checks if fee exceeds fee paid currency balance
*/
fun checkExceedBalance(feeBalance: BigDecimal?, feeAmount: BigDecimal?): Boolean {
return feeAmount == null || feeBalance == null || feeAmount.isZero() || feeAmount > feeBalance
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.send.v2.api.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import kotlinx.coroutines.flow.Flow
interface SendNotificationsUpdateListener {
/** Flow triggers notifications update */
val updateTriggerFlow: Flow<NotificationData>
/** Flow returns whether there is error notifications */
val hasErrorFlow: Flow<Boolean>
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.send.v2.api.subcomponents.notifications
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
interface SendNotificationsUpdateTrigger {
/** Trigger return callback with check result */
suspend fun callbackHasError(hasError: Boolean)
/** Trigger fee check reload */
suspend fun triggerUpdate(data: NotificationData)
}

View file

@ -0,0 +1,188 @@
package com.tangem.features.send.v2.api.subcomponents.feeSelector.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class FeeCalculationUtilsTest {
@Test
fun `GIVEN amount subtract available and fee coverage needed WHEN checkAndCalculateSubtractedAmount THEN returns subtracted amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("6"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(BigDecimal("3"))
}
@Test
fun `GIVEN amount subtract not available WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = false
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN sufficient balance for amount and fee WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(BigDecimal("10"))
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN no balance WHEN checkAndCalculateSubtractedAmount THEN returns original amount`() {
// GIVEN
val isAmountSubtractAvailable = true
val cryptoCurrencyStatus = createCryptoCurrencyStatus(null)
val amountValue = BigDecimal("5")
val feeValue = BigDecimal("2")
val reduceAmountBy = BigDecimal("1")
// WHEN
val result = FeeCalculationUtils.checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
// THEN
assertThat(result).isEqualTo(amountValue)
}
@Test
fun `GIVEN fee exceeds balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("5")
val feeAmount = BigDecimal("10")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN fee within balance WHEN checkExceedBalance THEN returns false`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isFalse()
}
@Test
fun `GIVEN null fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount: BigDecimal? = null
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN null fee balance WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance: BigDecimal? = null
val feeAmount = BigDecimal("5")
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
@Test
fun `GIVEN zero fee amount WHEN checkExceedBalance THEN returns true`() {
// GIVEN
val feeBalance = BigDecimal("10")
val feeAmount = BigDecimal.ZERO
// WHEN
val result = FeeCalculationUtils.checkExceedBalance(feeBalance, feeAmount)
// THEN
assertThat(result).isTrue()
}
private fun createCryptoCurrencyStatus(amount: BigDecimal?): CryptoCurrencyStatus {
val value = if (amount != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
priceChange = BigDecimal.ZERO,
yieldBalance = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
} else {
CryptoCurrencyStatus.NoAmount(
priceChange = BigDecimal.ZERO,
fiatRate = BigDecimal.ZERO,
)
}
return CryptoCurrencyStatus(
currency = mockk(relaxed = true),
value = value,
)
}
}

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.send.v2.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.sendV2.api)
@ -79,4 +83,13 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -8,4 +8,6 @@ internal class DefaultSendFeatureToggles(
) : SendFeatureToggles {
override val isSendRedesignEnabled: Boolean
get() = featureToggles.isFeatureEnabled("SEND_REDESIGN_ENABLED")
override val isSendWithSwapEnabled: Boolean
get() = featureToggles.isFeatureEnabled("SEND_VIA_SWAP_ENABLED")
}

View file

@ -18,6 +18,11 @@ internal sealed class CommonSendRoute : Route {
override val isEditMode: Boolean = true
}
@Serializable
data object ConfirmSuccess : CommonSendRoute() {
override val isEditMode: Boolean = false
}
@Serializable
data class Destination(
override val isEditMode: Boolean,

View file

@ -6,8 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.*
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
@ -15,6 +14,8 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
@Composable
internal fun SendContent(
@ -32,12 +33,20 @@ internal fun SendContent(
SendAppBar(navigationUM = navigationUM)
Children(
stack = stackState,
animation = stackAnimation(slide()),
animation = stackAnimation { child ->
when (child.instance) {
is SendConfirmSuccessComponent -> fade(minAlpha = 1.0f)
is SendConfirmComponent -> fade()
else -> slide()
}
},
modifier = Modifier.weight(1f),
) {
it.instance.Content(Modifier.weight(1f))
}
SendNavigationButtons(navigationUM = navigationUM)
if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) {
SendNavigationButtons(navigationUM = navigationUM)
}
}
}

View file

@ -1,62 +1,66 @@
package com.tangem.features.send.v2.feeselector
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
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.ui.components.SpacerWMax
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.core.ui.extensions.conditional
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.FeeSelectorComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorModel
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.feeselector.ui.FeeSelectorBlockContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.math.BigDecimal
import kotlinx.serialization.builtins.serializer
internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: FeeSelectorParams.FeeSelectorBlockParams,
@Assisted private val params: FeeSelectorParams.FeeSelectorBlockParams,
@Assisted onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
private val feeSelectorComponentFactory: FeeSelectorComponent.Factory,
) : FeeSelectorBlockComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.feeSelectorBottomSheet,
serializer = Unit.serializer(),
handleBackButton = false,
childFactory = { _, componentContext ->
feeSelectorComponentFactory.create(
context = childByContext(componentContext),
params = FeeSelectorParams.FeeSelectorDetailsParams(
state = model.uiState.value,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
callback = model,
suggestedFeeState = FeeSelectorParams.SuggestedFeeState.None,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
),
onDismiss = {
model.feeSelectorBottomSheet.dismiss()
},
)
},
)
init {
model.uiState
.onEach(params.callback::onFeeResult)
.onEach(onResult)
.launchIn(componentScope)
}
@ -67,7 +71,18 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
FeeSelectorBlockContent(modifier = modifier, state = state)
val bottomSheet by bottomSheetSlot.subscribeAsState()
FeeSelectorBlockContent(
state = state,
modifier = modifier
.conditional(params.feeDisplaySource == FeeSelectorParams.FeeDisplaySource.Screen) {
Modifier.clickable {
model.feeSelectorBottomSheet.activate(Unit)
}
},
)
bottomSheet.child?.instance?.BottomSheet()
}
@AssistedFactory
@ -75,121 +90,7 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
override fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorBlockParams,
onResult: (feeSelectorUM: FeeSelectorUM) -> Unit,
): DefaultFeeSelectorBlockComponent
}
}
@Composable
private fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_fee_new_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
Text(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
SpacerWMax()
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Loading -> FeeLoading()
is FeeSelectorUM.Error -> FeeError()
}
}
}
@Composable
private fun RowScope.FeeError() {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun RowScope.FeeLoading() {
TextShimmer(
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.body1,
modifier = Modifier.width(width = TangemTheme.dimens.size90),
)
}
@Composable
private fun RowScope.FeeContent(state: FeeSelectorUM.Content) {
val fiatRate = state.feeFiatRateUM
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier
.weight(1f)
.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeSelectorBlockContent_Preview() {
TangemThemePreview {
val feeItem = FeeItem.Market(
Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)),
)
FeeSelectorBlockContent(
modifier = Modifier.fillMaxWidth(),
state = FeeSelectorUM.Content(
feeItems = persistentListOf(feeItem),
selectedFeeItem = feeItem,
isFeeApproximate = false,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
displayNonceInput = false,
nonce = null,
onNonceChange = {},
),
)
}
}

View file

@ -16,18 +16,24 @@ import dagger.assisted.AssistedInject
internal class DefaultFeeSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: FeeSelectorParams.FeeSelectorDetailsParams,
@Assisted private val onDismiss: () -> Unit,
) : FeeSelectorComponent, AppComponentContext by appComponentContext {
private val model: FeeSelectorModel = getOrCreateModel(params = params)
override fun dismiss() {
model.dismiss()
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
FeeSelectorModalBottomSheet(onDismiss = ::dismiss, state = state, feeSelectorIntents = model)
FeeSelectorModalBottomSheet(
onDismiss = ::dismiss,
state = state,
feeSelectorIntents = model,
feeDisplaySource = params.feeDisplaySource,
)
}
@AssistedFactory
@ -35,6 +41,7 @@ internal class DefaultFeeSelectorComponent @AssistedInject constructor(
override fun create(
context: AppComponentContext,
params: FeeSelectorParams.FeeSelectorDetailsParams,
onDismiss: () -> Unit,
): DefaultFeeSelectorComponent
}
}

View file

@ -5,13 +5,13 @@ import com.tangem.features.send.v2.api.entity.FeeItem
internal interface FeeSelectorIntents {
fun onFeeItemSelected(feeItem: FeeItem)
fun onCustomFeeValueChange(index: Int, value: String)
fun onCustomFeeNextClick()
fun onNonceChange(value: String)
fun onDoneClick()
}
internal class StubFeeSelectorIntents : FeeSelectorIntents {
override fun onFeeItemSelected(feeItem: FeeItem) {}
override fun onCustomFeeValueChange(index: Int, value: String) {}
override fun onCustomFeeNextClick() {}
override fun onNonceChange(value: String) {}
override fun onDoneClick() {}
}

View file

@ -2,20 +2,20 @@ package com.tangem.features.send.v2.feeselector.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.AmountType
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.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.transformers.FeeItemSelectedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorErrorTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.FeeSelectorLoadedTransformer
import com.tangem.features.send.v2.feeselector.model.transformers.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
@ -29,13 +29,14 @@ internal class FeeSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), FeeSelectorIntents {
) : Model(), FeeSelectorIntents, FeeSelectorModelCallback {
private val params = paramsContainer.require<FeeSelectorParams>()
private var appCurrency: AppCurrency = AppCurrency.Default
val feeSelectorBottomSheet = SlotNavigation<Unit>()
val uiState: StateFlow<FeeSelectorUM>
field = MutableStateFlow<FeeSelectorUM>(params.state)
@ -48,10 +49,6 @@ internal class FeeSelectorModel @Inject constructor(
uiState.value = feeSelectorUM
}
fun dismiss() {
router.pop()
}
private fun initAppCurrency() {
modelScope.launch {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
@ -67,6 +64,7 @@ internal class FeeSelectorModel @Inject constructor(
uiState.update(
FeeSelectorLoadedTransformer(
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
appCurrency = appCurrency,
fees = fee,
suggestedFeeState = params.suggestedFeeState,
@ -80,7 +78,7 @@ internal class FeeSelectorModel @Inject constructor(
}
private fun isFeeApproximate(amountType: AmountType): Boolean {
val networkId = params.cryptoCurrencyStatus.currency.network.id
val networkId = params.feeCryptoCurrencyStatus.currency.network.id
return isFeeApproximateUseCase(networkId = networkId, amountType = amountType)
}
@ -89,15 +87,27 @@ internal class FeeSelectorModel @Inject constructor(
}
override fun onCustomFeeValueChange(index: Int, value: String) {
// TODO: [REDACTED_JIRA]
uiState.update(
FeeSelectorCustomValueChangedTransformer(
index = index,
value = value,
intents = this,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
),
)
}
override fun onCustomFeeNextClick() {
// TODO: [REDACTED_JIRA]
override fun onNonceChange(value: String) {
uiState.update(FeeSelectorNonceChangeTransformer(value = value))
}
override fun onDoneClick() {
params.callback.onFeeResult(uiState.value)
dismiss()
(params as? FeeSelectorParams.FeeSelectorDetailsParams)?.callback?.onFeeResult(uiState.value)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
uiState.value = feeSelectorUM
feeSelectorBottomSheet.dismiss()
}
}

View file

@ -4,8 +4,8 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
@ -17,7 +17,7 @@ internal class FeeItemConverter(
private val feeSelectorIntents: FeeSelectorIntents,
private val appCurrency: AppCurrency,
cryptoCurrencyStatus: CryptoCurrencyStatus,
) : Converter<TransactionFee, ImmutableList<FeeItem>> {
) : Converter<FeeItemConverter.Input, ImmutableList<FeeItem>> {
private val customFeeFieldConverter = FeeSelectorCustomFieldConverter(
feeSelectorIntents = feeSelectorIntents,
@ -26,7 +26,7 @@ internal class FeeItemConverter(
normalFee = normalFee,
)
override fun convert(value: TransactionFee): ImmutableList<FeeItem> {
override fun convert(value: Input): ImmutableList<FeeItem> {
val fees = mutableListOf<FeeItem>()
when (suggestedFeeState) {
@ -38,26 +38,33 @@ internal class FeeItemConverter(
),
)
}
when (value) {
when (value.transactionFee) {
is TransactionFee.Choosable -> {
fees.add(FeeItem.Slow(fee = value.minimum))
fees.add(FeeItem.Market(fee = value.normal))
fees.add(FeeItem.Fast(fee = value.priority))
fees.add(FeeItem.Slow(fee = value.transactionFee.minimum))
fees.add(FeeItem.Market(fee = value.transactionFee.normal))
fees.add(FeeItem.Fast(fee = value.transactionFee.priority))
}
is TransactionFee.Single -> {
fees.add(FeeItem.Market(fee = value.normal))
fees.add(FeeItem.Market(fee = value.transactionFee.normal))
}
}
val customFeeFields = customFeeFieldConverter.convert(normalFee)
if (customFeeFields.isNotEmpty()) {
fees.add(
FeeItem.Custom(
fee = customFeeFieldConverter.convertBack(customFeeFields),
customValues = customFeeFields,
),
)
}
val customFee = value.customFee ?: constructCustomFee()
customFee?.let(fees::add)
return fees.toImmutableList()
}
private fun constructCustomFee(): FeeItem.Custom? {
val customFeeFields = customFeeFieldConverter.convert(normalFee)
if (customFeeFields.isEmpty()) return null
return FeeItem.Custom(
fee = customFeeFieldConverter.convertBack(customFeeFields),
customValues = customFeeFields,
)
}
data class Input(val transactionFee: TransactionFee, val customFee: FeeItem.Custom?)
}

View file

@ -5,12 +5,12 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter
import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.utils.converter.TwoWayConverter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -25,7 +25,7 @@ internal class FeeSelectorCustomFieldConverter(
private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
EthereumCustomFeeConverter(
onCustomFeeValueChange = feeSelectorIntents::onCustomFeeValueChange,
onNextClick = feeSelectorIntents::onCustomFeeNextClick,
onNextClick = null,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
@ -34,7 +34,7 @@ internal class FeeSelectorCustomFieldConverter(
private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) {
BitcoinCustomFeeConverter(
onCustomFeeValueChange = feeSelectorIntents::onCustomFeeValueChange,
onNextClick = feeSelectorIntents::onCustomFeeNextClick,
onNextClick = null,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
@ -77,38 +77,43 @@ internal class FeeSelectorCustomFieldConverter(
}
}
fun onValueChange(feeSelectorState: FeeSelectorUM.Content, index: Int, value: String) =
when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
feeValue = fee,
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
txSize = fee.txSize,
)
is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange(
customValues = feeSelectorState.customValues,
index = index,
value = value,
)
else -> feeSelectorState.customValues
}
fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content) = when (feeSelectorState.fees) {
is TransactionFee.Choosable -> feeSelectorState.fees.minimum
is TransactionFee.Single -> feeSelectorState.fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = feeSelectorState.customValues,
)
else -> feeSelectorState.customValues
}
fun onValueChange(
feeSelectorState: FeeSelectorUM.Content,
customValues: ImmutableList<CustomFeeFieldUM>,
index: Int,
value: String,
) = when (val fee = feeSelectorState.fees.normal) {
is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange(
feeValue = fee,
customValues = customValues,
index = index,
value = value,
)
is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange(
customValues = customValues,
index = index,
value = value,
txSize = fee.txSize,
)
is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange(
customValues = customValues,
index = index,
value = value,
)
else -> customValues
}
fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content, customValues: ImmutableList<CustomFeeFieldUM>) =
when (val fees = feeSelectorState.fees) {
is TransactionFee.Choosable -> fees.minimum
is TransactionFee.Single -> fees.normal
}.let {
when (it) {
is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue(
minimumFee = it,
customValues = customValues,
)
else -> customValues
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class FeeSelectorCustomValueChangedTransformer(
private val index: Int,
private val value: String,
private val intents: FeeSelectorIntents,
private val appCurrency: AppCurrency,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val customFee = state.feeItems.filterIsInstance<FeeItem.Custom>().firstOrNull() ?: return prevState
val customFeeConverter = FeeSelectorCustomFieldConverter(
feeSelectorIntents = intents,
appCurrency = appCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
normalFee = state.selectedFeeItem.fee,
)
val updatedCustomValues = customFeeConverter.onValueChange(state, customFee.customValues, index, value)
val newCustomFee = customFee.copy(
fee = customFeeConverter.convertBack(updatedCustomValues),
customValues = updatedCustomValues,
)
return state.copy(
feeItems = state.feeItems.map { if (it is FeeItem.Custom) newCustomFee else it }.toImmutableList(),
selectedFeeItem = newCustomFee,
)
}
}

View file

@ -1,19 +1,21 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
@Suppress("LongParameterList")
internal class FeeSelectorLoadedTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val fees: TransactionFee,
private val suggestedFeeState: FeeSelectorParams.SuggestedFeeState,
@ -26,32 +28,50 @@ internal class FeeSelectorLoadedTransformer(
normalFee = fees.normal,
feeSelectorIntents = feeSelectorIntents,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = feeCryptoCurrencyStatus,
)
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(fees)
val prevCustomFee = if (prevState is FeeSelectorUM.Content) {
prevState.feeItems.find { it is FeeItem.Custom } as? FeeItem.Custom
} else {
null
}
val feeItems: ImmutableList<FeeItem> = feeItemsConverter.convert(FeeItemConverter.Input(fees, prevCustomFee))
val selectedFee = when (prevState) {
is FeeSelectorUM.Content -> feeItems.first { it.isSame(prevState.selectedFeeItem) }
is FeeSelectorUM.Content -> feeItems.first { it.isSameClass(prevState.selectedFeeItem) }
is FeeSelectorUM.Error,
FeeSelectorUM.Loading,
-> feeItems.find { it is FeeItem.Suggested } ?: feeItems.first { it is FeeItem.Market }
}
val nonce = ((prevState as? FeeSelectorUM.Content)?.feeNonce as? FeeNonce.Nonce)?.nonce
return FeeSelectorUM.Content(
fees = fees,
feeItems = feeItems,
selectedFeeItem = selectedFee,
isFeeApproximate = isFeeApproximate,
feeFiatRateUM = cryptoCurrencyStatus.value.fiatRate?.let { rate ->
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = isFeeApproximate,
isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate,
isTronToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token &&
isTron(cryptoCurrencyStatus.currency.network.rawId),
),
feeFiatRateUM = feeCryptoCurrencyStatus.value.fiatRate?.let { rate ->
FeeFiatRateUM(
rate = rate,
appCurrency = appCurrency,
)
},
displayNonceInput = false,
nonce = null,
onNonceChange = {},
feeNonce = if (fees.normal is Fee.Ethereum) {
FeeNonce.Nonce(
nonce = nonce,
onNonceChange = feeSelectorIntents::onNonceChange,
)
} else {
FeeNonce.None
},
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.send.v2.feeselector.model.transformers
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.utils.transformer.Transformer
internal class FeeSelectorNonceChangeTransformer(
private val value: String,
) : Transformer<FeeSelectorUM> {
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
val state = prevState as? FeeSelectorUM.Content ?: return prevState
val feeNonce = state.feeNonce as? FeeNonce.Nonce ?: return prevState
if (value.isEmpty()) {
return state.copy(feeNonce = feeNonce.copy(null))
}
val nonce = value.toBigIntegerOrNull() ?: return prevState
return state.copy(feeNonce = feeNonce.copy(nonce = nonce))
}
}

View file

@ -0,0 +1,196 @@
package com.tangem.features.send.v2.feeselector.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun FeeSelectorBlockContent(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_fee_new_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
FeeSelectorDescription(state = state)
}
}
@Composable
private fun FeeSelectorDescription(state: FeeSelectorUM, modifier: Modifier = Modifier) {
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
FeeSelectorStaticPart(modifier = Modifier.weight(1f))
when (state) {
is FeeSelectorUM.Content -> FeeContent(state)
is FeeSelectorUM.Loading -> FeeLoading()
is FeeSelectorUM.Error -> FeeError()
}
}
}
@Composable
private fun FeeSelectorStaticPart(modifier: Modifier = Modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing4)
.weight(1f, fill = false),
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing6)
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_token_info_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Composable
private fun FeeError() {
Text(
text = EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun FeeLoading() {
TextShimmer(
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.body1,
modifier = Modifier.width(width = TangemTheme.dimens.size90),
)
}
@Composable
private fun FeeContent(state: FeeSelectorUM.Content, modifier: Modifier = Modifier) {
val fiatRate = state.feeFiatRateUM
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
EllipsisText(
text = if (fiatRate != null) {
getFiatString(
value = state.selectedFeeItem.fee.amount.value,
rate = fiatRate.rate,
appCurrency = fiatRate.appCurrency,
approximate = state.feeExtraInfo.isFeeApproximate,
)
} else {
state.selectedFeeItem.fee.amount.value.format {
crypto(
symbol = state.selectedFeeItem.fee.amount.currencySymbol,
decimals = state.selectedFeeItem.fee.amount.decimals,
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
}
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.End,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
Icon(
modifier = Modifier.size(width = 18.dp, height = 24.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeSelectorBlockContent_Preview(@PreviewParameter(FeeSelectorUMProvider::class) state: FeeSelectorUM) {
TangemThemePreview {
FeeSelectorBlockContent(modifier = Modifier.fillMaxWidth(), state = state)
}
}
private class FeeSelectorUMProvider : PreviewParameterProvider<FeeSelectorUM> {
private val maxFeeItem = FeeItem.Market(
fee = Fee.Common(amount = Amount(value = BigDecimal("100000000"), blockchain = Blockchain.Ethereum)),
)
private val lowFeeItem =
FeeItem.Market(Fee.Common(amount = Amount(value = BigDecimal("0.0002876"), blockchain = Blockchain.Ethereum)))
override val values: Sequence<FeeSelectorUM> = sequenceOf(
FeeSelectorUM.Content(
feeItems = persistentListOf(lowFeeItem),
selectedFeeItem = lowFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(lowFeeItem.fee),
),
FeeSelectorUM.Content(
feeItems = persistentListOf(maxFeeItem),
selectedFeeItem = maxFeeItem,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeNonce = FeeNonce.None,
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal("2500000000000"),
appCurrency = AppCurrency.Default,
),
fees = TransactionFee.Single(maxFeeItem.fee),
),
FeeSelectorUM.Error(GetFeeError.UnknownError),
FeeSelectorUM.Loading,
)
}

View file

@ -4,10 +4,8 @@ import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
@ -17,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
@ -31,6 +28,7 @@ import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.atoms.text.EllipsisText
@ -39,6 +37,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.inputrow.InputRowEnter
import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmountV2
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
@ -47,10 +46,8 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.send.v2.api.entity.CustomFeeFieldUM
import com.tangem.features.send.v2.api.entity.FeeFiatRateUM
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.*
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.feeselector.model.FeeSelectorIntents
import com.tangem.features.send.v2.feeselector.model.StubFeeSelectorIntents
import com.tangem.features.send.v2.impl.R
@ -58,12 +55,12 @@ import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.math.BigInteger
@Composable
internal fun FeeSelectorModalBottomSheet(
state: FeeSelectorUM,
feeSelectorIntents: FeeSelectorIntents,
feeDisplaySource: FeeSelectorParams.FeeDisplaySource,
onDismiss: () -> Unit,
) {
if (state !is FeeSelectorUM.Content) return
@ -76,17 +73,13 @@ internal fun FeeSelectorModalBottomSheet(
),
containerColor = TangemTheme.colors.background.primary,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
FeeTitle(feeDisplaySource = feeDisplaySource, onDismiss = onDismiss)
},
content = {
FeeSelectorItems(
state = state,
feeSelectorIntents = feeSelectorIntents,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 13.dp),
)
},
footer = {
@ -101,6 +94,26 @@ internal fun FeeSelectorModalBottomSheet(
)
}
@Composable
private fun FeeTitle(feeDisplaySource: FeeSelectorParams.FeeDisplaySource, onDismiss: () -> Unit) {
when (feeDisplaySource) {
FeeSelectorParams.FeeDisplaySource.Screen -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
endIconRes = R.drawable.ic_close_24,
onEndClick = onDismiss,
)
}
FeeSelectorParams.FeeDisplaySource.BottomSheet -> {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.common_network_fee_title),
startIconRes = R.drawable.ic_back_24,
onStartClick = onDismiss,
)
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun FeeSelectorItems(
@ -111,7 +124,7 @@ private fun FeeSelectorItems(
Column(modifier = modifier) {
val feeFiatRateUM = state.feeFiatRateUM
state.feeItems.fastForEachIndexed { index, item ->
val isSelected = item.isSame(state.selectedFeeItem)
val isSelected = item.isSameClass(state.selectedFeeItem)
val lastItem = index == state.feeItems.size - 1
val iconTint by animateColorAsState(
targetValue = if (isSelected) TangemTheme.colors.icon.accent else TangemTheme.colors.text.tertiary,
@ -128,21 +141,7 @@ private fun FeeSelectorItems(
val itemModifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.primary)
.then(
if (isSelected) {
Modifier
.border(
width = 2.5.dp,
color = iconTint.copy(alpha = 0.2F),
shape = RoundedCornerShape(16.dp),
)
.padding(2.5.dp)
.border(width = 1.dp, color = iconTint, shape = RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
} else {
Modifier
},
)
.selectedBorder(isSelected = isSelected)
.clickableSingle(onClick = { feeSelectorIntents.onFeeItemSelected(item) })
when (item) {
is FeeItem.Suggested -> RegularFeeItemContent(
@ -156,7 +155,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -182,7 +181,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -208,7 +207,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -234,7 +233,7 @@ private fun FeeSelectorItems(
crypto(
symbol = item.fee.amount.currencySymbol,
decimals = item.fee.amount.decimals,
).fee(canBeLower = state.isFeeApproximate)
).fee(canBeLower = state.feeExtraInfo.isFeeApproximate)
},
),
postDot = if (feeFiatRateUM != null) {
@ -255,9 +254,8 @@ private fun FeeSelectorItems(
isSelected = isSelected,
iconBackgroundColor = iconBackgroundColor,
iconTint = iconTint,
displayNonceInput = state.displayNonceInput,
nonce = state.nonce,
onNonceChange = state.onNonceChange,
onValueChange = feeSelectorIntents::onCustomFeeValueChange,
nonce = state.feeNonce,
)
}
}
@ -271,9 +269,8 @@ private fun CustomFeeBlock(
isSelected: Boolean,
iconBackgroundColor: Color,
iconTint: Color,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
onValueChange: (Int, String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -305,10 +302,8 @@ private fun CustomFeeBlock(
) {
ExpandedCustomFeeItems(
customFeeFields = customFee.customValues,
onValueChange = { _, _ -> },
displayNonceInput = displayNonceInput,
onValueChange = onValueChange,
nonce = nonce,
onNonceChange = onNonceChange,
)
}
}
@ -318,14 +313,12 @@ private fun CustomFeeBlock(
private fun ExpandedCustomFeeItems(
customFeeFields: ImmutableList<CustomFeeFieldUM>,
onValueChange: (Int, String) -> Unit,
displayNonceInput: Boolean,
nonce: BigInteger?,
onNonceChange: (String) -> Unit,
nonce: FeeNonce,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
customFeeFields.fastForEachIndexed { index, field ->
val showDivider = index != customFeeFields.size - 1 || displayNonceInput
val showDivider = index != customFeeFields.size - 1 || nonce is FeeNonce.Nonce
if (field.label != null) {
InputRowEnterInfoAmountV2(
text = field.value,
@ -334,6 +327,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
info = field.label,
description = field.footer,
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
onValueChange = { onValueChange(index, it) },
@ -347,6 +341,7 @@ private fun ExpandedCustomFeeItems(
title = field.title,
titleColor = TangemTheme.colors.text.tertiary,
symbol = field.symbol,
description = field.footer,
onValueChange = { onValueChange(index, it) },
keyboardOptions = field.keyboardOptions,
keyboardActions = field.keyboardActions,
@ -355,18 +350,21 @@ private fun ExpandedCustomFeeItems(
}
}
if (displayNonceInput) {
// TODO implement v2 input without binding to amount
InputRowEnterInfoAmountV2(
text = nonce?.toString() ?: "",
decimals = 0,
if (nonce is FeeNonce.Nonce) {
InputRowEnter(
text = nonce.nonce?.toString().orEmpty(),
title = resourceReference(R.string.send_nonce),
titleColor = TangemTheme.colors.text.tertiary,
symbol = null,
onValueChange = onNonceChange,
description = resourceReference(R.string.send_nonce_footer),
onValueChange = nonce.onNonceChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions(),
placeholder = resourceReference(R.string.send_nonce_hint),
titleColor = TangemTheme.colors.text.secondary,
showDivider = false,
modifier = Modifier
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
)
}
}
@ -474,7 +472,12 @@ private fun FeeSelectorBS_Preview(
state: FeeSelectorUM.Content,
) {
TangemThemePreview {
FeeSelectorModalBottomSheet(onDismiss = {}, state = state, feeSelectorIntents = StubFeeSelectorIntents())
FeeSelectorModalBottomSheet(
onDismiss = {},
state = state,
feeSelectorIntents = StubFeeSelectorIntents(),
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet,
)
}
}
@ -495,14 +498,17 @@ private class FeeSelectorUMContentProvider : CollectionPreviewParameterProvider<
// amount = Amount(value = BigDecimal("0.02"), blockchain = Blockchain.Ethereum),
// ),
selectedFeeItem = customFeeItem,
isFeeApproximate = true,
feeExtraInfo = FeeExtraInfo(
isFeeApproximate = true,
isFeeConvertibleToFiat = true,
isTronToken = false,
),
feeFiatRateUM = FeeFiatRateUM(
rate = BigDecimal.TEN,
appCurrency = AppCurrency.Default,
),
displayNonceInput = true,
onNonceChange = {},
nonce = null,
feeNonce = FeeNonce.None,
fees = TransactionFee.Single(customFeeItem.fee),
),
),
)

View file

@ -14,12 +14,14 @@ import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
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.core.ui.extensions.resourceReference
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
@ -30,11 +32,14 @@ import com.tangem.features.send.v2.common.utils.safeNextClick
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
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.fee.SendFeeBlockComponent
import com.tangem.features.send.v2.subcomponents.destination.DefaultSendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams
import dagger.assisted.Assisted
@ -44,10 +49,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
@Suppress("LargeClass")
internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -142,7 +149,8 @@ internal class DefaultSendComponent @AssistedInject constructor(
is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route)
is CommonSendRoute.Amount -> getAmountComponent(factoryContext, route)
is CommonSendRoute.Fee -> getFeeComponent(factoryContext)
CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext)
}
private fun getDestinationComponent(
@ -288,7 +296,11 @@ internal class DefaultSendComponent @AssistedInject constructor(
callback = model,
predefinedValues = model.predefinedValues,
onLoadFee = model::loadFee,
onSendTransaction = {
innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess)
},
),
feeSelectorComponentFactory = feeSelectorComponentFactory,
)
} else {
model.showAlertError()
@ -296,6 +308,69 @@ internal class DefaultSendComponent @AssistedInject constructor(
}
}
private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
val state = model.uiState.value
val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value
val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl
val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value
val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value
if (sendAmount == null ||
destinationAddress == null ||
txUrl == null
) {
model.showAlertError()
return getStubComponent()
}
val destinationBlockComponent =
DefaultSendDestinationBlockComponent(
appComponentContext = child("sendConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
analyticsCategoryName = analyticCategoryName,
userWalletId = model.userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = model.predefinedValues,
),
onResult = { },
onClick = {},
)
val feeBlockComponent = SendFeeBlockComponent(
appComponentContext = child("sendConfirmFeeBlock"),
params = SendFeeComponentParams.FeeBlockParams(
state = model.uiState.value.feeUM,
analyticsCategoryName = analyticCategoryName,
userWallet = model.userWallet,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
sendAmount = sendAmount,
destinationAddress = destinationAddress,
blockClickEnableFlow = MutableStateFlow(true),
onLoadFee = model::loadFee,
),
onResult = { },
onClick = {},
)
return SendConfirmSuccessComponent(
appComponentContext = factoryContext,
params = SendConfirmSuccessComponent.Params(
sendUMFlow = model.uiState,
feeBlockComponent = feeBlockComponent,
destinationBlockComponent = destinationBlockComponent,
analyticsCategoryName = analyticCategoryName,
currentRoute = currentRoute,
txUrl = txUrl,
callback = model,
),
)
}
private fun getStubComponent() = StubComponent()
class StubComponent : ComposableContentComponent {

View file

@ -14,9 +14,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.*
internal class SendConfirmComponent(
appComponentContext: AppComponentContext,
params: Params,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmModel = getOrCreateModel(params = params)
@ -92,6 +95,19 @@ internal class SendConfirmComponent(
onClick = model::showEditFee,
)
private val feeSelectorBlockComponent = feeSelectorComponentFactory.create(
context = appComponentContext,
params = FeeSelectorParams.FeeSelectorBlockParams(
state = model.uiState.value.feeSelectorUM,
onLoadFee = params.onLoadFee,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
suggestedFeeState = model.suggestedFeeState,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
),
onResult = model::onFeeResult,
)
private val notificationsComponent = DefaultSendNotificationsComponent(
appComponentContext = child("sendConfirmNotifications"),
params = SendNotificationsComponent.Params(
@ -136,6 +152,7 @@ internal class SendConfirmComponent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
notificationsComponent = notificationsComponent,
notificationsUM = notificationState,
)
@ -155,6 +172,7 @@ internal class SendConfirmComponent(
val isBalanceHidingFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
val onSendTransaction: () -> Unit,
)
interface ModelCallback {

View file

@ -35,7 +35,13 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeNonce
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -49,13 +55,13 @@ import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmIn
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer
import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.extensions.stripZeroPlainString
@ -86,13 +92,14 @@ internal class SendConfirmModel @Inject constructor(
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val alertFactory: SendConfirmAlertFactory,
private val sendAnalyticHelper: SendAnalyticHelper,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
sendBalanceUpdaterFactory: SendBalanceUpdater.Factory,
) : Model(), SendConfirmClickIntents {
) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback {
private val params: SendConfirmComponent.Params = paramsContainer.require()
@ -115,6 +122,8 @@ internal class SendConfirmModel @Inject constructor(
get() = uiState.value.feeUM as? FeeUM.Content
private val feeSelectorUM
get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
private val feeUMV2
get() = uiState.value.feeSelectorUM as? FeeSelectorUMRedesigned.Content
val confirmData: ConfirmData
get() = ConfirmData(
@ -129,6 +138,7 @@ internal class SendConfirmModel @Inject constructor(
private var sendIdleTimer: Long = 0L
private var isAmountSubtractAvailable = false
internal var suggestedFeeState: FeeSelectorParams.SuggestedFeeState = FeeSelectorParams.SuggestedFeeState.None
init {
modelScope.launch {
@ -291,6 +301,7 @@ internal class SendConfirmModel @Inject constructor(
isShowTapHelp = isShowTapHelp,
walletName = stringReference(userWallet.name),
).transform(uiState.value.confirmUM),
confirmData = confirmData,
)
}
updateConfirmNotifications()
@ -299,16 +310,25 @@ internal class SendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
if (_uiState.value.isRedesignEnabled) {
val feeUM = it.feeSelectorUM as? FeeSelectorUMRedesigned.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeUM != null,
) ?: it.confirmUM,
)
} else {
val feeUM = it.feeUM as? FeeUM.Content
val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content
it.copy(
confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy(
isPrimaryButtonEnabled = !hasError && feeSelectorUM != null,
) ?: it.confirmUM,
)
}
}
}
.launchIn(modelScope)
@ -318,9 +338,18 @@ internal class SendConfirmModel @Inject constructor(
val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return
val destination = destinationUM?.addressTextField?.actualAddress ?: return
val memo = destinationUM?.memoTextField?.value
val fee = feeSelectorUM?.selectedFee
val isRedesignEnabled = uiState.value.isRedesignEnabled
val fee = if (isRedesignEnabled) {
feeUMV2?.selectedFeeItem?.fee
} else {
feeSelectorUM?.selectedFee
}
val nonce = if (isRedesignEnabled) {
(feeUMV2?.feeNonce as? FeeNonce.Nonce)?.nonce
} else {
feeSelectorUM?.nonce
}
val feeValue = fee?.amount?.value ?: return
val nonce = feeSelectorUM?.nonce
val receivingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
@ -385,6 +414,10 @@ internal class SendConfirmModel @Inject constructor(
addTokenToWalletIfNeeded()
sendBalanceUpdater.scheduleUpdates()
sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value)
if (uiState.value.isRedesignEnabled) {
params.callback.onResult(uiState.value)
params.onSendTransaction()
}
},
)
}
@ -444,14 +477,25 @@ internal class SendConfirmModel @Inject constructor(
)
_uiState.update {
it.copy(
confirmUM = SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM),
confirmUM = if (uiState.value.isRedesignEnabled) {
SendConfirmationNotificationsTransformerV2(
feeSelectorUM = uiState.value.feeSelectorUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
} else {
SendConfirmationNotificationsTransformer(
feeUM = uiState.value.feeUM,
amountUM = uiState.value.amountUM,
analyticsEventHandler = analyticsEventHandler,
cryptoCurrency = cryptoCurrencyStatus.currency,
appCurrency = appCurrency,
analyticsCategoryName = params.analyticsCategoryName,
).transform(uiState.value.confirmUM)
},
)
}
}
@ -463,23 +507,35 @@ internal class SendConfirmModel @Inject constructor(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.Confirm }.onEach { (state, _) ->
).filter {
it.second is CommonSendRoute.Confirm
}.onEach { (state, _) ->
val amountUM = state.amountUM as? AmountState.Data
val confirmUM = state.confirmUM
val isReadyToSend = confirmUM is ConfirmUM.Content && !confirmUM.isSending
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
title = resourceReference(
id = R.string.send_summary_title,
formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name),
),
title = if (state.isRedesignEnabled) {
stringReference("")
} else {
resourceReference(
id = R.string.send_summary_title,
formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name),
)
},
subtitle = if (uiState.value.isRedesignEnabled) {
null
} else {
amountUM?.title
},
backIconRes = R.drawable.ic_close_24,
backIconRes = if (state.isRedesignEnabled) {
when (confirmUM) {
is ConfirmUM.Success -> R.drawable.ic_close_24
else -> R.drawable.ic_back_24
}
} else {
R.drawable.ic_close_24
},
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
@ -491,31 +547,7 @@ internal class SendConfirmModel @Inject constructor(
)
appRouter.pop()
},
primaryButton = NavigationButton(
textReference = when (confirmUM) {
is ConfirmUM.Success -> resourceReference(R.string.common_close)
is ConfirmUM.Content -> if (confirmUM.isSending) {
resourceReference(R.string.send_sending)
} else {
resourceReference(R.string.common_send)
}
else -> resourceReference(R.string.common_send)
},
iconRes = R.drawable.ic_tangem_24.takeIf { isReadyToSend },
isEnabled = confirmUM.isPrimaryButtonEnabled,
isHapticClick = isReadyToSend,
onClick = {
when (confirmUM) {
is ConfirmUM.Success -> appRouter.pop()
is ConfirmUM.Content -> if (confirmUM.isSending) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
),
primaryButton = primaryButtonUM(),
prevButton = null,
secondaryPairButtonsUM = (
NavigationButton(
@ -534,6 +566,42 @@ internal class SendConfirmModel @Inject constructor(
}.launchIn(modelScope)
}
private fun primaryButtonUM(): NavigationButton {
val confirmUM = uiState.value.confirmUM
val isReadyToSend = confirmUM is ConfirmUM.Content && !confirmUM.isSending
return NavigationButton(
textReference = when (confirmUM) {
is ConfirmUM.Success -> resourceReference(R.string.common_close)
is ConfirmUM.Content -> if (confirmUM.isSending) {
resourceReference(R.string.send_sending)
} else {
resourceReference(R.string.common_send)
}
else -> resourceReference(R.string.common_send)
},
iconRes = R.drawable.ic_tangem_24.takeIf { isReadyToSend },
isEnabled = confirmUM.isPrimaryButtonEnabled,
isHapticClick = isReadyToSend,
onClick = {
when (confirmUM) {
is ConfirmUM.Success -> appRouter.pop()
is ConfirmUM.Content -> if (confirmUM.isSending) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
)
}
override fun onFeeResult(feeSelectorUM: FeeSelectorUMRedesigned) {
sendIdleTimer = SystemClock.elapsedRealtime()
_uiState.update { it.copy(feeSelectorUM = feeSelectorUM) }
updateConfirmNotifications()
}
private companion object {
const val CHECK_FEE_UPDATE_DELAY = 10_000L
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.send.v2.send.confirm.model.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.utils.formatFooterFiatFee
import com.tangem.features.send.v2.common.utils.getTronTokenFeeSendingText
import com.tangem.features.send.v2.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class SendConfirmationNotificationsTransformerV2(
private val feeSelectorUM: FeeSelectorUM,
private val amountUM: AmountState,
private val analyticsEventHandler: AnalyticsEventHandler,
private val cryptoCurrency: CryptoCurrency,
private val appCurrency: AppCurrency,
private val analyticsCategoryName: String,
) : Transformer<ConfirmUM> {
override fun transform(prevState: ConfirmUM): ConfirmUM {
val state = prevState as? ConfirmUM.Content ?: return prevState
val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content ?: return prevState
return state.copy(
sendingFooter = getSendingFooterText(),
notifications = buildList {
addTooHighNotification(feeSelectorUM)
addTooLowNotification(feeSelectorUM)
}.toPersistentList(),
)
}
private fun MutableList<NotificationUM>.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) {
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) {
add(NotificationUM.Warning.FeeTooLow)
analyticsEventHandler.send(
CommonSendAnalyticEvents.NoticeTransactionDelays(
categoryName = analyticsCategoryName,
token = cryptoCurrency.symbol,
),
)
}
}
private fun MutableList<NotificationUM>.addTooHighNotification(feeSelectorUM: FeeSelectorUM.Content) {
val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM)
if (isFeeTooHigh) {
add(NotificationUM.Warning.TooHigh(diff))
}
}
private fun getSendingFooterText(): TextReference {
val feeSelectorUM = feeSelectorUM as? FeeSelectorUM.Content
val amountUM = amountUM as? AmountState.Data
val fee = feeSelectorUM?.selectedFeeItem?.fee
if (fee == null || amountUM == null) return TextReference.EMPTY
val fiatAmountValue = amountUM.amountTextField.fiatAmount.value
val fiatFeeValue = feeSelectorUM.feeFiatRateUM?.rate?.let { fee.amount.value?.multiply(it) }
val fiatSendingValue = if (feeSelectorUM.feeFiatRateUM != null) {
fiatFeeValue?.let { fiatAmountValue?.plus(it) }
} else {
fiatAmountValue
}
val fiatSending = fiatSendingValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val fiatFee = formatFooterFiatFee(
amount = fee.amount.copy(value = fiatFeeValue),
isFeeConvertibleToFiat = feeSelectorUM.feeFiatRateUM != null,
isFeeApproximate = feeSelectorUM.feeExtraInfo.isFeeApproximate,
appCurrency = appCurrency,
)
return if (fee is Fee.Tron) {
getTronTokenFeeSendingText(
fee = fee,
fiatFee = fiatFee,
fiatSending = stringReference(fiatSending),
)
} else {
resourceReference(
id = if (feeSelectorUM.feeFiatRateUM != null) {
R.string.send_summary_transaction_description
} else {
R.string.send_summary_transaction_description_no_fiat_fee
},
formatArgs = wrappedList(fiatSending, fiatFee),
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.send.v2.send.confirm.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -9,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
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.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.SpacerHMax
@ -19,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.common.ui.SendingText
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.common.ui.tapHelp
@ -40,6 +43,7 @@ internal fun SendConfirmContent(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
notificationsComponent: DefaultSendNotificationsComponent,
notificationsUM: ImmutableList<NotificationUM>,
) {
@ -54,6 +58,7 @@ internal fun SendConfirmContent(
destinationBlockComponent = destinationBlockComponent,
amountBlockComponent = amountBlockComponent,
feeBlockComponent = feeBlockComponent,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
if (confirmUM != null) {
tapHelp(isDisplay = confirmUM.showTapHelp)
@ -79,34 +84,45 @@ private fun LazyListScope.blocks(
destinationBlockComponent: DefaultSendDestinationBlockComponent,
amountBlockComponent: SendAmountBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
) {
item(key = BLOCKS_KEY) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
AnimatedVisibility(
visible = uiState.confirmUM is ConfirmUM.Success,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
) {
val wrappedConfirmUM = remember(this) { uiState.confirmUM as ConfirmUM.Success }
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
wrappedConfirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
if (uiState.isRedesignEnabled) {
amountBlockComponent.Content(modifier = Modifier)
destinationBlockComponent.Content(modifier = Modifier)
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
} else {
TransactionDoneTitleAnimated(uiState)
destinationBlockComponent.Content(modifier = Modifier)
amountBlockComponent.Content(modifier = Modifier)
feeBlockComponent.Content(modifier = Modifier)
}
feeBlockComponent.Content(modifier = Modifier)
}
}
}
@Composable
internal fun TransactionDoneTitleAnimated(uiState: SendUM) {
AnimatedVisibility(
visible = uiState.confirmUM is ConfirmUM.Success,
modifier = Modifier.padding(vertical = 12.dp),
) {
val wrappedConfirmUM = remember(this) { uiState.confirmUM as ConfirmUM.Success }
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
wrappedConfirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel
import com.tangem.features.send.v2.send.model.SendModel
import com.tangem.features.send.v2.send.success.model.SendConfirmSuccessModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +24,9 @@ internal interface CommonSendModelModule {
@IntoMap
@ClassKey(SendConfirmModel::class)
fun provideSendConfirmModel(model: SendConfirmModel): Model
@Binds
@IntoMap
@ClassKey(SendConfirmSuccessModel::class)
fun provideSendConfirmSuccessModel(model: SendConfirmSuccessModel): Model
}

View file

@ -39,16 +39,19 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.send.confirm.SendConfirmComponent
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
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.amount.SendAmountUpdateQRTrigger
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.v2.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer
import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
@ -65,7 +68,8 @@ internal interface SendComponentCallback :
SendAmountComponent.ModelCallback,
SendFeeComponent.ModelCallback,
SendDestinationComponent.ModelCallback,
SendConfirmComponent.ModelCallback
SendConfirmComponent.ModelCallback,
SendConfirmSuccessComponent.ModelCallback
@Stable
@ModelScoped
@ -87,7 +91,7 @@ internal class SendModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendAmountUpdateQRTrigger: SendAmountUpdateQRTrigger,
private val sendAmountUpdateTrigger: SendAmountUpdateTrigger,
private val sendFeatureToggles: SendFeatureToggles,
) : Model(), SendComponentCallback {
@ -151,6 +155,15 @@ internal class SendModel @Inject constructor(
_uiState.update { sendUM }
}
override fun onConvertToAnotherToken(lastAmount: String) {
params.callback?.onConvertToAnotherToken(lastAmount = lastAmount)
}
override fun onError(error: GetUserWalletError) {
Timber.w(error.toString())
showAlertError()
}
suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val predefinedValues = predefinedValues
val transferTransaction = if (predefinedValues is PredefinedValues.Content.Deeplink) {
@ -347,7 +360,7 @@ internal class SendModel @Inject constructor(
)
// If it is in active state use flow to update value in amount component
modelScope.launch {
amount?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) }
amount?.let { sendAmountUpdateTrigger.triggerUpdateAmount(it) }
}
}
@ -382,5 +395,7 @@ internal class SendModel @Inject constructor(
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
isRedesignEnabled = sendFeatureToggles.isSendRedesignEnabled,
confirmData = null,
feeSelectorUM = FeeSelectorUM.Loading,
)
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.send.v2.send.success
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.send.success.model.SendConfirmSuccessModel
import com.tangem.features.send.v2.send.success.ui.SendConfirmSuccessContent
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal class SendConfirmSuccessComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmSuccessModel = getOrCreateModel(params = params)
private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent
private val feeBlockComponent: SendFeeBlockComponent = params.feeBlockComponent
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsState()
SendConfirmSuccessContent(
sendUM = state,
destinationBlockComponent = destinationBlockComponent,
feeBlockComponent = feeBlockComponent,
)
}
data class Params(
val sendUMFlow: StateFlow<SendUM>,
val destinationBlockComponent: SendDestinationBlockComponent,
val feeBlockComponent: SendFeeBlockComponent,
val analyticsCategoryName: String,
val currentRoute: Flow<CommonSendRoute>,
val txUrl: String,
val callback: ModelCallback,
)
interface ModelCallback {
fun onResult(sendUM: SendUM)
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.send.v2.send.success.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
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.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Stable
@ModelScoped
internal class SendConfirmSuccessModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
) : Model() {
private val params: SendConfirmSuccessComponent.Params = paramsContainer.require()
private val _uiState = params.sendUMFlow
val uiState = _uiState
init {
configConfirmSuccessNavigation()
}
private fun configConfirmSuccessNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
appRouter.pop()
},
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = {
appRouter.pop()
},
),
prevButton = null,
secondaryPairButtonsUM = NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
),
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl)
}
private fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl)
}
interface ModelCallback {
fun onResult(sendUM: SendUM)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.send.v2.send.success.ui
import androidx.compose.animation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.ui.SendNavigationButtons
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.ui.state.SendUM
import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent
import kotlinx.coroutines.delay
@Composable
internal fun SendConfirmSuccessContent(
sendUM: SendUM,
destinationBlockComponent: SendDestinationBlockComponent,
feeBlockComponent: SendFeeBlockComponent,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(ANIMATION_DELAY)
visible = true
}
val height = ANIMATION_OFFSET.toPx().toInt()
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { height },
).plus(fadeIn()),
exit = slideOutVertically().plus(fadeOut()),
label = "Animate success content",
) {
Column {
Column(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.scrollable(
state = rememberScrollState(),
orientation = Orientation.Horizontal,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (sendUM.confirmUM is ConfirmUM.Success) {
TransactionDoneTitle(
title = resourceReference(R.string.sent_transaction_sent_title),
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
sendUM.confirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter),
sendUM.confirmUM.transactionDate.toTimeFormat(),
),
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
AmountBlock(
amountState = sendUM.amountUM,
isClickDisabled = true,
isEditingDisabled = true,
onClick = {},
)
destinationBlockComponent.Content(modifier = Modifier)
feeBlockComponent.Content(modifier = Modifier)
}
SpacerHMax()
SendNavigationButtons(navigationUM = sendUM.navigationUM)
}
}
}
private const val ANIMATION_DELAY = 600L
private val ANIMATION_OFFSET = (-40).dp

View file

@ -2,15 +2,19 @@ package com.tangem.features.send.v2.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.send.confirm.model.ConfirmData
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
internal data class SendUM(
val amountUM: AmountState,
val destinationUM: DestinationUM,
val feeUM: FeeUM,
val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
val isRedesignEnabled: Boolean,
val confirmData: ConfirmData?,
)

View file

@ -2,8 +2,11 @@ package com.tangem.features.send.v2.sendnft.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.NONCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.ui.extensions.capitalize
import com.tangem.features.send.v2.common.analytics.CommonSendAnalyticEvents
/**
@ -18,11 +21,15 @@ internal sealed class NFTSendAnalyticEvents(
data class TransactionScreenOpened(
val token: String,
val feeType: AnalyticsParam.FeeType,
val blockchain: String,
val nonceNotEmpty: Boolean,
) : NFTSendAnalyticEvents(
event = "NFT Sent Screen Opened",
params = mapOf(
TOKEN_PARAM to token,
FEE_TYPE to feeType.value,
BLOCKCHAIN to blockchain,
NONCE to nonceNotEmpty.toString().capitalize(),
),
)
}

View file

@ -26,6 +26,8 @@ internal class NFTSendAnalyticHelper @Inject constructor(
NFTSendAnalyticEvents.TransactionScreenOpened(
token = cryptoCurrency.symbol,
feeType = feeType,
blockchain = cryptoCurrency.network.name,
nonceNotEmpty = feeSelectorUM.nonce != null,
),
)
analyticsEventHandler.send(

View file

@ -30,6 +30,8 @@ import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.v2.api.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.v2.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.v2.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.v2.common.CommonSendRoute
import com.tangem.features.send.v2.common.SendBalanceUpdater
import com.tangem.features.send.v2.common.SendConfirmAlertFactory
@ -47,7 +49,6 @@ import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener
import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM
import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM
import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.utils.transformer.update
@ -72,7 +73,8 @@ internal class NFTSendConfirmModel @Inject constructor(
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val notificationsUpdateTrigger: NotificationsUpdateTrigger,
private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger,
private val notificationsUpdateListener: SendNotificationsUpdateListener,
private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger,
private val sendFeeCheckReloadListener: SendFeeCheckReloadListener,
private val alertFactory: SendConfirmAlertFactory,
@ -242,7 +244,7 @@ internal class NFTSendConfirmModel @Inject constructor(
}
private fun subscribeOnNotificationsUpdateTrigger() {
notificationsUpdateTrigger.hasErrorFlow
notificationsUpdateListener.hasErrorFlow
.onEach { hasError ->
_uiState.update {
val feeUM = it.feeUM as? FeeUM.Content

View file

@ -20,9 +20,7 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase
@ -61,6 +59,8 @@ internal class NFTSendModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val tokensFeatureToggles: TokensFeatureToggles,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase,
@ -146,9 +146,16 @@ internal class NFTSendModel @Inject constructor(
ifRight = { wallet ->
userWallet = wallet
cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId).getOrNull()
?.filterIsInstance<CryptoCurrency.Coin>()
?.firstOrNull { it.network == nftAsset.network }
cryptoCurrency = if (tokensFeatureToggles.isWalletBalanceFetcherEnabled) {
multiWalletCryptoCurrenciesSupplier.getSyncOrNull(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId),
)
?.firstOrNull { it is CryptoCurrency.Coin && it.network == nftAsset.network }
} else {
getCryptoCurrenciesUseCase(userWalletId).getOrNull()
?.filterIsInstance<CryptoCurrency.Coin>()
?.firstOrNull { it.network == nftAsset.network }
}
?: return@launch
getCurrenciesStatusUpdates(

View file

@ -1,19 +1,18 @@
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.common.ui.navigationButtons.NavigationModelCallback
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.domain.wallets.models.GetUserWalletError
import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountParams
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.v2.subcomponents.amount.ui.SendAmountContent
internal class SendAmountComponent(
appComponentContext: AppComponentContext,
@ -29,15 +28,18 @@ internal class SendAmountComponent(
val state by model.uiState.collectAsStateWithLifecycle()
val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle()
AmountScreenContent(
SendAmountContent(
amountState = state,
isBalanceHidden = isBalanceHidden,
clickIntents = model,
modifier = Modifier.background(TangemTheme.colors.background.tertiary),
isSendWithSwapEnabled = model.isSendWithSwapEnabled,
modifier = modifier,
)
}
interface ModelCallback : NavigationModelCallback {
fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean)
fun onConvertToAnotherToken(lastAmount: String)
fun onError(error: GetUserWalletError)
}
}

View file

@ -29,7 +29,7 @@ interface SendAmountReduceListener {
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateQRTrigger {
interface SendAmountUpdateTrigger {
suspend fun triggerUpdateAmount(amountValue: String)
}
@ -37,7 +37,7 @@ interface SendAmountUpdateQRTrigger {
* Trigger amount change from another component.
* Different from another triggers because it takes raw string instead of BigDecimal
*/
interface SendAmountUpdateQRListener {
interface SendAmountUpdateListener {
val updateAmountTriggerFlow: Flow<String>
}
@ -45,8 +45,8 @@ interface SendAmountUpdateQRListener {
internal class DefaultSendAmountReduceTrigger @Inject constructor() :
SendAmountReduceTrigger,
SendAmountReduceListener,
SendAmountUpdateQRTrigger,
SendAmountUpdateQRListener {
SendAmountUpdateTrigger,
SendAmountUpdateListener {
override val reduceToTriggerFlow = MutableSharedFlow<BigDecimal>()
override val reduceByTriggerFlow = MutableSharedFlow<ReduceByData>()

View file

@ -1,7 +1,6 @@
package com.tangem.features.send.v2.subcomponents.amount.di
import com.tangem.features.send.v2.subcomponents.amount.*
import com.tangem.features.send.v2.subcomponents.amount.DefaultSendAmountReduceTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -22,9 +21,9 @@ internal interface SendAmountModule {
@Singleton
@Binds
fun provideSendAmountUpdateQRListener(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateQRListener
fun provideSendAmountUpdateListener(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateListener
@Singleton
@Binds
fun provideSendAmountUpdateQRTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateQRTrigger
fun provideSendAmountUpdateTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountUpdateTrigger
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.send.v2.subcomponents.amount.model
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
interface SendAmountClickIntents : AmountScreenClickIntents {
fun onConvertToAnotherToken()
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.send.v2.subcomponents.amount.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.*
import com.tangem.common.ui.amountScreen.converters.field.AmountBoundaryUpdateTransformer
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer
@ -31,7 +30,7 @@ import com.tangem.features.send.v2.api.entity.PredefinedValues
import com.tangem.features.send.v2.impl.R
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.SendAmountUpdateQRListener
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents
import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.features.send.v2.subcomponents.fee.SendFeeData
@ -54,12 +53,12 @@ internal class SendAmountModel @Inject constructor(
private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase,
private val sendAmountReduceListener: SendAmountReduceListener,
private val feeReloadTrigger: SendFeeReloadTrigger,
private val sendAmountUpdateQRListener: SendAmountUpdateQRListener,
private val sendAmountUpdateListener: SendAmountUpdateListener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeatureToggles: SendFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model(), AmountScreenClickIntents {
) : Model(), SendAmountClickIntents {
private val params: SendAmountComponentParams = paramsContainer.require()
private var appCurrency: AppCurrency = AppCurrency.Default
@ -68,6 +67,8 @@ internal class SendAmountModel @Inject constructor(
private val _uiState = MutableStateFlow(params.state)
val uiState = _uiState.asStateFlow()
val isSendWithSwapEnabled = sendFeatureToggles.isSendWithSwapEnabled
private val analyticsCategoryName = params.analyticsCategoryName
private var cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = params.cryptoCurrency,
@ -84,10 +85,18 @@ internal class SendAmountModel @Inject constructor(
}
private fun initAppCurrency() {
modelScope.launch {
userWallet = getUserWalletUseCase(params.userWalletId).getOrNull()
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
}
getUserWalletUseCase.invokeFlow(params.userWalletId)
.onEach { either ->
either.fold(
ifLeft = { error ->
val amountParams = params as? SendAmountComponentParams.AmountParams
amountParams?.callback?.onError(error)
},
ifRight = { wallet ->
userWallet = wallet
},
)
}.launchIn(modelScope)
}
private fun subscribeOnCryptoCurrencyStatusFlow() {
@ -99,7 +108,7 @@ internal class SendAmountModel @Inject constructor(
subscribeOnAmountReduceByTriggerUpdates()
subscribeOnAmountReduceToTriggerUpdates()
subscribeOnAmountIgnoreReduceTriggerUpdates()
subscribeOnAmountUpdateQRTriggerUpdates()
subscribeOnAmountUpdateTriggerUpdates()
}
initMinBoundary()
}
@ -118,6 +127,8 @@ internal class SendAmountModel @Inject constructor(
)
}
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
if (uiState.value is AmountState.Data) {
_uiState.update(
AmountBoundaryUpdateTransformer(
@ -217,6 +228,12 @@ internal class SendAmountModel @Inject constructor(
saveResult()
}
override fun onConvertToAnotherToken() {
val amountFieldData = uiState.value as? AmountState.Data
val amountParams = params as? SendAmountComponentParams.AmountParams
amountParams?.callback?.onConvertToAnotherToken(amountFieldData?.amountTextField?.value.orEmpty())
}
private fun subscribeOnAmountReduceToTriggerUpdates() {
sendAmountReduceListener.reduceToTriggerFlow
.onEach { reduceTo ->
@ -261,8 +278,8 @@ internal class SendAmountModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeOnAmountUpdateQRTriggerUpdates() {
sendAmountUpdateQRListener.updateAmountTriggerFlow
private fun subscribeOnAmountUpdateTriggerUpdates() {
sendAmountUpdateListener.updateAmountTriggerFlow
.onEach { amount ->
onAmountValueChange(amount)
saveResult()

View file

@ -0,0 +1,109 @@
package com.tangem.features.send.v2.subcomponents.amount.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
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.send.v2.impl.R
import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountClickIntents
import com.tangem.features.send.v2.subcomponents.amount.ui.preview.SendAmountClickIntentsStub
@Composable
fun SendAmountContent(
amountState: AmountState,
isBalanceHidden: Boolean,
clickIntents: SendAmountClickIntents,
isSendWithSwapEnabled: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.background(TangemTheme.colors.background.tertiary)) {
AmountScreenContent(
amountState = amountState,
isBalanceHidden = isBalanceHidden,
clickIntents = clickIntents,
)
if (isSendWithSwapEnabled) {
SendConvertTokenButton(
onConvertToAnother = clickIntents::onConvertToAnotherToken,
)
}
}
}
@Composable
private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = null,
onClick = onConvertToAnother,
),
) {
Row(
modifier = Modifier
.padding(12.dp)
.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_convert_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(20.dp)
.background(TangemTheme.colors.control.unchecked, CircleShape)
.padding(2.dp),
)
Text(
text = stringResourceSafe(com.tangem.common.ui.R.string.send_amount_convert_to_another_token),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SendAmountContent_Preview(@PreviewParameter(SendAmountContentPreviewProvider::class) params: AmountState) {
TangemThemePreview {
SendAmountContent(
amountState = params,
isBalanceHidden = true,
clickIntents = SendAmountClickIntentsStub,
isSendWithSwapEnabled = true,
)
}
}
private class SendAmountContentPreviewProvider : PreviewParameterProvider<AmountState> {
override val values: Sequence<AmountState>
get() = sequenceOf(
AmountStatePreviewData.amountStateV2,
)
}
// endregion

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