[#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] ## [Unreleased]
### Changed ### Changed
- The user interface for both the Recovery Seed screen within the New Wallet Screens flow and the one accessible from - Updated user interface of these screens:
Settings has been updated. - 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, contentColor = textColor,
strokeColor = buttonColor, strokeColor = buttonColor,
strokeWidth = 1.dp, strokeWidth = 1.dp,
offsetX = ZcashTheme.dimens.shadowOffsetX, offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
offsetY = ZcashTheme.dimens.shadowOffsetY, offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
spread = ZcashTheme.dimens.shadowSpread, spread = ZcashTheme.dimens.buttonShadowSpread,
) )
.translationClick( .translationClick(
translationX = ZcashTheme.dimens.shadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.shadowOffsetX + 6.dp 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), .border(1.dp, Color.Black),
colors = buttonColors( colors = buttonColors(
containerColor = buttonColor, containerColor = buttonColor,
@ -124,15 +124,15 @@ fun SecondaryButton(
.shadow( .shadow(
contentColor = textColor, contentColor = textColor,
strokeColor = textColor, strokeColor = textColor,
offsetX = ZcashTheme.dimens.shadowOffsetX, offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
offsetY = ZcashTheme.dimens.shadowOffsetY, offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
spread = ZcashTheme.dimens.shadowSpread, spread = ZcashTheme.dimens.buttonShadowSpread,
) )
.translationClick( .translationClick(
translationX = ZcashTheme.dimens.shadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow translationX = ZcashTheme.dimens.buttonShadowOffsetX + 6.dp, // + 6dp to exactly cover the bottom shadow
translationY = ZcashTheme.dimens.shadowOffsetX + 6.dp 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), .border(1.dp, Color.Black),
colors = buttonColors( colors = buttonColors(
containerColor = buttonColor, containerColor = buttonColor,

View File

@ -1,5 +1,7 @@
package co.electriccoin.zcash.ui.design.component 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.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface 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.platform.testTag
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.spackle.model.Index import co.electriccoin.zcash.spackle.model.Index
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@ -18,7 +19,15 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Composable @Composable
private fun ComposableChipPreview() { private fun ComposableChipPreview() {
ZcashTheme(forceDarkMode = false) { 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 @Composable
private fun ComposableLongChipPreview() { private fun ComposableLongChipPreview() {
ZcashTheme(forceDarkMode = false) { 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 @Composable
fun Chip( 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, index: Index,
text: String, text: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -47,22 +79,32 @@ fun Chip(
} }
@Composable @Composable
fun Chip( fun ChipOnSurface(
text: String, text: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Surface( Surface(
shape = RectangleShape, 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, color = MaterialTheme.colorScheme.secondary,
shadowElevation = 8.dp shadowElevation = ZcashTheme.dimens.chipShadowElevation,
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSecondary, color = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier 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) .testTag(CommonTag.CHIP)
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -121,7 +121,9 @@ data class ExtendedTypography(
val aboutText: TextStyle, val aboutText: TextStyle,
val buttonText: TextStyle, val buttonText: TextStyle,
val checkboxText: TextStyle, val checkboxText: TextStyle,
val securityWarningText: TextStyle val securityWarningText: TextStyle,
val textFieldHint: TextStyle,
val textFieldValue: TextStyle,
) )
@Suppress("CompositionLocalAllowlist") @Suppress("CompositionLocalAllowlist")
@ -160,6 +162,14 @@ val LocalExtendedTypography = staticCompositionLocalOf {
), ),
securityWarningText = PrimaryTypography.bodySmall.copy( securityWarningText = PrimaryTypography.bodySmall.copy(
lineHeight = 22.32.sp 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() it.assertDoesNotExist()
} }
composeTestRule.onNode( composeTestRule.onNode(matcher = hasText("abandon", substring = true)).also {
matcher = hasText("abandon", substring = true) and hasTestTag(CommonTag.CHIP),
useUnmergedTree = true
).also {
it.assertExists() it.assertExists()
} }
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { 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 { composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("") it.assertTextEquals("abandon ", includeEditableText = true)
} }
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also { composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
composeTestRule.onNode( composeTestRule.onNode(matcher = hasText(text = "abandon", substring = true))
matcher = hasText(text = "abandon", substring = true) and hasTestTag(CommonTag.CHIP),
useUnmergedTree = true
)
.also { .also {
it.assertExists() it.assertExists()
} }
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("")
}
} }
@Test @Test
@ -140,7 +130,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList()) newTestSetup(initialWordsList = generateSequence { "abandon" }.take(SeedPhrase.SEED_PHRASE_SIZE).toList())
composeTestRule.onNodeWithText( composeTestRule.onNodeWithText(
text = getStringResource(R.string.restore_seed_button_restore), text = getStringResource(R.string.restore_seed_button_next),
ignoreCase = true ignoreCase = true
).also { ).also {
it.assertIsNotEnabled() it.assertIsNotEnabled()
@ -156,7 +146,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = SeedPhraseFixture.new().split) newTestSetup(initialWordsList = SeedPhraseFixture.new().split)
composeTestRule.onNodeWithText( composeTestRule.onNodeWithText(
text = getStringResource(R.string.restore_seed_button_restore), text = getStringResource(R.string.restore_seed_button_next),
ignoreCase = true ignoreCase = true
).also { ).also {
it.assertExists() it.assertExists()
@ -169,7 +159,7 @@ class RestoreViewTest : UiTestPrerequisites() {
newTestSetup(initialWordsList = listOf("abandon")) newTestSetup(initialWordsList = listOf("abandon"))
composeTestRule.onNode( composeTestRule.onNode(
matcher = hasText(text = "abandon", substring = true) and hasTestTag(CommonTag.CHIP), matcher = hasText(text = "abandon", substring = true),
useUnmergedTree = true useUnmergedTree = true
).also { ).also {
it.assertExists() 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.Index
import co.electriccoin.zcash.spackle.model.Progress import co.electriccoin.zcash.spackle.model.Progress
import co.electriccoin.zcash.ui.design.component.Body 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.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.NavigationButton import co.electriccoin.zcash.ui.design.component.NavigationButton
@ -50,7 +50,7 @@ fun DesignGuide() {
Callout(Icons.Filled.Person, contentDescription = "Person") Callout(Icons.Filled.Person, contentDescription = "Person")
Callout(Icons.Filled.List, contentDescription = "List") Callout(Icons.Filled.List, contentDescription = "List")
PinkProgress(progress = Progress(Index(1), Index(4)), Modifier.fillMaxWidth()) 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 import java.util.Locale
internal sealed class ParseResult { internal sealed class ParseResult {
object Continue : ParseResult() object Continue : ParseResult() {
override fun toString() = "Continue"
}
data class Add(val words: List<String>) : ParseResult() { data class Add(val words: List<String>) : ParseResult() {
// Override to prevent logging of user secrets // Override to prevent logging of user secrets
override fun toString() = "Add" override fun toString() = "Add"
@ -52,6 +55,10 @@ internal sealed class ParseResult {
return Warn(findSuggestions(trimmed, completeWordList)) return Warn(findSuggestions(trimmed, completeWordList))
} }
} }
override fun toString(): String {
return "ParseResult()"
}
} }
internal fun findSuggestions(input: String, completeWordList: Set<String>): List<String> { 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 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 // Custom toString to prevent leaking word list
override fun toString() = "WordList" override fun toString() = "WordList"
} }

View File

@ -3,12 +3,14 @@
package co.electriccoin.zcash.ui.screen.restore.view package co.electriccoin.zcash.ui.screen.restore.view
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape 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.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource 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.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.model.SeedPhraseValidation 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.R
import co.electriccoin.zcash.ui.common.SecureScreen import co.electriccoin.zcash.ui.common.SecureScreen
import co.electriccoin.zcash.ui.common.shouldSecureScreen import co.electriccoin.zcash.ui.common.shouldSecureScreen
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body 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.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header 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.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.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
import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens
import co.electriccoin.zcash.ui.screen.restore.RestoreTag 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.ParseResult
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage 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.RestoreState
import co.electriccoin.zcash.ui.screen.restore.state.WordList import co.electriccoin.zcash.ui.screen.restore.state.WordList
import co.electriccoin.zcash.ui.screen.restore.state.wordValidation import co.electriccoin.zcash.ui.screen.restore.state.wordValidation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentHashSetOf import kotlinx.collections.immutable.persistentHashSetOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -151,12 +148,22 @@ fun RestoreWallet(
paste: () -> String?, paste: () -> String?,
onFinished: () -> Unit onFinished: () -> Unit
) { ) {
var textState by rememberSaveable { mutableStateOf("") } val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } var text by rememberSaveable { mutableStateOf("") }
val parseResult = ParseResult.new(completeWordList, textState) val parseResult = ParseResult.new(completeWordList, text)
val currentStage = restoreState.current.collectAsStateWithLifecycle().value 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( Scaffold(
modifier = Modifier.navigationBarsPadding(), modifier = Modifier.navigationBarsPadding(),
topBar = { topBar = {
@ -169,7 +176,10 @@ fun RestoreWallet(
} }
}, },
isShowClear = currentStage == RestoreStage.Seed, isShowClear = currentStage == RestoreStage.Seed,
onClear = { userWordList.set(emptyList()) } onClear = {
userWordList.set(emptyList())
text = ""
}
) )
}, },
bottomBar = { bottomBar = {
@ -177,9 +187,9 @@ fun RestoreWallet(
RestoreStage.Seed -> { RestoreStage.Seed -> {
RestoreSeedBottomBar( RestoreSeedBottomBar(
userWordList = userWordList, userWordList = userWordList,
isSeedValid = isSeedValid,
parseResult = parseResult, parseResult = parseResult,
setTextState = { textState = it }, setText = { text = it },
focusRequester = focusRequester,
modifier = Modifier modifier = Modifier
.imePadding() .imePadding()
.navigationBarsPadding() .navigationBarsPadding()
@ -197,12 +207,11 @@ fun RestoreWallet(
}, },
content = { paddingValues -> content = { paddingValues ->
val commonModifier = Modifier val commonModifier = Modifier
// We intentionally set the bottom smaller to save space in case of the software keyboard is visible
.padding( .padding(
top = paddingValues.calculateTopPadding() + dimens.spacingDefault, top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + dimens.spacingSmall, bottom = paddingValues.calculateBottomPadding(),
start = dimens.screenHorizontalSpacing, start = ZcashTheme.dimens.screenHorizontalSpacing,
end = dimens.screenHorizontalSpacing end = ZcashTheme.dimens.screenHorizontalSpacing
) )
when (currentStage) { when (currentStage) {
@ -210,12 +219,11 @@ fun RestoreWallet(
if (shouldSecureScreen) { if (shouldSecureScreen) {
SecureScreen() SecureScreen()
} }
RestoreSeedMainContent( RestoreSeedMainContent(
userWordList = userWordList, userWordList = userWordList,
textState = textState, isSeedValid = isSeedValid,
setTextState = { textState = it }, text = text,
focusRequester = focusRequester, setText = { text = it },
parseResult = parseResult, parseResult = parseResult,
paste = paste, paste = paste,
goNext = { restoreState.goNext() }, goNext = { restoreState.goNext() },
@ -229,6 +237,9 @@ fun RestoreWallet(
setRestoreHeight = setRestoreHeight, setRestoreHeight = setRestoreHeight,
onNext = { restoreState.goNext() }, onNext = { restoreState.goNext() },
modifier = commonModifier modifier = commonModifier
.imePadding()
.navigationBarsPadding()
.animateContentSize()
) )
} }
RestoreStage.Complete -> { RestoreStage.Complete -> {
@ -247,59 +258,72 @@ fun RestoreWallet(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun RestoreTopAppBar( private fun RestoreTopAppBar(
onBack: () -> Unit, onBack: () -> Unit,
onClear: () -> Unit,
isShowClear: Boolean, isShowClear: Boolean,
onClear: () -> Unit modifier: Modifier = Modifier,
) { ) {
TopAppBar( SmallTopAppBar(
title = { Text(text = stringResource(id = R.string.restore_title)) }, backText = stringResource(id = R.string.restore_back).uppercase(),
navigationIcon = { backContentDescriptionText = stringResource(R.string.restore_back_content_description),
IconButton( onBack = onBack,
onClick = onBack regularActions = if (isShowClear) { {
) { ClearSeedMenuItem(
Icon( onSeedClear = onClear
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.restore_back_content_description)
) )
} }
} else {
null
}, },
actions = { modifier = modifier,
if (isShowClear) { )
NavigationButton(onClick = onClear, stringResource(R.string.restore_button_clear))
}
} }
@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]: Implement custom seed phrase pasting for wallet import
// TODO [#672]: https://github.com/Electric-Coin-Company/zashi-android/issues/672 // 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) @OptIn(ExperimentalComposeUiApi::class)
@Suppress("UNUSED_PARAMETER", "LongParameterList") @Suppress("UNUSED_PARAMETER", "LongParameterList", "LongMethod")
@Composable @Composable
private fun RestoreSeedMainContent( private fun RestoreSeedMainContent(
userWordList: WordList, userWordList: WordList,
textState: String, isSeedValid: Boolean,
setTextState: (String) -> Unit, text: String,
focusRequester: FocusRequester, setText: (String) -> Unit,
parseResult: ParseResult, parseResult: ParseResult,
paste: () -> String?, paste: () -> String?,
goNext: () -> Unit, goNext: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope() 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) { if (parseResult is ParseResult.Add) {
setTextState("") setText("")
userWordList.append(parseResult.words) userWordList.append(parseResult.words)
} }
val isSeedValid = userWordList.wordValidation().collectAsState(null).value is SeedPhraseValidation.Valid
Column( Column(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
@ -307,63 +331,84 @@ private fun RestoreSeedMainContent(
.then(modifier), .then(modifier),
horizontalAlignment = Alignment.CenterHorizontally 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) Body(
text = stringResource(id = R.string.restore_seed_instructions),
if (!isSeedValid) { textAlign = TextAlign.Center
NextWordTextField(
parseResult = parseResult,
text = textState,
setText = { setTextState(it) },
modifier = Modifier.focusRequester(focusRequester)
) )
} }
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
SeedGridWithText(
text = text,
userWordList = userWordList,
focusRequester = focusRequester,
parseResult = parseResult,
setText = setText
)
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(MINIMAL_WEIGHT) .weight(MINIMAL_WEIGHT)
) )
Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge))
PrimaryButton( PrimaryButton(
onClick = goNext, onClick = goNext,
text = stringResource(id = R.string.restore_seed_button_restore),
enabled = isSeedValid, 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) { 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() LocalSoftwareKeyboardController.current?.hide()
LocalFocusManager.current.clearFocus()
} }
// Cause text field to refocus
DisposableEffect(parseResult) { DisposableEffect(parseResult) {
// Causes the TextFiled to refocus
if (!isSeedValid) { if (!isSeedValid) {
Twig.error { "NUT" }
focusRequester.requestFocus() focusRequester.requestFocus()
} }
// Causes scroll to the TextField after the first type action
if (text.isNotEmpty() && userWordList.current.value.isEmpty()) {
scope.launch { scope.launch {
scrollState.scrollTo(scrollState.maxValue) scrollState.animateScrollTo(textFieldScrollToHeight.intValue)
} }
onDispose { } }
onDispose { /* Nothing to dispose */ }
} }
} }
@Composable @Composable
private fun RestoreSeedBottomBar( private fun RestoreSeedBottomBar(
userWordList: WordList, userWordList: WordList,
isSeedValid: Boolean,
parseResult: ParseResult, parseResult: ParseResult,
setTextState: (String) -> Unit, setText: (String) -> Unit,
focusRequester: FocusRequester, modifier: Modifier = Modifier,
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 // 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 // the user can hit the clear button
if (!isSeedValid) { if (!isSeedValid) {
@ -374,81 +419,82 @@ private fun RestoreSeedBottomBar(
parseResult = parseResult, parseResult = parseResult,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// Note we don't set the top, as it's set by the confirm button above
.padding( .padding(
bottom = dimens.spacingDefault, horizontal = ZcashTheme.dimens.spacingDefault,
start = dimens.spacingDefault, vertical = ZcashTheme.dimens.spacingSmall
end = dimens.spacingDefault
) )
) )
Autocomplete(parseResult = parseResult, { Autocomplete(parseResult = parseResult, {
setTextState("") setText("")
userWordList.append(listOf(it)) userWordList.append(listOf(it))
focusRequester.requestFocus()
}) })
} }
} }
} }
const val CHIP_GRID_ROW_SIZE = 3
@Composable @Composable
private fun ChipGridWithText( @Suppress("LongParameterList", "LongMethod")
userWordList: ImmutableList<String> private fun SeedGridWithText(
) {
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,
text: String, text: String,
setText: (String) -> Unit, setText: (String) -> Unit,
userWordList: WordList,
focusRequester: FocusRequester,
parseResult: ParseResult,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Surface( val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
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 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 * Treat the user input as a password for more secure input, but disable the transformation
* to obscure typing. * to obscure typing.
*/ */
TextField( TextField(
textStyle = ZcashTheme.extendedTypography.textFieldValue,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(dimens.spacingTiny) .padding(ZcashTheme.dimens.spacingTiny)
.testTag(RestoreTag.SEED_WORD_TEXT_FIELD), .testTag(RestoreTag.SEED_WORD_TEXT_FIELD)
value = text, .focusRequester(focusRequester),
onValueChange = setText, 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( keyboardOptions = KeyboardOptions(
KeyboardCapitalization.None, KeyboardCapitalization.None,
autoCorrect = false, autoCorrect = false,
@ -456,7 +502,6 @@ private fun NextWordTextField(
keyboardType = KeyboardType.Password keyboardType = KeyboardType.Password
), ),
keyboardActions = KeyboardActions(onAny = {}), keyboardActions = KeyboardActions(onAny = {}),
shape = RoundedCornerShape(8.dp),
isError = parseResult is ParseResult.Warn, isError = parseResult is ParseResult.Warn,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, 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 @Composable
@Suppress("UNUSED_VARIABLE")
private fun Autocomplete( private fun Autocomplete(
parseResult: ParseResult, parseResult: ParseResult,
onSuggestionSelected: (String) -> Unit, onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier 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) { val (isHighlight, suggestions) = when (parseResult) {
is ParseResult.Autocomplete -> { is ParseResult.Autocomplete -> {
Pair(false, parseResult.suggestions) Pair(false, parseResult.suggestions)
} }
is ParseResult.Warn -> { is ParseResult.Warn -> {
return return
} }
else -> { else -> {
Pair(false, null) Pair(false, null)
} }
} }
suggestions?.let { suggestions?.let {
val highlightModifier = if (isHighlight) {
modifier.border(2.dp, ZcashTheme.colors.highlight)
} else {
modifier
}
@Suppress("ModifierReused")
LazyRow( LazyRow(
modifier = highlightModifier.testTag(RestoreTag.AUTOCOMPLETE_LAYOUT), modifier = modifier
// Note we don't set the top, as it's set by the confirm button above .testTag(RestoreTag.AUTOCOMPLETE_LAYOUT)
// And we also set the bottom smaller, as the keyboard will be always visible .fillMaxWidth(),
contentPadding = PaddingValues( contentPadding = PaddingValues(all = ZcashTheme.dimens.spacingSmall),
bottom = dimens.spacingDefault, horizontalArrangement = Arrangement.Absolute.Center
start = dimens.spacingDefault,
end = dimens.spacingSmall
)
) { ) {
items(it) { items(it) {
Chip( ChipOnSurface(
text = it, text = it,
modifier = Modifier modifier = Modifier
.testTag(RestoreTag.AUTOCOMPLETE_ITEM) .testTag(RestoreTag.AUTOCOMPLETE_ITEM)
@ -527,15 +609,22 @@ private fun Warn(
) { ) {
if (parseResult is ParseResult.Warn) { if (parseResult is ParseResult.Warn) {
Surface( Surface(
modifier = modifier, modifier = modifier.then(
Modifier.border(
border = BorderStroke(
width = ZcashTheme.dimens.chipStroke,
color = ZcashTheme.colors.layoutStroke
)
)
),
shape = RectangleShape, shape = RectangleShape,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
shadowElevation = 4.dp shadowElevation = ZcashTheme.dimens.chipShadowElevation
) { ) {
Text( Text(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(dimens.spacingTiny), .padding(ZcashTheme.dimens.spacingSmall),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
text = if (parseResult.suggestions.isEmpty()) { text = if (parseResult.suggestions.isEmpty()) {
stringResource(id = R.string.restore_seed_warning_no_suggestions) stringResource(id = R.string.restore_seed_warning_no_suggestions)
@ -569,11 +658,11 @@ private fun RestoreBirthday(
) { ) {
Header(stringResource(R.string.restore_birthday_header)) 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)) Body(stringResource(R.string.restore_birthday_body))
Spacer(modifier = Modifier.height(dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
FormTextField( FormTextField(
value = height, value = height,
@ -583,7 +672,7 @@ private fun RestoreBirthday(
}, },
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(dimens.spacingTiny) .padding(ZcashTheme.dimens.spacingTiny)
.testTag(RestoreTag.BIRTHDAY_TEXT_FIELD), .testTag(RestoreTag.BIRTHDAY_TEXT_FIELD),
label = { Text(stringResource(id = R.string.restore_birthday_hint)) }, label = { Text(stringResource(id = R.string.restore_birthday_hint)) },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@ -602,6 +691,8 @@ private fun RestoreBirthday(
.weight(MINIMAL_WEIGHT) .weight(MINIMAL_WEIGHT)
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
val isBirthdayValid = height.toLongOrNull()?.let { val isBirthdayValid = height.toLongOrNull()?.let {
it >= zcashNetwork.saplingActivationHeight.value it >= zcashNetwork.saplingActivationHeight.value
} ?: false } ?: false
@ -613,7 +704,7 @@ private fun RestoreBirthday(
}, },
text = stringResource(R.string.restore_birthday_button_restore), text = stringResource(R.string.restore_birthday_button_restore),
enabled = isBirthdayValid, enabled = isBirthdayValid,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall) outerPaddingValues = PaddingValues(top = ZcashTheme.dimens.spacingSmall)
) )
TertiaryButton( TertiaryButton(
@ -622,7 +713,7 @@ private fun RestoreBirthday(
onNext() onNext()
}, },
text = stringResource(R.string.restore_birthday_button_skip), 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)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
@ -643,11 +734,11 @@ private fun RestoreComplete(
) { ) {
Header(stringResource(R.string.restore_complete_header)) 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)) Body(stringResource(R.string.restore_complete_info))
Spacer(modifier = Modifier.height(dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer( Spacer(
modifier = Modifier modifier = Modifier
@ -658,7 +749,7 @@ private fun RestoreComplete(
PrimaryButton( PrimaryButton(
onClick = onComplete, onClick = onComplete,
text = stringResource(R.string.restore_button_see_wallet), 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)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))

View File

@ -1,11 +1,13 @@
<resources> <resources>
<string name="restore_header">Create a wallet</string> <string name="restore_back">Back</string>
<string name="restore_title">Wallet import</string>
<string name="restore_back_content_description">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_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> <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 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() it.performScrollTo()
// Even with waiting for the word list in the view model, there's some latency before the button is enabled // Even with waiting for the word list in the view model, there's some latency before the button is enabled