Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-19 14:32:47 +03:00
parent e6c8647f03
commit 6cc6bcfa4c
10 changed files with 327 additions and 62 deletions

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
/** Domain */
implementation(projects.domain.models)
@ -51,6 +52,7 @@ dependencies {
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
/** Other libraries */
implementation(deps.kotlin.immutable.collections)

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -36,6 +37,10 @@ class MultiWalletSeedPhraseComponent(
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(enabled = true) {
model.onBack()
}
MultiWalletSeedPhrase(
modifier = modifier,
state = state,

View file

@ -0,0 +1,35 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model
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.sdk.extensions.getWordlist
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class MnemonicRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val wordlist = Wordlist.getWordlist(context)
val words: Set<String> = wordlist.words.toHashSet()
fun generateMnemonic(type: MnemonicType = MnemonicType.Words12): Mnemonic = DefaultMnemonic(
entropy = when (type) {
MnemonicType.Words12 -> EntropyLength.Bits128Length
MnemonicType.Words24 -> EntropyLength.Bits256Length
},
wordlist = wordlist,
)
fun generateMnemonic(mnemonicString: String): Mnemonic = DefaultMnemonic(
mnemonic = mnemonicString,
wordlist = wordlist,
)
enum class MnemonicType {
Words12, Words24,
}
}

View file

@ -2,9 +2,11 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.mode
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.SeedPhraseCheckUiStateBuilder
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@ -13,44 +15,92 @@ import javax.inject.Inject
@ComponentScoped
class MultiWalletSeedPhraseModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val mnemonicRepository: MnemonicRepository,
private val urlOpener: UrlOpener,
) : Model() {
private val state = MutableStateFlow(SeedPhraseState())
private val _uiState = MutableStateFlow(getStartState())
private val _uiState = MutableStateFlow<MultiWalletSeedPhraseUM>(
MultiWalletSeedPhraseUM.Start(
onImportSeedPhraseClicked = {},
onGenerateSeedPhraseClicked = {
openGeneratedSeedPhrase()
},
onLearnMoreClicked = {},
),
private val generateSeedPhraseUiStateBuilder = GenerateSeedPhraseUiStateBuilder(
state = state,
updateUiState = { block -> updateUiStateSpecific(block) },
onContinue = { openWordsCheck() },
)
private val seedPhraseCheckUiStateBuilder = SeedPhraseCheckUiStateBuilder(
state = state,
currentUiState = { _uiState.value },
updateUiState = { block -> updateUiStateSpecific(block) },
importWallet = { },
)
val uiState = _uiState.asStateFlow()
fun onBack() {
TODO("Not yet implemented")
when (_uiState.value) {
is MultiWalletSeedPhraseUM.Start -> {
// TODO navigate back
}
is MultiWalletSeedPhraseUM.Import,
is MultiWalletSeedPhraseUM.GenerateSeedPhrase,
-> {
_uiState.value = getStartState()
state.value = SeedPhraseState()
}
is MultiWalletSeedPhraseUM.GeneratedWordsCheck -> {
openGeneratedSeedPhrase()
}
}
}
private fun openGeneratedSeedPhrase() {
// TODO state.value.generatedWords
val words = listOf("word, word")
_uiState.value = MultiWalletSeedPhraseUM.GenerateSeedPhrase(
words24OptionSelected = false,
words = words.mapIndexed { index, s ->
MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index, s)
}.toImmutableList(),
onWords24OptionSwitch = {
_uiState.update {
if (it !is MultiWalletSeedPhraseUM.GenerateSeedPhrase) return@update it
it.copy(words24OptionSelected = it.words24OptionSelected.not())
}
state.update { it.copy(words24Option = it.words24Option.not()) }
private fun getStartState(): MultiWalletSeedPhraseUM {
return MultiWalletSeedPhraseUM.Start(
onImportSeedPhraseClicked = {},
onGenerateSeedPhraseClicked = {
generateSeedPhrase()
openGeneratedSeedPhrase()
},
onContinueClick = {
onLearnMoreClicked = {
urlOpener.openUrl(seedPhraseLearnMoreUrl())
},
)
}
private fun openGeneratedSeedPhrase() {
// TODO analytics SeedPhraseEvents.ButtonGenerateSeedPhrase
val words12 = state.value.generatedWords12 ?: return
val words24 = state.value.generatedWords24 ?: return
// TODO disallow screenshots
_uiState.value = generateSeedPhraseUiStateBuilder.getState(
generatedWords12 = words12,
generatedWords24 = words24,
)
}
private fun openWordsCheck() {
_uiState.value = seedPhraseCheckUiStateBuilder.getState()
}
private fun generateSeedPhrase() {
val words12 = mnemonicRepository.generateMnemonic(MnemonicRepository.MnemonicType.Words12)
val words24 = mnemonicRepository.generateMnemonic(MnemonicRepository.MnemonicType.Words24)
state.update {
it.copy(
generatedWords12 = words12,
generatedWords24 = words24,
)
}
}
private inline fun <reified T : MultiWalletSeedPhraseUM> updateUiStateSpecific(block: (T) -> T) {
_uiState.update {
if (it !is T) return@update it
block(it)
}
}
}

View file

@ -1,6 +1,10 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model
import com.tangem.crypto.bip39.Mnemonic
data class SeedPhraseState(
val generatedWords: List<String>? = null,
val generatedWords12: Mnemonic? = null,
val generatedWords24: Mnemonic? = null,
val words24Option: Boolean = false,
val readyToImport: Boolean = false,
)

View file

@ -0,0 +1,27 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model
import android.net.Uri
import com.tangem.utils.SupportedLanguages
import java.util.Locale
internal fun seedPhraseLearnMoreUrl(): String {
val language = Locale.getDefault().language
val languageBy = "by"
if (SupportedLanguages.RUSSIAN.equals(language, true) ||
languageBy.equals(language, true)
) {
SupportedLanguages.RUSSIAN
} else {
SupportedLanguages.ENGLISH
}
val webUri = Uri.Builder()
.scheme("https")
.authority("tangem.com")
.appendPath(language)
.appendPath("blog/post/seed-phrase-a-risky-solution")
.build()
return webUri.toString()
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.SeedPhraseState
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class GenerateSeedPhraseUiStateBuilder(
private val state: MutableStateFlow<SeedPhraseState>,
private val updateUiState: (
(MultiWalletSeedPhraseUM.GenerateSeedPhrase) -> MultiWalletSeedPhraseUM.GenerateSeedPhrase,
) -> Unit,
private val onContinue: () -> Unit,
) {
fun getState(generatedWords12: Mnemonic, generatedWords24: Mnemonic): MultiWalletSeedPhraseUM.GenerateSeedPhrase {
val words12 = generatedWords12.mnemonicComponents.mapIndexed { index, s ->
MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s)
}.toImmutableList()
val words24 = generatedWords24.mnemonicComponents.mapIndexed { index, s ->
MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem(index + 1, s)
}.toImmutableList()
return MultiWalletSeedPhraseUM.GenerateSeedPhrase(
words24OptionSelected = state.value.words24Option,
words = words12,
onWords24OptionSwitch = { switchType(words12, words24) },
onContinueClick = onContinue,
)
}
private fun switchType(
generatedWords12: ImmutableList<MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem>,
generatedWords24: ImmutableList<MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem>,
) {
updateUiState { uiSt ->
state.update { it.copy(words24Option = uiSt.words24OptionSelected.not()) }
uiSt.copy(
words24OptionSelected = uiSt.words24OptionSelected.not(),
words = if (uiSt.words24OptionSelected) {
generatedWords12
} else {
generatedWords24
},
)
}
}
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.SeedPhraseState
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class SeedPhraseCheckUiStateBuilder(
private val state: MutableStateFlow<SeedPhraseState>,
private val currentUiState: () -> MultiWalletSeedPhraseUM,
private val updateUiState: (
(MultiWalletSeedPhraseUM.GeneratedWordsCheck) -> MultiWalletSeedPhraseUM.GeneratedWordsCheck,
) -> Unit,
private val importWallet: () -> Unit,
) {
@Suppress("MagicNumber")
fun getState(): MultiWalletSeedPhraseUM.GeneratedWordsCheck {
val wordFields = List(3) { index ->
val shownIndex = when (index) {
0 -> 2
1 -> 7
2 -> 11
else -> error("")
}
MultiWalletSeedPhraseUM.GeneratedWordsCheck.WordField(
index = shownIndex,
word = TextFieldValue(""),
error = false,
onChange = { changeField ->
updateUiState { st ->
val newState = st.copy(
wordFields = st.wordFields.map { wordField ->
if (wordField.index == shownIndex) {
wordField.updateField(changeField, shownIndex)
} else {
wordField
}
}.toImmutableList(),
)
val allFieldsCorrect = newState.allFieldsCorrect()
state.update { it.copy(readyToImport = allFieldsCorrect) }
newState.copy(
createWalletButtonEnabled = allFieldsCorrect,
)
}
},
)
}.toImmutableList()
return MultiWalletSeedPhraseUM.GeneratedWordsCheck(
wordFields = wordFields,
createWalletButtonEnabled = false,
createWalletButtonProgress = false,
onCreateWalletButtonClick = {
val currentState = currentUiState() as? MultiWalletSeedPhraseUM.GeneratedWordsCheck
?: return@GeneratedWordsCheck
if (currentState.createWalletButtonEnabled) {
importWallet()
}
},
)
}
private fun MultiWalletSeedPhraseUM.GeneratedWordsCheck.WordField.updateField(
newText: TextFieldValue,
shownIndex: Int,
): MultiWalletSeedPhraseUM.GeneratedWordsCheck.WordField {
val correct = checkWordField(word = newText.text, shownIndex = shownIndex)
return copy(
word = newText,
error = correct.not(),
)
}
private fun MultiWalletSeedPhraseUM.GeneratedWordsCheck.allFieldsCorrect(): Boolean {
return wordFields.all {
checkWordField(
word = it.word.text,
shownIndex = it.index,
)
}
}
private fun checkWordField(word: String, shownIndex: Int): Boolean {
val currentState = state.value
currentState.generatedWords12 ?: return false
currentState.generatedWords24 ?: return false
val wordList = if (currentState.words24Option) {
currentState.generatedWords24
} else {
currentState.generatedWords12
}.mnemonicComponents
return wordList[shownIndex - 1] == word
}
}

View file

@ -23,6 +23,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.st
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM.GenerateSeedPhrase.MnemonicGridItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
fun MultiWalletSeedPhraseWords(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase, modifier: Modifier = Modifier) {
@ -37,19 +38,18 @@ fun MultiWalletSeedPhraseWords(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase
SeedPhraseGridBlock(
mnemonicGridItems = state.words,
modifier = Modifier
.padding(top = 20.dp, bottom = 92.dp),
.weight(1f)
.fillMaxWidth()
.padding(top = 20.dp, bottom = 32.dp),
)
Box(Modifier.weight(1f)) {
PrimaryButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
text = stringResource(id = R.string.common_continue),
onClick = state.onContinueClick,
)
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
text = stringResource(id = R.string.common_continue),
onClick = state.onContinueClick,
)
}
}
@ -61,7 +61,7 @@ private fun SegmentSeedBlock(state: MultiWalletSeedPhraseUM.GenerateSeedPhrase,
config = persistentListOf(0, 1),
initialSelectedItem = if (state.words24OptionSelected.not()) 0 else 1,
onClick = {
if (state.words24OptionSelected && it == 1 || state.words24OptionSelected.not() && it == 0) {
if (state.words24OptionSelected && it == 0 || state.words24OptionSelected.not() && it == 1) {
state.onWords24OptionSwitch()
}
},
@ -160,10 +160,10 @@ private inline fun <T> VerticalGrid(
) {
val columnLength = items.size / 2
Row(
modifier = modifier.fillMaxWidth(),
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (index in 0 until 2) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
@ -174,34 +174,18 @@ private inline fun <T> VerticalGrid(
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, heightDp = 640)
@Composable
private fun Preview() {
TangemThemePreview {
MultiWalletSeedPhraseWords(
state = MultiWalletSeedPhraseUM.GenerateSeedPhrase(
words = persistentListOf(
words = List(24) {
MnemonicGridItem(
index = 0,
index = it + 1,
mnemonic = "word1",
),
MnemonicGridItem(
index = 1,
mnemonic = "word1",
),
MnemonicGridItem(
index = 2,
mnemonic = "word1",
),
MnemonicGridItem(
index = 3,
mnemonic = "word1",
),
MnemonicGridItem(
index = 4,
mnemonic = "word1",
),
),
)
}.toImmutableList(),
),
)
}

View file

@ -42,6 +42,7 @@ internal class OnboardingMultiWalletModel @Inject constructor(
private fun getInitialStep(): OnboardingMultiWalletState.Step {
val card = params.scanResponse.card
// todo check local storage for scan response
return when {
card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup ->
OnboardingMultiWalletState.Step.AddBackupDevice