[#1059] Restore Seed Screen

* [#1059] Restore Seed Screen

- Reworked UI according to Figma design
- Closes #1059

* Changelog update
This commit is contained in:
Honza Rychnovský 2023-11-27 10:55:15 +01:00 committed by GitHub
parent cb9d3cf70b
commit b15c1e9063
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 407 additions and 224 deletions

View File

@ -10,5 +10,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
## [Unreleased]
### Changed
- The user interface for both the Recovery Seed screen within the New Wallet Screens flow and the one accessible from
Settings has been updated.
- Updated user interface of these screens:
- New Wallet Recovery Seed screen accessible from onboarding
- Seed Recovery screen accessible from Settings
- Restore existing wallet accessible from onboarding

View File

@ -76,15 +76,15 @@ fun PrimaryButton(
contentColor = textColor,
strokeColor = buttonColor,
strokeWidth = 1.dp,
offsetX = ZcashTheme.dimens.shadowOffsetX,
offsetY = ZcashTheme.dimens.shadowOffsetY,
spread = ZcashTheme.dimens.shadowSpread,
offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
spread = ZcashTheme.dimens.buttonShadowSpread,
)
.translationClick(
translationX = ZcashTheme.dimens.shadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.shadowOffsetX + 6.dp
translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp
)
.defaultMinSize(ZcashTheme.dimens.defaultButtonWidth, ZcashTheme.dimens.defaultButtonHeight)
.defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight)
.border(1.dp, Color.Black),
colors = buttonColors(
containerColor = buttonColor,
@ -124,15 +124,15 @@ fun SecondaryButton(
.shadow(
contentColor = textColor,
strokeColor = textColor,
offsetX = ZcashTheme.dimens.shadowOffsetX,
offsetY = ZcashTheme.dimens.shadowOffsetY,
spread = ZcashTheme.dimens.shadowSpread,
offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
spread = ZcashTheme.dimens.buttonShadowSpread,
)
.translationClick(
translationX = ZcashTheme.dimens.shadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.shadowOffsetX + 6.dp
translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp
)
.defaultMinSize(ZcashTheme.dimens.defaultButtonWidth, ZcashTheme.dimens.defaultButtonHeight)
.defaultMinSize(ZcashTheme.dimens.buttonWidth, ZcashTheme.dimens.buttonHeight)
.border(1.dp, Color.Black),
colors = buttonColors(
containerColor = buttonColor,

View File

@ -1,5 +1,7 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -10,7 +12,6 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@ -18,7 +19,15 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Composable
private fun ComposableChipPreview() {
ZcashTheme(forceDarkMode = false) {
Chip(Index(0), "edict")
Chip("route")
}
}
@Preview
@Composable
private fun ComposableChipIndexedPreview() {
ZcashTheme(forceDarkMode = false) {
ChipIndexed(Index(0), "edict")
}
}
@ -26,12 +35,35 @@ private fun ComposableChipPreview() {
@Composable
private fun ComposableLongChipPreview() {
ZcashTheme(forceDarkMode = false) {
Chip(Index(1), "a_very_long_seed_word_that_does_not_fit_into_the_chip_and_thus_needs_to_be_truncated")
ChipIndexed(Index(1), "a_very_long_seed_word_that_does_not_fit_into_the_chip_and_thus_needs_to_be_truncated")
}
}
@Preview
@Composable
private fun ComposableChipOnSurfacePreview() {
ZcashTheme(forceDarkMode = false) {
ChipOnSurface("ribbon")
}
}
@Composable
fun Chip(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.then(Modifier.testTag(CommonTag.CHIP))
)
}
@Composable
fun ChipIndexed(
index: Index,
text: String,
modifier: Modifier = Modifier
@ -47,22 +79,32 @@ fun Chip(
}
@Composable
fun Chip(
fun ChipOnSurface(
text: String,
modifier: Modifier = Modifier
) {
Surface(
shape = RectangleShape,
modifier = modifier.padding(4.dp),
modifier = modifier
.padding(horizontal = ZcashTheme.dimens.spacingTiny)
.border(
border = BorderStroke(
width = ZcashTheme.dimens.chipStroke,
color = ZcashTheme.colors.layoutStroke
)
),
color = MaterialTheme.colorScheme.secondary,
shadowElevation = 8.dp
shadowElevation = ZcashTheme.dimens.chipShadowElevation,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.padding(
vertical = ZcashTheme.dimens.spacingSmall,
horizontal = ZcashTheme.dimens.spacingDefault
)
.testTag(CommonTag.CHIP)
)
}

View File

@ -64,7 +64,7 @@ fun ChipGrid(
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingDefault)
) {
chunk.forEachIndexed { subIndex, word ->
Chip(
ChipIndexed(
index = Index(chunkIndex * CHIP_GRID_COLUMN_SIZE + subIndex),
text = word,
modifier = Modifier.padding(ZcashTheme.dimens.spacingXtiny)

View File

@ -22,21 +22,31 @@ data class Dimens(
// List of custom spacings:
// Button:
val shadowOffsetX: Dp,
val shadowOffsetY: Dp,
val shadowSpread: Dp,
val defaultButtonWidth: Dp,
val defaultButtonHeight: Dp,
val buttonShadowOffsetX: Dp,
val buttonShadowOffsetY: Dp,
val buttonShadowSpread: Dp,
val buttonWidth: Dp,
val buttonHeight: Dp,
// Chip
val chipShadowElevation: Dp,
val chipStroke: Dp,
// TopAppBar:
val topAppBarZcashLogoHeight: Dp,
val topAppBarActionRippleCorner: Dp,
// TextField:
val textFieldDefaultHeight: Dp,
// Any Layout:
val layoutStroke: Dp,
// Screen custom spacings:
val inScreenZcashLogoHeight: Dp,
val inScreenZcashLogoWidth: Dp,
val inScreenZcashTextLogoHeight: Dp,
val screenHorizontalSpacing: Dp
val screenHorizontalSpacing: Dp,
)
private val defaultDimens = Dimens(
@ -48,13 +58,17 @@ private val defaultDimens = Dimens(
spacingLarge = 24.dp,
spacingXlarge = 32.dp,
spacingHuge = 64.dp,
shadowOffsetX = 20.dp,
shadowOffsetY = 20.dp,
shadowSpread = 10.dp,
defaultButtonWidth = 230.dp,
defaultButtonHeight = 50.dp,
buttonShadowOffsetX = 20.dp,
buttonShadowOffsetY = 20.dp,
buttonShadowSpread = 10.dp,
buttonWidth = 230.dp,
buttonHeight = 50.dp,
chipShadowElevation = 4.dp,
chipStroke = 0.5.dp,
topAppBarZcashLogoHeight = 24.dp,
topAppBarActionRippleCorner = 28.dp,
textFieldDefaultHeight = 215.dp,
layoutStroke = 1.dp,
inScreenZcashLogoHeight = 100.dp,
inScreenZcashLogoWidth = 60.dp,
inScreenZcashTextLogoHeight = 30.dp,

View File

@ -18,6 +18,8 @@ data class ExtendedColors(
val progressEnd: Color,
val progressBackground: Color,
val chipIndex: Color,
val textFieldHint: Color,
val layoutStroke: Color,
val overlay: Color,
val highlight: Color,
val addressHighlightBorder: Color,

View File

@ -8,6 +8,9 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import co.electriccoin.zcash.ui.design.theme.ExtendedColors
// TODO [#998]: Check and enhance screen dark mode
// TODO [#998]: https://github.com/Electric-Coin-Company/zashi-android/issues/998
internal object Dark {
val backgroundStart = Color(0xFF000000)
val backgroundEnd = Color(0xFF000000)
@ -20,6 +23,8 @@ internal object Dark {
val textNavigationButton = Color.Black
val textCaption = Color(0xFFFFFFFF)
val textChipIndex = Color(0xFFFFB900)
val textFieldHint = Color(0xFFB7B7B7)
val layoutStroke = Color(0xFFFFFFFF)
val primaryButton = Color(0xFFFFFFFF)
val primaryButtonPressed = Color(0xFFFFFFFF)
@ -80,6 +85,8 @@ internal object Light {
val textTertiaryButton = Color(0xFF000000)
val textCaption = Color(0xFF000000)
val textChipIndex = Color(0xFFEE8592)
val textFieldHint = Color(0xFFB7B7B7)
val layoutStroke = Color(0xFF000000)
// TODO [#159]: The button colors are wrong for light
// TODO [#159]: https://github.com/Electric-Coin-Company/zashi-android/issues/159
@ -163,6 +170,8 @@ internal val DarkExtendedColorPalette = ExtendedColors(
progressEnd = Dark.progressEnd,
progressBackground = Dark.progressBackground,
chipIndex = Dark.textChipIndex,
textFieldHint = Dark.textFieldHint,
layoutStroke = Dark.layoutStroke,
overlay = Dark.overlay,
highlight = Dark.highlight,
addressHighlightBorder = Dark.addressHighlightBorder,
@ -191,6 +200,8 @@ internal val LightExtendedColorPalette = ExtendedColors(
progressEnd = Light.progressEnd,
progressBackground = Light.progressBackground,
chipIndex = Light.textChipIndex,
textFieldHint = Light.textFieldHint,
layoutStroke = Light.layoutStroke,
overlay = Light.overlay,
highlight = Light.highlight,
addressHighlightBorder = Light.addressHighlightBorder,
@ -221,6 +232,8 @@ internal val LocalExtendedColors = staticCompositionLocalOf {
progressEnd = Color.Unspecified,
progressBackground = Color.Unspecified,
chipIndex = Color.Unspecified,
textFieldHint = Color.Unspecified,
layoutStroke = Color.Unspecified,
overlay = Color.Unspecified,
highlight = Color.Unspecified,
addressHighlightBorder = Color.Unspecified,

View File

@ -121,7 +121,9 @@ data class ExtendedTypography(
val aboutText: TextStyle,
val buttonText: TextStyle,
val checkboxText: TextStyle,
val securityWarningText: TextStyle
val securityWarningText: TextStyle,
val textFieldHint: TextStyle,
val textFieldValue: TextStyle,
)
@Suppress("CompositionLocalAllowlist")
@ -160,6 +162,14 @@ val LocalExtendedTypography = staticCompositionLocalOf {
),
securityWarningText = PrimaryTypography.bodySmall.copy(
lineHeight = 22.32.sp
)
),
textFieldHint = PrimaryTypography.bodySmall.copy(
fontSize = 13.sp,
lineHeight = 15.73.sp,
fontWeight = FontWeight.Normal
),
textFieldValue = PrimaryTypography.bodyLarge.copy(
fontSize = 17.sp,
),
)
}

View File

@ -92,15 +92,12 @@ class RestoreViewTest : UiTestPrerequisites() {
it.assertDoesNotExist()
}
composeTestRule.onNode(
matcher = hasText("abandon", substring = true) and hasTestTag(CommonTag.CHIP),
useUnmergedTree = true
).also {
composeTestRule.onNode(matcher = hasText("abandon", substring = true)).also {
it.assertExists()
}
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("")
it.assertTextEquals("abandon ", includeEditableText = true)
}
}
@ -114,24 +111,17 @@ class RestoreViewTest : UiTestPrerequisites() {
}
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("")
it.assertTextEquals("abandon ", includeEditableText = true)
}
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
it.assertDoesNotExist()
}
composeTestRule.onNode(
matcher = hasText(text = "abandon", substring = true) and hasTestTag(CommonTag.CHIP),
useUnmergedTree = true
)
composeTestRule.onNode(matcher = hasText(text = "abandon", substring = true))
.also {
it.assertExists()
}
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("")
}
}
@Test
@ -140,7 +130,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
composeTestRule.onNodeWithText(
text = getStringResource(R.string.restore_seed_button_restore),
text = getStringResource(R.string.restore_seed_button_next),
ignoreCase = true
).also {
it.assertIsNotEnabled()
@ -156,7 +146,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = SeedPhraseFixture.new().split)
composeTestRule.onNodeWithText(
text = getStringResource(R.string.restore_seed_button_restore),
text = getStringResource(R.string.restore_seed_button_next),
ignoreCase = true
).also {
it.assertExists()
@ -169,7 +159,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = listOf("abandon"))
composeTestRule.onNode(
matcher = hasText(text = "abandon", substring = true) and hasTestTag(CommonTag.CHIP),
matcher = hasText(text = "abandon", substring = true),
useUnmergedTree = true
).also {
it.assertExists()

View File

@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.spackle.model.Progress
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.Chip
import co.electriccoin.zcash.ui.design.component.ChipIndexed
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.NavigationButton
@ -50,7 +50,7 @@ fun DesignGuide() {
Callout(Icons.Filled.Person, contentDescription = "Person")
Callout(Icons.Filled.List, contentDescription = "List")
PinkProgress(progress = Progress(Index(1), Index(4)), Modifier.fillMaxWidth())
Chip(Index(1), "edict")
ChipIndexed(Index(1), "edict")
}
}
}

View File

@ -5,7 +5,10 @@ import co.electriccoin.zcash.ui.common.first
import java.util.Locale
internal sealed class ParseResult {
object Continue : ParseResult()
object Continue : ParseResult() {
override fun toString() = "Continue"
}
data class Add(val words: List<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Add"
@ -52,6 +55,10 @@ internal sealed class ParseResult {
return Warn(findSuggestions(trimmed, completeWordList))
}
}
override fun toString(): String {
return "ParseResult()"
}
}
internal fun findSuggestions(input: String, completeWordList: Set<String>): List<String> {

View File

@ -26,6 +26,16 @@ class WordList(initial: List<String> = emptyList()) {
mutableState.value = newList
}
fun removeLast() {
val newList = if (mutableState.value.isNotEmpty()) {
current.value.subList(0, current.value.size - 1)
} else {
current.value
}.toPersistentList()
mutableState.value = newList
}
// Custom toString to prevent leaking word list
override fun toString() = "WordList"
}

View File

@ -3,12 +3,14 @@
package co.electriccoin.zcash.ui.screen.restore.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -18,75 +20,70 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
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.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.model.SeedPhraseValidation
import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.SecureScreen
import co.electriccoin.zcash.ui.common.shouldSecureScreen
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.Chip
import co.electriccoin.zcash.ui.design.component.ChipOnSurface
import co.electriccoin.zcash.ui.design.component.FormTextField
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.Reference
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.component.TopScreenLogoTitle
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.model.ParseResult
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import co.electriccoin.zcash.ui.screen.restore.state.wordValidation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentHashSetOf
import kotlinx.coroutines.launch
@ -151,12 +148,22 @@ fun RestoreWallet(
paste: () -> String?,
onFinished: () -> Unit
) {
var textState by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val parseResult = ParseResult.new(completeWordList, textState)
val scope = rememberCoroutineScope()
var text by rememberSaveable { mutableStateOf("") }
val parseResult = ParseResult.new(completeWordList, text)
val currentStage = restoreState.current.collectAsStateWithLifecycle().value
var isSeedValid by rememberSaveable { mutableStateOf(false) }
// To avoid unnecessary recompositions that this flow produces
SideEffect {
scope.launch {
userWordList.wordValidation().collect {
isSeedValid = it is SeedPhraseValidation.Valid
}
}
}
Scaffold(
modifier = Modifier.navigationBarsPadding(),
topBar = {
@ -169,7 +176,10 @@ fun RestoreWallet(
}
},
isShowClear = currentStage == RestoreStage.Seed,
onClear = { userWordList.set(emptyList()) }
onClear = {
userWordList.set(emptyList())
text = ""
}
)
},
bottomBar = {
@ -177,9 +187,9 @@ fun RestoreWallet(
RestoreStage.Seed -> {
RestoreSeedBottomBar(
userWordList = userWordList,
isSeedValid = isSeedValid,
parseResult = parseResult,
setTextState = { textState = it },
focusRequester = focusRequester,
setText = { text = it },
modifier = Modifier
.imePadding()
.navigationBarsPadding()
@ -197,12 +207,11 @@ fun RestoreWallet(
},
content = { paddingValues ->
val commonModifier = Modifier
// We intentionally set the bottom smaller to save space in case of the software keyboard is visible
.padding(
top = paddingValues.calculateTopPadding() + dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + dimens.spacingSmall,
start = dimens.screenHorizontalSpacing,
end = dimens.screenHorizontalSpacing
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.screenHorizontalSpacing,
end = ZcashTheme.dimens.screenHorizontalSpacing
)
when (currentStage) {
@ -210,12 +219,11 @@ fun RestoreWallet(
if (shouldSecureScreen) {
SecureScreen()
}
RestoreSeedMainContent(
userWordList = userWordList,
textState = textState,
setTextState = { textState = it },
focusRequester = focusRequester,
isSeedValid = isSeedValid,
text = text,
setText = { text = it },
parseResult = parseResult,
paste = paste,
goNext = { restoreState.goNext() },
@ -229,6 +237,9 @@ fun RestoreWallet(
setRestoreHeight = setRestoreHeight,
onNext = { restoreState.goNext() },
modifier = commonModifier
.imePadding()
.navigationBarsPadding()
.animateContentSize()
)
}
RestoreStage.Complete -> {
@ -247,59 +258,72 @@ fun RestoreWallet(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun RestoreTopAppBar(
onBack: () -> Unit,
onClear: () -> Unit,
isShowClear: Boolean,
onClear: () -> Unit
modifier: Modifier = Modifier,
) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.restore_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.restore_back_content_description)
)
}
},
actions = {
if (isShowClear) {
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear))
}
SmallTopAppBar(
backText = stringResource(id = R.string.restore_back).uppercase(),
backContentDescriptionText = stringResource(R.string.restore_back_content_description),
onBack = onBack,
regularActions = if (isShowClear) { {
ClearSeedMenuItem(
onSeedClear = onClear
)
}
} else {
null
},
modifier = modifier,
)
}
@Composable
private fun ClearSeedMenuItem(
modifier: Modifier = Modifier,
onSeedClear: () -> Unit,
) {
Reference(
text = stringResource(id = R.string.restore_button_clear),
onClick = onSeedClear,
textAlign = TextAlign.Center,
modifier = modifier.then(
Modifier.padding(all = ZcashTheme.dimens.spacingDefault)
)
)
}
// TODO [#672]: Implement custom seed phrase pasting for wallet import
// TODO [#672]: https://github.com/Electric-Coin-Company/zashi-android/issues/672
// TODO [#1060]: https://github.com/Electric-Coin-Company/zashi-android/issues/1060
@OptIn(ExperimentalComposeUiApi::class)
@Suppress("UNUSED_PARAMETER", "LongParameterList")
@Suppress("UNUSED_PARAMETER", "LongParameterList", "LongMethod")
@Composable
private fun RestoreSeedMainContent(
userWordList: WordList,
textState: String,
setTextState: (String) -> Unit,
focusRequester: FocusRequester,
isSeedValid: Boolean,
text: String,
setText: (String) -> Unit,
parseResult: ParseResult,
paste: () -> String?,
goNext: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
val textFieldScrollToHeight = rememberSaveable { mutableIntStateOf(0) }
Twig.error { "TEST: $parseResult, $text" }
if (parseResult is ParseResult.Add) {
setTextState("")
setText("")
userWordList.append(parseResult.words)
}
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
Column(
Modifier
.fillMaxHeight()
@ -307,63 +331,84 @@ private fun RestoreSeedMainContent(
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Body(text = stringResource(id = R.string.restore_seed_instructions))
// Used to calculate necessary scroll to have the seed TextFiled visible
Column(
modifier = Modifier.onSizeChanged { size ->
textFieldScrollToHeight.intValue = size.height
Twig.debug { "TextField scroll height: ${textFieldScrollToHeight.intValue}" }
}
) {
TopScreenLogoTitle(
title = stringResource(R.string.restore_title),
logoContentDescription = stringResource(R.string.zcash_logo_content_description),
)
Spacer(Modifier.height(dimens.spacingSmall))
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
ChipGridWithText(currentUserWordList)
if (!isSeedValid) {
NextWordTextField(
parseResult = parseResult,
text = textState,
setText = { setTextState(it) },
modifier = Modifier.focusRequester(focusRequester)
Body(
text = stringResource(id = R.string.restore_seed_instructions),
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
SeedGridWithText(
text = text,
userWordList = userWordList,
focusRequester = focusRequester,
parseResult = parseResult,
setText = setText
)
Spacer(
modifier = Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge))
PrimaryButton(
onClick = goNext,
text = stringResource(id = R.string.restore_seed_button_restore),
enabled = isSeedValid,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
text = stringResource(id = R.string.restore_seed_button_next),
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
}
if (isSeedValid) {
// Hides the keyboard, making it easier for users to see the next button
// Clear focus and hide keyboard to make it easier for users to see the next button
LocalSoftwareKeyboardController.current?.hide()
LocalFocusManager.current.clearFocus()
}
// Cause text field to refocus
DisposableEffect(parseResult) {
// Causes the TextFiled to refocus
if (!isSeedValid) {
Twig.error { "NUT" }
focusRequester.requestFocus()
}
scope.launch {
scrollState.scrollTo(scrollState.maxValue)
// Causes scroll to the TextField after the first type action
if (text.isNotEmpty() && userWordList.current.value.isEmpty()) {
scope.launch {
scrollState.animateScrollTo(textFieldScrollToHeight.intValue)
}
}
onDispose { }
onDispose { /* Nothing to dispose */ }
}
}
@Composable
private fun RestoreSeedBottomBar(
userWordList: WordList,
isSeedValid: Boolean,
parseResult: ParseResult,
setTextState: (String) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier
setText: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
// Hide the field once the user has completed the seed phrase; if they need the field back then
// the user can hit the clear button
if (!isSeedValid) {
@ -374,81 +419,82 @@ private fun RestoreSeedBottomBar(
parseResult = parseResult,
modifier = Modifier
.fillMaxWidth()
// Note we don't set the top, as it's set by the confirm button above
.padding(
bottom = dimens.spacingDefault,
start = dimens.spacingDefault,
end = dimens.spacingDefault
horizontal = ZcashTheme.dimens.spacingDefault,
vertical = ZcashTheme.dimens.spacingSmall
)
)
Autocomplete(parseResult = parseResult, {
setTextState("")
setText("")
userWordList.append(listOf(it))
focusRequester.requestFocus()
})
}
}
}
const val CHIP_GRID_ROW_SIZE = 3
@Composable
private fun ChipGridWithText(
userWordList: ImmutableList<String>
) {
Column(Modifier.testTag(RestoreTag.CHIP_LAYOUT)) {
userWordList.chunked(CHIP_GRID_ROW_SIZE).forEachIndexed { chunkIndex, chunk ->
Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
val remainder = (chunk.size % CHIP_GRID_ROW_SIZE)
val singleItemWeight = 1f / CHIP_GRID_ROW_SIZE
chunk.forEachIndexed { subIndex, word ->
Chip(
index = Index(chunkIndex * CHIP_GRID_ROW_SIZE + subIndex),
text = word,
modifier = Modifier.weight(singleItemWeight)
)
}
if (0 != remainder) {
Spacer(modifier = Modifier.weight((CHIP_GRID_ROW_SIZE - chunk.size) * singleItemWeight))
}
}
}
}
}
@Composable
private fun NextWordTextField(
parseResult: ParseResult,
@Suppress("LongParameterList", "LongMethod")
private fun SeedGridWithText(
text: String,
setText: (String) -> Unit,
userWordList: WordList,
focusRequester: FocusRequester,
parseResult: ParseResult,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(dimens.spacingTiny)
.shadow(
elevation = 12.dp,
ambientColor = MaterialTheme.colorScheme.primary,
spotColor = MaterialTheme.colorScheme.primary
),
shape = RectangleShape,
color = MaterialTheme.colorScheme.surface,
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
val currentSeedText = currentUserWordList.run {
if (isEmpty()) {
text
} else {
joinToString(separator = " ", postfix = " ").plus(text)
}
}
Column(
modifier = Modifier
.border(
border = BorderStroke(
width = ZcashTheme.dimens.layoutStroke,
color = ZcashTheme.colors.layoutStroke
)
)
.fillMaxWidth()
.defaultMinSize(minHeight = ZcashTheme.dimens.textFieldDefaultHeight)
.then(modifier)
.testTag(RestoreTag.CHIP_LAYOUT)
) {
/*
* Treat the user input as a password for more secure input, but disable the transformation
* to obscure typing.
*/
TextField(
textStyle = ZcashTheme.extendedTypography.textFieldValue,
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingTiny)
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD),
value = text,
onValueChange = setText,
.padding(ZcashTheme.dimens.spacingTiny)
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD)
.focusRequester(focusRequester),
value = TextFieldValue(
text = currentSeedText,
selection = TextRange(index = currentSeedText.length)
),
placeholder = {
Text(
text = stringResource(id = R.string.restore_seed_hint),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
onValueChange = {
processTextInput(
currentSeedText = currentSeedText,
updateSeedText = it.text,
userWordList = userWordList,
setText = setText
)
},
keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None,
autoCorrect = false,
@ -456,7 +502,6 @@ private fun NextWordTextField(
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(onAny = {}),
shape = RoundedCornerShape(8.dp),
isError = parseResult is ParseResult.Warn,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@ -471,45 +516,82 @@ private fun NextWordTextField(
}
}
val pasteSeedWordRegex by lazy { Regex("\\s\\S") } // $NON-NLS
val whiteSpaceRegex by lazy { "\\s".toRegex() } // $NON-NLS
// TODO [#1061]: Restore screen input validation refactoring and adding tests
// TODO [#1061]: https://github.com/Electric-Coin-Company/zashi-android/issues/1061
/**
* This function processes the text from user input after every change. It compares with what is already typed in. It
* does a simple validation as well.
*
* @param currentSeedText Previously typed in text
* @param updateSeedText Updated text after every user input
* @param userWordList Validated type-safe list of seed words
* @param setText New text callback
*/
fun processTextInput(
currentSeedText: String,
updateSeedText: String,
userWordList: WordList,
setText: (String) -> Unit
) {
val textDifference = if (updateSeedText.length > currentSeedText.length) {
updateSeedText.substring(currentSeedText.length)
} else {
""
}
Twig.debug { "Text difference: $textDifference" }
if (whiteSpaceRegex.matches(textDifference)) {
// User tried to type a white space without confirming a valid seed word
} else if (pasteSeedWordRegex.containsMatchIn(textDifference)) {
// User pasted their seed from the device buffer
setText(updateSeedText)
} else if (updateSeedText < currentSeedText &&
whiteSpaceRegex.matches(currentSeedText.last().toString()) &&
currentSeedText.isNotEmpty()
) {
// User backspaced to a previously confirmed word - remove it
userWordList.removeLast()
} else {
// User typed in a character
setText(updateSeedText.split(whiteSpaceRegex).last())
}
}
@Composable
@Suppress("UNUSED_VARIABLE")
private fun Autocomplete(
parseResult: ParseResult,
onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
// TODO [#1061]: Restore screen input validation refactoring and adding tests
// TODO [#1061]: https://github.com/Electric-Coin-Company/zashi-android/issues/1061
// Note that we currently do not use the highlighting of the suggestion bar
val (isHighlight, suggestions) = when (parseResult) {
is ParseResult.Autocomplete -> {
Pair(false, parseResult.suggestions)
}
is ParseResult.Warn -> {
return
}
else -> {
Pair(false, null)
}
}
suggestions?.let {
val highlightModifier = if (isHighlight) {
modifier.border(2.dp, ZcashTheme.colors.highlight)
} else {
modifier
}
@Suppress("ModifierReused")
LazyRow(
modifier = highlightModifier.testTag(RestoreTag.AUTOCOMPLETE_LAYOUT),
// Note we don't set the top, as it's set by the confirm button above
// And we also set the bottom smaller, as the keyboard will be always visible
contentPadding = PaddingValues(
bottom = dimens.spacingDefault,
start = dimens.spacingDefault,
end = dimens.spacingSmall
)
modifier = modifier
.testTag(RestoreTag.AUTOCOMPLETE_LAYOUT)
.fillMaxWidth(),
contentPadding = PaddingValues(all = ZcashTheme.dimens.spacingSmall),
horizontalArrangement = Arrangement.Absolute.Center
) {
items(it) {
Chip(
ChipOnSurface(
text = it,
modifier = Modifier
.testTag(RestoreTag.AUTOCOMPLETE_ITEM)
@ -527,15 +609,22 @@ private fun Warn(
) {
if (parseResult is ParseResult.Warn) {
Surface(
modifier = modifier,
modifier = modifier.then(
Modifier.border(
border = BorderStroke(
width = ZcashTheme.dimens.chipStroke,
color = ZcashTheme.colors.layoutStroke
)
)
),
shape = RectangleShape,
color = MaterialTheme.colorScheme.secondary,
shadowElevation = 4.dp
shadowElevation = ZcashTheme.dimens.chipShadowElevation
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingTiny),
.padding(ZcashTheme.dimens.spacingSmall),
textAlign = TextAlign.Center,
text = if (parseResult.suggestions.isEmpty()) {
stringResource(id = R.string.restore_seed_warning_no_suggestions)
@ -569,11 +658,11 @@ private fun RestoreBirthday(
) {
Header(stringResource(R.string.restore_birthday_header))
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Body(stringResource(R.string.restore_birthday_body))
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
FormTextField(
value = height,
@ -583,7 +672,7 @@ private fun RestoreBirthday(
},
Modifier
.fillMaxWidth()
.padding(dimens.spacingTiny)
.padding(ZcashTheme.dimens.spacingTiny)
.testTag(RestoreTag.BIRTHDAY_TEXT_FIELD),
label = { Text(stringResource(id = R.string.restore_birthday_hint)) },
keyboardOptions = KeyboardOptions(
@ -602,6 +691,8 @@ private fun RestoreBirthday(
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
val isBirthdayValid = height.toLongOrNull()?.let {
it >= zcashNetwork.saplingActivationHeight.value
} ?: false
@ -613,7 +704,7 @@ private fun RestoreBirthday(
},
text = stringResource(R.string.restore_birthday_button_restore),
enabled = isBirthdayValid,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
TertiaryButton(
@ -622,7 +713,7 @@ private fun RestoreBirthday(
onNext()
},
text = stringResource(R.string.restore_birthday_button_skip),
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingDefault)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
@ -643,11 +734,11 @@ private fun RestoreComplete(
) {
Header(stringResource(R.string.restore_complete_header))
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Body(stringResource(R.string.restore_complete_info))
Spacer(modifier = Modifier.height(dimens.spacingDefault))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(
modifier = Modifier
@ -658,7 +749,7 @@ private fun RestoreComplete(
PrimaryButton(
onClick = onComplete,
text = stringResource(R.string.restore_button_see_wallet),
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))

View File

@ -1,11 +1,13 @@
<resources>
<string name="restore_header">Create a wallet</string>
<string name="restore_title">Wallet import</string>
<string name="restore_back">Back</string>
<string name="restore_back_content_description">Back</string>
<string name="restore_button_clear">Clear Seed</string>
<string name="restore_title">Enter secret recovery phrase</string>
<string name="restore_seed_instructions">Enter your 24-word seed phrase to restore the associated wallet.</string>
<string name="restore_seed_hint">Enter private seed here…</string>
<string name="restore_seed_button_next">Next</string>
<string name="restore_button_clear">Clear</string>
<string name="restore_seed_instructions">You can import your backed up wallet by entering your backup recovery phrase (aka seed phrase) now.</string>
<string name="restore_seed_button_restore">Restore wallet</string>
<string name="restore_seed_warning_suggestions">This word is not in the seed phrase dictionary. Please select the correct one from the suggestions.</string>
<string name="restore_seed_warning_no_suggestions">This word is not in the seed phrase dictionary.</string>

View File

@ -211,7 +211,7 @@ class ScreenshotTest : UiTestPrerequisites() {
return
}
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_seed_button_restore)).also {
composeTestRule.onNodeWithText(resContext.getString(R.string.restore_seed_button_next)).also {
it.performScrollTo()
// Even with waiting for the word list in the view model, there's some latency before the button is enabled