secant-android-wallet/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/backup/view/BackupView.kt

275 lines
10 KiB
Kotlin

package co.electriccoin.zcash.ui.screen.backup.view
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import cash.z.ecc.sdk.model.PersistableWallet
import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.CHIP_GRID_ROW_SIZE
import co.electriccoin.zcash.ui.design.component.Chip
import co.electriccoin.zcash.ui.design.component.ChipGrid
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.NavigationButton
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.TestChoicesFixture
import co.electriccoin.zcash.ui.screen.backup.BackupTag
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
import co.electriccoin.zcash.ui.screen.backup.state.BackupState
import co.electriccoin.zcash.ui.screen.backup.state.TestChoices
@Preview(device = Devices.PIXEL_4)
@Composable
fun ComposablePreview() {
ZcashTheme(darkTheme = false) {
GradientSurface {
BackupWallet(
PersistableWalletFixture.new(),
BackupState(BackupStage.EducationOverview),
TestChoicesFixture.new(mutableMapOf()),
onCopyToClipboard = {},
onComplete = {},
onChoicesChanged = {}
)
}
}
}
/**
* @param onComplete Callback when the user has completed the backup test.
*/
@Composable
@Suppress("LongParameterList")
fun BackupWallet(
wallet: PersistableWallet,
backupState: BackupState,
choices: TestChoices,
onCopyToClipboard: () -> Unit,
onComplete: () -> Unit,
onChoicesChanged: ((choicesCount: Int) -> Unit)?
) {
Column {
when (backupState.current.collectAsState().value) {
BackupStage.EducationOverview -> EducationOverview(onNext = backupState::goNext)
BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase(onNext = backupState::goNext)
BackupStage.Seed -> SeedPhrase(
wallet,
onNext = backupState::goNext,
onCopyToClipboard = onCopyToClipboard
)
BackupStage.Test -> Test(
wallet,
choices,
onBack = backupState::goPrevious,
onNext = backupState::goNext,
onChoicesChanged = onChoicesChanged
)
BackupStage.Complete -> Complete(
onComplete = onComplete,
onBackToSeedPhrase = backupState::goToSeed
)
}
}
}
@Composable
private fun EducationOverview(onNext: () -> Unit) {
Column {
Header(stringResource(R.string.new_wallet_1_header))
Body(stringResource(R.string.new_wallet_1_body_1))
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT, true)
)
Body(stringResource(R.string.new_wallet_1_body_2))
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EducationRecoveryPhrase(onNext: () -> Unit) {
Column {
Header(stringResource(R.string.new_wallet_2_header))
Body(stringResource(R.string.new_wallet_2_body_1))
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT, true)
)
Body(stringResource(R.string.new_wallet_2_body_2))
Card {
Body(stringResource(R.string.new_wallet_2_body_3))
}
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button))
}
}
@Composable
private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, onCopyToClipboard: () -> Unit) {
Column(Modifier.verticalScroll(rememberScrollState())) {
Header(stringResource(R.string.new_wallet_3_header))
Body(stringResource(R.string.new_wallet_3_body_1))
ChipGrid(persistableWallet.seedPhrase.split)
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished))
TertiaryButton(onClick = onCopyToClipboard, text = stringResource(R.string.new_wallet_3_button_copy))
}
}
@Suppress("MagicNumber")
private val testIndices = listOf(Index(4), Index(9), Index(16), Index(20))
private data class TestChoice(val originalIndex: Index, val word: String)
@Composable
private fun Test(
wallet: PersistableWallet,
selectedTestChoices: TestChoices,
onBack: () -> Unit,
onNext: () -> Unit,
onChoicesChanged: ((choicesCount: Int) -> Unit)?
) {
val splitSeedPhrase = wallet.seedPhrase.split
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
when {
currentSelectedTestChoice.size != testIndices.size -> {
TestInProgress(splitSeedPhrase, selectedTestChoices, onBack, onChoicesChanged)
}
currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value } -> {
// The user got the test correct
onNext()
}
currentSelectedTestChoice.none { null == it.value } -> {
TestFailure {
// Clear the user's prior test inputs for the retest
selectedTestChoices.set(emptyMap())
onBack()
}
}
}
}
/*
* A few implementation notes on the test:
* - It is possible for the same word to appear twice in the word choices
* - The test answer ordering is not randomized, to ensure it can never be in the correct order to start with
*/
@Composable
private fun TestInProgress(
splitSeedPhrase: List<String>,
selectedTestChoices: TestChoices,
onBack: () -> Unit,
onChoicesChanged: ((choicesCount: Int) -> Unit)?
) {
val testChoices = splitSeedPhrase
.mapIndexed { index, word -> TestChoice(Index(index), word) }
.filter { testIndices.contains(it.originalIndex) }
.let {
// Don't randomize; otherwise there's a chance they'll be in the right order to start with.
@Suppress("MagicNumber")
listOf(it[1], it[0], it[3], it[2])
}
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
Column {
// This button doesn't match the design; just providing the navigation hook for now
NavigationButton(onClick = onBack, text = stringResource(R.string.new_wallet_4_button_back))
Header(stringResource(R.string.new_wallet_4_header_verify))
// Body(stringResource(R.string.new_wallet_4_body_verify))
Column {
splitSeedPhrase.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
Row(Modifier.fillMaxWidth()) {
chunk.forEachIndexed { subIndex, word ->
val currentIndex = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex)
if (testIndices.contains(currentIndex)) {
ChipDropDown(
currentIndex,
dropdownText = currentSelectedTestChoice[currentIndex]
?: "",
choices = testChoices.map { it.word },
modifier = Modifier
.weight(MINIMAL_WEIGHT)
.testTag(BackupTag.DROPDOWN_CHIP)
) {
selectedTestChoices.set(
HashMap(currentSelectedTestChoice).apply {
this[currentIndex] = testChoices[it.value].word
}
)
if (onChoicesChanged != null) {
onChoicesChanged(selectedTestChoices.current.value.size)
}
}
} else {
Chip(
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
text = word,
modifier = Modifier.weight(MINIMAL_WEIGHT)
)
}
}
}
}
}
}
}
@Composable
private fun TestFailure(onBackToSeedPhrase: () -> Unit) {
Column {
// This button doesn't match the design; just providing the navigation hook for now
NavigationButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_back))
Header(stringResource(R.string.new_wallet_4_header_ouch))
Box(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
Body(stringResource(R.string.new_wallet_4_body_ouch_retry))
PrimaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_4_button_retry))
}
}
@Composable
private fun Complete(onComplete: () -> Unit, onBackToSeedPhrase: () -> Unit) {
Column {
Header(stringResource(R.string.new_wallet_5_header))
Body(stringResource(R.string.new_wallet_5_body))
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT, true)
)
PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished))
TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back))
}
}