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

462 lines
16 KiB
Kotlin

@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.backup.view
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.SecureScreen
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.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
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Preview(device = Devices.PIXEL_4)
@Composable
fun ComposablePreview() {
ZcashTheme(darkTheme = false) {
GradientSurface {
LongNewWalletBackup(
PersistableWalletFixture.new(),
BackupState(BackupStage.EducationOverview),
TestChoicesFixture.new(mutableMapOf()),
onCopyToClipboard = {},
onComplete = {},
onChoicesChanged = {}
)
}
}
}
/**
* @param onComplete Callback when the user has completed the backup test.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongParameterList")
fun LongNewWalletBackup(
wallet: PersistableWallet,
backupState: BackupState,
choices: TestChoices,
onCopyToClipboard: () -> Unit,
onComplete: () -> Unit,
onChoicesChanged: ((choicesCount: Int) -> Unit)?
) {
val currentBackupStage = backupState.current.collectAsStateWithLifecycle().value
Scaffold(
topBar = {
BackupTopAppBar(
backupStage = currentBackupStage,
onCopyToClipboard = onCopyToClipboard,
onBack = backupState::goPrevious,
selectedTestChoices = choices
)
},
bottomBar = {
BackupBottomNav(
backupStage = currentBackupStage,
onNext = backupState::goNext,
onBack = backupState::goPrevious,
selectedTestChoices = choices,
onComplete = onComplete,
onBackToSeedPhrase = {
backupState.goToStage(BackupStage.ReviewSeed)
}
)
}
) { paddingValues ->
BackupMainContent(
paddingValues = paddingValues,
backupState = backupState,
wallet = wallet,
choices = choices,
onChoicesChanged = onChoicesChanged
)
}
}
@Composable
fun BackupMainContent(
paddingValues: PaddingValues,
backupState: BackupState,
wallet: PersistableWallet,
choices: TestChoices,
onChoicesChanged: ((choicesCount: Int) -> Unit)?
) {
Column(
Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
)
) {
when (backupState.current.collectAsStateWithLifecycle().value) {
is BackupStage.EducationOverview -> EducationOverview()
is BackupStage.EducationRecoveryPhrase -> EducationRecoveryPhrase()
is BackupStage.Seed -> SeedPhrase(wallet)
is BackupStage.Test -> TestInProgress(
selectedTestChoices = choices,
onChoicesChanged = onChoicesChanged,
splitSeedPhrase = wallet.seedPhrase.split.toPersistentList(),
backupState = backupState
)
is BackupStage.Failure -> TestFailure()
is BackupStage.Complete -> TestComplete()
is BackupStage.ReviewSeed -> SeedPhrase(wallet)
}
}
}
@Composable
private fun EducationOverview() {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
Body(stringResource(R.string.new_wallet_1_body_1))
Image(
painter = painterResource(id = R.drawable.backup_1),
contentDescription = stringResource(id = R.string.backup_1_content_description)
)
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT, true)
)
Body(stringResource(R.string.new_wallet_1_body_2))
}
}
@Composable
private fun EducationRecoveryPhrase() {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
Body(stringResource(R.string.new_wallet_2_body_1))
Image(
painter = painterResource(id = R.drawable.backup_2),
contentDescription = stringResource(id = R.string.backup_2_content_description)
)
Body(stringResource(R.string.new_wallet_2_body_2))
Card {
Body(stringResource(R.string.new_wallet_2_body_3))
}
}
}
@Composable
private fun SeedPhrase(persistableWallet: PersistableWallet) {
SecureScreen()
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = ZcashTheme.paddings.padding)
) {
Body(stringResource(R.string.new_wallet_3_body_1))
ChipGrid(persistableWallet.seedPhrase.split.toPersistentList())
}
}
@Suppress("MagicNumber")
private val testIndices = listOf(Index(4), Index(9), Index(16), Index(20))
private data class TestChoice(val originalIndex: Index, val word: String)
/*
* 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: ImmutableList<String>,
selectedTestChoices: TestChoices,
onChoicesChanged: ((choicesCount: Int) -> Unit)?,
backupState: BackupState
) {
SecureScreen()
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.collectAsStateWithLifecycle().value
if (currentSelectedTestChoice.size == testIndices.size) {
if (currentSelectedTestChoice.all { splitSeedPhrase[it.key.value] == it.value }) {
// the user got the test correct
backupState.goNext()
} else {
backupState.goToStage(BackupStage.Failure)
}
}
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = ZcashTheme.paddings.padding)
) {
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 }.toPersistentList(),
{
selectedTestChoices.set(
HashMap(currentSelectedTestChoice).apply {
this[currentIndex] = testChoices[it.value].word
}
)
if (onChoicesChanged != null) {
onChoicesChanged(selectedTestChoices.current.value.size)
}
},
modifier = Modifier
.weight(MINIMAL_WEIGHT)
.testTag(BackupTag.DROPDOWN_CHIP)
)
} else {
Chip(
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
text = word,
modifier = Modifier.weight(MINIMAL_WEIGHT)
)
}
}
}
}
}
}
@Composable
private fun TestFailure() {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
Image(
painter = painterResource(id = R.drawable.backup_failure),
contentDescription = stringResource(id = R.string.backup_failure_content_description)
)
Box(Modifier.fillMaxHeight(MINIMAL_WEIGHT))
Body(stringResource(R.string.new_wallet_4_body_ouch_retry))
}
}
@Composable
private fun TestComplete() {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
Body(stringResource(R.string.new_wallet_5_body))
Image(
painter = painterResource(id = R.drawable.backup_success),
contentDescription = stringResource(id = R.string.backup_success_content_description)
)
Spacer(
Modifier
.fillMaxWidth()
.weight(MINIMAL_WEIGHT, true)
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun BackupTopAppBar(
backupStage: BackupStage,
onCopyToClipboard: () -> Unit,
onBack: () -> Unit,
selectedTestChoices: TestChoices
) {
var showCopySeedMenu = false
val screenTitleResId = when (backupStage) {
is BackupStage.EducationOverview -> {
R.string.new_wallet_1_header
}
is BackupStage.EducationRecoveryPhrase -> {
R.string.new_wallet_2_header
}
is BackupStage.Seed -> {
showCopySeedMenu = true
R.string.new_wallet_3_header
}
is BackupStage.Test -> {
R.string.new_wallet_4_header
}
is BackupStage.Failure -> {
R.string.new_wallet_4_header_ouch
}
is BackupStage.Complete -> {
R.string.new_wallet_5_header
}
is BackupStage.ReviewSeed -> {
showCopySeedMenu = true
R.string.new_wallet_3_header
}
}
TopAppBar(
title = { Text(text = stringResource(id = screenTitleResId)) },
navigationIcon = {
// hide back navigation button for the first and Complete stages
if (backupStage.hasPrevious() && backupStage != BackupStage.Complete) {
val onBackClickListener = {
if (backupStage is BackupStage.Failure) {
// Clear the user's prior test inputs for the retest
selectedTestChoices.set(emptyMap())
}
onBack()
}
BackHandler(enabled = true) { onBackClickListener() }
IconButton(onBackClickListener) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(
R.string.new_wallet_navigation_back_button_content_description
)
)
}
}
},
actions = {
if (showCopySeedMenu) {
CopySeedMenu(onCopyToClipboard)
}
}
)
}
@Composable
private fun CopySeedMenu(onCopyToClipboard: () -> Unit) {
Column {
var expanded by rememberSaveable { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(R.string.new_wallet_toolbar_more_button_content_description)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.new_wallet_3_button_copy)) },
onClick = {
expanded = false
onCopyToClipboard()
}
)
}
}
}
@Suppress("LongParameterList")
@Composable
private fun BackupBottomNav(
backupStage: BackupStage,
onNext: () -> Unit,
onBack: () -> Unit,
selectedTestChoices: TestChoices,
onComplete: () -> Unit,
onBackToSeedPhrase: () -> Unit
) {
Column {
when (backupStage) {
is BackupStage.EducationOverview -> {
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_1_button))
}
is BackupStage.EducationRecoveryPhrase -> {
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_2_button))
}
is BackupStage.Seed -> {
PrimaryButton(onClick = onNext, text = stringResource(R.string.new_wallet_3_button_finished))
}
is BackupStage.Test -> {
// no bottom navigation button placed
}
is BackupStage.Failure -> {
PrimaryButton(
onClick = {
// Clear the user's prior test inputs for the retest
selectedTestChoices.set(emptyMap())
onBack()
},
text = stringResource(R.string.new_wallet_4_button_retry)
)
}
is BackupStage.Complete -> {
PrimaryButton(onClick = onComplete, text = stringResource(R.string.new_wallet_5_button_finished))
TertiaryButton(onClick = onBackToSeedPhrase, text = stringResource(R.string.new_wallet_5_button_back))
}
is BackupStage.ReviewSeed -> {
PrimaryButton(onClick = onBack, text = stringResource(R.string.new_wallet_3_button_finished))
}
}
}
}