Restore redesign

This commit is contained in:
Milan Cerovsky 2025-03-11 19:53:30 +01:00
parent 8c6d873d04
commit cee77ea0f9
48 changed files with 1356 additions and 1599 deletions

View File

@ -13,8 +13,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@ -66,7 +68,9 @@ fun LabeledCheckBox(
text: String,
modifier: Modifier = Modifier,
checked: Boolean = false,
checkBoxTestTag: String? = null
checkBoxTestTag: String? = null,
color: Color = ZcashTheme.colors.textPrimary,
style: TextStyle = ZcashTheme.extendedTypography.checkboxText
) {
val (checkedState, setCheckedState) = rememberSaveable { mutableStateOf(checked) }
@ -114,8 +118,8 @@ fun LabeledCheckBox(
)
Text(
text = AnnotatedString(text),
color = ZcashTheme.colors.textPrimary,
style = ZcashTheme.extendedTypography.checkboxText
color = color,
style = style
)
}
}

View File

@ -1,144 +0,0 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.StringResource
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList", "LongMethod")
@Composable
fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
enabled: Boolean = true,
textStyle: TextStyle = ZcashTheme.extendedTypography.textFieldValue,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
colors: TextFieldColors =
TextFieldDefaults.colors(
cursorColor = ZcashTheme.colors.textPrimary,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
keyboardActions: KeyboardActions = KeyboardActions.Default,
shape: Shape = TextFieldDefaults.shape,
// To enable border around the TextField
withBorder: Boolean = true,
bringIntoViewRequester: BringIntoViewRequester? = null,
minHeight: Dp = ZcashTheme.dimens.textFieldDefaultHeight,
testTag: String? = null
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.then(modifier)) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder =
if (enabled) {
placeholder
} else {
null
},
textStyle = textStyle,
keyboardOptions = keyboardOptions,
colors = colors,
modifier =
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = minHeight)
.onFocusEvent { focusState ->
bringIntoViewRequester?.run {
if (focusState.isFocused) {
coroutineScope.launch {
bringIntoView()
}
}
}
}
.then(
if (withBorder) {
Modifier.border(
width = 1.dp,
color =
if (enabled) {
ZcashTheme.colors.textFieldFrame
} else {
ZcashTheme.colors.textDisabled
}
)
} else {
Modifier
}
)
.then(
if (testTag.isNullOrEmpty()) {
Modifier
} else {
Modifier.testTag(testTag)
}
),
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
keyboardActions = keyboardActions,
shape = shape,
enabled = enabled
)
if (!error.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BodySmall(
text = error,
color = ZcashTheme.colors.textFieldWarning,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Immutable
data class TextFieldState(
val value: StringResource,
val error: StringResource? = null,
val isEnabled: Boolean = true,
val onValueChange: (String) -> Unit,
) {
val isError = error != null
}

View File

@ -67,13 +67,13 @@ fun ZashiButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = ZashiButtonDefaults.style,
shape: Shape = ZashiButtonDefaults.shape,
contentPadding: PaddingValues = ZashiButtonDefaults.contentPadding,
@DrawableRes icon: Int? = null,
@DrawableRes trailingIcon: Int? = null,
enabled: Boolean = true,
isLoading: Boolean = false,
style: TextStyle = ZashiButtonDefaults.style,
shape: Shape = ZashiButtonDefaults.shape,
contentPadding: PaddingValues = ZashiButtonDefaults.contentPadding,
colors: ZashiButtonColors = ZashiButtonDefaults.primaryColors(),
content: @Composable RowScope.(ZashiButtonScope) -> Unit = ZashiButtonDefaults.content
) {

View File

@ -21,7 +21,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.R
@ -40,6 +42,9 @@ fun ZashiCheckbox(
isChecked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = ZashiTypography.textSm,
fontWeight: FontWeight = FontWeight.Medium,
color: Color = ZashiColors.Text.textPrimary,
) {
ZashiCheckbox(
state =
@ -49,6 +54,9 @@ fun ZashiCheckbox(
onClick = onClick,
),
modifier = modifier,
style = style,
fontWeight = fontWeight,
color = color,
)
}
@ -56,6 +64,9 @@ fun ZashiCheckbox(
fun ZashiCheckbox(
state: CheckboxState,
modifier: Modifier = Modifier,
style: TextStyle = ZashiTypography.textSm,
fontWeight: FontWeight = FontWeight.Medium,
color: Color = ZashiColors.Text.textPrimary,
) {
Row(
modifier =
@ -70,9 +81,9 @@ fun ZashiCheckbox(
Text(
text = state.text.getValue(),
style = ZashiTypography.textSm,
fontWeight = FontWeight.Medium,
color = ZashiColors.Text.textPrimary,
style = style,
fontWeight = fontWeight,
color = color,
)
}
}

View File

@ -0,0 +1,94 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowOverflow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreenSizes
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZashiSeedTextField(
state: SeedTextFieldState,
modifier: Modifier = Modifier
) {
val focusRequesters = remember { state.values.map { FocusRequester() } }
val focusManager = LocalFocusManager.current
FlowRow(
modifier = modifier.fillMaxWidth(),
maxItemsInEachRow = 3,
horizontalArrangement = spacedBy(4.dp),
verticalArrangement = spacedBy(4.dp),
overflow = FlowRowOverflow.Visible,
) {
state.values.forEachIndexed { index, wordState ->
val focusRequester = remember { focusRequesters[index] }
ZashiSeedWordTextField(
modifier =
Modifier
.weight(1f)
.focusRequester(focusRequester),
prefix = (index + 1).toString(),
state = wordState,
keyboardActions =
KeyboardActions(
onDone = {
focusManager.clearFocus(true)
},
onNext = {
if (index != state.values.lastIndex) {
focusRequesters[index + 1].requestFocus()
}
}
),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = if (index == state.values.lastIndex) ImeAction.Done else ImeAction.Next
),
)
}
}
}
@Immutable
data class SeedTextFieldState(
val values: List<SeedWordTextFieldState>,
)
@PreviewScreenSizes
@Composable
private fun Preview() =
ZcashTheme {
BlankSurface {
ZashiSeedTextField(
state =
SeedTextFieldState(
values =
(1..24).map {
SeedWordTextFieldState(
value = stringRes("Word"),
onValueChange = { },
isError = false
)
}
)
)
}
}

View File

@ -0,0 +1,103 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun ZashiSeedWordTextField(
prefix: String,
state: SeedWordTextFieldState,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ZashiTextField(
modifier = modifier,
innerModifier = Modifier,
shape = RoundedCornerShape(12.dp),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = true,
maxLines = 1,
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
state =
TextFieldState(
value = state.value,
onValueChange = state.onValueChange,
),
textStyle = ZashiTypography.textMd,
prefix = {
Box(
modifier =
Modifier
.size(22.dp)
.background(ZashiColors.Tags.tcCountBg, CircleShape)
.padding(end = 1.dp),
contentAlignment = Alignment.Center
) {
Text(
text = prefix,
style = ZashiTypography.textSm,
color = ZashiColors.Tags.tcCountFg,
fontWeight = FontWeight.Medium
)
}
},
colors =
ZashiTextFieldDefaults.defaultColors(
containerColor = ZashiColors.Surfaces.bgSecondary,
focusedContainerColor = ZashiColors.Surfaces.bgPrimary,
focusedBorderColor = ZashiColors.Accordion.focusStroke
)
)
}
@Immutable
data class SeedWordTextFieldState(
val value: StringResource,
val isError: Boolean,
// val isFocused: Boolean,
// val onFocusChange: (Boolean) -> Unit,
val onValueChange: (String) -> Unit
)
@Composable
@PreviewScreens
private fun Preview() =
ZcashTheme {
BlankSurface {
ZashiSeedWordTextField(
prefix = "12",
state =
SeedWordTextFieldState(
value = stringRes("asd"),
isError = false,
onValueChange = {},
)
)
}
}

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -39,6 +40,7 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
@ -48,7 +50,7 @@ fun ZashiTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
error: String? = null,
isEnabled: Boolean = true,
readOnly: Boolean = false,
@ -106,7 +108,7 @@ fun ZashiTextField(
fun ZashiTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null,
@ -124,6 +126,13 @@ fun ZashiTextField(
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = ZashiTextFieldDefaults.shape,
contentPadding: PaddingValues =
PaddingValues(
start = if (leadingIcon != null) 8.dp else 14.dp,
end = if (suffix != null) 4.dp else 12.dp,
top = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
bottom = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
),
colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) {
TextFieldInternal(
@ -147,10 +156,20 @@ fun ZashiTextField(
interactionSource = interactionSource,
shape = shape,
colors = colors,
contentPadding = contentPadding,
innerModifier = innerModifier
)
}
@Composable
fun ZashiTextFieldPlaceholder(res: StringResource) {
Text(
text = res.getValue(),
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
}
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -174,10 +193,13 @@ private fun TextFieldInternal(
interactionSource: MutableInteractionSource,
shape: Shape,
colors: ZashiTextFieldColors,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
) {
val borderColor by colors.borderColor(state)
val isFocused by interactionSource.collectIsFocusedAsState()
val borderColor by colors.borderColor(state, isFocused)
val androidColors = colors.toTextFieldColors()
// If color is not provided via the text style, use content color as a default
val textColor =
@ -193,16 +215,16 @@ private fun TextFieldInternal(
BasicTextField(
value = state.value.getValue(),
modifier =
innerModifier.fillMaxWidth() then
innerModifier then
if (borderColor == Color.Unspecified) {
Modifier
} else {
Modifier.border(
width = 1.dp,
color = borderColor,
shape = ZashiTextFieldDefaults.shape
shape = shape
)
} then Modifier.defaultMinSize(minWidth = TextFieldDefaults.MinWidth),
},
onValueChange = state.onValueChange,
enabled = state.isEnabled,
readOnly = readOnly,
@ -243,13 +265,7 @@ private fun TextFieldInternal(
isError = state.isError,
interactionSource = interactionSource,
colors = androidColors,
contentPadding =
PaddingValues(
start = if (leadingIcon != null) 8.dp else 14.dp,
end = if (suffix != null) 4.dp else 12.dp,
top = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
bottom = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
)
contentPadding = contentPadding
)
}
)
@ -303,7 +319,9 @@ data class ZashiTextFieldColors(
val textColor: Color,
val hintColor: Color,
val borderColor: Color,
val focusedBorderColor: Color,
val containerColor: Color,
val focusedContainerColor: Color,
val placeholderColor: Color,
val disabledTextColor: Color,
val disabledHintColor: Color,
@ -317,11 +335,15 @@ data class ZashiTextFieldColors(
val errorPlaceholderColor: Color,
) {
@Composable
internal fun borderColor(state: TextFieldState): State<Color> {
internal fun borderColor(
state: TextFieldState,
isFocused: Boolean
): State<Color> {
val targetValue =
when {
!state.isEnabled -> disabledBorderColor
state.isError -> errorBorderColor
isFocused -> focusedBorderColor.takeOrElse { borderColor }
else -> borderColor
}
return rememberUpdatedState(targetValue)
@ -345,7 +367,7 @@ data class ZashiTextFieldColors(
unfocusedTextColor = textColor,
disabledTextColor = disabledTextColor,
errorTextColor = errorTextColor,
focusedContainerColor = containerColor,
focusedContainerColor = focusedContainerColor.takeOrElse { containerColor },
unfocusedContainerColor = containerColor,
disabledContainerColor = disabledContainerColor,
errorContainerColor = errorContainerColor,
@ -391,13 +413,18 @@ object ZashiTextFieldDefaults {
val shape: Shape
get() = RoundedCornerShape(8.dp)
val innerModifier: Modifier
get() = Modifier.defaultMinSize(minWidth = TextFieldDefaults.MinWidth).fillMaxWidth()
@Suppress("LongParameterList")
@Composable
fun defaultColors(
textColor: Color = ZashiColors.Inputs.Filled.text,
hintColor: Color = ZashiColors.Inputs.Default.hint,
borderColor: Color = Color.Unspecified,
focusedBorderColor: Color = ZashiColors.Inputs.Focused.stroke,
containerColor: Color = ZashiColors.Inputs.Default.bg,
focusedContainerColor: Color = ZashiColors.Inputs.Focused.bg,
placeholderColor: Color = ZashiColors.Inputs.Default.text,
disabledTextColor: Color = ZashiColors.Inputs.Disabled.text,
disabledHintColor: Color = ZashiColors.Inputs.Disabled.hint,
@ -413,7 +440,9 @@ object ZashiTextFieldDefaults {
textColor = textColor,
hintColor = hintColor,
borderColor = borderColor,
focusedBorderColor = focusedBorderColor,
containerColor = containerColor,
focusedContainerColor = focusedContainerColor,
placeholderColor = placeholderColor,
disabledTextColor = disabledTextColor,
disabledHintColor = disabledHintColor,
@ -428,6 +457,16 @@ object ZashiTextFieldDefaults {
)
}
@Immutable
data class TextFieldState(
val value: StringResource,
val error: StringResource? = null,
val isEnabled: Boolean = true,
val onValueChange: (String) -> Unit,
) {
val isError = error != null
}
@PreviewScreens
@Composable
private fun DefaultPreview() =

View File

@ -25,8 +25,8 @@ import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.CommonTag
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedTag
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
@ -55,7 +55,7 @@ class RestoreViewAndroidTest : UiTestPrerequisites() {
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.assertIsFocused()
}
@ -81,7 +81,7 @@ class RestoreViewAndroidTest : UiTestPrerequisites() {
SeedPhraseFixture.SEED_PHRASE + " " + SeedPhraseFixture.SEED_PHRASE
)
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performKeyInput {
withKeyDown(Key.CtrlLeft) {
pressKey(Key.V)
@ -94,11 +94,11 @@ class RestoreViewAndroidTest : UiTestPrerequisites() {
assertEquals(SeedPhrase.SEED_PHRASE_SIZE, testSetup.getUserInputWords().size)
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.AUTOCOMPLETE_LAYOUT).also {
it.assertDoesNotExist()
}
@ -116,7 +116,7 @@ class RestoreViewAndroidTest : UiTestPrerequisites() {
composeTestRule.waitForIdle()
// Insert uncompleted seed words
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("test")
}
@ -139,7 +139,7 @@ class RestoreViewAndroidTest : UiTestPrerequisites() {
composeTestRule.waitForIdle()
// Insert complete seed words
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput(SeedPhraseFixture.SEED_PHRASE)
}

View File

@ -10,7 +10,8 @@ import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.compose.LocalScreenSecurity
import co.electriccoin.zcash.ui.common.compose.ScreenSecurity
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.restore.state.RestoreState
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedState
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedView
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -43,9 +44,9 @@ class RestoreViewSecuredScreenTest : UiTestPrerequisites() {
composeTestRule.setContent {
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
ZcashTheme {
RestoreWallet(
RestoreSeedView(
ZcashNetwork.Mainnet,
RestoreState(),
RestoreSeedState(),
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),
WordList(emptyList()),
restoreHeight = null,

View File

@ -25,9 +25,10 @@ import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.CommonTag
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
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.seed.RestoreSeedState
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedTag
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedView
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.collections.immutable.toPersistentSet
@ -54,7 +55,7 @@ class RestoreViewTest : UiTestPrerequisites() {
fun seed_autocomplete_suggestions_appear() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("ab")
// Make sure text isn't cleared
@ -62,13 +63,13 @@ class RestoreViewTest : UiTestPrerequisites() {
}
composeTestRule.onNode(
matcher = hasText("abandon", substring = true) and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)
matcher = hasText("abandon", substring = true) and hasTestTag(RestoreSeedTag.AUTOCOMPLETE_ITEM)
).also {
it.assertExists()
}
composeTestRule.onNode(
matcher = hasText("able", substring = true) and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)
matcher = hasText("able", substring = true) and hasTestTag(RestoreSeedTag.AUTOCOMPLETE_ITEM)
).also {
it.assertExists()
}
@ -79,17 +80,17 @@ class RestoreViewTest : UiTestPrerequisites() {
fun seed_choose_autocomplete() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("ab")
}
composeTestRule.onNode(
matcher = hasText("abandon", substring = true) and hasTestTag(RestoreTag.AUTOCOMPLETE_ITEM)
matcher = hasText("abandon", substring = true) and hasTestTag(RestoreSeedTag.AUTOCOMPLETE_ITEM)
).also {
it.performClick()
}
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.AUTOCOMPLETE_LAYOUT).also {
it.assertDoesNotExist()
}
@ -97,7 +98,7 @@ class RestoreViewTest : UiTestPrerequisites() {
it.assertExists()
}
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("abandon ", includeEditableText = true)
}
}
@ -107,15 +108,15 @@ class RestoreViewTest : UiTestPrerequisites() {
fun seed_type_full_word() {
newTestSetup()
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput("abandon")
}
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.assertTextEquals("abandon ", includeEditableText = true)
}
composeTestRule.onNodeWithTag(RestoreTag.AUTOCOMPLETE_LAYOUT).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.AUTOCOMPLETE_LAYOUT).also {
it.assertDoesNotExist()
}
@ -209,7 +210,7 @@ class RestoreViewTest : UiTestPrerequisites() {
initialWordsList = SeedPhraseFixture.new().split
)
composeTestRule.onNodeWithTag(RestoreTag.BIRTHDAY_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.BIRTHDAY_TEXT_FIELD).also {
it.performTextInput(ZcashNetwork.Mainnet.saplingActivationHeight.value.toString())
}
@ -241,7 +242,7 @@ class RestoreViewTest : UiTestPrerequisites() {
it.assertIsEnabled()
}
composeTestRule.onNodeWithTag(RestoreTag.BIRTHDAY_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.BIRTHDAY_TEXT_FIELD).also {
it.performTextInput((ZcashNetwork.Mainnet.saplingActivationHeight.value - 1L).toString())
}
@ -266,7 +267,7 @@ class RestoreViewTest : UiTestPrerequisites() {
initialWordsList = SeedPhraseFixture.new().split
)
composeTestRule.onNodeWithTag(RestoreTag.BIRTHDAY_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.BIRTHDAY_TEXT_FIELD).also {
it.performTextInput("1.2")
}
@ -353,7 +354,7 @@ class RestoreViewTest : UiTestPrerequisites() {
initialStage: RestoreStage,
initialWordsList: List<String>
) {
private val state = RestoreState(initialStage)
private val state = RestoreSeedState(initialStage)
private val wordList = WordList(initialWordsList)
@ -391,7 +392,7 @@ class RestoreViewTest : UiTestPrerequisites() {
init {
composeTestRule.setContent {
ZcashTheme {
RestoreWallet(
RestoreSeedView(
ZcashNetwork.Mainnet,
state,
Mnemonics.getCachedWords(Locale.ENGLISH.language).toPersistentSet(),

View File

@ -16,7 +16,6 @@ import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.DeriveKeystoneAccountUnifiedAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ExportTaxUseCase
import co.electriccoin.zcash.ui.common.usecase.FlipTransactionBookmarkUseCase
import co.electriccoin.zcash.ui.common.usecase.GetBackupPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase
@ -121,7 +120,6 @@ val useCaseModule =
factoryOf(::IsCoinbaseAvailableUseCase)
factoryOf(::GetZashiSpendingKeyUseCase)
factoryOf(::ObservePersistableWalletUseCase)
factoryOf(::GetBackupPersistableWalletUseCase)
factoryOf(::GetSupportUseCase)
factoryOf(::SendEmailUseCase)
factoryOf(::SendSupportEmailUseCase)

View File

@ -16,11 +16,11 @@ import co.electriccoin.zcash.ui.screen.feedback.viewmodel.FeedbackViewModel
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.home.HomeViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeightViewModel
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransactionViewModel
import co.electriccoin.zcash.ui.screen.scan.Scan
@ -57,9 +57,8 @@ val viewModelModule =
viewModelOf(::WalletViewModel)
viewModelOf(::AuthenticationViewModel)
viewModelOf(::OldHomeViewModel)
viewModelOf(::OnboardingViewModel)
viewModelOf(::StorageCheckViewModel)
viewModelOf(::RestoreViewModel)
viewModelOf(::RestoreSeedViewModel)
viewModelOf(::ScreenBrightnessViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
@ -156,4 +155,5 @@ val viewModelModule =
viewModelOf(::TaxExportViewModel)
viewModelOf(::BalanceViewModel)
viewModelOf(::HomeViewModel)
viewModelOf(::RestoreBDHeightViewModel)
}

View File

@ -48,7 +48,7 @@ import co.electriccoin.zcash.ui.screen.authentication.RETRY_TRIGGER_DELAY
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
import co.electriccoin.zcash.ui.screen.authentication.view.AnimationConstants
import co.electriccoin.zcash.ui.screen.authentication.view.WelcomeAnimationAutostart
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
import co.electriccoin.zcash.ui.screen.onboarding.RestoreNavigation
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning
import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs
@ -249,7 +249,7 @@ class MainActivity : FragmentActivity() {
CompositionLocalProvider(RemoteConfig provides configuration) {
when (secretState) {
SecretState.None -> {
WrapOnboarding()
RestoreNavigation()
}
is SecretState.NeedsWarning -> {
@ -263,7 +263,11 @@ class MainActivity : FragmentActivity() {
applicationContext,
walletViewModel,
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
WalletFixture.Alice.getBirthday(
ZcashNetwork.fromResources(
applicationContext
)
)
)
} else {
walletViewModel.persistNewWalletAndRestoringState(WalletRestoringState.INITIATING)
@ -284,7 +288,7 @@ class MainActivity : FragmentActivity() {
}
else -> {
error("Unhandled secret state: $secretState")
// should not happen
}
}
}

View File

@ -70,7 +70,6 @@ interface WalletRepository {
val synchronizer: StateFlow<Synchronizer?>
val secretState: StateFlow<SecretState>
val fastestServers: StateFlow<FastestServersState>
val persistableWallet: Flow<PersistableWallet?>
val onboardingState: Flow<OnboardingState>
val allAccounts: Flow<List<WalletAccount>?>
@ -99,8 +98,6 @@ interface WalletRepository {
suspend fun getSynchronizer(): Synchronizer
suspend fun getPersistableWallet(): PersistableWallet
fun persistExistingWalletWithSeedPhrase(
network: ZcashNetwork,
seedPhrase: SeedPhrase,
@ -110,7 +107,7 @@ interface WalletRepository {
class WalletRepositoryImpl(
accountDataSource: AccountDataSource,
persistableWalletProvider: PersistableWalletProvider,
private val persistableWalletProvider: PersistableWalletProvider,
private val synchronizerProvider: SynchronizerProvider,
private val application: Application,
private val getDefaultServers: GetDefaultServersProvider,
@ -143,22 +140,12 @@ class WalletRepositoryImpl(
override val allAccounts: StateFlow<List<WalletAccount>?> = accountDataSource.allAccounts
override val secretState: StateFlow<SecretState> =
combine(
persistableWalletProvider.persistableWallet,
onboardingState
) { persistableWallet: PersistableWallet?, onboardingState: OnboardingState ->
when {
onboardingState == OnboardingState.NONE -> SecretState.None
onboardingState == OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
onboardingState == OnboardingState.NEEDS_BACKUP && persistableWallet != null -> {
SecretState.NeedsBackup(persistableWallet)
}
onboardingState == OnboardingState.READY && persistableWallet != null -> {
SecretState.Ready(persistableWallet)
}
else -> SecretState.None
onboardingState.map { onboardingState: OnboardingState ->
when (onboardingState) {
OnboardingState.NONE -> SecretState.None
OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
OnboardingState.NEEDS_BACKUP -> SecretState.NeedsBackup
OnboardingState.READY -> SecretState.Ready
}
}.stateIn(
scope = scope,
@ -204,11 +191,6 @@ class WalletRepositoryImpl(
initialValue = FastestServersState(servers = emptyList(), isLoading = true)
)
override val persistableWallet: Flow<PersistableWallet?> =
secretState.map {
(it as? SecretState.Ready?)?.persistableWallet
}
@OptIn(ExperimentalCoroutinesApi::class)
override val currentWalletSnapshot: StateFlow<WalletSnapshot?> =
combine(synchronizer, currentAccount) { synchronizer, currentAccount ->
@ -317,7 +299,7 @@ class WalletRepositoryImpl(
}
override suspend fun getSelectedServer(): LightWalletEndpoint {
return persistableWallet
return persistableWalletProvider.persistableWallet
.map {
it?.endpoint
}
@ -338,8 +320,6 @@ class WalletRepositoryImpl(
override suspend fun getSynchronizer(): Synchronizer = synchronizerProvider.getSynchronizer()
override suspend fun getPersistableWallet(): PersistableWallet = persistableWallet.filterNotNull().first()
override fun persistExistingWalletWithSeedPhrase(
network: ZcashNetwork,
seedPhrase: SeedPhrase,

View File

@ -1,17 +0,0 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class GetBackupPersistableWalletUseCase(
private val walletRepository: WalletRepository
) {
suspend operator fun invoke() =
walletRepository.secretState
.map { (it as? SecretState.NeedsBackup)?.persistableWallet }
.filterNotNull()
.first()
}

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
class GetPersistableWalletUseCase(
private val walletRepository: WalletRepository
private val persistableWalletProvider: PersistableWalletProvider
) {
suspend operator fun invoke() = walletRepository.getPersistableWallet()
suspend operator fun invoke() = persistableWalletProvider.getPersistableWallet()
}

View File

@ -1,14 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
class ObserveSelectedEndpointUseCase(
private val walletRepository: WalletRepository
private val persistableWalletProvider: PersistableWalletProvider
) {
operator fun invoke() =
walletRepository.persistableWallet
persistableWalletProvider.persistableWallet
.map {
it?.endpoint
}

View File

@ -168,9 +168,9 @@ sealed class SecretState {
object NeedsWarning : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
object NeedsBackup : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
object Ready : SecretState()
}
/**

View File

@ -4,74 +4,103 @@ package co.electriccoin.zcash.ui.screen.onboarding
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.Navigator
import co.electriccoin.zcash.ui.NavigatorImpl
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.enterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.restore.WrapRestore
import co.electriccoin.zcash.ui.screen.restore.height.AndroidRestoreBDHeight
import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeight
import co.electriccoin.zcash.ui.screen.restore.seed.AndroidRestoreSeed
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeed
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Suppress("LongMethod")
@Composable
internal fun WrapOnboarding() {
fun MainActivity.RestoreNavigation() {
val activity = LocalActivity.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val onboardingViewModel = koinActivityViewModel<OnboardingViewModel>()
val navigationRouter = koinInject<NavigationRouter>()
val navController = LocalNavController.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigator: Navigator = remember { NavigatorImpl(this@RestoreNavigation, navController, flexaViewModel) }
val versionInfo = VersionInfo.new(activity.applicationContext)
// TODO [#383]: https://github.com/Electric-Coin-Company/zashi-android/issues/383
// TODO [#383]: Refactoring of UI state retention into rememberSaveable fields
if (!onboardingViewModel.isImporting.collectAsStateWithLifecycle().value) {
val onCreateWallet = {
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
}
val onImportWallet = {
// In the case of the app currently being messed with by the robo test runner on
// Firebase Test Lab or Google Play pre-launch report, we want to skip creating
// a new or restoring an existing wallet screens by persisting an existing wallet
// with a mock seed.
if (FirebaseTestLabUtil.isFirebaseTestLab(activity.applicationContext)) {
persistExistingWalletWithSeedPhrase(
activity.applicationContext,
walletViewModel,
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(activity.applicationContext))
)
} else {
onboardingViewModel.setIsImporting(true)
}
}
val onFixtureWallet: (String) -> Unit = { seed ->
val onCreateWallet = {
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_WARN)
}
val onImportWallet = {
// In the case of the app currently being messed with by the robo test runner on
// Firebase Test Lab or Google Play pre-launch report, we want to skip creating
// a new or restoring an existing wallet screens by persisting an existing wallet
// with a mock seed.
if (FirebaseTestLabUtil.isFirebaseTestLab(activity.applicationContext)) {
persistExistingWalletWithSeedPhrase(
activity.applicationContext,
walletViewModel,
SeedPhrase.new(seed),
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(activity.applicationContext))
)
} else {
navigationRouter.forward(RestoreSeed)
}
}
Onboarding(
isDebugMenuEnabled = versionInfo.isDebuggable && !versionInfo.isRunningUnderTestService,
onImportWallet = onImportWallet,
onCreateWallet = onCreateWallet,
onFixtureWallet = onFixtureWallet
val onFixtureWallet: (String) -> Unit = { seed ->
persistExistingWalletWithSeedPhrase(
activity.applicationContext,
walletViewModel,
SeedPhrase.new(seed),
birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(activity.applicationContext))
)
}
activity.reportFullyDrawn()
} else {
WrapRestore()
LaunchedEffect(Unit) {
navigationRouter.observePipeline().collect {
navigator.executeCommand(it)
}
}
NavHost(
navController = navController,
startDestination = Onboarding,
enterTransition = { enterTransition() },
exitTransition = { exitTransition() },
popEnterTransition = { popEnterTransition() },
popExitTransition = { popExitTransition() }
) {
composable<Onboarding> {
Onboarding(
isDebugMenuEnabled = versionInfo.isDebuggable && !versionInfo.isRunningUnderTestService,
onImportWallet = onImportWallet,
onCreateWallet = onCreateWallet,
onFixtureWallet = onFixtureWallet
)
}
composable<RestoreSeed> {
AndroidRestoreSeed()
}
composable<RestoreBDHeight> {
AndroidRestoreBDHeight()
}
}
}

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.ui.screen.onboarding
import kotlinx.serialization.Serializable
@Serializable
data object Onboarding

View File

@ -1,27 +0,0 @@
package co.electriccoin.zcash.ui.screen.onboarding.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
/*
* Android-specific ViewModel. This is used to save and restore state across Activity recreations
* outside of the Compose framework.
*/
class OnboardingViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
// This is a bit weird being placed here, but onboarding currently is considered complete when
// the user has a persisted wallet. Also import allows the user to go back to onboarding, while
// creating a new wallet does not.
val isImporting = savedStateHandle.getStateFlow(KEY_IS_IMPORTING, false)
fun setIsImporting(isImporting: Boolean) {
savedStateHandle[KEY_IS_IMPORTING] = isImporting
}
companion object {
private const val KEY_IS_IMPORTING = "is_importing" // $NON-NLS
}
}

View File

@ -1,61 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore
import android.content.ClipboardManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
import co.electriccoin.zcash.ui.screen.restore.viewmodel.CompleteWordSetState
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
@Composable
fun WrapRestore() {
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val onboardingViewModel = koinActivityViewModel<OnboardingViewModel>()
val restoreViewModel = koinActivityViewModel<RestoreViewModel>()
val applicationContext = LocalContext.current.applicationContext
when (val completeWordList = restoreViewModel.completeWordList.collectAsStateWithLifecycle().value) {
CompleteWordSetState.Loading -> {
// Although it might perform IO, it should be relatively fast.
// Consider whether to display indeterminate progress here.
// Another option would be to go straight to the restore screen with autocomplete
// disabled for a few milliseconds. Users would probably never notice due to the
// time it takes to re-orient on the new screen, unless users were doing this
// on a daily basis and become very proficient at our UI. The Therac-25 has
// historical precedent on how that could cause problems.
}
is CompleteWordSetState.Loaded -> {
RestoreWallet(
ZcashNetwork.fromResources(applicationContext),
restoreViewModel.restoreState,
completeWordList.list,
restoreViewModel.userWordList,
restoreViewModel.userBirthdayHeight.collectAsStateWithLifecycle().value,
setRestoreHeight = { restoreViewModel.userBirthdayHeight.value = it },
onBack = { onboardingViewModel.setIsImporting(false) },
paste = {
val clipboardManager = applicationContext.getSystemService(ClipboardManager::class.java)
return@RestoreWallet clipboardManager?.primaryClip?.toString()
},
onFinished = {
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhrase(restoreViewModel.userWordList.current.value),
restoreViewModel.userBirthdayHeight.value
)
onboardingViewModel.setIsImporting(false)
}
)
}
}
}

View File

@ -0,0 +1,131 @@
package co.electriccoin.zcash.ui.screen.restore
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiInScreenModalBottomSheet
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun RestoreSeedDialog(
state: RestoreSeedDialogState?,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
ZashiInScreenModalBottomSheet(
state = state,
sheetState = sheetState,
content = {
Content(it)
},
)
}
@Composable
private fun Content(state: RestoreSeedDialogState) {
Column(
modifier = Modifier.padding(horizontal = 24.dp)
) {
Text(
text = stringResource(R.string.integrations_dialog_more_options),
style = ZashiTypography.header6,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(12.dp))
Info(
text =
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = ZashiColors.Text.textPrimary)) {
append(stringResource(id = R.string.restore_dialog_message_1_bold_part))
}
append(" ")
append(stringResource(R.string.restore_dialog_message_1))
}
)
Spacer(modifier = Modifier.height(12.dp))
Info(
text =
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = ZashiColors.Text.textPrimary)) {
append(stringResource(id = R.string.restore_dialog_message_2_bold_part))
}
append(" ")
append(stringResource(R.string.restore_dialog_message_2))
}
)
Spacer(modifier = Modifier.height(32.dp))
ZashiButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.restore_dialog_button),
onClick = state.onBack
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
@Composable
private fun Info(text: AnnotatedString) {
Row {
Image(
painterResource(R.drawable.ic_info),
contentDescription = ""
)
Spacer(Modifier.width(8.dp))
Text(
text = text,
style = ZashiTypography.textSm,
fontWeight = FontWeight.Normal,
color = ZashiColors.Text.textTertiary
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
RestoreSeedDialog(
sheetState =
rememberModalBottomSheetState(
skipPartiallyExpanded = true,
skipHiddenState = true,
initialValue = SheetValue.Expanded,
),
state = RestoreSeedDialogState { },
)
}

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.ui.screen.restore
import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState
data class RestoreSeedDialogState(
override val onBack: () -> Unit
) : ModalBottomSheetState

View File

@ -0,0 +1,28 @@
package co.electriccoin.zcash.ui.screen.restore.height
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialog
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidRestoreBDHeight() {
val vm = koinViewModel<RestoreBDHeightViewModel>()
val state by vm.state.collectAsStateWithLifecycle()
val dialogState by vm.dialogState.collectAsStateWithLifecycle()
RestoreBDHeightView(state)
BackHandler {
state.onBack()
}
RestoreSeedDialog(dialogState)
}
@Serializable
data object RestoreBDHeight

View File

@ -0,0 +1,13 @@
package co.electriccoin.zcash.ui.screen.restore.height
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
data class RestoreBDHeightState(
val blockHeight: TextFieldState,
val estimate: ButtonState,
val restore: ButtonState,
val dialogButton: IconButtonState,
val onBack: () -> Unit
)

View File

@ -0,0 +1,215 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.restore.height
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
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.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarTags
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldPlaceholder
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import java.text.DecimalFormat
import java.text.NumberFormat
@Composable
fun RestoreBDHeightView(state: RestoreBDHeightState) {
BlankBgScaffold(
topBar = { AppBar(state) },
bottomBar = {},
content = { padding ->
Content(
state = state,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.scaffoldPadding(padding)
)
}
)
}
@Composable
private fun Content(
state: RestoreBDHeightState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text(
text = stringResource(R.string.restore_bd_subtitle),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.restore_bd_message),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(32.dp))
Text(
text = stringResource(R.string.restore_bd_text_field_title),
style = ZashiTypography.textSm,
color = ZashiColors.Inputs.Default.label,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(6.dp))
ZashiTextField(
state = state.blockHeight,
modifier = Modifier.fillMaxWidth(),
placeholder = {
ZashiTextFieldPlaceholder(
stringRes(R.string.restore_bd_text_field_hint)
)
},
keyboardOptions =
KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number
),
visualTransformation = ThousandSeparatorTransformation()
)
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.restore_bd_text_field_note),
style = ZashiTypography.textXs,
color = ZashiColors.Text.textTertiary
)
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(24.dp))
ZashiButton(
state.estimate,
modifier = Modifier.fillMaxWidth(),
colors = ZashiButtonDefaults.secondaryColors()
)
Spacer(Modifier.height(12.dp))
ZashiButton(
state.restore,
modifier = Modifier.fillMaxWidth(),
)
}
}
private class ThousandSeparatorTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val symbols = DecimalFormat().decimalFormatSymbols
val decimalSeparator = symbols.decimalSeparator
var outputText = ""
val integerPart: Long
val decimalPart: String
if (text.text.isNotEmpty()) {
val number = text.text.toDouble()
integerPart = number.toLong()
outputText += NumberFormat.getIntegerInstance().format(integerPart)
if (text.text.contains(decimalSeparator)) {
decimalPart = text.text.substring(text.text.indexOf(decimalSeparator))
if (decimalPart.isNotEmpty()) {
outputText += decimalPart
}
}
}
val numberOffsetTranslator =
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return outputText.length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
}
}
return TransformedText(
text = AnnotatedString(outputText),
offsetMapping = numberOffsetTranslator
)
}
}
@Composable
private fun AppBar(state: RestoreBDHeightState) {
ZashiSmallTopAppBar(
title = stringResource(R.string.restore_title),
navigationAction = {
ZashiTopAppBarBackNavigation(
onBack = state.onBack,
modifier = Modifier.testTag(ZashiTopAppBarTags.BACK)
)
},
regularActions = {
ZashiIconButton(state.dialogButton, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(20.dp))
},
colors =
ZcashTheme.colors.topAppBarColors orDark
ZcashTheme.colors.topAppBarColors.copyColors(
containerColor = Color.Transparent
),
)
}
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
RestoreBDHeightView(
state =
RestoreBDHeightState(
onBack = {},
dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {},
blockHeight = TextFieldState(stringRes("")) {},
estimate = ButtonState(stringRes("Estimate")) {},
restore = ButtonState(stringRes("Restore")) {}
)
)
}

View File

@ -0,0 +1,84 @@
package co.electriccoin.zcash.ui.screen.restore.height
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialogState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class RestoreBDHeightViewModel(
private val navigationRouter: NavigationRouter
) : ViewModel() {
private val blockHeightText = MutableStateFlow("")
private val isDialogVisible = MutableStateFlow(false)
val dialogState =
isDialogVisible
.map { isDialogVisible ->
RestoreSeedDialogState(
::onCloseDialogClick
).takeIf { isDialogVisible }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val state: StateFlow<RestoreBDHeightState> =
blockHeightText
.map { text ->
createState(text)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(blockHeightText.value)
)
private fun createState(blockHeight: String) =
RestoreBDHeightState(
onBack = ::onBack,
dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick),
restore = ButtonState(stringRes(R.string.restore_bd_restore_btn), onClick = ::onRestoreClick),
estimate = ButtonState(stringRes(R.string.restore_bd_height_btn), onClick = ::onEstimateClick),
blockHeight = TextFieldState(stringRes(blockHeight), onValueChange = ::onValueChanged)
)
private fun onEstimateClick() {
// do nothing
}
private fun onRestoreClick() {
// do nothing
}
private fun onBack() {
navigationRouter.back()
}
private fun onInfoButtonClick() {
isDialogVisible.update { true }
}
private fun onCloseDialogClick() {
isDialogVisible.update { false }
}
private fun onValueChanged(string: String) {
blockHeightText.update { string }
}
}

View File

@ -1,79 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore.model
import cash.z.ecc.android.sdk.model.SeedPhrase
import co.electriccoin.zcash.ui.common.extension.first
import java.util.Locale
internal sealed class 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"
}
data class Autocomplete(val suggestions: List<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Autocomplete"
}
data class Warn(val suggestions: List<String>) : ParseResult() {
// Override to prevent logging of user secrets
override fun toString() = "Warn"
}
companion object {
@Suppress("ReturnCount")
fun new(
completeWordList: Set<String>,
rawInput: String
): ParseResult {
// Note: This assumes the word list is English words, thus the Locale.US is intended.
val trimmed = rawInput.lowercase(Locale.US).trim()
if (trimmed.isBlank()) {
return Continue
}
val autocomplete = completeWordList.filter { it.startsWith(trimmed) }
// we accept the word only in case that there is no other available
if (completeWordList.contains(trimmed) && autocomplete.size == 1) {
return Add(listOf(trimmed))
}
if (autocomplete.isNotEmpty()) {
return Autocomplete(autocomplete)
}
val multiple =
trimmed.split(SeedPhrase.DEFAULT_DELIMITER)
.filter { completeWordList.contains(it) }
.first(SeedPhrase.SEED_PHRASE_SIZE)
if (multiple.isNotEmpty()) {
return Add(multiple)
}
return Warn(findSuggestions(trimmed, completeWordList))
}
}
override fun toString(): String {
return "ParseResult()"
}
}
internal fun findSuggestions(
input: String,
completeWordList: Set<String>
): List<String> {
return if (input.isBlank()) {
emptyList()
} else {
completeWordList.filter { it.startsWith(input) }.ifEmpty {
findSuggestions(input.dropLast(1), completeWordList)
}
}
}

View File

@ -1,28 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore.model
enum class RestoreStage {
// Note: the ordinal order is used to manage progression through each stage
// so be careful if reordering these
Seed,
Birthday;
/**
* @see getPrevious
*/
fun hasPrevious() = ordinal > 0
/**
* @see getNext
*/
fun hasNext() = ordinal < entries.size - 1
/**
* @return Previous item in ordinal order. Returns the first item when it cannot go further back.
*/
fun getPrevious() = entries[maxOf(0, ordinal - 1)]
/**
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
*/
fun getNext() = entries[minOf(entries.size - 1, ordinal + 1)]
}

View File

@ -0,0 +1,24 @@
package co.electriccoin.zcash.ui.screen.restore.seed
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialog
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidRestoreSeed() {
val vm = koinViewModel<RestoreSeedViewModel>()
val state by vm.state.collectAsStateWithLifecycle()
val dialogState by vm.dialogState.collectAsStateWithLifecycle()
state?.let { RestoreSeedView(it) }
BackHandler { state?.onBack?.invoke() }
RestoreSeedDialog(dialogState)
}
@Serializable
data object RestoreSeed

View File

@ -0,0 +1,12 @@
package co.electriccoin.zcash.ui.screen.restore.seed
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.SeedTextFieldState
class RestoreSeedState(
val seed: SeedTextFieldState,
val onBack: () -> Unit,
val dialogButton: IconButtonState,
val nextButton: ButtonState
)

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.screen.restore
package co.electriccoin.zcash.ui.screen.restore.seed
/**
* These are only used for automated testing.
*/
object RestoreTag {
object RestoreSeedTag {
const val SEED_WORD_TEXT_FIELD = "seed_text_field"
const val BIRTHDAY_TEXT_FIELD = "birthday_text_field"
const val CHIP_LAYOUT = "chip_group"

View File

@ -0,0 +1,142 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.restore.seed
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarTags
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.SeedTextFieldState
import co.electriccoin.zcash.ui.design.component.SeedWordTextFieldState
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiSeedTextField
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun RestoreSeedView(state: RestoreSeedState) {
BlankBgScaffold(
topBar = { AppBar(state) },
bottomBar = {},
content = { padding ->
Content(
state = state,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.scaffoldPadding(padding)
)
}
)
}
@Composable
private fun Content(
state: RestoreSeedState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text(
text = stringResource(R.string.restore_subtitle),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.restore_message),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(20.dp))
ZashiSeedTextField(
state = state.seed
)
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(24.dp))
ZashiButton(
state.nextButton,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun AppBar(state: RestoreSeedState) {
ZashiSmallTopAppBar(
title = stringResource(R.string.restore_title),
navigationAction = {
ZashiTopAppBarBackNavigation(
onBack = state.onBack,
modifier = Modifier.testTag(ZashiTopAppBarTags.BACK)
)
},
regularActions = {
ZashiIconButton(state.dialogButton, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(20.dp))
},
colors =
ZcashTheme.colors.topAppBarColors orDark
ZcashTheme.colors.topAppBarColors.copyColors(
containerColor = Color.Transparent
),
)
}
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
RestoreSeedView(
state =
RestoreSeedState(
seed =
SeedTextFieldState(
values =
(1..24).map {
SeedWordTextFieldState(
value = stringRes("Word"),
onValueChange = { },
isError = false
)
}
),
onBack = {},
dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {},
nextButton =
ButtonState(
text = stringRes("Next"),
onClick = {}
)
)
)
}

View File

@ -0,0 +1,122 @@
package co.electriccoin.zcash.ui.screen.restore.seed
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.SeedTextFieldState
import co.electriccoin.zcash.ui.design.component.SeedWordTextFieldState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.restore.RestoreSeedDialogState
import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeight
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class RestoreSeedViewModel(
private val navigationRouter: NavigationRouter
) : ViewModel() {
@Suppress("MagicNumber")
private val seedWords =
MutableStateFlow(
(0..23).map { index ->
SeedWordTextFieldState(
value = stringRes(""),
onValueChange = { onValueChange(index, it) },
isError = false
)
}
)
private val isDialogVisible = MutableStateFlow(false)
val dialogState =
isDialogVisible
.map { isDialogVisible ->
RestoreSeedDialogState(
::onCloseDialogClick
).takeIf { isDialogVisible }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val state: StateFlow<RestoreSeedState?> =
seedWords
.map { words ->
createState(words)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
// /**
// * The complete word list that the user can choose from; useful for autocomplete
// */
// val completeWordList =
// // This is a hack to prevent disk IO on the main thread
// flow<CompleteWordSetState> {
// // Using IO context because of https://github.com/Electric-Coin-Company/kotlin-bip39/issues/13
// val completeWordList =
// withContext(Dispatchers.IO) {
// Mnemonics.getCachedWords(Locale.ENGLISH.language)
// }
//
// emit(CompleteWordSetState.Loaded(completeWordList.toPersistentSet()))
// }.stateIn(
// viewModelScope,
// SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
// CompleteWordSetState.Loading
// )
private fun createState(words: List<SeedWordTextFieldState>) =
RestoreSeedState(
seed = SeedTextFieldState(values = words),
onBack = ::onBack,
dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick),
nextButton =
ButtonState(
stringRes(R.string.restore_button),
onClick = ::onNextClicked
)
)
private fun onBack() {
navigationRouter.back()
}
private fun onInfoButtonClick() {
isDialogVisible.update { true }
}
private fun onNextClicked() {
navigationRouter.forward(RestoreBDHeight)
}
private fun onValueChange(
index: Int,
value: String
) {
seedWords.update {
val newSeedWords = it.toMutableList()
newSeedWords[index] = newSeedWords[index].copy(value = stringRes(value))
newSeedWords.toList()
}
}
private fun onCloseDialogClick() {
isDialogVisible.update { false }
}
}

View File

@ -1,24 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore.state
import co.electriccoin.zcash.ui.screen.restore.model.RestoreStage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* @param initialState Allows restoring the state from a different starting point. This is
* primarily useful on Android, for automated tests, and for iterative debugging with the Compose
* layout preview. The default constructor argument is generally fine for other platforms.
*/
class RestoreState(initialState: RestoreStage = RestoreStage.values().first()) {
private val mutableState = MutableStateFlow(initialState)
val current: StateFlow<RestoreStage> = mutableState
fun goNext() {
mutableState.value = current.value.getNext()
}
fun goPrevious() {
mutableState.value = current.value.getPrevious()
}
}

View File

@ -1,47 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore.state
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.model.SeedPhraseValidation
import co.electriccoin.zcash.ui.common.extension.first
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class WordList(initial: List<String> = emptyList()) {
private val mutableState: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(initial.toPersistentList())
val current: StateFlow<ImmutableList<String>> = mutableState
fun set(list: List<String>) {
mutableState.value = list.toPersistentList()
}
fun append(words: List<String>) {
val newList =
(current.value + words)
.first(SeedPhrase.SEED_PHRASE_SIZE) // Prevent pasting too many words
.toPersistentList()
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"
}
fun WordList.wordValidation() =
current
.map { SeedPhraseValidation.new(it) }

View File

@ -1,859 +0,0 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.restore.view
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
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.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
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.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.SecureScreen
import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.ChipOnSurface
import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.component.TopScreenLogoTitle
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
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.ImmutableSet
import kotlinx.collections.immutable.persistentHashSetOf
import kotlinx.coroutines.delay
@Preview
@Composable
private fun RestoreSeedPreview() {
ZcashTheme(forceDarkMode = false) {
RestoreWallet(
ZcashNetwork.Mainnet,
restoreState = RestoreState(RestoreStage.Seed),
completeWordList =
persistentHashSetOf(
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"absorb",
"abstract",
"rib",
"ribbon"
),
userWordList = WordList(listOf("abandon", "absorb")),
restoreHeight = null,
setRestoreHeight = {},
onBack = {},
paste = { "" },
onFinished = {}
)
}
}
@Preview
@Composable
private fun RestoreSeedDarkPreview() {
ZcashTheme(forceDarkMode = true) {
RestoreWallet(
ZcashNetwork.Mainnet,
restoreState = RestoreState(RestoreStage.Seed),
completeWordList =
persistentHashSetOf(
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"absorb",
"abstract",
"rib",
"ribbon"
),
userWordList = WordList(listOf("abandon", "absorb")),
restoreHeight = null,
setRestoreHeight = {},
onBack = {},
paste = { "" },
onFinished = {}
)
}
}
@Preview
@Composable
private fun RestoreBirthdayPreview() {
ZcashTheme(forceDarkMode = false) {
RestoreWallet(
ZcashNetwork.Mainnet,
restoreState = RestoreState(RestoreStage.Birthday),
completeWordList =
persistentHashSetOf(
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"absorb",
"abstract",
"rib",
"ribbon"
),
userWordList = WordList(listOf("abandon", "absorb")),
restoreHeight = null,
setRestoreHeight = {},
onBack = {},
paste = { "" },
onFinished = {}
)
}
}
@Preview
@Composable
private fun RestoreBirthdayDarkPreview() {
ZcashTheme(forceDarkMode = true) {
RestoreWallet(
ZcashNetwork.Mainnet,
restoreState = RestoreState(RestoreStage.Birthday),
completeWordList =
persistentHashSetOf(
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"absorb",
"abstract",
"rib",
"ribbon"
),
userWordList = WordList(listOf("abandon", "absorb")),
restoreHeight = null,
setRestoreHeight = {},
onBack = {},
paste = { "" },
onFinished = {}
)
}
}
/**
* Note that the restore review doesn't allow the user to go back once the seed is entered correctly.
*
* @param restoreHeight A null height indicates no user input.
*/
@Suppress("LongParameterList", "LongMethod")
@Composable
fun RestoreWallet(
zcashNetwork: ZcashNetwork,
restoreState: RestoreState,
completeWordList: ImmutableSet<String>,
userWordList: WordList,
restoreHeight: BlockHeight?,
setRestoreHeight: (BlockHeight?) -> Unit,
onBack: () -> Unit,
paste: () -> String?,
onFinished: () -> Unit
) {
var text by rememberSaveable { mutableStateOf("") }
val parseResult = ParseResult.new(completeWordList, text)
val currentStage = restoreState.current.collectAsStateWithLifecycle().value
var isSeedValid by rememberSaveable { mutableStateOf(false) }
BackHandler {
onBack()
}
// To avoid unnecessary recompositions that this flow produces
LaunchedEffect(Unit) {
userWordList.wordValidation().collect {
if (it is SeedPhraseValidation.Valid) {
// TODO [#1522]: temporary fix to wait for other states to update first
// TODO [#1522]: https://github.com/Electric-Coin-Company/zashi-android/issues/1522
@Suppress("MagicNumber")
delay(100)
isSeedValid = true
} else {
isSeedValid = false
}
}
}
BlankBgScaffold(
modifier = Modifier.navigationBarsPadding(),
topBar = {
when (currentStage) {
RestoreStage.Seed -> {
RestoreSeedTopAppBar(
onBack = onBack,
onClear = {
userWordList.set(emptyList())
text = ""
}
)
}
RestoreStage.Birthday -> {
RestoreSeedBirthdayTopAppBar(
onBack = {
if (currentStage.hasPrevious()) {
restoreState.goPrevious()
} else {
onBack()
}
}
)
}
}
},
bottomBar = {
when (currentStage) {
RestoreStage.Seed -> {
RestoreSeedBottomBar(
userWordList = userWordList,
isSeedValid = isSeedValid,
parseResult = parseResult,
setText = { text = it },
modifier =
Modifier
.imePadding()
.navigationBarsPadding()
.animateContentSize()
.fillMaxWidth()
)
}
RestoreStage.Birthday -> {
// No content. The action button is part of scrollable content.
}
}
},
content = { paddingValues ->
val commonModifier =
Modifier
.scaffoldPadding(paddingValues)
when (currentStage) {
RestoreStage.Seed -> {
if (shouldSecureScreen) {
SecureScreen()
}
RestoreSeedMainContent(
userWordList = userWordList,
isSeedValid = isSeedValid,
text = text,
setText = { text = it },
parseResult = parseResult,
paste = paste,
goNext = { restoreState.goNext() },
modifier = commonModifier
)
}
RestoreStage.Birthday -> {
RestoreBirthdayMainContent(
zcashNetwork = zcashNetwork,
initialRestoreHeight = restoreHeight,
setRestoreHeight = setRestoreHeight,
onDone = onFinished,
modifier =
commonModifier
.imePadding()
.navigationBarsPadding()
)
}
}
}
)
}
@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.padding(all = ZcashTheme.dimens.spacingDefault)
)
}
@Composable
private fun RestoreSeedTopAppBar(
onBack: () -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
SmallTopAppBar(
modifier = modifier,
navigationAction = {
TopAppBarBackNavigation(
backText = stringResource(id = R.string.back_navigation).uppercase(),
backContentDescriptionText = stringResource(R.string.back_navigation_content_description),
onBack = onBack
)
},
regularActions = {
ClearSeedMenuItem(
onSeedClear = onClear
)
},
)
}
@Composable
private fun RestoreSeedBirthdayTopAppBar(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
SmallTopAppBar(
modifier = modifier,
navigationAction = {
TopAppBarBackNavigation(
backText = stringResource(id = R.string.back_navigation).uppercase(),
backContentDescriptionText = stringResource(R.string.back_navigation_content_description),
onBack = onBack
)
},
)
}
@Suppress("UNUSED_PARAMETER", "LongParameterList", "LongMethod")
@Composable
private fun RestoreSeedMainContent(
userWordList: WordList,
isSeedValid: Boolean,
text: String,
setText: (String) -> Unit,
parseResult: ParseResult,
paste: () -> String?,
goNext: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
val textFieldScrollToHeight = rememberSaveable { mutableIntStateOf(0) }
if (parseResult is ParseResult.Add) {
setText("")
userWordList.append(parseResult.words)
}
Column(
Modifier
.fillMaxHeight()
.verticalScroll(scrollState)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Used to calculate necessary scroll to have the seed TextField 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(ZcashTheme.dimens.spacingDefault))
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))
ZashiButton(
onClick = goNext,
enabled = isSeedValid,
text = stringResource(id = R.string.restore_seed_button_next),
modifier = Modifier.fillMaxWidth()
)
}
LaunchedEffect(isSeedValid) {
if (isSeedValid) {
// Clear focus and hide keyboard to make it easier for users to see the next button
focusManager.clearFocus()
}
}
LaunchedEffect(parseResult) {
// Causes the TextField to refocus
if (!isSeedValid) {
focusRequester.requestFocus()
}
if (text.isNotEmpty() && userWordList.current.value.isEmpty()) {
scrollState.animateScrollTo(textFieldScrollToHeight.intValue)
}
}
}
@Composable
private fun RestoreSeedBottomBar(
userWordList: WordList,
isSeedValid: Boolean,
parseResult: ParseResult,
setText: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// 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) {
Column(
modifier = modifier
) {
Warn(
parseResult = parseResult,
modifier =
Modifier
.fillMaxWidth()
.padding(
horizontal = ZcashTheme.dimens.spacingDefault,
vertical = ZcashTheme.dimens.spacingSmall
)
)
Autocomplete(parseResult = parseResult, {
setText("")
userWordList.append(listOf(it))
})
}
}
}
@Composable
@Suppress("LongParameterList", "LongMethod")
private fun SeedGridWithText(
text: String,
setText: (String) -> Unit,
userWordList: WordList,
focusRequester: FocusRequester,
parseResult: ParseResult,
modifier: Modifier = Modifier
) {
val currentUserWordList = userWordList.current.collectAsStateWithLifecycle().value
val currentSeedText =
currentUserWordList.run {
if (isEmpty()) {
text
} else {
joinToString(separator = " ", postfix = " ").plus(text)
}
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(ZashiDimensions.Radius.radius2xl),
border = BorderStroke(1.dp, ZashiColors.Modals.surfaceStroke),
color = ZashiColors.Modals.surfacePrimary
) {
Column(
modifier =
Modifier
.border(
border =
BorderStroke(
width = ZcashTheme.dimens.layoutStroke,
color = ZcashTheme.colors.layoutStroke
)
)
.fillMaxWidth()
.defaultMinSize(minHeight = ZcashTheme.dimens.textFieldSeedPanelDefaultHeight)
.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 = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
modifier =
Modifier
.fillMaxWidth()
.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 = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
},
onValueChange = {
processTextInput(
currentSeedText = currentSeedText,
updateSeedText = it.text,
userWordList = userWordList,
setText = setText
)
},
keyboardOptions =
KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(onAny = {}),
isError = parseResult is ParseResult.Warn,
colors =
TextFieldDefaults.colors(
cursorColor = ZcashTheme.colors.textPrimary,
disabledContainerColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = ZashiColors.Inputs.Filled.text,
unfocusedTextColor = ZashiColors.Inputs.Filled.text,
)
)
}
}
}
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 {
LazyRow(
modifier =
modifier
.testTag(RestoreTag.AUTOCOMPLETE_LAYOUT)
.fillMaxWidth(),
contentPadding = PaddingValues(all = ZcashTheme.dimens.spacingSmall),
horizontalArrangement = Arrangement.Absolute.Center
) {
items(it) {
ChipOnSurface(
text = it,
onClick = { onSuggestionSelected(it) },
modifier = Modifier.testTag(RestoreTag.AUTOCOMPLETE_ITEM)
)
}
}
}
}
@Composable
private fun Warn(
parseResult: ParseResult,
modifier: Modifier = Modifier
) {
if (parseResult is ParseResult.Warn) {
Surface(
shape = RoundedCornerShape(size = ZcashTheme.dimens.tinyRippleEffectCorner),
modifier =
modifier.then(
Modifier.border(
border =
BorderStroke(
width = ZcashTheme.dimens.chipStroke,
color = ZcashTheme.colors.layoutStrokeSecondary
),
shape = RoundedCornerShape(size = ZcashTheme.dimens.tinyRippleEffectCorner),
)
),
color = ZcashTheme.colors.primaryColor,
shadowElevation = ZcashTheme.dimens.chipShadowElevation
) {
Text(
color = ZcashTheme.colors.textPrimary,
modifier =
Modifier
.fillMaxWidth()
.padding(ZcashTheme.dimens.spacingSmall),
textAlign = TextAlign.Center,
text =
if (parseResult.suggestions.isEmpty()) {
stringResource(id = R.string.restore_seed_warning_no_suggestions)
} else {
stringResource(id = R.string.restore_seed_warning_suggestions)
}
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
@Suppress("LongMethod")
private fun RestoreBirthdayMainContent(
zcashNetwork: ZcashNetwork,
initialRestoreHeight: BlockHeight?,
setRestoreHeight: (BlockHeight?) -> Unit,
onDone: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
val (height, setHeight) =
rememberSaveable {
mutableStateOf(initialRestoreHeight?.value?.toString() ?: "")
}
Column(
Modifier
.fillMaxHeight()
.verticalScroll(scrollState)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
TopScreenLogoTitle(
title = stringResource(R.string.restore_birthday_header),
logoContentDescription = stringResource(R.string.zcash_logo_content_description),
)
Body(stringResource(R.string.restore_birthday_sub_header))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
FormTextField(
value = height,
onValueChange = { heightString ->
val filteredHeightString = heightString.filter { it.isDigit() }
setHeight(filteredHeightString)
},
colors =
TextFieldDefaults.colors(
cursorColor = ZcashTheme.colors.textPrimary,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = ZcashTheme.colors.secondaryDividerColor,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = ZcashTheme.extendedTypography.textFieldBirthday,
keyboardOptions =
KeyboardOptions(
KeyboardCapitalization.None,
autoCorrectEnabled = false,
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number
),
keyboardActions = KeyboardActions(onAny = {}),
withBorder = false,
testTag = RestoreTag.BIRTHDAY_TEXT_FIELD
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
// Empty birthday value is a valid birthday height too, thus run validation only in case of non-empty heights.
val isBirthdayValid =
height.isEmpty() || height.toLongOrNull()?.let {
it >= zcashNetwork.saplingActivationHeight.value
} ?: false
val isEmptyBirthday = height.isEmpty()
ZashiButton(
onClick = {
if (isEmptyBirthday) {
setRestoreHeight(null)
} else if (isBirthdayValid) {
setRestoreHeight(BlockHeight.new(height.toLong()))
} else {
error("The restore button should not expect click events")
}
onDone()
},
text = stringResource(R.string.restore_birthday_button_restore),
enabled = isBirthdayValid,
modifier = Modifier.fillMaxWidth()
)
}
LaunchedEffect(Unit) {
// Causes the TextField to focus on the first screen visit
focusRequester.requestFocus()
}
}

View File

@ -1,111 +0,0 @@
package co.electriccoin.zcash.ui.screen.restore.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
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 kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import java.util.Locale
class RestoreViewModel(application: Application, savedStateHandle: SavedStateHandle) : AndroidViewModel(application) {
val restoreState: RestoreState =
run {
val initialValue =
if (savedStateHandle.contains(KEY_STAGE)) {
savedStateHandle.get<RestoreStage>(KEY_STAGE)
} else {
null
}
if (null == initialValue) {
RestoreState()
} else {
RestoreState(initialValue)
}
}
/**
* The complete word list that the user can choose from; useful for autocomplete
*/
val completeWordList =
// This is a hack to prevent disk IO on the main thread
flow<CompleteWordSetState> {
// Using IO context because of https://github.com/Electric-Coin-Company/kotlin-bip39/issues/13
val completeWordList =
withContext(Dispatchers.IO) {
Mnemonics.getCachedWords(Locale.ENGLISH.language)
}
emit(CompleteWordSetState.Loaded(completeWordList.toPersistentSet()))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
CompleteWordSetState.Loading
)
val userWordList: WordList =
run {
val initialValue =
if (savedStateHandle.contains(KEY_WORD_LIST)) {
savedStateHandle.get<ArrayList<String>>(KEY_WORD_LIST)
} else {
null
}
if (null == initialValue) {
WordList()
} else {
WordList(initialValue)
}
}
val userBirthdayHeight: MutableStateFlow<BlockHeight?> =
run {
val initialValue: BlockHeight? =
savedStateHandle.get<Long>(KEY_BIRTHDAY_HEIGHT)?.let {
BlockHeight.new(it)
}
MutableStateFlow(initialValue)
}
init {
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
// update the save state as soon as a change occurs.
userWordList.current.collectWith(viewModelScope) {
savedStateHandle[KEY_WORD_LIST] = ArrayList(it)
}
userBirthdayHeight.collectWith(viewModelScope) {
savedStateHandle[KEY_BIRTHDAY_HEIGHT] = it?.value
}
}
companion object {
private const val KEY_STAGE = "stage" // $NON-NLS
private const val KEY_WORD_LIST = "word_list" // $NON-NLS
private const val KEY_BIRTHDAY_HEIGHT = "birthday_height" // $NON-NLS
}
}
sealed class CompleteWordSetState {
object Loading : CompleteWordSetState()
data class Loaded(val list: ImmutableSet<String>) : CompleteWordSetState()
}

View File

@ -11,29 +11,41 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.LabeledCheckBox
import co.electriccoin.zcash.ui.design.component.GradientBgScaffold
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiCheckbox
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun RestoreSuccess(state: RestoreSuccessViewState) {
BlankBgScaffold { paddingValues ->
GradientBgScaffold(
startColor = ZashiColors.Utility.WarningYellow.utilityOrange100,
endColor = ZashiColors.Surfaces.bgPrimary,
) { paddingValues ->
RestoreSuccessContent(
state = state,
modifier =
@ -53,16 +65,8 @@ private fun RestoreSuccessContent(
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingBig))
Text(
text = stringResource(id = R.string.restore_success_title),
style = ZcashTheme.typography.secondary.headlineMedium
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingUpLarge))
Spacer(Modifier.height(64.dp))
Image(
painter = painterResource(id = R.drawable.img_success_dialog),
@ -70,32 +74,74 @@ private fun RestoreSuccessContent(
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingUpLarge))
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.restore_success_title),
style = ZashiTypography.header6,
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.restore_success_subtitle),
textAlign = TextAlign.Center,
style = ZcashTheme.typography.secondary.headlineSmall,
fontWeight = FontWeight.SemiBold
style = ZashiTypography.textMd,
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingUpLarge))
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.restore_success_description),
style = ZcashTheme.typography.secondary.bodySmall,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary,
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge))
val bulletString = "\u2022 "
val bulletTextStyle = ZashiTypography.textSm
val bulletTextMeasurer = rememberTextMeasurer()
val bulletStringWidth =
remember(bulletTextStyle, bulletTextMeasurer) {
bulletTextMeasurer.measure(text = bulletString, style = bulletTextStyle).size.width
}
val bulletRestLine = with(LocalDensity.current) { bulletStringWidth.toSp() }
val bulletParagraphStyle = ParagraphStyle(textIndent = TextIndent(restLine = bulletRestLine))
LabeledCheckBox(
modifier = Modifier.align(Alignment.Start),
checked = state.isKeepScreenOnChecked,
onCheckedChange = { state.onCheckboxClick() },
text = stringResource(id = R.string.restoring_initial_dialog_checkbox)
Spacer(Modifier.height(4.dp))
val bulletText1 = stringResource(R.string.restore_success_bullet_1)
Text(
text =
buildAnnotatedString {
withStyle(style = bulletParagraphStyle) {
append(bulletString)
append(bulletText1)
}
},
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary,
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge))
Spacer(Modifier.height(2.dp))
val bulletText2 = stringResource(R.string.restore_success_bullet_2)
Text(
text =
buildAnnotatedString {
withStyle(style = bulletParagraphStyle) {
append(bulletString)
append(bulletText2)
}
},
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary,
)
Spacer(Modifier.weight(1f))
Text(
text =
@ -106,12 +152,23 @@ private fun RestoreSuccessContent(
append(" ")
append(stringResource(id = R.string.restore_success_note_part_2))
},
style = ZcashTheme.extendedTypography.footnote,
style = ZashiTypography.textXs,
color = ZashiColors.Text.textPrimary,
)
Spacer(Modifier.height(ZcashTheme.dimens.spacingBig))
Spacer(Modifier.height(14.dp))
Spacer(Modifier.weight(1f))
ZashiCheckbox(
modifier = Modifier.align(Alignment.Start),
isChecked = state.isKeepScreenOnChecked,
onClick = state.onCheckboxClick,
text = stringRes(R.string.restoring_initial_dialog_checkbox),
style = ZashiTypography.textMd,
fontWeight = FontWeight.Medium,
color = ZashiColors.Text.textPrimary,
)
Spacer(Modifier.height(14.dp))
ZashiButton(
modifier = Modifier.fillMaxWidth(),

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0,0h20v20h-20z"/>
<path
android:pathData="M10,13.333V10M10,6.667H10.008M18.333,10C18.333,14.602 14.602,18.333 10,18.333C5.398,18.333 1.667,14.602 1.667,10C1.667,5.397 5.398,1.666 10,1.666C14.602,1.666 18.333,5.397 18.333,10Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#E8E8E8"
android:strokeLineCap="round"/>
</group>
</vector>

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0,0h20v20h-20z"/>
<path
android:pathData="M10,13.333V10M10,6.667H10.008M18.333,10C18.333,14.602 14.602,18.333 10,18.333C5.398,18.333 1.667,14.602 1.667,10C1.667,5.398 5.398,1.667 10,1.667C14.602,1.667 18.333,5.398 18.333,10Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</group>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.09,9C9.325,8.332 9.789,7.768 10.4,7.409C11.011,7.05 11.729,6.919 12.427,7.039C13.125,7.158 13.759,7.522 14.215,8.064C14.671,8.606 14.921,9.292 14.92,10C14.92,12 11.92,13 11.92,13M12,17H12.01M22,12C22,17.523 17.523,22 12,22C6.477,22 2,17.523 2,12C2,6.477 6.477,2 12,2C17.523,2 22,6.477 22,12Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector>

View File

@ -1,16 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="restore_button_clear">Borrar Semilla</string>
<string name="restore_title">Ingresa la frase secreta de recuperación</string>
<string name="restore_seed_instructions">Ingresa tu frase de semilla de 24 palabras para restaurar la billetera asociada.</string>
<string name="restore_seed_hint">privacidad dignidad libertad …</string>
<string name="restore_seed_button_next">Siguiente</string>
<string name="restore_seed_warning_suggestions">Esta palabra no está en el diccionario de frases semilla. Por favor, selecciona la correcta de las sugerencias.</string>
<string name="restore_seed_warning_no_suggestions">Esta palabra no está en el diccionario de frases semilla.</string>
<string name="restore_birthday_header">Altura de cumpleaños de la billetera</string>
<string name="restore_birthday_sub_header">(opcional)</string>
<string name="restore_birthday_button_restore">Restaurar</string>
</resources>

View File

@ -1,16 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="restore_button_clear">Clear Seed</string>
<string name="restore_title">Restore</string>
<string name="restore_subtitle">Seed Recovery Phrase</string>
<string name="restore_message">Please type in your 24-word secret recovery phrase in the correct order.</string>
<string name="restore_button">Next</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">privacy dignity freedom …</string>
<string name="restore_seed_button_next">Next</string>
<string name="restore_dialog_title">Need to know more?</string>
<string name="restore_dialog_message_1_bold_part">The Secret Recovery Phrase</string>
<string name="restore_dialog_message_1">is a unique set of 24 words, appearing in a
precise order. It can be used to gain full control of your funds from any device via any Zcash wallet app.
Think of it as the master key to your wallet. It is stored in Zashis Advanced Settings.</string>
<string name="restore_dialog_message_2_bold_part">The Wallet Birthday Height</string>
<string name="restore_dialog_message_2">is the block height (block # in the
blockchain) at which your wallet was created. If you ever lose access to your Zashi app and need to recover
your funds, providing the block height along with your recovery phrase can significantly speed up the process.
</string>
<string name="restore_dialog_button">Got it!</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_bd_height_btn">Estimate my block height</string>
<string name="restore_bd_restore_btn">Restore</string>
<string name="restore_bd_subtitle">Wallet Birthday Height</string>
<string name="restore_bd_message">Entering your Wallet Birthday Height helps speed up the restore process.</string>
<string name="restore_bd_text_field_title">Block Height</string>
<string name="restore_bd_text_field_hint">Enter number</string>
<string name="restore_bd_text_field_note">Wallet Birthday Height is the point in time when your wallet was created.</string>
<string name="restore_birthday_header">Wallet birthday height</string>
<string name="restore_birthday_sub_header">(optional)</string>
<string name="restore_birthday_button_restore">Restore</string>
</resources>

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="restore_success_title">¡Mantén Zashi abierto!</string>
<string name="restore_success_subtitle">Tu billetera ha sido restaurada con éxito y ahora se está sincronizando</string>
<string name="restore_success_description">Para evitar interrupciones, mantén la pantalla encendida, conecta tu dispositivo a una fuente de energía y guárdalo en un lugar seguro.</string>
<string name="restore_success_subtitle">Your wallet is being restored.</string>
<string name="restore_success_description">Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:</string>
<string name="restoring_initial_dialog_checkbox">Mantener la pantalla encendida mientras se restaura.</string>
<string name="restore_success_note_part_1">Nota:</string>
<string name="restore_success_note_part_2">Durante la sincronización inicial, tus fondos no se pueden enviar ni gastar. Dependiendo de la antigüedad de tu billetera, la sincronización completa puede tomar algunas horas.</string>
<string name="restore_success_note_part_2">Your funds cannot be spent with Zashi until your wallet is fully restored.</string>
<string name="restore_success_button">¡Entendido!</string>
</resources>

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="restore_success_title">Keep Zashi open!</string>
<string name="restore_success_subtitle">Your wallet has been successfully restored and is now syncing</string>
<string name="restore_success_description">To prevent interruption, keep your screen awake, plug your device into a power source, and keep it in a secure place.</string>
<string name="restore_success_subtitle">Your wallet is being restored.</string>
<string name="restore_success_description">Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:</string>
<string name="restoring_initial_dialog_checkbox">Keep screen on while restoring.</string>
<string name="restore_success_note_part_1">Note:</string>
<string name="restore_success_note_part_2">During the initial sync your funds cannot be sent or
spent. Depending on the age of your wallet, it may take a few hours to fully sync.</string>
<string name="restore_success_note_part_2">Your funds cannot be spent with Zashi until your wallet is fully restored.</string>
<string name="restore_success_button">Got it!</string>
<string name="restore_success_bullet_1">Keep the Zashi app open on an active phone screen.</string>
<string name="restore_success_bullet_2">To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in.</string>
</resources>

View File

@ -46,8 +46,8 @@ import co.electriccoin.zcash.ui.design.component.UiMode
import co.electriccoin.zcash.ui.screen.authentication.view.AnimationConstants.WELCOME_ANIM_TEST_TAG
import co.electriccoin.zcash.ui.screen.balances.BalanceTag
import co.electriccoin.zcash.ui.screen.home.HomeTags
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedTag
import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedViewModel
import co.electriccoin.zcash.ui.screen.securitywarning.view.SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG
import co.electriccoin.zcash.ui.screen.send.SendTag
import kotlinx.coroutines.Dispatchers
@ -210,7 +210,7 @@ class ScreenshotTest : UiTestPrerequisites() {
val seedPhraseSplitLength = SeedPhraseFixture.new().split.size
SeedPhraseFixture.new().split.forEachIndexed { index, string ->
composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also {
composeTestRule.onNodeWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD).also {
it.performTextInput(string)
// Take a screenshot half-way through filling in the seed phrase
@ -221,7 +221,7 @@ class ScreenshotTest : UiTestPrerequisites() {
}
composeTestRule.waitUntil {
composeTestRule.activity.viewModels<RestoreViewModel>().value.userWordList.current.value.size ==
composeTestRule.activity.viewModels<RestoreSeedViewModel>().value.userWordList.current.value.size ==
SeedPhrase.SEED_PHRASE_SIZE
}