Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-21 18:34:38 +03:00
commit 438126dc82
665 changed files with 16174 additions and 6245 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

@ -14,6 +14,7 @@ android {
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
implementation(projects.features.pushNotifications.api)
/** Core modules */
implementation(projects.core.configToggles)
@ -26,6 +27,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 +41,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

@ -6,9 +6,11 @@ import com.arkivanov.decompose.router.stack.push
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.hotwallet.addexistingwallet.root.routing.AddExistingWalletRoute
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
@ -20,6 +22,8 @@ internal class AddExistingWalletModel @Inject constructor(
val addExistingWalletStartModelCallbacks = AddExistingWalletStartModelCallbacks()
val addExistingWalletImportModelCallbacks = AddExistingWalletImportModelCallbacks()
val pushNotificationsComponentModelCallbacks = PushNotificationsComponentModelCallbacks()
val accessCodeModelCallbacks = AccessCodeModelCallbacks()
val stackNavigation = StackNavigation<AddExistingWalletRoute>()
@ -38,4 +42,20 @@ internal class AddExistingWalletModel @Inject constructor(
stackNavigation.pop()
}
}
inner class PushNotificationsComponentModelCallbacks : PushNotificationsComponent.ModelCallbacks {
override fun onResult() {
// TODO [REDACTED_TASK_KEY]
}
}
inner class AccessCodeModelCallbacks : SetAccessCodeComponent.ModelCallbacks {
override fun onBackClick() {
// TODO [REDACTED_TASK_KEY]
}
override fun onAccessCodeSet() {
// TODO [REDACTED_TASK_KEY]
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.hotwallet.addexistingwallet.root.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeModel
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.root.DefaultAddExistingWalletComponent
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartModel
@ -42,4 +43,9 @@ internal interface AddExistingWalletModuleBinds {
@IntoMap
@ClassKey(AddExistingWalletImportModel::class)
fun bindAddExistingWalletImportModel(model: AddExistingWalletImportModel): Model
@Binds
@IntoMap
@ClassKey(SetAccessCodeModel::class)
fun bindAccessCodeModel(model: SetAccessCodeModel): Model
}

View file

@ -2,12 +2,16 @@ package com.tangem.features.hotwallet.addexistingwallet.root.routing
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.hotwallet.setaccesscode.SetAccessCodeComponent
import com.tangem.features.hotwallet.addexistingwallet.root.AddExistingWalletModel
import com.tangem.features.hotwallet.addexistingwallet.start.AddExistingWalletStartComponent
import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import javax.inject.Inject
internal class AddExistingWalletChildFactory @Inject constructor() {
internal class AddExistingWalletChildFactory @Inject constructor(
private val pushNotificationsComponent: PushNotificationsComponent.Factory,
) {
fun createChild(
route: AddExistingWalletRoute,
@ -27,6 +31,18 @@ internal class AddExistingWalletChildFactory @Inject constructor() {
callbacks = model.addExistingWalletImportModelCallbacks,
),
)
is AddExistingWalletRoute.PushNotifications -> pushNotificationsComponent.create(
context = childContext,
params = PushNotificationsComponent.Params.Callbacks(
callbacks = model.pushNotificationsComponentModelCallbacks,
),
)
is AddExistingWalletRoute.AccessCode -> SetAccessCodeComponent(
context = childContext,
params = SetAccessCodeComponent.Params(
callbacks = model.accessCodeModelCallbacks,
),
)
}
}
}

View file

@ -10,4 +10,10 @@ internal sealed class AddExistingWalletRoute : Route {
@Serializable
object Import : AddExistingWalletRoute()
@Serializable
object PushNotifications : AddExistingWalletRoute()
@Serializable
object AccessCode : AddExistingWalletRoute()
}

View file

@ -1,18 +1,30 @@
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 timber.log.Timber
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 +32,30 @@ 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 {
Timber.e(it)
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

@ -10,18 +10,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object CreateMobileWalletModule
@Module
@InstallIn(SingletonComponent::class)
internal interface CreateMobileWalletModuleBinds {
@Binds
@Singleton
fun bindCreateMobileWalletComponentFactory(
impl: DefaultCreateMobileWalletComponent.Factory,
): CreateMobileWalletComponent.Factory

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,43 @@
package com.tangem.features.hotwallet.setaccesscode
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.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.features.hotwallet.setaccesscode.ui.SetAccessCodeContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class SetAccessCodeComponent @AssistedInject constructor(
@Assisted private val context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val model: SetAccessCodeModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SetAccessCodeContent(
state = state,
onBack = { model.onBack() },
modifier = modifier,
)
DisableScreenshotsDisposableEffect()
}
interface ModelCallbacks {
fun onBackClick()
fun onAccessCodeSet()
}
data class Params(
val callbacks: ModelCallbacks,
)
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.hotwallet.setaccesscode
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.setaccesscode.entity.SetAccessCodeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@Stable
@ModelScoped
internal class SetAccessCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<SetAccessCodeComponent.Params>()
internal val uiState: StateFlow<SetAccessCodeUM>
field = MutableStateFlow(getInitialState())
fun onBack() {
when (uiState.value.step) {
SetAccessCodeUM.Step.AccessCode -> {
params.callbacks.onBackClick()
}
SetAccessCodeUM.Step.ConfirmAccessCode -> {
uiState.update {
it.copy(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeSecond = "",
)
}
}
}
}
private fun getInitialState() = SetAccessCodeUM(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = ::onAccessCodeFirstChange,
onAccessCodeSecondChange = ::onAccessCodeSecondChange,
buttonEnabled = false,
onContinue = ::onContinue,
)
private fun onAccessCodeFirstChange(value: String) {
uiState.update {
it.copy(
accessCodeFirst = value,
buttonEnabled = value.length == uiState.value.accessCodeLength,
)
}
}
private fun onAccessCodeSecondChange(value: String) {
uiState.update {
it.copy(
accessCodeSecond = value,
buttonEnabled = uiState.value.accessCodeFirst == uiState.value.accessCodeSecond,
)
}
}
private fun onContinue() {
when (uiState.value.step) {
SetAccessCodeUM.Step.AccessCode -> {
uiState.update {
it.copy(
step = SetAccessCodeUM.Step.ConfirmAccessCode,
)
}
}
SetAccessCodeUM.Step.ConfirmAccessCode -> {
params.callbacks.onAccessCodeSet()
}
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.hotwallet.setaccesscode.entity
internal data class SetAccessCodeUM(
val step: Step,
val accessCodeFirst: String,
val accessCodeSecond: String,
val onAccessCodeFirstChange: (String) -> Unit,
val onAccessCodeSecondChange: (String) -> Unit,
val buttonEnabled: Boolean,
val onContinue: () -> Unit,
) {
val accessCodeLength: Int = ACCESS_CODE_LENGTH
enum class Step {
AccessCode,
ConfirmAccessCode,
}
companion object {
private const val ACCESS_CODE_LENGTH = 6
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.features.hotwallet.setaccesscode.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.TangemAnimations
import com.tangem.features.hotwallet.setaccesscode.entity.SetAccessCodeUM
import com.tangem.features.hotwallet.setaccesscode.entity.SetAccessCodeUM.Step.*
import com.tangem.core.res.R
@Composable
internal fun SetAccessCodeContent(state: SetAccessCodeUM, onBack: () -> Unit, modifier: Modifier = Modifier) {
BackHandler(onBack = onBack)
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
AnimatedContent(
modifier = Modifier.weight(1f),
targetState = state.step,
transitionSpec = TangemAnimations.AnimatedContent
.slide { initial, target -> target.ordinal > initial.ordinal },
label = "AnimatedContent",
) { step ->
when (step) {
AccessCode -> SetAccessCodeEnter(
modifier = Modifier.padding(top = 16.dp),
state = state,
reEnterAccessCodeState = false,
)
ConfirmAccessCode -> SetAccessCodeEnter(
modifier = Modifier.padding(top = 16.dp),
state = state,
reEnterAccessCodeState = true,
)
}
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.imePadding(),
text = if (state.step == ConfirmAccessCode) {
stringResourceSafe(R.string.common_confirm)
} else {
stringResourceSafe(R.string.common_continue)
},
onClick = state.onContinue,
)
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.hotwallet.setaccesscode.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.res.R
import com.tangem.core.ui.components.fields.PinTextField
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.setaccesscode.entity.SetAccessCodeUM
@Composable
internal fun SetAccessCodeEnter(
state: SetAccessCodeUM,
reEnterAccessCodeState: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.padding(top = 56.dp)
.align(Alignment.CenterHorizontally),
text = if (reEnterAccessCodeState) {
stringResourceSafe(R.string.access_code_confirm_title)
} else {
stringResourceSafe(R.string.access_code_create_title)
},
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterHorizontally),
text = if (reEnterAccessCodeState) {
stringResourceSafe(R.string.access_code_confirm_description)
} else {
stringResourceSafe(
R.string.access_code_create_description,
state.accessCodeLength,
)
},
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
contentAlignment = Alignment.Center,
) {
PinTextField(
length = state.accessCodeLength,
isPasswordVisual = true,
value = if (reEnterAccessCodeState) {
state.accessCodeSecond
} else {
state.accessCodeFirst
},
onValueChange = if (reEnterAccessCodeState) {
state.onAccessCodeSecondChange
} else {
state.onAccessCodeFirstChange
},
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
SetAccessCodeEnter(
reEnterAccessCodeState = false,
state = SetAccessCodeUM(
step = SetAccessCodeUM.Step.AccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = {},
onAccessCodeSecondChange = {},
buttonEnabled = false,
onContinue = {},
),
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview2() {
TangemThemePreview {
SetAccessCodeEnter(
reEnterAccessCodeState = true,
state = SetAccessCodeUM(
step = SetAccessCodeUM.Step.ConfirmAccessCode,
accessCodeFirst = "",
accessCodeSecond = "",
onAccessCodeFirstChange = {},
onAccessCodeSecondChange = {},
buttonEnabled = false,
onContinue = {},
),
)
}
}

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

@ -15,6 +15,7 @@ dependencies {
/* Project - API */
api(projects.features.markets.api)
api(projects.features.onramp.api)
api(projects.features.sendV2.api)
/* Data */
implementation(projects.data.common)
@ -74,7 +75,6 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
implementation(projects.core.deepLinks)
/* Common */
implementation(projects.common.ui)

View file

@ -3,7 +3,7 @@ package com.tangem.features.markets.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency

View file

@ -25,7 +25,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.markets.impl.R
@ -194,8 +193,7 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun loadArtworks(wallets: List<UserWallet>) {
modelScope.launch {
loadArtworksMutex.withLock {
wallets.forEach { wallet ->
wallet.requireColdWallet() // TODO [REDACTED_TASK_KEY]
wallets.filterIsInstance<UserWallet.Cold>().forEach { wallet ->
if (!loadedArtworks.containsKey(wallet.walletId)) {
val artwork = getCardImageUseCase(
cardId = wallet.cardId,

View file

@ -23,7 +23,7 @@ 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.features.send.v2.api.SendFeatureToggles
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -41,14 +41,11 @@ 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 sendFeatureToggles: SendFeatureToggles,
) {
private val disabledActionsInDemoMode = buildSet {
if (!onrampFeatureToggles.isFeatureEnabled) {
add(TokenActionsBSContentUM.Action.Buy)
}
add(TokenActionsBSContentUM.Action.Sell)
}
@ -132,12 +129,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,
),
)
}
@ -163,12 +159,18 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
val route = if (sendFeatureToggles.isSendWithSwapEnabled) {
AppRoute.SendEntryPoint(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
)
} else {
AppRoute.Send(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
),
)
)
}
router.push(route)
}
private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

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

@ -24,7 +24,7 @@ internal fun NFTContent(stackState: ChildStack<NFTRoute, ComposableContentCompon
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Children(
stack = stackState,

View file

@ -71,13 +71,11 @@ internal fun MultiWalletAccessCodeEnter(
state.onAccessCodeFirstChange
},
label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third),
isError = state.codesNotMatchError || state.atLeast4CharError,
isError = state.codesNotMatchError,
visualTransformation = PasswordVisualTransformation(),
caption = when {
state.codesNotMatchError && reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_codes_doesnt_match)
state.atLeast4CharError && !reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_code_too_short)
else -> null
},
)

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)
}
}
@ -197,12 +204,9 @@ internal class OnboardingNoteTopUpModel @Inject constructor(
}
private fun loadAvailableForBuy(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val userWalletId = userWallet?.walletId ?: return
modelScope.launch {
val availableForBuy = rampStateManager.availableForBuy(
scanResponse = scanResponse,
userWalletId = userWalletId,
userWallet = userWallet ?: return@launch,
cryptoCurrency = cryptoCurrencyStatus.currency,
)
_uiState.update {

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

@ -13,7 +13,6 @@ dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.deepLinks)
/* Project - Domain */
implementation(projects.domain.onramp.models)

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

@ -22,7 +22,6 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)

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() {
@ -84,8 +83,7 @@ internal class OnrampOperationModel @Inject constructor(
fun onHotTokenClick(status: CryptoCurrencyStatus) {
modelScope.launch {
val unavailabilityReason = rampStateManager.availableForBuy(
scanResponse = selectedUserWallet.scanResponse,
userWalletId = params.userWalletId,
userWallet = selectedUserWallet,
cryptoCurrency = status.currency,
)
@ -100,7 +98,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 +107,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 +153,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

@ -229,8 +229,7 @@ internal class OnrampTokenListModel @Inject constructor(
return when (params.filterOperation) {
OnrampOperation.BUY -> {
rampStateManager.availableForBuy(
scanResponse = userWallet.scanResponse,
userWalletId = params.userWalletId,
userWallet = userWallet,
cryptoCurrency = status.currency,
).isAvailable()
}

View file

@ -16,4 +16,7 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.analytics.models)
/** Common */
implementation(projects.common.routing)
}

View file

@ -1,9 +1,19 @@
package com.tangem.features.pushnotifications.api
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface PushNotificationsComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Unit, PushNotificationsComponent>
interface Factory : ComponentFactory<Params, PushNotificationsComponent>
interface ModelCallbacks {
fun onResult()
}
sealed class Params {
data class Callbacks(val callbacks: ModelCallbacks) : Params()
data class Route(val route: AppRoute) : Params()
}
}

View file

@ -19,7 +19,7 @@ import dagger.assisted.AssistedInject
internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
@Assisted params: PushNotificationsComponent.Params,
) : PushNotificationsComponent, AppComponentContext by appComponentContext {
private val model: PushNotificationsModel = getOrCreateModel(params)
@ -41,6 +41,9 @@ internal class DefaultPushNotificationsComponent @AssistedInject constructor(
@AssistedFactory
interface Factory : PushNotificationsComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultPushNotificationsComponent
override fun create(
context: AppComponentContext,
params: PushNotificationsComponent.Params,
): DefaultPushNotificationsComponent
}
}

View file

@ -1,15 +1,16 @@
package com.tangem.features.pushnotifications.impl.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.notifications.toggles.NotificationsFeatureToggles
import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.pushnotifications.impl.presentation.state.PushNotificationsUM
@ -21,15 +22,19 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class PushNotificationsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase,
private val appRouter: AppRouter,
private val analyticHandler: AnalyticsEventHandler,
private val notificationsFeatureToggles: NotificationsFeatureToggles,
notificationsFeatureToggles: NotificationsFeatureToggles,
) : Model(), PushNotificationsClickIntents {
private val params: PushNotificationsComponent.Params = paramsContainer.require()
private val _state = MutableStateFlow(
PushNotificationsUM(
showInfoAboutNotifications = notificationsFeatureToggles.isNotificationsEnabled,
@ -51,7 +56,7 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
@ -62,7 +67,7 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
@ -73,7 +78,18 @@ internal class PushNotificationsModel @Inject constructor(
modelScope.launch {
neverRequestPermissionUseCase(PUSH_PERMISSION)
neverToInitiallyAskPermissionUseCase(PUSH_PERMISSION)
appRouter.push(AppRoute.Home)
onResult()
}
}
private fun onResult() {
when (params) {
is PushNotificationsComponent.Params.Callbacks -> {
params.callbacks.onResult()
}
is PushNotificationsComponent.Params.Route -> {
appRouter.push(params.route)
}
}
}
}

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

@ -11,7 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.StartReferralBody
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.feature.referral.converters.ReferralConverter
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.feature.referral.domain.models.ReferralData
@ -97,13 +96,13 @@ internal class ReferralRepositoryImpl @Inject constructor(
sdkToken = sdkToken,
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
userWallet = userWallet,
)
} else {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
userWallet = userWallet,
)
}
}

View file

@ -22,7 +22,6 @@ dependencies {
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
implementation(projects.libs.crypto)
implementation(projects.common.routing)

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

@ -0,0 +1,16 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface SendEntryPointComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
)
interface Factory : ComponentFactory<Params, SendEntryPointComponent>
}

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

@ -0,0 +1,13 @@
package com.tangem.features.send.v2.api.feeselector
import kotlinx.coroutines.flow.Flow
data class FeeSelectorReloadData(val removeSuggestedFee: Boolean = false)
interface FeeSelectorReloadTrigger {
suspend fun triggerUpdate(data: FeeSelectorReloadData = FeeSelectorReloadData())
}
interface FeeSelectorReloadListener {
val reloadTriggerFlow: Flow<FeeSelectorReloadData>
}

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,11 +11,16 @@ android {
namespace = "com.tangem.features.send.v2.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.sendV2.api)
implementation(projects.features.txhistory.api)
implementation(projects.features.nft.api)
implementation(projects.features.swapV2.api)
/** Libs */
implementation(projects.libs.crypto)
@ -60,6 +65,7 @@ dependencies {
implementation(projects.domain.nft.models)
implementation(projects.domain.nft)
implementation(projects.domain.notifications)
implementation(projects.domain.swap.models)
/** Compose libraries */
@ -79,4 +85,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(
@ -22,22 +23,30 @@ internal fun SendContent(
stackState: ChildStack<CommonSendRoute, ComposableContentComponent>,
) {
Column(
modifier = Modifier.Companion
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.Companion.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally,
) {
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

@ -2,10 +2,8 @@ package com.tangem.features.send.v2.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.send.v2.DefaultSendFeatureToggles
import com.tangem.features.send.v2.api.NFTSendComponent
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.SendNotificationsComponent
import com.tangem.features.send.v2.api.*
import com.tangem.features.send.v2.entrypoint.DefaultSendEntryPointComponent
import com.tangem.features.send.v2.send.DefaultSendComponent
import com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent
import com.tangem.features.send.v2.subcomponents.notifications.DefaultSendNotificationsComponent
@ -43,4 +41,10 @@ internal interface SendFeatureModuleBinds {
fun provideNotificationComponentFactory(
impl: DefaultSendNotificationsComponent.Factory,
): SendNotificationsComponent.Factory
@Binds
@Singleton
fun provideSendEntryPointComponentFactory(
impl: DefaultSendEntryPointComponent.Factory,
): SendEntryPointComponent.Factory
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.send.v2.entrypoint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendEntryPointComponent
import com.tangem.features.send.v2.entrypoint.model.SendEntryPoint
import com.tangem.features.send.v2.entrypoint.model.SendEntryPointModel
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSendEntryPointComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendEntryPointComponent.Params,
sendWithSwapComponentFactory: SendWithSwapComponent.Factory,
sendComponentFactory: SendComponent.Factory,
) : SendEntryPointComponent, AppComponentContext by appComponentContext {
private val model: SendEntryPointModel = getOrCreateModel(params = params)
private val sendWithSwapComponent = sendWithSwapComponentFactory.create(
context = child("sendEntrySendWithSwap"),
params = SendWithSwapComponent.Params(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
callback = model,
),
)
private val sendComponent = sendComponentFactory.create(
context = child("sendEntryVanillaSend"),
params = SendComponent.Params(
userWalletId = params.userWalletId,
currency = params.cryptoCurrency,
callback = model,
),
)
@Composable
override fun Content(modifier: Modifier) {
val sendEntryState by model.sendEntryPointState.collectAsStateWithLifecycle()
sendComponent.Content(modifier)
AnimatedVisibility(
visible = sendEntryState == SendEntryPoint.SendWithSwap,
enter = fadeIn(),
exit = fadeOut(),
) {
sendWithSwapComponent.Content(modifier)
}
}
@AssistedFactory
interface Factory : SendEntryPointComponent.Factory {
override fun create(
context: AppComponentContext,
params: SendEntryPointComponent.Params,
): DefaultSendEntryPointComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.entrypoint.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.entrypoint.model.SendEntryPointModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface SendEntryPointModule {
@Binds
@IntoMap
@ClassKey(SendEntryPointModel::class)
fun provideSendEntryPointModel(model: SendEntryPointModel): Model
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.send.v2.entrypoint.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
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.send.v2.api.SendComponent
import com.tangem.features.send.v2.api.SendEntryPointComponent
import com.tangem.features.send.v2.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import jakarta.inject.Inject
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ModelScoped
internal class SendEntryPointModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
val appRouter: AppRouter,
private val swapChooseTokenNetworkListener: SwapChooseTokenNetworkListener,
private val sendAmountUpdateTrigger: SendAmountUpdateTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
) : Model(), SendComponent.ModelCallback, SendWithSwapComponent.ModelCallback {
private val params: SendEntryPointComponent.Params = paramsContainer.require()
val sendEntryPointState: StateFlow<SendEntryPoint>
field = MutableStateFlow(SendEntryPoint.SendVanilla)
private var swapChooseTokenListenerJobHolder = JobHolder()
override fun onConvertToAnotherToken(lastAmount: String) {
appRouter.push(
AppRoute.ChooseManagedTokens(
userWalletId = params.userWalletId,
initialCurrency = params.cryptoCurrency,
source = AppRoute.ChooseManagedTokens.Source.SendViaSwap,
),
)
observeChooseSelectToken(lastAmount)
}
override fun onCloseSwap(lastAmount: String) {
modelScope.launch {
if (lastAmount.isNotBlank()) {
sendAmountUpdateTrigger.triggerUpdateAmount(lastAmount)
}
triggerScreenUpdate(SendEntryPoint.SendVanilla)
}
}
private fun observeChooseSelectToken(lastAmount: String) {
swapChooseTokenNetworkListener.swapChooseTokenNetworkResultFlow
.onEach { currency ->
if (lastAmount.isNotBlank()) {
swapAmountUpdateTrigger.triggerUpdateAmount(lastAmount)
}
triggerScreenUpdate(SendEntryPoint.SendWithSwap)
}
.launchIn(modelScope)
.saveIn(swapChooseTokenListenerJobHolder)
}
private fun triggerScreenUpdate(entry: SendEntryPoint) {
swapChooseTokenListenerJobHolder.cancel()
sendEntryPointState.update { entry }
}
}
enum class SendEntryPoint {
SendVanilla,
SendWithSwap,
}

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

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.feeselector
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadData
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadListener
import com.tangem.features.send.v2.api.feeselector.FeeSelectorReloadTrigger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultFeeSelectorReload @Inject constructor() : FeeSelectorReloadListener, FeeSelectorReloadTrigger {
private val _reloadTriggerFlow = MutableSharedFlow<FeeSelectorReloadData>()
override val reloadTriggerFlow: Flow<FeeSelectorReloadData> = _reloadTriggerFlow.asSharedFlow()
override suspend fun triggerUpdate(data: FeeSelectorReloadData) {
_reloadTriggerFlow.emit(data)
}
}

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