From b0b60810405785f9c974192648c8a9743fd766f3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 22 Jul 2025 12:33:37 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../java/com/tangem/utils/StringsSigns.kt | 1 + features/hot-wallet/api/build.gradle.kts | 3 + .../features/hotwallet/MnemonicRepository.kt | 16 ++ .../port/AddExistingWalletImportComponent.kt | 1 + .../im/port/AddExistingWalletImportModel.kt | 26 -- .../port/entity/AddExistingWalletImportUM.kt | 20 +- .../model/AddExistingWalletImportModel.kt | 44 ++++ .../model/ImportSeedPhraseUiStateBuilder.kt | 188 +++++++++++++++ .../port/ui/AddExistingWalletImportContent.kt | 223 ++++++++++++++++-- .../im/port/ui/PassphraseInfoBottomSheet.kt | 90 +++++++ .../utils/InvalidWordsColorTransformation.kt | 77 ++++++ .../root/di/AddExistingWalletModule.kt | 2 +- .../repository/DefaultMnemonicRepository.kt} | 18 +- .../hotwallet/di/HotWalletFeatureModule.kt | 9 +- features/onboarding-v2/impl/build.gradle.kts | 1 + .../model/MultiWalletSeedPhraseModel.kt | 1 + .../builder/ImportSeedPhraseUiStateBuilder.kt | 2 +- .../utils/InvalidWordsColorTransformation.kt | 2 +- 18 files changed, 669 insertions(+), 55 deletions(-) create mode 100644 features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/MnemonicRepository.kt delete mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportModel.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/ImportSeedPhraseUiStateBuilder.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/PassphraseInfoBottomSheet.kt create mode 100644 features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/utils/InvalidWordsColorTransformation.kt rename features/{onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MnemonicRepository.kt => hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/repository/DefaultMnemonicRepository.kt} (59%) diff --git a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt index f1423fabac..534b602d2c 100644 --- a/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt +++ b/core/utils/src/main/java/com/tangem/utils/StringsSigns.kt @@ -15,4 +15,5 @@ object StringsSigns { const val THREE_STARS = "\u2217\u2217\u2217" const val PASSWORD_VISUAL_CHAR = '\u2022' const val APPROXIMATE = "≈" + const val WHITE_SPACE = " " } \ No newline at end of file diff --git a/features/hot-wallet/api/build.gradle.kts b/features/hot-wallet/api/build.gradle.kts index 6260c97d71..9b15221cb2 100644 --- a/features/hot-wallet/api/build.gradle.kts +++ b/features/hot-wallet/api/build.gradle.kts @@ -17,6 +17,9 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) + /* Tangem libraries */ + implementation(tangemDeps.card.core) + /* Compose */ implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/MnemonicRepository.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/MnemonicRepository.kt new file mode 100644 index 0000000000..8276127b07 --- /dev/null +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/MnemonicRepository.kt @@ -0,0 +1,16 @@ +package com.tangem.features.hotwallet + +import com.tangem.crypto.bip39.Mnemonic + +interface MnemonicRepository { + + val words: Set + + fun generateMnemonic(type: MnemonicType = MnemonicType.Words12): Mnemonic + + fun generateMnemonic(mnemonicString: String): Mnemonic + + enum class MnemonicType { + Words12, Words24, + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportComponent.kt index a29db42a78..c6a56a4194 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.hotwallet.addexistingwallet.im.port.model.AddExistingWalletImportModel import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.AddExistingWalletImportContent import dagger.assisted.Assisted import dagger.assisted.AssistedInject diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportModel.kt deleted file mode 100644 index 7f39c0e85a..0000000000 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/AddExistingWalletImportModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.features.hotwallet.addexistingwallet.im.port - -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.addexistingwallet.im.port.entity.AddExistingWalletImportUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -@ModelScoped -internal class AddExistingWalletImportModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, -) : Model() { - - private val params: AddExistingWalletImportComponent.Params = paramsContainer.require() - - internal val uiState: StateFlow - field = MutableStateFlow( - AddExistingWalletImportUM( - onBackClick = params.callbacks::onBackClick, - ), - ) -} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/entity/AddExistingWalletImportUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/entity/AddExistingWalletImportUM.kt index 5daf5de360..420bd92d31 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/entity/AddExistingWalletImportUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/entity/AddExistingWalletImportUM.kt @@ -1,5 +1,23 @@ package com.tangem.features.hotwallet.addexistingwallet.im.port.entity +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + internal data class AddExistingWalletImportUM( - val onBackClick: () -> Unit, + val words: TextFieldValue, + val wordsChange: (TextFieldValue) -> Unit, + val passPhrase: TextFieldValue, + val passPhraseChange: (TextFieldValue) -> Unit, + val onPassphraseInfoClick: () -> Unit, + val wordsErrorText: TextReference?, + val invalidWords: ImmutableList, + val createWalletEnabled: Boolean, + val createWalletProgress: Boolean, + val createWalletClick: () -> Unit, + val suggestionsList: ImmutableList, + val onSuggestionClick: (String) -> Unit, + val infoBottomSheetConfig: TangemBottomSheetConfig, + val readyToImport: Boolean, ) \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt new file mode 100644 index 0000000000..502b2ccff9 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -0,0 +1,44 @@ +package com.tangem.features.hotwallet.addexistingwallet.im.port.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.crypto.bip39.Mnemonic +import com.tangem.features.hotwallet.MnemonicRepository +import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistingWalletImportUM +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 + +@ModelScoped +internal class AddExistingWalletImportModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val mnemonicRepository: MnemonicRepository, +) : Model() { + + private val importSeedPhraseUiStateBuilder: ImportSeedPhraseUiStateBuilder + + init { + importSeedPhraseUiStateBuilder = ImportSeedPhraseUiStateBuilder( + modelScope = modelScope, + mnemonicRepository = mnemonicRepository, + updateUiState = { block -> uiState.update { block(it) } }, + readyToImport = { ready -> uiState.update { it.copy(readyToImport = ready) } }, + importWallet = { mnemonic: Mnemonic, passphrase: String? -> + importWallet( + mnemonic = mnemonic, + passphrase = passphrase, + ) + }, + ) + } + + internal val uiState: StateFlow + field = MutableStateFlow(importSeedPhraseUiStateBuilder.getState()) + + @Suppress("UnusedPrivateMember") + private fun importWallet(mnemonic: Mnemonic, passphrase: String?) { + // TODO implement importing seed phrase + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/ImportSeedPhraseUiStateBuilder.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/ImportSeedPhraseUiStateBuilder.kt new file mode 100644 index 0000000000..bc05c78e98 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/ImportSeedPhraseUiStateBuilder.kt @@ -0,0 +1,188 @@ +package com.tangem.features.hotwallet.addexistingwallet.im.port.model + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.core.TangemSdkError +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.crypto.bip39.Mnemonic +import com.tangem.crypto.bip39.MnemonicErrorResult +import com.tangem.features.hotwallet.MnemonicRepository +import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistingWalletImportUM +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class ImportSeedPhraseUiStateBuilder( + private val modelScope: CoroutineScope, + private val mnemonicRepository: MnemonicRepository, + private val readyToImport: (Boolean) -> Unit, + private val updateUiState: ((AddExistingWalletImportUM) -> AddExistingWalletImportUM) -> Unit, + private val importWallet: (mnemonic: Mnemonic, passphrase: String?) -> Unit, +) { + private val wordsCheckJobHolder = JobHolder() + private var importedMnemonic: Mnemonic? = null + private var passphrase: String? = null + + fun getState(): AddExistingWalletImportUM { + return AddExistingWalletImportUM( + words = TextFieldValue(""), + passPhrase = TextFieldValue(""), + wordsErrorText = null, + invalidWords = persistentListOf(), + createWalletEnabled = false, + createWalletProgress = false, + suggestionsList = persistentListOf(), + wordsChange = { + launchInterceptWords(wordsField = it) + suggestNextWord(it) + updateUiState { state -> + state.copy(words = it) + } + }, + passPhraseChange = { + passphrase = it.text + updateUiState { state -> state.copy(passPhrase = it) } + }, + onPassphraseInfoClick = ::showInfoBS, + createWalletClick = ::onCreateWallet, + onSuggestionClick = { word -> addSuggestedWord(word) }, + readyToImport = false, + infoBottomSheetConfig = TangemBottomSheetConfig.Empty, + ) + } + + private fun onCreateWallet() { + val mnemonic = importedMnemonic ?: return + val passphrase = passphrase?.takeIf { it.isNotEmpty() } + importWallet(mnemonic, passphrase) + } + + private fun addSuggestedWord(word: String) { + updateUiState { st -> + val text = st.words.text + val wordsFromText = text.split(" ").filter { it.isNotBlank() }.map { it.trim() } + val newWords = wordsFromText.dropLast(1) + word + val newWordsText = newWords.joinToString(" ") + st.copy( + words = TextFieldValue( + text = newWordsText, + selection = TextRange(newWordsText.length), + ), + ) + } + } + + private fun suggestNextWord(wordsField: TextFieldValue) { + if (wordsField.text.endsWith(" ")) { + updateUiState { it.copy(suggestionsList = emptyList().toImmutableList()) } + return + } + + val text = wordsField.text + val wordsFromText = text.split(" ").filter { it.isNotBlank() }.map { it.trim() } + val lastWord = wordsFromText.lastOrNull() ?: return + if (lastWord.length < MINIMUM_WORD_LENGTH) return + + if (mnemonicRepository.words.contains(lastWord) && mnemonicRepository.words.none { it.startsWith(lastWord) }) { + updateUiState { it.copy(suggestionsList = emptyList().toImmutableList()) } + return + } + + updateUiState { st -> + st.copy( + suggestionsList = mnemonicRepository.words + .filter { it.startsWith(lastWord) && it != lastWord } + .toImmutableList(), + ) + } + } + + private fun launchInterceptWords(wordsField: TextFieldValue) { + modelScope.launch { + delay(timeMillis = WORDS_INTERCEPT_DELAY_MS) + interceptWords(wordsField) + }.saveIn(wordsCheckJobHolder) + } + + private fun interceptWords(wordsField: TextFieldValue) { + readyToImport(false) + + updateUiState { + it.copy( + createWalletEnabled = false, + wordsErrorText = null, + ) + } + + importedMnemonic = null + + val text = wordsField.text + val wordsFromText = text.split(" ").filter { it.isNotBlank() }.map { it.trim() } + val invalidWords = wordsFromText.filterNot { it in mnemonicRepository.words } + if (invalidWords.isNotEmpty()) { + updateUiState { + it.copy( + invalidWords = invalidWords.toImmutableList(), + wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_wrong_words), + createWalletEnabled = false, + ) + } + return + } + + try { + val mnemonic = mnemonicRepository.generateMnemonic(text) + importedMnemonic = mnemonic + updateUiState { + it.copy( + invalidWords = emptyList().toImmutableList(), + wordsErrorText = null, + createWalletEnabled = true, + ) + } + readyToImport(true) + } catch (ex: TangemSdkError.MnemonicException) { + readyToImport(false) + val error = ex.mnemonicResult + if (error is MnemonicErrorResult.InvalidChecksum) { + updateUiState { + it.copy( + wordsErrorText = resourceReference(R.string.onboarding_seed_mnemonic_invalid_checksum), + createWalletEnabled = false, + ) + } + } else { + updateUiState { + it.copy( + createWalletEnabled = false, + wordsErrorText = null, + ) + } + } + } + } + + private fun showInfoBS() { + updateUiState { state -> + state.copy( + infoBottomSheetConfig = TangemBottomSheetConfig.Companion.Empty.copy( + isShown = true, + onDismissRequest = { + updateUiState { it.copy(infoBottomSheetConfig = TangemBottomSheetConfig.Companion.Empty) } + }, + ), + ) + } + } + + companion object { + private const val MINIMUM_WORD_LENGTH = 2 + private const val WORDS_INTERCEPT_DELAY_MS = 500L + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt index 4e3fc2638a..22cb0309c7 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt @@ -1,36 +1,218 @@ package com.tangem.features.hotwallet.addexistingwallet.im.port.ui import android.content.res.Configuration -import androidx.compose.foundation.background +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +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 com.tangem.core.ui.components.appbar.TangemTopAppBar -import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp 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.addexistingwallet.im.port.entity.AddExistingWalletImportUM import com.tangem.core.ui.R +import com.tangem.core.ui.components.Keyboard +import com.tangem.core.ui.components.Notifier +import com.tangem.core.ui.components.OutlineTextFieldWithIcon +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.TangemTextFieldsDefault +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.utils.InvalidWordsColorTransformation +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .background(TangemTheme.colors.background.primary) - .fillMaxSize() - .systemBarsPadding(), - ) { - TangemTopAppBar( + Box(modifier.fillMaxSize()) { + Column( modifier = Modifier - .statusBarsPadding(), - startButton = TopAppBarButtonUM.Back(state.onBackClick), - title = stringResourceSafe(R.string.wallet_import_title), + .fillMaxSize() + .imePadding(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .weight(1f), + ) { + Text( + text = stringResourceSafe(id = R.string.onboarding_seed_import_message), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 16.dp) + .fillMaxWidth(), + ) + + PhraseBlock( + state = state, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + OutlineTextFieldWithIcon( + modifier = modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = state.passPhrase, + onValueChange = state.passPhraseChange, + iconResId = R.drawable.ic_information_24, + iconColor = TangemTheme.colors.icon.informative, + label = stringResourceSafe(id = R.string.common_passphrase), + placeholder = stringResourceSafe(id = R.string.send_optional_field), + onIconClick = state.onPassphraseInfoClick, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + ) + } + + PrimaryButtonIconEnd( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + text = stringResourceSafe(id = R.string.common_import), + iconResId = R.drawable.ic_tangem_24, + enabled = state.createWalletEnabled, + showProgress = state.createWalletProgress, + onClick = state.createWalletClick, + ) + } + + val keyboard by keyboardAsState() + if (keyboard is Keyboard.Opened) { + SuggestionsBlock( + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding(), + suggestionsList = state.suggestionsList, + onClick = { index -> state.onSuggestionClick(state.suggestionsList[index]) }, + ) + } + } + + PassphraseInfoBottomSheet(state.infoBottomSheetConfig) +} + +@Composable +private fun PhraseBlock(state: AddExistingWalletImportUM, modifier: Modifier = Modifier) { + val warningColor = TangemTheme.colors.text.warning + val invalidWordsColorTransformation = remember(state.invalidWords, warningColor) { + InvalidWordsColorTransformation( + wordsToBrush = state.invalidWords, + style = SpanStyle(color = warningColor), ) } + + Column( + modifier = modifier.fillMaxWidth(), + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens.size142), + value = state.words, + onValueChange = state.wordsChange, + textStyle = TangemTheme.typography.body1, + singleLine = false, + colors = TangemTextFieldsDefault.defaultTextFieldColors, + visualTransformation = invalidWordsColorTransformation, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(TangemTheme.dimens.size32), + ) { + if (state.wordsErrorText != null) { + Text( + modifier = Modifier.fillMaxSize(), + text = state.wordsErrorText.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.warning, + ) + } + } + } +} + +@Composable +private fun SuggestionsBlock( + suggestionsList: ImmutableList, + onClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + enter = fadeIn() + slideIn(initialOffset = { IntOffset(x = 200, y = 0) }), + exit = slideOut(targetOffset = { IntOffset(x = -200, y = 0) }) + fadeOut(), + visible = suggestionsList.isNotEmpty(), + ) { + LazyRow( + modifier = Modifier.padding(bottom = 8.dp), + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.size16), + ) { + items(suggestionsList.size) { index -> + SuggestionButton( + modifier = Modifier.rowPadding( + index = index, + rowSize = suggestionsList.size, + outSide = TangemTheme.dimens.size0, + inSide = TangemTheme.dimens.size4, + ), + text = suggestionsList[index], + onClick = { onClick(index) }, + ) + } + } + } +} + +@Composable +private fun SuggestionButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Notifier( + text = text, + modifier = modifier + .clip(TangemTheme.shapes.roundedCorners8) + .clickable(onClick = onClick), + backgroundColor = TangemTheme.colors.icon.primary1, + textColor = TangemTheme.colors.text.primary2, + ) +} + +private fun Modifier.rowPadding(index: Int, rowSize: Int, outSide: Dp, inSide: Dp): Modifier = when (index) { + 0 -> this.padding(start = outSide, end = inSide) + rowSize - 1 -> this.padding(start = inSide, end = outSide) + else -> this.padding(horizontal = inSide) } @Preview(showBackground = true, widthDp = 360) @@ -40,7 +222,20 @@ private fun PreviewAddExistingWalletImportContent() { TangemThemePreview { AddExistingWalletImportContent( state = AddExistingWalletImportUM( - onBackClick = {}, + words = TextFieldValue(""), + wordsChange = {}, + passPhrase = TextFieldValue(""), + passPhraseChange = {}, + onPassphraseInfoClick = {}, + wordsErrorText = null, + invalidWords = persistentListOf(), + createWalletEnabled = false, + createWalletProgress = false, + createWalletClick = {}, + suggestionsList = persistentListOf(), + onSuggestionClick = {}, + infoBottomSheetConfig = TangemBottomSheetConfig.Empty, + readyToImport = false, ), ) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/PassphraseInfoBottomSheet.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/PassphraseInfoBottomSheet.kt new file mode 100644 index 0000000000..76521aab7a --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/PassphraseInfoBottomSheet.kt @@ -0,0 +1,90 @@ +package com.tangem.features.hotwallet.addexistingwallet.im.port.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun PassphraseInfoBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.primary, + ) { _: TangemBottomSheetConfigContent.Empty -> + PassphraseInfoBottomSheetContent(config.onDismissRequest) + } +} + +@Composable +fun PassphraseInfoBottomSheetContent(onDismiss: () -> Unit) { + Column( + modifier = Modifier + .background(color = TangemTheme.colors.background.primary) + .fillMaxWidth(), + ) { + Icon( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = TangemTheme.dimens.size40) + .size(TangemTheme.dimens.size48), + painter = painterResource(id = R.drawable.ic_information_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + + Text( + text = stringResourceSafe(id = R.string.common_passphrase), + modifier = Modifier + .padding(top = TangemTheme.dimens.size40) + .align(Alignment.CenterHorizontally), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.h2, + ) + + Text( + text = stringResourceSafe(id = R.string.onboarding_bottom_sheet_passphrase_description), + modifier = Modifier + .padding(top = TangemTheme.dimens.size16) + .padding(horizontal = TangemTheme.dimens.size24) + .align(Alignment.CenterHorizontally), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + textAlign = TextAlign.Center, + ) + + PrimaryButton( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.size16) + .padding(top = TangemTheme.dimens.size40) + .padding(bottom = TangemTheme.dimens.size32) + .fillMaxWidth(), + text = stringResourceSafe(id = R.string.common_ok), + onClick = onDismiss, + ) + } +} + +@Preview +@Composable +private fun PassphraseInfoBottomSheetContentPreview() { + TangemThemePreview { + PassphraseInfoBottomSheetContent({ }) + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/utils/InvalidWordsColorTransformation.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/utils/InvalidWordsColorTransformation.kt new file mode 100644 index 0000000000..be1caab724 --- /dev/null +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/utils/InvalidWordsColorTransformation.kt @@ -0,0 +1,77 @@ +package com.tangem.features.hotwallet.addexistingwallet.im.port.ui.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import com.tangem.utils.StringsSigns.WHITE_SPACE +import com.tangem.utils.extensions.isSingleItem +import kotlinx.collections.immutable.ImmutableList + +class InvalidWordsColorTransformation( + private val wordsToBrush: ImmutableList, + private val style: SpanStyle, +) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText { + val plainText = text.text + if (wordsToBrush.isSingleItem() && wordsToBrush.first() == plainText) { + return plainText.annotate().toTransformedText() + } + + val wordList = makeSeparatedWordlist(plainText) + val annotatedString = makeAnnotatedString(wordList) + + return annotatedString.toTransformedText() + } + + private fun makeSeparatedWordlist(plainText: String): List { + val plainTextCharArray = plainText.toCharArray() + val wordlist = mutableListOf() + var wordBuilder = StringBuilder() + + for (index in plainTextCharArray.indices) { + val char = plainTextCharArray[index] + if (char.isWhitespace()) { + if (wordBuilder.isEmpty()) { + wordlist.add(WORD_SEPARATOR) + } else { + wordlist.add(wordBuilder.toString()) + wordBuilder = StringBuilder() + wordlist.add(WORD_SEPARATOR) + } + } else { + if (index == plainTextCharArray.lastIndex) { + wordBuilder.append(char) + wordlist.add(wordBuilder.toString()) + wordBuilder = StringBuilder() + } else { + wordBuilder.append(char) + } + } + } + return wordlist.toList() + } + + private fun makeAnnotatedString(wordList: List): AnnotatedString = buildAnnotatedString { + wordList.forEach { + if (it == WORD_SEPARATOR) { + append(WHITE_SPACE) + } else if (wordsToBrush.contains(it)) { + append(it.annotate()) + } else { + append(it) + } + } + } + + private fun String.annotate(): AnnotatedString = AnnotatedString(this, style) + + private fun AnnotatedString.toTransformedText(): TransformedText = TransformedText(this, OffsetMapping.Identity) + + companion object { + private const val WORD_SEPARATOR = "_$@-*" + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/root/di/AddExistingWalletModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/root/di/AddExistingWalletModule.kt index eebd8bf8cb..8112462b14 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/root/di/AddExistingWalletModule.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/root/di/AddExistingWalletModule.kt @@ -6,7 +6,7 @@ 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 -import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportModel +import com.tangem.features.hotwallet.addexistingwallet.im.port.model.AddExistingWalletImportModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MnemonicRepository.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/repository/DefaultMnemonicRepository.kt similarity index 59% rename from features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MnemonicRepository.kt rename to features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/repository/DefaultMnemonicRepository.kt index 63f732c4de..0c3b768d31 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MnemonicRepository.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/common/repository/DefaultMnemonicRepository.kt @@ -1,22 +1,24 @@ -package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model +package com.tangem.features.hotwallet.common.repository import android.content.Context import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.bip39.EntropyLength import com.tangem.crypto.bip39.Mnemonic import com.tangem.crypto.bip39.Wordlist +import com.tangem.features.hotwallet.MnemonicRepository +import com.tangem.features.hotwallet.MnemonicRepository.MnemonicType import com.tangem.sdk.extensions.getWordlist import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -internal class MnemonicRepository @Inject constructor( +internal class DefaultMnemonicRepository @Inject constructor( @ApplicationContext private val context: Context, -) { +) : MnemonicRepository { private val wordlist = Wordlist.getWordlist(context) - val words: Set = wordlist.words.toHashSet() + override val words: Set = wordlist.words.toHashSet() - fun generateMnemonic(type: MnemonicType = MnemonicType.Words12): Mnemonic = DefaultMnemonic( + override fun generateMnemonic(type: MnemonicType): Mnemonic = DefaultMnemonic( entropy = when (type) { MnemonicType.Words12 -> EntropyLength.Bits128Length MnemonicType.Words24 -> EntropyLength.Bits256Length @@ -24,12 +26,8 @@ internal class MnemonicRepository @Inject constructor( wordlist = wordlist, ) - fun generateMnemonic(mnemonicString: String): Mnemonic = DefaultMnemonic( + override fun generateMnemonic(mnemonicString: String): Mnemonic = DefaultMnemonic( mnemonic = mnemonicString, wordlist = wordlist, ) - - enum class MnemonicType { - Words12, Words24, - } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt index 3b8b7412a8..feec2263d5 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/di/HotWalletFeatureModule.kt @@ -3,6 +3,9 @@ package com.tangem.features.hotwallet.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.hotwallet.DefaultHotWalletFeatureToggles import com.tangem.features.hotwallet.HotWalletFeatureToggles +import com.tangem.features.hotwallet.MnemonicRepository +import com.tangem.features.hotwallet.common.repository.DefaultMnemonicRepository +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,4 +25,8 @@ internal object HotWalletFeatureModule { @Module @InstallIn(SingletonComponent::class) -internal interface HotWalletFeatureModuleBinds \ No newline at end of file +internal interface HotWalletFeatureModuleBinds { + @Binds + @Singleton + fun provideMnemonicRepository(repository: DefaultMnemonicRepository): MnemonicRepository +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index bc3dc77d8d..eac8270d6f 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(projects.features.onboardingV2.api) implementation(projects.features.manageTokens.api) implementation(projects.features.biometry.api) + implementation(projects.features.hotWallet.api) /** Core modules */ implementation(projects.core.configToggles) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index fcb2c47870..c59be4fb41 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -13,6 +13,7 @@ import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.features.hotwallet.MnemonicRepository import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/ImportSeedPhraseUiStateBuilder.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/ImportSeedPhraseUiStateBuilder.kt index fc500a55f4..0fd46b3494 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/ImportSeedPhraseUiStateBuilder.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/builder/ImportSeedPhraseUiStateBuilder.kt @@ -7,8 +7,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.crypto.bip39.Mnemonic import com.tangem.crypto.bip39.MnemonicErrorResult +import com.tangem.features.hotwallet.MnemonicRepository import com.tangem.features.onboarding.v2.impl.R -import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.MnemonicRepository import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/utils/InvalidWordsColorTransformation.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/utils/InvalidWordsColorTransformation.kt index 1650f7cd62..3d4f7019f6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/utils/InvalidWordsColorTransformation.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/utils/InvalidWordsColorTransformation.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import com.tangem.utils.StringsSigns.WHITE_SPACE import com.tangem.utils.extensions.isSingleItem import kotlinx.collections.immutable.ImmutableList @@ -71,7 +72,6 @@ class InvalidWordsColorTransformation( private fun AnnotatedString.toTransformedText(): TransformedText = TransformedText(this, OffsetMapping.Identity) companion object { - private const val WHITE_SPACE = " " private const val WORD_SEPARATOR = "_$@-*" } } \ No newline at end of file