diff --git a/configuration-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/configuration/api/MergingConfigurationProvider.kt b/configuration-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/configuration/api/MergingConfigurationProvider.kt index a2006e822..2b3ff1519 100644 --- a/configuration-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/configuration/api/MergingConfigurationProvider.kt +++ b/configuration-api-lib/src/commonMain/kotlin/co/electriccoin/zcash/configuration/api/MergingConfigurationProvider.kt @@ -19,7 +19,7 @@ class MergingConfigurationProvider( override fun getConfigurationFlow(): Flow { return if (configurationProviders.isEmpty()) { - flowOf(MergingConfiguration(persistentListOf())) + flowOf(MergingConfiguration(persistentListOf())) } else { combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations -> MergingConfiguration(configurations.toList().toPersistentList()) diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt deleted file mode 100644 index c2ca4a920..000000000 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/model/SeedPhraseValidation.kt +++ /dev/null @@ -1,41 +0,0 @@ -package cash.z.ecc.sdk.model - -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.sdk.model.SeedPhrase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.Locale - -// This is a stopgap; would like to see improvements to the SeedPhrase class to have validation moved -// there as part of creating the object -sealed class SeedPhraseValidation { - object BadCount : SeedPhraseValidation() - - object BadWord : SeedPhraseValidation() - - object FailedChecksum : SeedPhraseValidation() - - class Valid(val seedPhrase: SeedPhrase) : SeedPhraseValidation() - - companion object { - suspend fun new(list: List): SeedPhraseValidation { - if (list.size != SeedPhrase.SEED_PHRASE_SIZE) { - return BadCount - } - - @Suppress("SwallowedException") - return try { - val stringified = list.joinToString(SeedPhrase.DEFAULT_DELIMITER) - withContext(Dispatchers.Default) { - Mnemonics.MnemonicCode(stringified, Locale.ENGLISH.language).validate() - } - - Valid(SeedPhrase.new(stringified)) - } catch (e: Mnemonics.InvalidWordException) { - BadWord - } catch (e: Mnemonics.ChecksumException) { - FailedChecksum - } - } - } -} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/KeyboardManager.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/KeyboardManager.kt new file mode 100644 index 000000000..4bc6526fa --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/KeyboardManager.kt @@ -0,0 +1,80 @@ +package co.electriccoin.zcash.ui.design + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds + +@Stable +class KeyboardManager( + isOpen: Boolean, + private val softwareKeyboardController: SoftwareKeyboardController? +) { + private var targetState = MutableStateFlow(isOpen) + + var isOpen by mutableStateOf(isOpen) + private set + + suspend fun close() { + if (targetState.value) { + withTimeoutOrNull(.5.seconds) { + softwareKeyboardController?.hide() + targetState.filter { !it }.first() + } + } + } + + fun onKeyboardOpened() { + targetState.update { true } + isOpen = true + } + + fun onKeyboardClosed() { + targetState.update { false } + isOpen = false + } +} + +@Composable +fun rememberKeyboardManager(): KeyboardManager { + val isKeyboardOpen by rememberKeyboardState() + val softwareKeyboardController = LocalSoftwareKeyboardController.current + val keyboardManager = remember { KeyboardManager(isKeyboardOpen, softwareKeyboardController) } + LaunchedEffect(isKeyboardOpen) { + if (isKeyboardOpen) { + keyboardManager.onKeyboardOpened() + } else { + keyboardManager.onKeyboardClosed() + } + } + return keyboardManager +} + +@Composable +private fun rememberKeyboardState(): State { + val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + return rememberUpdatedState(isImeVisible) +} + +@Suppress("CompositionLocalAllowlist") +val LocalKeyboardManager = + compositionLocalOf { + error("Keyboard manager not provided") + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/SheetStateManager.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/SheetStateManager.kt new file mode 100644 index 000000000..12c83d27e --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/SheetStateManager.kt @@ -0,0 +1,41 @@ +package co.electriccoin.zcash.ui.design + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalMaterial3Api::class) +@Stable +class SheetStateManager { + private var sheetState: SheetState? = null + + fun onSheetOpened(sheetState: SheetState) { + this.sheetState = sheetState + } + + fun onSheetDisposed(sheetState: SheetState) { + if (this.sheetState == sheetState) { + this.sheetState = null + } + } + + suspend fun hide() { + withTimeoutOrNull(.5.seconds) { + sheetState?.hide() + } + } +} + +@Composable +fun rememberSheetStateManager() = remember { SheetStateManager() } + +@Suppress("CompositionLocalAllowlist") +val LocalSheetStateManager = + compositionLocalOf { + error("Sheet state manager not provided") + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/BlurCompat.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/BlurCompat.kt new file mode 100644 index 000000000..5425c749b --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/BlurCompat.kt @@ -0,0 +1,20 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.unit.Dp +import co.electriccoin.zcash.spackle.AndroidApiVersion + +fun Modifier.blurCompat( + radius: Dp, + max: Dp +): Modifier { + return if (AndroidApiVersion.isAtLeastS) { + this.blur(radius) + } else { + val progression = 1 - (radius.value / max.value) + this + .alpha(progression) + } +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Checkbox.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Checkbox.kt index 827c3aae8..45e0a5eac 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Checkbox.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Checkbox.kt @@ -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 ) } } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt deleted file mode 100644 index 1c072752a..000000000 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt +++ /dev/null @@ -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 -} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt index 0697d93ee..2419d7e1e 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt @@ -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 ) { diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiCheckbox.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiCheckbox.kt index 9236239ef..ee431623c 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiCheckbox.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiCheckbox.kt @@ -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, ) } } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiChipButton.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiChipButton.kt index 2a17bffad..9726a1acd 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiChipButton.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiChipButton.kt @@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,7 +13,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface 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.Color @@ -41,30 +39,17 @@ fun ZashiChipButton( border: BorderStroke? = ZashiChipButtonDefaults.border, color: Color = ZashiChipButtonDefaults.color, contentPadding: PaddingValues = ZashiChipButtonDefaults.contentPadding, - hasRippleEffect: Boolean = true, textStyle: TextStyle = ZashiChipButtonDefaults.textStyle, endIconSpacer: Dp = ZashiChipButtonDefaults.endIconSpacer, ) { - val clickableModifier = - if (hasRippleEffect) { - modifier.clickable(onClick = state.onClick) - } else { - val interactionSource = remember { MutableInteractionSource() } - modifier.clickable( - onClick = state.onClick, - indication = null, - interactionSource = interactionSource - ) - } - Surface( - modifier = clickableModifier, + modifier = modifier, shape = shape, border = border, color = color, ) { Row( - modifier = Modifier.padding(contentPadding), + modifier = Modifier.clickable(onClick = state.onClick) then Modifier.padding(contentPadding), verticalAlignment = Alignment.CenterVertically ) { if (state.startIcon != null) { diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiModalBottomSheet.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiModalBottomSheet.kt index 8f6967714..7c253a8f5 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiModalBottomSheet.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiModalBottomSheet.kt @@ -90,7 +90,7 @@ fun rememberModalBottomSheetState( @Composable @ExperimentalMaterial3Api -private fun rememberSheetState( +fun rememberSheetState( skipPartiallyExpanded: Boolean, confirmValueChange: (SheetValue) -> Boolean, initialValue: SheetValue, diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiScreenModalBottomSheet.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiScreenModalBottomSheet.kt new file mode 100644 index 000000000..b2bae7adc --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiScreenModalBottomSheet.kt @@ -0,0 +1,59 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.activity.compose.BackHandler +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SheetValue.Hidden +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import co.electriccoin.zcash.ui.design.LocalSheetStateManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ZashiScreenModalBottomSheet( + state: T?, + sheetState: SheetState = rememberScreenModalBottomSheetState(), + content: @Composable () -> Unit = {}, +) { + ZashiModalBottomSheet( + sheetState = sheetState, + content = { + BackHandler(state != null) { + state?.onBack?.invoke() + } + content() + }, + onDismissRequest = { state?.onBack?.invoke() } + ) + + LaunchedEffect(Unit) { + sheetState.show() + } +} + +@Composable +@ExperimentalMaterial3Api +fun rememberScreenModalBottomSheetState( + initialValue: SheetValue = Hidden, + skipHiddenState: Boolean = false, + skipPartiallyExpanded: Boolean = true, + confirmValueChange: (SheetValue) -> Boolean = { true }, +): SheetState { + val sheetManager = LocalSheetStateManager.current + val sheetState = + rememberSheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + confirmValueChange = confirmValueChange, + initialValue = initialValue, + skipHiddenState = skipHiddenState, + ) + DisposableEffect(sheetState) { + sheetManager.onSheetOpened(sheetState) + onDispose { + sheetManager.onSheetDisposed(sheetState) + } + } + return sheetState +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedText.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedText.kt new file mode 100644 index 000000000..5e2d1a214 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedText.kt @@ -0,0 +1,172 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import co.electriccoin.zcash.spackle.AndroidApiVersion +import co.electriccoin.zcash.ui.design.R +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.dimensions.ZashiDimensions +import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography + +@Suppress("MagicNumber") +@Composable +fun ZashiSeedText( + state: SeedTextState, + modifier: Modifier = Modifier +) { + val blur by animateDpAsState(if (state.isRevealed) 0.dp else 14.dp, label = "") + val color by animateColorAsState( + when { + AndroidApiVersion.isAtLeastS -> Color.Unspecified + state.isRevealed -> ZashiColors.Surfaces.bgPrimary + else -> ZashiColors.Surfaces.bgSecondary + }, + label = "" + ) + Box( + modifier = modifier.background(color, RoundedCornerShape(10.dp)), + ) { + val rowItems = remember(state) { state.seed.split(" ").withIndex().chunked(3) } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = spacedBy(4.dp) + ) { + rowItems.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = spacedBy(4.dp) + ) { + row.forEach { (index, string) -> + ZashiSeedWordText( + modifier = Modifier.weight(1f), + prefix = (index + 1).toString(), + state = + SeedWordTextState( + text = string, + ), + content = { mod, text -> + ZashiSeedWordTextContent( + text = text, + modifier = mod.blurCompat(blur, 14.dp) + ) + }, + prefixContent = { mod, text -> + ZashiSeedWordPrefixContent( + text = text, + modifier = + mod then + if (!AndroidApiVersion.isAtLeastS) { + Modifier.blurCompat(blur, 14.dp) + } else { + Modifier + } + ) + } + ) + } + } + } + } + + AnimatedVisibility( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.Center), + visible = !AndroidApiVersion.isAtLeastS && state.isRevealed.not(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_reveal), + contentDescription = null, + colorFilter = ColorFilter.tint(ZashiColors.Text.textPrimary) + ) + + Spacer(Modifier.height(ZashiDimensions.Spacing.spacingMd)) + + Text( + text = stringResource(R.string.seed_recovery_reveal), + style = ZashiTypography.textLg, + fontWeight = FontWeight.SemiBold, + color = ZashiColors.Text.textPrimary + ) + } + } + } +} + +@Immutable +data class SeedTextState( + val seed: String, + val isRevealed: Boolean +) + +@PreviewScreens +@Composable +private fun Preview() = + ZcashTheme { + BlankSurface { + ZashiSeedText( + modifier = Modifier.fillMaxWidth(), + state = + SeedTextState( + seed = (1..24).joinToString(separator = " ") { "word" }, + isRevealed = true, + ) + ) + } + } + +@PreviewScreens +@Composable +private fun HiddenPreview() = + ZcashTheme { + BlankSurface { + ZashiSeedText( + modifier = Modifier.fillMaxWidth(), + state = + SeedTextState( + seed = (1..24).joinToString(separator = " ") { "word" }, + isRevealed = false, + ) + ) + } + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedTextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedTextField.kt new file mode 100644 index 000000000..1b961d3d0 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedTextField.kt @@ -0,0 +1,289 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +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.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +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.combineToFlow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ZashiSeedTextField( + state: SeedTextFieldState, + modifier: Modifier = Modifier, + wordModifier: (index: Int) -> Modifier = { Modifier }, + handle: SeedTextFieldHandle = rememberSeedTextFieldHandle(), +) { + val focusManager = LocalFocusManager.current + + LaunchedEffect(state.values.map { it.value }) { + val newValues = state.values.map { it.value } + handle.internalState = + handle.internalState.copy( + texts = newValues, + selectedText = + if (handle.internalState.selectedIndex <= -1) { + null + } else { + newValues[handle.internalState.selectedIndex] + } + ) + } + + LaunchedEffect(handle.selectedIndex) { + if (handle.selectedIndex >= 0) { + handle.focusRequesters[handle.selectedIndex].requestFocus() + } else { + focusManager.clearFocus(true) + } + } + + LaunchedEffect(Unit) { + handle.interactions + .observeSelectedIndex() + .collect { index -> + handle.setSelectedIndex(index) + } + } + + 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 { handle.focusRequesters[index] } + val interaction = remember { handle.interactions[index] } + val textFieldHandle = remember { handle.textFieldHandles[index] } + val previousHandle = + remember { + if (index > 0) handle.textFieldHandles[index - 1] else null + } + ZashiSeedWordTextField( + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onKeyEvent { event -> + when { + event.key == Key.Spacebar -> { + handle.requestNextFocus() + true + } + + event.key == Key.Backspace && wordState.value.isEmpty() -> { + previousHandle?.moveCursorToEnd() + handle.requestPreviousFocus() + true + } + + else -> { + false + } + } + }, + handle = textFieldHandle, + innerModifier = wordModifier(index), + prefix = (index + 1).toString(), + state = wordState, + keyboardActions = + KeyboardActions( + onDone = { + handle.requestNextFocus() + }, + onNext = { + handle.requestNextFocus() + }, + ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + imeAction = if (index == state.values.lastIndex) ImeAction.Done else ImeAction.Next + ), + interactionSource = interaction + ) + } + } +} + +private fun List.observeSelectedIndex() = + this + .map { interaction -> + interaction.isFocused() + } + .combineToFlow() + .map { + it.indexOfFirst { isFocused -> isFocused } + } + +private fun InteractionSource.isFocused(): Flow = + channelFlow { + val focusInteractions = mutableListOf() + val isFocused = MutableStateFlow(false) + + launch { + interactions.collect { interaction -> + when (interaction) { + is FocusInteraction.Focus -> focusInteractions.add(interaction) + is FocusInteraction.Unfocus -> focusInteractions.remove(interaction.focus) + } + isFocused.update { focusInteractions.isNotEmpty() } + } + } + + launch { + isFocused.collect { + send(it) + } + } + + awaitClose { + // do nothing + } + } + +@Immutable +data class SeedTextFieldState( + val values: List, +) + +@Suppress("MagicNumber") +@Stable +class SeedTextFieldHandle(seedTextFieldState: SeedTextFieldState, selectedIndex: Int) { + internal val textFieldHandles = seedTextFieldState.values.map { ZashiTextFieldHandle(it.value) } + + internal val interactions = List(24) { MutableInteractionSource() } + + internal val focusRequesters = List(24) { FocusRequester() } + + internal var internalState by mutableStateOf( + SeedTextFieldInternalState( + selectedIndex = selectedIndex, + selectedText = null, + texts = seedTextFieldState.values.map { it.value } + ) + ) + + val selectedText: String? by derivedStateOf { internalState.selectedText } + + val selectedIndex by derivedStateOf { internalState.selectedIndex } + + @Suppress("MagicNumber") + fun requestNextFocus() { + internalState = + if (internalState.selectedIndex == 23) { + internalState.copy( + selectedIndex = -1, + selectedText = null, + ) + } else { + internalState.copy( + selectedIndex = internalState.selectedIndex + 1, + selectedText = internalState.texts[internalState.selectedIndex + 1], + ) + } + } + + fun requestPreviousFocus() { + internalState = + if (internalState.selectedIndex >= 1) { + internalState.copy( + selectedIndex = internalState.selectedIndex - 1, + selectedText = internalState.texts[internalState.selectedIndex - 1] + ) + } else { + internalState.copy( + selectedIndex = -1, + selectedText = null, + ) + } + } + + fun setSelectedIndex(index: Int) { + internalState = + internalState.copy( + selectedIndex = index, + selectedText = if (index <= -1) null else internalState.texts[index] + ) + } +} + +internal data class SeedTextFieldInternalState( + val selectedIndex: Int, + val selectedText: String?, + val texts: List +) + +@Suppress("MagicNumber") +@Composable +fun rememberSeedTextFieldHandle( + seedTextFieldState: SeedTextFieldState = + SeedTextFieldState( + List(24) { + SeedWordTextFieldState( + value = "", + onValueChange = {}, + isError = false + ) + } + ), + selectedIndex: Int = -1 +): SeedTextFieldHandle = remember { SeedTextFieldHandle(seedTextFieldState, selectedIndex) } + +@PreviewScreenSizes +@Composable +private fun Preview() = + ZcashTheme { + BlankSurface { + ZashiSeedTextField( + state = + SeedTextFieldState( + values = + (1..24).map { + SeedWordTextFieldState( + value = "Word", + onValueChange = { }, + isError = false + ) + } + ) + ) + } + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordText.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordText.kt new file mode 100644 index 000000000..9081357f0 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordText.kt @@ -0,0 +1,101 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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 + +@Composable +fun ZashiSeedWordText( + prefix: String, + state: SeedWordTextState, + modifier: Modifier = Modifier, + prefixContent: @Composable (Modifier, String) -> Unit = { mod, text -> ZashiSeedWordPrefixContent(text, mod) }, + content: @Composable (Modifier, String) -> Unit = { mod, text -> ZashiSeedWordTextContent(text, mod) } +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = ZashiColors.Surfaces.bgSecondary, + ) { + Box( + contentAlignment = Alignment.CenterStart + ) { + prefixContent(Modifier, prefix) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + content( + Modifier.weight(1f), + state.text + ) + } + } + } +} + +@Composable +fun ZashiSeedWordPrefixContent( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier then Modifier.padding(start = 12.dp), + text = text, + color = ZashiColors.Text.textTertiary, + style = ZashiTypography.textXs, + fontWeight = FontWeight.Medium, + ) +} + +@Composable +fun ZashiSeedWordTextContent( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier then Modifier.padding(start = 32.dp, top = 8.dp, bottom = 10.dp), + ) { + Text( + modifier = Modifier, + text = text, + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.textMd, + maxLines = 1, + overflow = TextOverflow.Clip + ) + } +} + +@Immutable +data class SeedWordTextState(val text: String) + +@Composable +@PreviewScreens +private fun Preview() = + ZcashTheme { + BlankSurface { + ZashiSeedWordText( + modifier = Modifier.fillMaxWidth(), + prefix = "11", + state = + SeedWordTextState( + text = "asdasdasd", + ) + ) + } + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordTextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordTextField.kt new file mode 100644 index 000000000..13374f098 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSeedWordTextField.kt @@ -0,0 +1,111 @@ +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.stringRes + +@Composable +fun ZashiSeedWordTextField( + prefix: String, + state: SeedWordTextFieldState, + modifier: Modifier = Modifier, + innerModifier: Modifier = Modifier, + handle: ZashiTextFieldHandle = + rememberZashiTextFieldHandle( + TextFieldState( + value = stringRes(state.value), + onValueChange = state.onValueChange, + error = stringRes("").takeIf { state.isError } + ) + ), + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ZashiTextField( + modifier = modifier, + innerModifier = innerModifier, + shape = RoundedCornerShape(12.dp), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = true, + maxLines = 1, + handle = handle, + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + state = + TextFieldState( + value = stringRes(state.value), + onValueChange = state.onValueChange, + error = stringRes("").takeIf { state.isError } + ), + 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: String, + val isError: Boolean, + val onValueChange: (String) -> Unit +) + +@Composable +@PreviewScreens +private fun Preview() = + ZcashTheme { + BlankSurface { + ZashiSeedWordTextField( + prefix = "12", + state = + SeedWordTextFieldState( + value = "asd", + isError = false, + onValueChange = {}, + ) + ) + } + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSpacer.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSpacer.kt new file mode 100644 index 000000000..ba9d960d5 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSpacer.kt @@ -0,0 +1,30 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun VerticalSpacer(height: Dp) { + Spacer(Modifier.height(height)) +} + +@Composable +fun ColumnScope.VerticalSpacer(weight: Float) { + Spacer(Modifier.weight(weight)) +} + +@Composable +fun RowScope.VerticalSpacer(weight: Float) { + Spacer(Modifier.weight(weight)) +} + +@Composable +fun HorizontalSpacer(width: Dp) { + Spacer(Modifier.width(width)) +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt index f13366d7e..f343457ae 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt @@ -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 @@ -22,23 +23,32 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation 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.getString import co.electriccoin.zcash.ui.design.util.getValue import co.electriccoin.zcash.ui.design.util.stringRes @@ -48,9 +58,18 @@ fun ZashiTextField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, - innerModifier: Modifier = Modifier, + innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier, error: String? = null, isEnabled: Boolean = true, + handle: ZashiTextFieldHandle = + rememberZashiTextFieldHandle( + TextFieldState( + value = stringRes(value), + error = error?.let { stringRes(it) }, + isEnabled = isEnabled, + onValueChange = onValueChange, + ) + ), readOnly: Boolean = false, textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium), label: @Composable (() -> Unit)? = null, @@ -97,7 +116,8 @@ fun ZashiTextField( interactionSource = interactionSource, shape = shape, colors = colors, - innerModifier = innerModifier + innerModifier = innerModifier, + handle = handle, ) } @@ -106,7 +126,8 @@ fun ZashiTextField( fun ZashiTextField( state: TextFieldState, modifier: Modifier = Modifier, - innerModifier: Modifier = Modifier, + innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier, + handle: ZashiTextFieldHandle = rememberZashiTextFieldHandle(state), readOnly: Boolean = false, textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium), label: @Composable (() -> Unit)? = null, @@ -124,6 +145,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 +175,39 @@ fun ZashiTextField( interactionSource = interactionSource, shape = shape, colors = colors, - innerModifier = innerModifier + contentPadding = contentPadding, + innerModifier = innerModifier, + handle = handle ) } +@Composable +fun ZashiTextFieldPlaceholder(res: StringResource) { + Text( + text = res.getValue(), + style = ZashiTypography.textMd, + color = ZashiColors.Inputs.Default.text + ) +} + +@Stable +class ZashiTextFieldHandle(text: String) { + var textFieldValueState by mutableStateOf(TextFieldValue(text = text)) + + fun moveCursorToEnd() { + textFieldValueState = + textFieldValueState.copy( + selection = TextRange(textFieldValueState.text.length), + ) + } +} + +@Composable +fun rememberZashiTextFieldHandle(state: TextFieldState): ZashiTextFieldHandle { + val context = LocalContext.current + return remember { ZashiTextFieldHandle(state.value.getString(context)) } +} + @Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -174,10 +231,32 @@ private fun TextFieldInternal( interactionSource: MutableInteractionSource, shape: Shape, colors: ZashiTextFieldColors, + contentPadding: PaddingValues, + handle: ZashiTextFieldHandle, modifier: Modifier = Modifier, innerModifier: Modifier = Modifier, ) { - val borderColor by colors.borderColor(state) + val context = LocalContext.current + val value = remember(state.value) { state.value.getString(context) } + // Holds the latest internal TextFieldValue state. We need to keep it to have the correct value + // of the composition. + val textFieldValueState = handle.textFieldValueState + // Holds the latest TextFieldValue that BasicTextField was recomposed with. We couldn't simply + // pass `TextFieldValue(text = value)` to the CoreTextField because we need to preserve the + // composition. + val textFieldValue = textFieldValueState.copy(text = value, selection = textFieldValueState.selection) + + SideEffect { + if (textFieldValue.text != textFieldValueState.text || + textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition + ) { + handle.textFieldValueState = textFieldValue + } + } + + 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 = @@ -186,24 +265,35 @@ private fun TextFieldInternal( } val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + var lastTextValue by remember(value) { mutableStateOf(value) } + CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) { Column( modifier = modifier, ) { BasicTextField( - value = state.value.getValue(), + value = textFieldValue, 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, + }, + onValueChange = { newTextFieldValueState -> + handle.textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + state.onValueChange(newTextFieldValueState.text) + } + }, enabled = state.isEnabled, readOnly = readOnly, textStyle = mergedTextStyle, @@ -215,44 +305,37 @@ private fun TextFieldInternal( singleLine = singleLine, maxLines = maxLines, minLines = minLines, - decorationBox = @Composable { innerTextField -> - // places leading icon, text field with label and placeholder, trailing icon - TextFieldDefaults.DecorationBox( - value = state.value.getValue(), - visualTransformation = visualTransformation, - innerTextField = { - DecorationBox(prefix = prefix, suffix = suffix, content = innerTextField) + ) { innerTextField: @Composable () -> Unit -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = state.value.getValue(), + visualTransformation = visualTransformation, + innerTextField = { + DecorationBox(prefix = prefix, suffix = suffix, content = innerTextField) + }, + placeholder = + if (placeholder != null) { + { + DecorationBox(prefix, suffix, placeholder) + } + } else { + null }, - placeholder = - if (placeholder != null) { - { - DecorationBox(prefix, suffix, placeholder) - } - } else { - null - }, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = state.isEnabled, - 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), - ) - ) - } - ) + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = state.isEnabled, + isError = state.isError, + interactionSource = interactionSource, + colors = androidColors, + contentPadding = contentPadding + ) + } if (state.error != null && state.error.getValue().isNotEmpty()) { Spacer(modifier = Modifier.height(6.dp)) @@ -303,7 +386,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 +402,15 @@ data class ZashiTextFieldColors( val errorPlaceholderColor: Color, ) { @Composable - internal fun borderColor(state: TextFieldState): State { + internal fun borderColor( + state: TextFieldState, + isFocused: Boolean + ): State { val targetValue = when { !state.isEnabled -> disabledBorderColor state.isError -> errorBorderColor + isFocused -> focusedBorderColor.takeOrElse { borderColor } else -> borderColor } return rememberUpdatedState(targetValue) @@ -345,7 +434,7 @@ data class ZashiTextFieldColors( unfocusedTextColor = textColor, disabledTextColor = disabledTextColor, errorTextColor = errorTextColor, - focusedContainerColor = containerColor, + focusedContainerColor = focusedContainerColor.takeOrElse { containerColor }, unfocusedContainerColor = containerColor, disabledContainerColor = disabledContainerColor, errorContainerColor = errorContainerColor, @@ -391,13 +480,21 @@ 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 +510,9 @@ object ZashiTextFieldDefaults { textColor = textColor, hintColor = hintColor, borderColor = borderColor, + focusedBorderColor = focusedBorderColor, containerColor = containerColor, + focusedContainerColor = focusedContainerColor, placeholderColor = placeholderColor, disabledTextColor = disabledTextColor, disabledHintColor = disabledHintColor, @@ -428,6 +527,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() = diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiYearMonthWheelDatePicker.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiYearMonthWheelDatePicker.kt new file mode 100644 index 000000000..7dea799b9 --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiYearMonthWheelDatePicker.kt @@ -0,0 +1,292 @@ +package co.electriccoin.zcash.ui.design.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import co.electriccoin.zcash.spackle.Twig +import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors +import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography +import kotlinx.coroutines.launch +import java.text.DateFormatSymbols +import java.time.Month +import java.time.Year +import java.time.YearMonth +import kotlin.math.pow + +@Suppress("MagicNumber") +@Composable +fun ZashiYearMonthWheelDatePicker( + modifier: Modifier = Modifier, + verticallyVisibleItems: Int = 3, + startYear: Year = Year.of(2016), + endYear: Year = Year.now(), + selectedYear: YearMonth = YearMonth.now(), + onSelectionChanged: (YearMonth) -> Unit, +) { + val latestOnSelectionChanged by rememberUpdatedState(onSelectionChanged) + var selectedDate by remember { mutableStateOf(selectedYear) } + val months = + listOf( + Month.JANUARY, + Month.FEBRUARY, + Month.MARCH, + Month.APRIL, + Month.MAY, + Month.JUNE, + Month.JULY, + Month.AUGUST, + Month.SEPTEMBER, + Month.OCTOBER, + Month.NOVEMBER, + Month.DECEMBER + ) + val years = (startYear.value..endYear.value).toList() + + LaunchedEffect(selectedDate) { + Twig.debug { "Selection changed: $selectedDate" } + latestOnSelectionChanged(selectedDate) + } + + Box(modifier = modifier) { + Column( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + ZashiHorizontalDivider(color = ZashiColors.Surfaces.bgQuaternary, thickness = .5.dp) + VerticalSpacer(31.dp) + ZashiHorizontalDivider(color = ZashiColors.Surfaces.bgQuaternary, thickness = .5.dp) + } + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(Modifier.weight(.5f)) + WheelLazyList( + modifier = Modifier.weight(1f), + selection = maxOf(months.indexOf(selectedDate.month), 0), + itemCount = months.size, + itemVerticalOffset = verticallyVisibleItems, + isInfiniteScroll = true, + onFocusItem = { selectedDate = selectedDate.withMonth(months[it].value) }, + itemContent = { + Text( + text = DateFormatSymbols().months[months[it].value - 1], + textAlign = TextAlign.Center, + modifier = Modifier.fillParentMaxWidth(), + style = ZashiTypography.header6, + color = ZashiColors.Text.textPrimary, + maxLines = 1 + ) + } + ) + WheelLazyList( + modifier = Modifier.weight(.75f), + selection = years.indexOf(selectedDate.year), + itemCount = years.size, + itemVerticalOffset = verticallyVisibleItems, + isInfiniteScroll = false, + onFocusItem = { selectedDate = selectedDate.withYear(years[it]) }, + itemContent = { + Text( + text = years[it].toString(), + textAlign = TextAlign.Center, + modifier = Modifier.fillParentMaxWidth(), + style = ZashiTypography.header6, + color = ZashiColors.Text.textPrimary, + maxLines = 1 + ) + } + ) + Spacer(Modifier.weight(.5f)) + } + } +} + +@Suppress("MagicNumber") +@Composable +private fun WheelLazyList( + itemCount: Int, + selection: Int, + itemVerticalOffset: Int, + onFocusItem: (Int) -> Unit, + isInfiniteScroll: Boolean, + itemContent: @Composable LazyItemScope.(index: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnFocusItem by rememberUpdatedState(onFocusItem) + val coroutineScope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + val count = if (isInfiniteScroll) itemCount else itemCount + 2 * itemVerticalOffset + val rowOffsetCount = maxOf(1, minOf(itemVerticalOffset, 4)) + val rowCount = (rowOffsetCount * 2) + 1 + val startIndex = if (isInfiniteScroll) selection + (itemCount * 1000) - itemVerticalOffset else selection + val state = rememberLazyListState(startIndex) + val itemHeightPx = with(LocalDensity.current) { 27.dp.toPx() } + val height = 32.dp * rowCount + val isScrollInProgress = state.isScrollInProgress + + LaunchedEffect(itemCount) { + coroutineScope.launch { + state.scrollToItem(startIndex) + } + } + + LaunchedEffect(key1 = isScrollInProgress) { + if (!isScrollInProgress) { + calculateIndexToFocus(state, height).let { + val indexToFocus = + if (isInfiniteScroll) { + (it + rowOffsetCount) % itemCount + } else { + ((it + rowOffsetCount) % count) - itemVerticalOffset + } + + latestOnFocusItem(indexToFocus) + + if (state.firstVisibleItemScrollOffset != 0) { + coroutineScope.launch { + state.animateScrollToItem(it, 0) + } + } + } + } + } + + LaunchedEffect(state) { + snapshotFlow { state.firstVisibleItemIndex } + .collect { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + Box( + modifier = + modifier + .height(height) + .fillMaxWidth(), + ) { + LazyColumn( + modifier = + Modifier + .height(height) + .fillMaxWidth(), + state = state, + ) { + items(if (isInfiniteScroll) Int.MAX_VALUE else count) { index -> + val (scale, alpha, translationY) = + remember { + derivedStateOf { + val info = state.layoutInfo + val middleOffset = info.viewportSize.height / 2 + val item = info.visibleItemsInfo.firstOrNull { it.index == index } + val scrollOffset = if (item != null) item.offset + item.size / 2 else -1 + val coefficient = calculateCoefficient(middleOffset = middleOffset, offset = scrollOffset) + val scale = calculateScale(coefficient) + val alpha = calculateAlpha(coefficient) + val translationY = + calculateTranslationY( + coefficient = coefficient, + itemHeightPx = itemHeightPx, + middleOffset = middleOffset, + offset = scrollOffset + ) + Triple(scale, alpha, translationY) + } + }.value + + Box( + modifier = + Modifier + .height(height / rowCount) + .fillMaxWidth() + .graphicsLayer { + this.alpha = alpha + this.scaleX = scale + this.scaleY = scale + this.translationY = translationY + }, + contentAlignment = Alignment.Center, + ) { + if (isInfiniteScroll) { + itemContent(index % itemCount) + } else if (index >= rowOffsetCount && index < itemCount + rowOffsetCount) { + itemContent((index - rowOffsetCount) % itemCount) + } + } + } + } + } +} + +@Suppress("MagicNumber") +private fun calculateCoefficient( + middleOffset: Int, + offset: Int +): Float { + val diff = if (middleOffset > offset) middleOffset - offset else offset - middleOffset + return (1f - (diff.toFloat() / middleOffset.toFloat())).coerceAtLeast(0f) +} + +@Suppress("MagicNumber") +private fun calculateScale(coefficient: Float): Float { + return coefficient.coerceAtLeast(.6f) +} + +@Suppress("MagicNumber") +private fun calculateAlpha(coefficient: Float): Float { + return coefficient.pow(1.1f) +} + +@Suppress("MagicNumber") +private fun calculateTranslationY( + coefficient: Float, + itemHeightPx: Float, + middleOffset: Int, + offset: Int +): Float { + // if (coefficient in 0.66f..1f) return 0f + val exponentialCoefficient = 1.2f - 5f.pow(-(coefficient)) + val offsetBy = (1 - exponentialCoefficient) * itemHeightPx + return if (middleOffset > offset) offsetBy else -offsetBy +} + +@Suppress("MagicNumber") +private fun calculateIndexToFocus( + listState: LazyListState, + height: Dp +): Int { + val currentItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() + var index = currentItem?.index ?: 0 + if (currentItem?.offset != 0 && currentItem != null && currentItem.offset <= -height.value * 3 / 10) { + index++ + } + return index +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/ZcashTheme.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/ZcashTheme.kt index 375771aed..a9ea1bf24 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/ZcashTheme.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/ZcashTheme.kt @@ -9,6 +9,10 @@ import androidx.compose.material3.RippleConfiguration import androidx.compose.material3.RippleDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import co.electriccoin.zcash.ui.design.LocalKeyboardManager +import co.electriccoin.zcash.ui.design.LocalSheetStateManager +import co.electriccoin.zcash.ui.design.rememberKeyboardManager +import co.electriccoin.zcash.ui.design.rememberSheetStateManager import co.electriccoin.zcash.ui.design.theme.balances.LocalBalancesAvailable import co.electriccoin.zcash.ui.design.theme.colors.DarkZashiColorsInternal import co.electriccoin.zcash.ui.design.theme.colors.LightZashiColorsInternal @@ -49,7 +53,9 @@ fun ZcashTheme( LocalZashiColors provides zashiColors, LocalZashiTypography provides ZashiTypographyInternal, LocalRippleConfiguration provides MaterialRippleConfig, - LocalBalancesAvailable provides balancesAvailable + LocalBalancesAvailable provides balancesAvailable, + LocalKeyboardManager provides rememberKeyboardManager(), + LocalSheetStateManager provides rememberSheetStateManager() ) { ProvideDimens { MaterialTheme( diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/colors/DarkZashiColors.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/colors/DarkZashiColors.kt index d0e5922b6..2669f34e6 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/colors/DarkZashiColors.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/theme/colors/DarkZashiColors.kt @@ -551,8 +551,8 @@ val DarkZashiColorsInternal = utilityEspresso600 = Espresso.`300`, utilityEspresso500 = Espresso.`400`, utilityEspresso200 = Espresso.`700`, - utilityEspresso50 = Espresso.`900`, - utilityEspresso100 = Espresso.`800`, + utilityEspresso50 = Espresso.`950`, + utilityEspresso100 = Espresso.`900`, utilityEspresso400 = Espresso.`500`, utilityEspresso300 = Espresso.`600`, utilityEspresso900 = Espresso.`50`, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/util/FlowExt.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/FlowExt.kt similarity index 83% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/util/FlowExt.kt rename to ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/FlowExt.kt index 0c464ff49..7f8f74416 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/util/FlowExt.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/util/FlowExt.kt @@ -1,4 +1,4 @@ -package co.electriccoin.zcash.ui.util +package co.electriccoin.zcash.ui.design.util import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_reveal.xml b/ui-design-lib/src/main/res/ui/common/drawable/ic_reveal.xml similarity index 100% rename from ui-lib/src/main/res/ui/seed_recovery/drawable/ic_reveal.xml rename to ui-design-lib/src/main/res/ui/common/drawable/ic_reveal.xml diff --git a/ui-design-lib/src/main/res/ui/common/values-es/strings.xml b/ui-design-lib/src/main/res/ui/common/values-es/strings.xml index 87c4b76cb..d4b5b6268 100644 --- a/ui-design-lib/src/main/res/ui/common/values-es/strings.xml +++ b/ui-design-lib/src/main/res/ui/common/values-es/strings.xml @@ -3,4 +3,5 @@ ----- Atrás + Mostrar frase de recuperación diff --git a/ui-design-lib/src/main/res/ui/common/values/strings.xml b/ui-design-lib/src/main/res/ui/common/values/strings.xml index 0badc537b..f3dd6344d 100644 --- a/ui-design-lib/src/main/res/ui/common/values/strings.xml +++ b/ui-design-lib/src/main/res/ui/common/values/strings.xml @@ -3,4 +3,5 @@ ----- Back + Reveal recovery phrase diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingTestSetup.kt index 1fc6080d0..208d819de 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingTestSetup.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingTestSetup.kt @@ -28,10 +28,8 @@ class OnboardingTestSetup( ZcashTheme { Onboarding( // Debug only UI state does not need to be tested - isDebugMenuEnabled = false, onImportWallet = { onImportWalletCallbackCount.incrementAndGet() }, - onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() }, - onFixtureWallet = {} + onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() } ) } } diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt index e3f7a8046..6bf263e93 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewAndroidTest.kt @@ -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) } diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt index 4bf01bdeb..681b91dbf 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewSecuredScreenTest.kt @@ -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, diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt index a4d8018d7..602feaa8b 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreViewTest.kt @@ -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 ) { - 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(), diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTest.kt deleted file mode 100644 index f32aa4ff7..000000000 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package co.electriccoin.zcash.ui.screen.securitywarning.view - -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.junit4.ComposeContentTestRule -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import androidx.test.filters.MediumTest -import co.electriccoin.zcash.test.UiTestPrerequisites -import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.test.getStringResource -import org.junit.Rule -import kotlin.test.Test -import kotlin.test.assertEquals - -class SecurityWarningViewTest : UiTestPrerequisites() { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - @MediumTest - fun default_ui_state_test() { - val testSetup = newTestSetup() - - assertEquals(0, testSetup.getOnBackCount()) - assertEquals(false, testSetup.getOnAcknowledged()) - assertEquals(0, testSetup.getOnConfirmCount()) - - composeTestRule.onNodeWithTag(SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG).also { - it.assertExists() - it.assertIsDisplayed() - it.assertHasClickAction() - it.assertIsEnabled() - } - - composeTestRule.onNodeWithText(getStringResource(R.string.security_warning_confirm), ignoreCase = true).also { - it.performScrollTo() - it.assertExists() - it.assertIsDisplayed() - it.assertHasClickAction() - it.assertIsNotEnabled() - } - } - - @Test - @MediumTest - fun back_test() { - val testSetup = newTestSetup() - - assertEquals(0, testSetup.getOnBackCount()) - - composeTestRule.clickBack() - - assertEquals(1, testSetup.getOnBackCount()) - } - - @Test - @MediumTest - fun click_disabled_confirm_button_test() { - val testSetup = newTestSetup() - - assertEquals(0, testSetup.getOnConfirmCount()) - assertEquals(false, testSetup.getOnAcknowledged()) - - composeTestRule.clickConfirm() - - assertEquals(0, testSetup.getOnConfirmCount()) - assertEquals(false, testSetup.getOnAcknowledged()) - } - - @Test - @MediumTest - fun click_enabled_confirm_button_test() { - val testSetup = newTestSetup() - - assertEquals(0, testSetup.getOnConfirmCount()) - assertEquals(false, testSetup.getOnAcknowledged()) - - composeTestRule.clickAcknowledge() - - assertEquals(0, testSetup.getOnConfirmCount()) - assertEquals(true, testSetup.getOnAcknowledged()) - - composeTestRule.clickConfirm() - - assertEquals(1, testSetup.getOnConfirmCount()) - assertEquals(true, testSetup.getOnAcknowledged()) - } - - private fun newTestSetup() = - SecurityWarningViewTestSetup(composeTestRule).apply { - setDefaultContent() - } -} - -private fun ComposeContentTestRule.clickBack() { - onNodeWithContentDescription(getStringResource(R.string.back_navigation_content_description)).also { - it.performClick() - } -} - -private fun ComposeContentTestRule.clickConfirm() { - onNodeWithText(getStringResource(R.string.security_warning_confirm), ignoreCase = true).also { - it.performScrollTo() - it.performClick() - } -} - -private fun ComposeContentTestRule.clickAcknowledge() { - onNodeWithText(getStringResource(R.string.security_warning_acknowledge)).also { - it.performClick() - } -} diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTestSetup.kt deleted file mode 100644 index ce85949d4..000000000 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningViewTestSetup.kt +++ /dev/null @@ -1,56 +0,0 @@ -package co.electriccoin.zcash.ui.screen.securitywarning.view - -import androidx.compose.runtime.Composable -import androidx.compose.ui.test.junit4.ComposeContentTestRule -import co.electriccoin.zcash.ui.design.theme.ZcashTheme -import co.electriccoin.zcash.ui.fixture.VersionInfoFixture -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger - -class SecurityWarningViewTestSetup(private val composeTestRule: ComposeContentTestRule) { - private val onBackCount = AtomicInteger(0) - - private val onAcknowledged = AtomicBoolean(false) - - private val onConfirmCount = AtomicInteger(0) - - fun getOnBackCount(): Int { - composeTestRule.waitForIdle() - return onBackCount.get() - } - - fun getOnAcknowledged(): Boolean { - composeTestRule.waitForIdle() - return onAcknowledged.get() - } - - fun getOnConfirmCount(): Int { - composeTestRule.waitForIdle() - return onConfirmCount.get() - } - - @Composable - @Suppress("TestFunctionName") - fun DefaultContent() { - SecurityWarning( - onBack = { - onBackCount.incrementAndGet() - }, - onAcknowledged = { - onAcknowledged.getAndSet(it) - }, - onConfirm = { - onConfirmCount.incrementAndGet() - }, - versionInfo = VersionInfoFixture.new() - ) - } - - fun setDefaultContent() { - composeTestRule.setContent { - ZcashTheme { - DefaultContent() - } - } - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index 4939fc1a5..2174a93c2 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -16,11 +16,14 @@ 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.GetCoinbaseStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetConfigurationUseCase import co.electriccoin.zcash.ui.common.usecase.GetContactByAddressUseCase import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase import co.electriccoin.zcash.ui.common.usecase.GetExchangeRateUseCase +import co.electriccoin.zcash.ui.common.usecase.GetFlexaStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetKeystoneStatusUseCase import co.electriccoin.zcash.ui.common.usecase.GetMetadataUseCase import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase import co.electriccoin.zcash.ui.common.usecase.GetProposalUseCase @@ -32,6 +35,7 @@ import co.electriccoin.zcash.ui.common.usecase.GetTransactionDetailByIdUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransactionFiltersUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase +import co.electriccoin.zcash.ui.common.usecase.GetWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase import co.electriccoin.zcash.ui.common.usecase.GetZashiAccountUseCase @@ -42,10 +46,10 @@ import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseC import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase +import co.electriccoin.zcash.ui.common.usecase.NavigateToSeedRecoveryUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToTaxExportUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveClearSendUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase @@ -56,7 +60,6 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveTransactionSubmitStateUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveZashiAccountUseCase import co.electriccoin.zcash.ui.common.usecase.OnAddressScannedUseCase import co.electriccoin.zcash.ui.common.usecase.OnZip321ScannedUseCase @@ -70,6 +73,7 @@ import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase import co.electriccoin.zcash.ui.common.usecase.ResetInMemoryDataUseCase import co.electriccoin.zcash.ui.common.usecase.ResetSharedPrefsDataUseCase import co.electriccoin.zcash.ui.common.usecase.ResetTransactionFiltersUseCase +import co.electriccoin.zcash.ui.common.usecase.RestoreWalletUseCase import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase import co.electriccoin.zcash.ui.common.usecase.SelectWalletAccountUseCase import co.electriccoin.zcash.ui.common.usecase.SendEmailUseCase @@ -81,6 +85,7 @@ import co.electriccoin.zcash.ui.common.usecase.UpdateContactUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateContactAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase +import co.electriccoin.zcash.ui.common.usecase.ValidateSeedUseCase import co.electriccoin.zcash.ui.common.usecase.ViewTransactionDetailAfterSuccessfulProposalUseCase import co.electriccoin.zcash.ui.common.usecase.ViewTransactionsAfterSuccessfulProposalUseCase import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase @@ -102,7 +107,7 @@ val useCaseModule = factoryOf(::ValidateEndpointUseCase) factoryOf(::GetPersistableWalletUseCase) factoryOf(::GetSelectedEndpointUseCase) - factoryOf(::ObserveConfigurationUseCase) + factoryOf(::GetConfigurationUseCase) factoryOf(::RescanBlockchainUseCase) factoryOf(::GetTransparentAddressUseCase) factoryOf(::ValidateContactAddressUseCase) @@ -121,12 +126,11 @@ val useCaseModule = factoryOf(::IsCoinbaseAvailableUseCase) factoryOf(::GetZashiSpendingKeyUseCase) factoryOf(::ObservePersistableWalletUseCase) - factoryOf(::GetBackupPersistableWalletUseCase) factoryOf(::GetSupportUseCase) factoryOf(::SendEmailUseCase) factoryOf(::SendSupportEmailUseCase) factoryOf(::IsFlexaAvailableUseCase) - factoryOf(::ObserveWalletAccountsUseCase) + factoryOf(::GetWalletAccountsUseCase) factoryOf(::SelectWalletAccountUseCase) factoryOf(::ObserveSelectedWalletAccountUseCase) factoryOf(::ObserveZashiAccountUseCase) @@ -178,4 +182,10 @@ val useCaseModule = factoryOf(::NavigateToTaxExportUseCase) factoryOf(::CreateFlexaTransactionUseCase) factoryOf(::IsRestoreSuccessDialogVisibleUseCase) + factoryOf(::ValidateSeedUseCase) + factoryOf(::RestoreWalletUseCase) + factoryOf(::NavigateToSeedRecoveryUseCase) + factoryOf(::GetKeystoneStatusUseCase) + factoryOf(::GetCoinbaseStatusUseCase) + factoryOf(::GetFlexaStatusUseCase) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt index c7d5b5185..4c339fce9 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt @@ -7,7 +7,7 @@ import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.SelectRecipientViewModel -import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettingsViewModel +import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsViewModel import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel @@ -15,20 +15,21 @@ import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel 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.integrations.IntegrationsViewModel 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.date.RestoreBDDateViewModel +import co.electriccoin.zcash.ui.screen.restore.estimation.RestoreBDEstimationViewModel +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 import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystonePCZTViewModel import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel -import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs -import co.electriccoin.zcash.ui.screen.seed.viewmodel.SeedViewModel +import co.electriccoin.zcash.ui.screen.seed.SeedRecoveryViewModel import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.viewmodel.SelectKeystoneAccountViewModel import co.electriccoin.zcash.ui.screen.send.SendViewModel @@ -57,9 +58,8 @@ val viewModelModule = viewModelOf(::WalletViewModel) viewModelOf(::AuthenticationViewModel) viewModelOf(::OldHomeViewModel) - viewModelOf(::OnboardingViewModel) viewModelOf(::StorageCheckViewModel) - viewModelOf(::RestoreViewModel) + viewModelOf(::RestoreSeedViewModel) viewModelOf(::ScreenBrightnessViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::AdvancedSettingsViewModel) @@ -92,27 +92,10 @@ val viewModelModule = } viewModelOf(::ScanKeystoneSignInRequestViewModel) viewModelOf(::ScanKeystonePCZTViewModel) - viewModel { (isDialog: Boolean) -> - IntegrationsViewModel( - isDialog = isDialog, - getZcashCurrency = get(), - isFlexaAvailableUseCase = get(), - isCoinbaseAvailable = get(), - observeWalletAccounts = get(), - navigationRouter = get(), - navigateToCoinbase = get(), - getWalletRestoringState = get() - ) - } + viewModelOf(::IntegrationsViewModel) viewModelOf(::FlexaViewModel) viewModelOf(::SendViewModel) - viewModel { (args: SeedNavigationArgs) -> - SeedViewModel( - observePersistableWallet = get(), - args = args, - walletRepository = get(), - ) - } + viewModelOf(::SeedRecoveryViewModel) viewModelOf(::FeedbackViewModel) viewModelOf(::SignKeystoneTransactionViewModel) viewModelOf(::AccountListViewModel) @@ -156,4 +139,7 @@ val viewModelModule = viewModelOf(::TaxExportViewModel) viewModelOf(::BalanceViewModel) viewModelOf(::HomeViewModel) + viewModelOf(::RestoreBDHeightViewModel) + viewModelOf(::RestoreBDDateViewModel) + viewModelOf(::RestoreBDEstimationViewModel) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt index 538fbc8dc..a2e160be4 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/MainActivity.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -23,22 +22,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController -import cash.z.ecc.android.sdk.fixture.WalletFixture -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.spackle.FirebaseTestLabUtil import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider import co.electriccoin.zcash.ui.common.extension.setContentCompat -import co.electriccoin.zcash.ui.common.model.OnboardingState -import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel import co.electriccoin.zcash.ui.common.viewmodel.SecretState import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel -import co.electriccoin.zcash.ui.configuration.RemoteConfig import co.electriccoin.zcash.ui.design.component.BlankSurface import co.electriccoin.zcash.ui.design.component.ConfigurationOverride import co.electriccoin.zcash.ui.design.component.Override @@ -48,11 +39,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.persistExistingWalletWithSeedPhrase -import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning -import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs -import co.electriccoin.zcash.ui.screen.seed.WrapSeed +import co.electriccoin.zcash.ui.screen.onboarding.OnboardingNavigation import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel import co.electriccoin.zcash.work.WorkIds import kotlinx.coroutines.delay @@ -131,8 +118,7 @@ class MainActivity : FragmentActivity() { } } - // Note this condition needs to be kept in sync with the condition in MainContent() - oldHomeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value + SecretState.LOADING == walletViewModel.secretState.value } } @@ -235,58 +221,20 @@ class MainActivity : FragmentActivity() { @Composable private fun MainContent() { - val configuration = oldHomeViewModel.configurationFlow.collectAsStateWithLifecycle().value - val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value + val secretState by walletViewModel.secretState.collectAsStateWithLifecycle() - // Note this condition needs to be kept in sync with the condition in setupSplashScreen() - if (null == configuration || secretState == SecretState.Loading) { - // For now, keep displaying splash screen using condition above. - // In the future, we might consider displaying something different here. - } else { - // Note that the deeply nested child views will probably receive arguments derived from - // the configuration. The CompositionLocalProvider is helpful for passing the configuration - // to the "platform" layer, which is where the arguments will be derived from. - CompositionLocalProvider(RemoteConfig provides configuration) { - when (secretState) { - SecretState.None -> { - WrapOnboarding() - } + when (secretState) { + SecretState.NONE -> { + OnboardingNavigation() + } - is SecretState.NeedsWarning -> { - WrapSecurityWarning( - onBack = { walletViewModel.persistOnboardingState(OnboardingState.NONE) }, - onConfirm = { - walletViewModel.persistOnboardingState(OnboardingState.NEEDS_BACKUP) + SecretState.READY -> { + Navigation() + } - if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) { - persistExistingWalletWithSeedPhrase( - applicationContext, - walletViewModel, - SeedPhrase.new(WalletFixture.Alice.seedPhrase), - WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext)) - ) - } else { - walletViewModel.persistNewWalletAndRestoringState(WalletRestoringState.INITIATING) - } - } - ) - } - - is SecretState.NeedsBackup -> { - WrapSeed( - args = SeedNavigationArgs.NEW_WALLET, - goBackOverride = null - ) - } - - is SecretState.Ready -> { - Navigation() - } - - else -> { - error("Unhandled secret state: $secretState") - } - } + SecretState.LOADING -> { + // For now, keep displaying splash screen using condition above. + // In the future, we might consider displaying something different here. } } } @@ -294,7 +242,7 @@ class MainActivity : FragmentActivity() { private fun monitorForBackgroundSync() { val isEnableBackgroundSyncFlow = run { - val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready } + val isSecretReadyFlow = walletViewModel.secretState.map { it == SecretState.READY } val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull() isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled -> diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt index 391367c07..9fa26109c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt @@ -31,7 +31,6 @@ import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE import co.electriccoin.zcash.ui.NavigationTargets.REQUEST -import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT @@ -39,6 +38,8 @@ import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider import co.electriccoin.zcash.ui.common.provider.isInForeground +import co.electriccoin.zcash.ui.design.LocalKeyboardManager +import co.electriccoin.zcash.ui.design.LocalSheetStateManager 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 @@ -76,6 +77,8 @@ import co.electriccoin.zcash.ui.screen.receive.AndroidReceive import co.electriccoin.zcash.ui.screen.receive.Receive import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.request.WrapRequest +import co.electriccoin.zcash.ui.screen.restore.info.AndroidSeedInfo +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo import co.electriccoin.zcash.ui.screen.reviewtransaction.AndroidReviewTransaction import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction import co.electriccoin.zcash.ui.screen.scan.Scan @@ -84,8 +87,10 @@ import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystonePCZTRequest import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystoneSignInRequest import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystonePCZTRequest import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystoneSignInRequest -import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs -import co.electriccoin.zcash.ui.screen.seed.WrapSeed +import co.electriccoin.zcash.ui.screen.seed.AndroidSeedRecovery +import co.electriccoin.zcash.ui.screen.seed.SeedRecovery +import co.electriccoin.zcash.ui.screen.seed.backup.AndroidSeedBackup +import co.electriccoin.zcash.ui.screen.seed.backup.SeedBackup import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.AndroidSelectKeystoneAccount import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount import co.electriccoin.zcash.ui.screen.send.Send @@ -120,18 +125,32 @@ import org.koin.compose.koinInject @Suppress("LongMethod", "CyclomaticComplexMethod") internal fun MainActivity.Navigation() { val navController = LocalNavController.current + val keyboardManager = LocalKeyboardManager.current val flexaViewModel = koinViewModel() val navigationRouter = koinInject() + val sheetStateManager = LocalSheetStateManager.current // Helper properties for triggering the system security UI from callbacks val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) = rememberSaveable { mutableStateOf(false) } - val (seedRecoveryAuthentication, setSeedRecoveryAuthentication) = - rememberSaveable { mutableStateOf(false) } val (deleteWalletAuthentication, setDeleteWalletAuthentication) = rememberSaveable { mutableStateOf(false) } - val navigator: Navigator = remember { NavigatorImpl(this@Navigation, navController, flexaViewModel) } + val navigator: Navigator = + remember( + navController, + flexaViewModel, + keyboardManager, + sheetStateManager + ) { + NavigatorImpl( + activity = this@Navigation, + navController = navController, + flexaViewModel = flexaViewModel, + keyboardManager = keyboardManager, + sheetStateManager = sheetStateManager + ) + } LaunchedEffect(Unit) { navigationRouter.observePipeline().collect { @@ -163,14 +182,6 @@ internal fun MainActivity.Navigation() { unProtectedDestination = EXPORT_PRIVATE_DATA ) }, - goSeedRecovery = { - navController.checkProtectedDestination( - scope = lifecycleScope, - propertyToCheck = authenticationViewModel.isSeedAuthenticationRequired, - setCheckedProperty = setSeedRecoveryAuthentication, - unProtectedDestination = SEED_RECOVERY - ) - }, goDeleteWallet = { navController.checkProtectedDestination( scope = lifecycleScope, @@ -199,27 +210,13 @@ internal fun MainActivity.Navigation() { setCheckedProperty = setExportPrivateDataAuthentication ) } - - seedRecoveryAuthentication -> { - ShowSystemAuthentication( - navHostController = navController, - protectedDestination = SEED_RECOVERY, - protectedUseCase = AuthenticationUseCase.SeedRecovery, - setCheckedProperty = setSeedRecoveryAuthentication - ) - } } } composable(CHOOSE_SERVER) { WrapChooseServer() } - composable(SEED_RECOVERY) { - WrapSeed( - args = SeedNavigationArgs.RECOVERY, - goBackOverride = { - setSeedRecoveryAuthentication(false) - } - ) + composable { + AndroidSeedRecovery() } composable(SUPPORT) { // Pop back stack won't be right if we deep link into support @@ -275,21 +272,8 @@ internal fun MainActivity.Navigation() { ) { AndroidAccountList() } - composable( - route = Scan.ROUTE, - arguments = - listOf( - navArgument(Scan.KEY) { - type = NavType.EnumType(Scan::class.java) - defaultValue = Scan.SEND - } - ) - ) { backStackEntry -> - val mode = - backStackEntry.arguments - ?.getSerializableCompat(Scan.KEY) ?: Scan.SEND - - WrapScanValidator(args = mode) + composable { + WrapScanValidator(it.toRoute()) } composable(EXPORT_PRIVATE_DATA) { WrapExportPrivateData( @@ -405,6 +389,18 @@ internal fun MainActivity.Navigation() { composable { WrapSend(it.toRoute()) } + dialog( + dialogProperties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + ) { + AndroidSeedInfo() + } + composable { + AndroidSeedBackup() + } } } @@ -530,7 +526,6 @@ object NavigationTargets { const val NOT_ENOUGH_SPACE = "not_enough_space" const val QR_CODE = "qr_code" const val REQUEST = "request" - const val SEED_RECOVERY = "seed_recovery" const val SETTINGS = "settings" const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in" const val SUPPORT = "support" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigator.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigator.kt index 6fe051646..44eaf731b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigator.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigator.kt @@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity import androidx.navigation.NavHostController import androidx.navigation.NavOptionsBuilder import androidx.navigation.serialization.generateHashCode +import co.electriccoin.zcash.ui.design.KeyboardManager +import co.electriccoin.zcash.ui.design.SheetStateManager import co.electriccoin.zcash.ui.screen.ExternalUrl import co.electriccoin.zcash.ui.screen.about.util.WebBrowserUtil import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel @@ -14,15 +16,19 @@ import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.serializer interface Navigator { - fun executeCommand(command: NavigationCommand) + suspend fun executeCommand(command: NavigationCommand) } class NavigatorImpl( private val activity: ComponentActivity, private val navController: NavHostController, private val flexaViewModel: FlexaViewModel, + private val keyboardManager: KeyboardManager, + private val sheetStateManager: SheetStateManager, ) : Navigator { - override fun executeCommand(command: NavigationCommand) { + override suspend fun executeCommand(command: NavigationCommand) { + keyboardManager.close() + sheetStateManager.hide() when (command) { is NavigationCommand.Forward -> forward(command) is NavigationCommand.Replace -> replace(command) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/appbar/ZashiTopAppBarViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/appbar/ZashiTopAppBarViewModel.kt index ebfd45982..8faa5c5af 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/appbar/ZashiTopAppBarViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/appbar/ZashiTopAppBarViewModel.kt @@ -11,8 +11,9 @@ import co.electriccoin.zcash.ui.common.model.KeystoneAccount import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.ZashiAccount +import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase +import co.electriccoin.zcash.ui.common.usecase.GetWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCase import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.component.IconButtonState import co.electriccoin.zcash.ui.design.util.stringRes @@ -30,7 +31,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class ZashiTopAppBarViewModel( - observeSelectedWalletAccount: ObserveSelectedWalletAccountUseCase, + getWalletAccountUseCase: GetWalletAccountsUseCase, + getSelectedWalletAccount: GetSelectedWalletAccountUseCase, getWalletStateInformation: GetWalletStateInformationUseCase, private val standardPreferenceProvider: StandardPreferenceProvider, private val navigationRouter: NavigationRouter, @@ -39,7 +41,7 @@ class ZashiTopAppBarViewModel( val state = combine( - observeSelectedWalletAccount.require(), + getSelectedWalletAccount.observe().filterNotNull(), isHideBalances, getWalletStateInformation.observe() ) { currentAccount, isHideBalances, walletState -> @@ -47,11 +49,16 @@ class ZashiTopAppBarViewModel( }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - initialValue = null + initialValue = + createState( + currentAccount = getWalletAccountUseCase.observe().value?.firstOrNull { it.isSelected }, + isHideBalances = isHideBalances.value, + topAppBarSubTitleState = getWalletStateInformation.observe().value + ) ) private fun createState( - currentAccount: WalletAccount, + currentAccount: WalletAccount?, isHideBalances: Boolean?, topAppBarSubTitleState: TopAppBarSubTitleState ) = ZashiMainTopAppBarState( @@ -61,6 +68,7 @@ class ZashiTopAppBarViewModel( when (currentAccount) { is KeystoneAccount -> ZashiMainTopAppBarState.AccountType.KEYSTONE is ZashiAccount -> ZashiMainTopAppBarState.AccountType.ZASHI + else -> ZashiMainTopAppBarState.AccountType.ZASHI }, onAccountTypeClick = ::onAccountTypeClicked, ), diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/mapper/TransactionHistoryMapper.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/mapper/TransactionHistoryMapper.kt index 9ce9c5030..3f40312bf 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/mapper/TransactionHistoryMapper.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/mapper/TransactionHistoryMapper.kt @@ -49,7 +49,7 @@ class TransactionHistoryMapper { false } else { val transactionMetadata = data.metadata - hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not()) + hasMemo && transactionMetadata.isRead.not() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ConfigurationRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ConfigurationRepository.kt index 550e5143d..791f79bd7 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ConfigurationRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ConfigurationRepository.kt @@ -61,6 +61,7 @@ class ConfigurationRepositoryImpl( started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), initialValue = null ) + override val isCoinbaseAvailable: StateFlow = flow { val versionInfo = getVersionInfo() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ExchangeRateRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ExchangeRateRepository.kt index 36cf5a997..143125b0d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ExchangeRateRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/ExchangeRateRepository.kt @@ -7,6 +7,7 @@ import co.electriccoin.zcash.preference.model.entry.NullableBooleanPreferenceDef import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN +import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.common.wallet.RefreshLock import co.electriccoin.zcash.ui.common.wallet.StaleLock @@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds interface ExchangeRateRepository { val isExchangeRateUsdOptedIn: StateFlow @@ -48,7 +49,7 @@ interface ExchangeRateRepository { } class ExchangeRateRepositoryImpl( - private val walletRepository: WalletRepository, + private val synchronizerProvider: SynchronizerProvider, private val standardPreferenceProvider: StandardPreferenceProvider, private val navigationRouter: NavigationRouter, ) : ExchangeRateRepository { @@ -61,7 +62,8 @@ class ExchangeRateRepositoryImpl( private val exchangeRateUsdInternal = isExchangeRateUsdOptedIn.flatMapLatest { optedIn -> if (optedIn == true) { - walletRepository.synchronizer + synchronizerProvider + .synchronizer .filterNotNull() .flatMapLatest { synchronizer -> synchronizer.exchangeRateUsd @@ -105,46 +107,7 @@ class ExchangeRateRepositoryImpl( staleExchangeRateUsdLock.state, refreshExchangeRateUsdLock.state, ) { isOptedIn, exchangeRate, isStale, isRefreshEnabled -> - lastExchangeRateUsdValue = - when (isOptedIn) { - true -> - when (val lastValue = lastExchangeRateUsdValue) { - is ExchangeRateState.Data -> - lastValue.copy( - isLoading = exchangeRate.isLoading, - isStale = isStale, - isRefreshEnabled = isRefreshEnabled, - currencyConversion = exchangeRate.currencyConversion, - ) - - ExchangeRateState.OptedOut -> - ExchangeRateState.Data( - isLoading = exchangeRate.isLoading, - isStale = isStale, - isRefreshEnabled = isRefreshEnabled, - currencyConversion = exchangeRate.currencyConversion, - onRefresh = ::refreshExchangeRateUsd - ) - - is ExchangeRateState.OptIn -> - ExchangeRateState.Data( - isLoading = exchangeRate.isLoading, - isStale = isStale, - isRefreshEnabled = isRefreshEnabled, - currencyConversion = exchangeRate.currencyConversion, - onRefresh = ::refreshExchangeRateUsd - ) - } - - false -> ExchangeRateState.OptedOut - null -> - ExchangeRateState.OptIn( - onDismissClick = ::dismissWidgetOptInExchangeRateUsd, - onPrimaryClick = ::showOptInExchangeRateUsd - ) - } - - lastExchangeRateUsdValue + createState(isOptedIn, exchangeRate, isStale, isRefreshEnabled) }.distinctUntilChanged() .onEach { Twig.info { "[USD] $it" } @@ -157,17 +120,71 @@ class ExchangeRateRepositoryImpl( } }.stateIn( scope = scope, - started = SharingStarted.WhileSubscribed(), - initialValue = ExchangeRateState.OptedOut + started = SharingStarted.WhileSubscribed(5.seconds, 5.seconds), + initialValue = + createState( + isOptedIn = isExchangeRateUsdOptedIn.value, + exchangeRate = exchangeRateUsdInternal.value, + isStale = false, + isRefreshEnabled = false + ) ) + private fun createState( + isOptedIn: Boolean?, + exchangeRate: ObserveFiatCurrencyResult, + isStale: Boolean, + isRefreshEnabled: Boolean + ): ExchangeRateState { + lastExchangeRateUsdValue = + when (isOptedIn) { + true -> + when (val lastValue = lastExchangeRateUsdValue) { + is ExchangeRateState.Data -> + lastValue.copy( + isLoading = exchangeRate.isLoading, + isStale = isStale, + isRefreshEnabled = isRefreshEnabled, + currencyConversion = exchangeRate.currencyConversion, + ) + + ExchangeRateState.OptedOut -> + ExchangeRateState.Data( + isLoading = exchangeRate.isLoading, + isStale = isStale, + isRefreshEnabled = isRefreshEnabled, + currencyConversion = exchangeRate.currencyConversion, + onRefresh = ::refreshExchangeRateUsd + ) + + is ExchangeRateState.OptIn -> + ExchangeRateState.Data( + isLoading = exchangeRate.isLoading, + isStale = isStale, + isRefreshEnabled = isRefreshEnabled, + currencyConversion = exchangeRate.currencyConversion, + onRefresh = ::refreshExchangeRateUsd + ) + } + + false -> ExchangeRateState.OptedOut + null -> + ExchangeRateState.OptIn( + onDismissClick = ::dismissWidgetOptInExchangeRateUsd, + onPrimaryClick = ::showOptInExchangeRateUsd + ) + } + + return lastExchangeRateUsdValue + } + override fun refreshExchangeRateUsd() { refreshExchangeRateUsdInternal() } private fun refreshExchangeRateUsdInternal() = scope.launch { - val synchronizer = walletRepository.synchronizer.filterNotNull().first() + val synchronizer = synchronizerProvider.getSynchronizer() val value = state.value if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) { synchronizer.refreshExchangeRateUsd() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt index 9ad8fdd9a..b94e7e1f5 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/MetadataRepository.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -39,7 +38,7 @@ interface MetadataRepository { suspend fun markTxMemoAsRead(txId: String) - fun observeTransactionMetadataByTxId(txId: String): Flow + fun observeTransactionMetadataByTxId(txId: String): Flow } class MetadataRepositoryImpl( @@ -161,9 +160,9 @@ class MetadataRepositoryImpl( } } - override fun observeTransactionMetadataByTxId(txId: String): Flow = + override fun observeTransactionMetadataByTxId(txId: String): Flow = metadata - .map { metadata -> + .map { metadata -> val accountMetadata = metadata?.accountMetadata TransactionMetadata( @@ -173,7 +172,6 @@ class MetadataRepositoryImpl( ) } .distinctUntilChanged() - .onStart { emit(null) } private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey { val key = metadataKeyStorageProvider.get(selectedAccount) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt index 2e7cd5885..ee016438b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt @@ -70,7 +70,6 @@ interface WalletRepository { val synchronizer: StateFlow val secretState: StateFlow val fastestServers: StateFlow - val persistableWallet: Flow val onboardingState: Flow val allAccounts: Flow?> @@ -99,8 +98,6 @@ interface WalletRepository { suspend fun getSynchronizer(): Synchronizer - suspend fun getPersistableWallet(): PersistableWallet - fun persistExistingWalletWithSeedPhrase( network: ZcashNetwork, seedPhrase: SeedPhrase, @@ -110,7 +107,8 @@ interface WalletRepository { class WalletRepositoryImpl( accountDataSource: AccountDataSource, - persistableWalletProvider: PersistableWalletProvider, + configurationRepository: ConfigurationRepository, + private val persistableWalletProvider: PersistableWalletProvider, private val synchronizerProvider: SynchronizerProvider, private val application: Application, private val getDefaultServers: GetDefaultServersProvider, @@ -143,27 +141,21 @@ class WalletRepositoryImpl( override val allAccounts: StateFlow?> = accountDataSource.allAccounts override val secretState: StateFlow = - 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) + combine(configurationRepository.configurationFlow, onboardingState) { config, onboardingState -> + if (config == null) { + SecretState.LOADING + } else { + when (onboardingState) { + OnboardingState.NEEDS_WARN, + OnboardingState.NEEDS_BACKUP, + OnboardingState.NONE -> SecretState.NONE + OnboardingState.READY -> SecretState.READY } - - onboardingState == OnboardingState.READY && persistableWallet != null -> { - SecretState.Ready(persistableWallet) - } - - else -> SecretState.None } }.stateIn( scope = scope, started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - initialValue = SecretState.Loading + initialValue = SecretState.LOADING ) @OptIn(ExperimentalCoroutinesApi::class) @@ -204,11 +196,6 @@ class WalletRepositoryImpl( initialValue = FastestServersState(servers = emptyList(), isLoading = true) ) - override val persistableWallet: Flow = - secretState.map { - (it as? SecretState.Ready?)?.persistableWallet - } - @OptIn(ExperimentalCoroutinesApi::class) override val currentWalletSnapshot: StateFlow = combine(synchronizer, currentAccount) { synchronizer, currentAccount -> @@ -317,7 +304,7 @@ class WalletRepositoryImpl( } override suspend fun getSelectedServer(): LightWalletEndpoint { - return persistableWallet + return persistableWalletProvider.persistableWallet .map { it?.endpoint } @@ -338,8 +325,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, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ApplyTransactionFiltersUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ApplyTransactionFiltersUseCase.kt index 740e82b02..b634fd577 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ApplyTransactionFiltersUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ApplyTransactionFiltersUseCase.kt @@ -8,12 +8,8 @@ class ApplyTransactionFiltersUseCase( private val transactionFilterRepository: TransactionFilterRepository, private val navigationRouter: NavigationRouter, ) { - suspend operator fun invoke( - filters: List, - hideBottomSheet: suspend () -> Unit - ) { + operator fun invoke(filters: List) { transactionFilterRepository.apply(filters) - hideBottomSheet() navigationRouter.back() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateOrUpdateTransactionNoteUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateOrUpdateTransactionNoteUseCase.kt index 75f008769..d948b5467 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateOrUpdateTransactionNoteUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateOrUpdateTransactionNoteUseCase.kt @@ -9,11 +9,9 @@ class CreateOrUpdateTransactionNoteUseCase( ) { suspend operator fun invoke( txId: String, - note: String, - closeBottomSheet: suspend () -> Unit + note: String ) { metadataRepository.createOrUpdateTxNote(txId, note.trim()) - closeBottomSheet() navigationRouter.back() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteTransactionNoteUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteTransactionNoteUseCase.kt index 9c4e34865..9f0bf06fb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteTransactionNoteUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteTransactionNoteUseCase.kt @@ -7,12 +7,8 @@ class DeleteTransactionNoteUseCase( private val metadataRepository: MetadataRepository, private val navigationRouter: NavigationRouter ) { - suspend operator fun invoke( - txId: String, - closeBottomSheet: suspend () -> Unit - ) { + suspend operator fun invoke(txId: String) { metadataRepository.deleteTxNote(txId) - closeBottomSheet() navigationRouter.back() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetBackupPersistableWalletUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetBackupPersistableWalletUseCase.kt deleted file mode 100644 index 2f40c85b6..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetBackupPersistableWalletUseCase.kt +++ /dev/null @@ -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() -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCoinbaseStatusUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCoinbaseStatusUseCase.kt new file mode 100644 index 000000000..08972d4c9 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCoinbaseStatusUseCase.kt @@ -0,0 +1,31 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.common.datasource.AccountDataSource +import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class GetCoinbaseStatusUseCase( + private val configurationRepository: ConfigurationRepository, + private val accountDataSource: AccountDataSource, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun observe() = + configurationRepository + .isCoinbaseAvailable + .filterNotNull() + .flatMapLatest { isAvailable -> + if (isAvailable) { + accountDataSource.selectedAccount.map { + Status.ENABLED + } + } else { + flowOf(Status.UNAVAILABLE) + } + } + .distinctUntilChanged() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveConfigurationUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetConfigurationUseCase.kt similarity index 65% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveConfigurationUseCase.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetConfigurationUseCase.kt index d62c4a262..f5e51285b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveConfigurationUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetConfigurationUseCase.kt @@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.common.usecase import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository -class ObserveConfigurationUseCase( +class GetConfigurationUseCase( private val configurationRepository: ConfigurationRepository ) { - operator fun invoke() = configurationRepository.configurationFlow + fun observe() = configurationRepository.configurationFlow } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentFilteredTransactionsUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentFilteredTransactionsUseCase.kt index d1b705119..61ab5ee51 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentFilteredTransactionsUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentFilteredTransactionsUseCase.kt @@ -13,11 +13,11 @@ import co.electriccoin.zcash.ui.common.repository.TransactionFilter import co.electriccoin.zcash.ui.common.repository.TransactionFilterRepository import co.electriccoin.zcash.ui.common.repository.TransactionMetadata import co.electriccoin.zcash.ui.common.repository.TransactionRepository +import co.electriccoin.zcash.ui.design.util.combineToFlow import co.electriccoin.zcash.ui.design.util.getString import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.util.CloseableScopeHolder import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl -import co.electriccoin.zcash.ui.util.combineToFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -232,23 +232,23 @@ class GetCurrentFilteredTransactionsUseCase( } else { val transactionMetadata = transaction.transactionMetadata - hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not()) + hasMemo && transactionMetadata.isRead.not() } } private fun isBookmark(transaction: FilterTransactionData): Boolean { - return transaction.transactionMetadata?.isBookmarked ?: false + return transaction.transactionMetadata.isBookmarked } private fun hasNotes(transaction: FilterTransactionData): Boolean { - return transaction.transactionMetadata?.note != null + return transaction.transactionMetadata.note != null } private fun hasNotesWithFulltext( transaction: FilterTransactionData, fulltextFilter: String ): Boolean { - return transaction.transactionMetadata?.note + return transaction.transactionMetadata.note ?.contains( fulltextFilter, ignoreCase = true @@ -288,7 +288,7 @@ private data class FilterTransactionData( val transaction: Transaction, val contact: AddressBookContact?, val recipientAddress: String?, - val transactionMetadata: TransactionMetadata? + val transactionMetadata: TransactionMetadata ) private const val MIN_TEXT_FILTER_LENGTH = 3 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentTransactionsUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentTransactionsUseCase.kt index f2786a3d5..e03c9b104 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentTransactionsUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetCurrentTransactionsUseCase.kt @@ -4,7 +4,7 @@ import co.electriccoin.zcash.ui.common.repository.MetadataRepository import co.electriccoin.zcash.ui.common.repository.Transaction import co.electriccoin.zcash.ui.common.repository.TransactionMetadata import co.electriccoin.zcash.ui.common.repository.TransactionRepository -import co.electriccoin.zcash.ui.util.combineToFlow +import co.electriccoin.zcash.ui.design.util.combineToFlow import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -40,5 +40,5 @@ class GetCurrentTransactionsUseCase( data class ListTransactionData( val transaction: Transaction, - val metadata: TransactionMetadata? + val metadata: TransactionMetadata ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetFlexaStatusUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetFlexaStatusUseCase.kt new file mode 100644 index 000000000..397eca66d --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetFlexaStatusUseCase.kt @@ -0,0 +1,42 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.common.datasource.AccountDataSource +import co.electriccoin.zcash.ui.common.model.ZashiAccount +import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class GetFlexaStatusUseCase( + private val configurationRepository: ConfigurationRepository, + private val accountDataSource: AccountDataSource, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun observe() = + configurationRepository + .isFlexaAvailable + .filterNotNull() + .flatMapLatest { isAvailable -> + if (isAvailable) { + accountDataSource.selectedAccount.map { + if (it is ZashiAccount) { + Status.ENABLED + } else { + Status.DISABLED + } + } + } else { + flowOf(Status.UNAVAILABLE) + } + } + .distinctUntilChanged() +} + +enum class Status { + UNAVAILABLE, + ENABLED, + DISABLED +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetKeystoneStatusUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetKeystoneStatusUseCase.kt new file mode 100644 index 000000000..e7caff330 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetKeystoneStatusUseCase.kt @@ -0,0 +1,22 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.common.datasource.AccountDataSource +import co.electriccoin.zcash.ui.common.model.KeystoneAccount +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +class GetKeystoneStatusUseCase( + private val accountDataSource: AccountDataSource, +) { + fun observe() = + accountDataSource.allAccounts + .map { + val enabled = it?.none { account -> account is KeystoneAccount } ?: false + if (enabled) { + Status.ENABLED + } else { + Status.UNAVAILABLE + } + } + .distinctUntilChanged() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt index 7cd43ba07..52124f34c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt @@ -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() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionDetailByIdUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionDetailByIdUseCase.kt index 19e8fb549..c1b34cce3 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionDetailByIdUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionDetailByIdUseCase.kt @@ -12,15 +12,16 @@ import co.electriccoin.zcash.ui.common.repository.TransactionRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn class GetTransactionDetailByIdUseCase( private val transactionRepository: TransactionRepository, @@ -30,43 +31,56 @@ class GetTransactionDetailByIdUseCase( ) { @OptIn(ExperimentalCoroutinesApi::class) fun observe(txId: String) = - transactionRepository - .observeTransaction(txId).filterNotNull().flatMapLatest { transaction -> - channelFlow { - launch { - combine( - flow { - emit(null) - emit(getWalletAddress(transactionRepository.getRecipients(transaction))) - }, - flow { - emit(null) - emit(transaction.let { transactionRepository.getMemos(it) }) - }, - metadataRepository.observeTransactionMetadataByTxId(txId) - ) { address, memos, metadata -> - Triple(address, memos, metadata) - }.flatMapLatest { (address, memos, metadata) -> - addressBookRepository - .observeContactByAddress(address?.address.orEmpty()) - .mapLatest { contact -> - DetailedTransactionData( - transaction = transaction, - memos = memos, - contact = contact, - recipientAddress = address, - metadata = metadata - ) - } - }.collect { - send(it) - } - } - awaitClose { - // do nothing - } - } - }.distinctUntilChanged().flowOn(Dispatchers.Default) + channelFlow { + val transactionFlow = + transactionRepository + .observeTransaction(txId) + .filterNotNull() + .stateIn(this) + + val addressFlow = + transactionFlow + .mapLatest { getWalletAddress(transactionRepository.getRecipients(it)) } + .onStart { emit(null) } + .distinctUntilChanged() + + val memosFlow: Flow?> = + transactionFlow + .mapLatest?> { transactionRepository.getMemos(it) } + .onStart { emit(null) } + .distinctUntilChanged() + + val metadataFlow = + metadataRepository + .observeTransactionMetadataByTxId(txId) + + val contactFlow = + addressFlow + .flatMapLatest { addressBookRepository.observeContactByAddress(it?.address.orEmpty()) } + .distinctUntilChanged() + + combine( + transactionFlow, + addressFlow, + memosFlow, + metadataFlow, + contactFlow + ) { transaction, address, memos, metadata, contact -> + DetailedTransactionData( + transaction = transaction, + memos = memos, + contact = contact, + recipientAddress = address, + metadata = metadata + ) + }.collect { + send(it) + } + + awaitClose { + // do nothing + } + }.distinctUntilChanged().flowOn(Dispatchers.Default) private suspend fun getWalletAddress(address: String?): WalletAddress? { if (address == null) return null @@ -86,5 +100,5 @@ data class DetailedTransactionData( val memos: List?, val contact: AddressBookContact?, val recipientAddress: WalletAddress?, - val metadata: TransactionMetadata? + val metadata: TransactionMetadata ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionMetadataUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionMetadataUseCase.kt index 21f969365..ca18a0e3b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionMetadataUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetTransactionMetadataUseCase.kt @@ -1,13 +1,12 @@ package co.electriccoin.zcash.ui.common.usecase import co.electriccoin.zcash.ui.common.repository.MetadataRepository -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first class GetTransactionMetadataUseCase( private val metadataRepository: MetadataRepository, ) { - suspend operator fun invoke(txId: String) = observe(txId).filterNotNull().first() + suspend operator fun invoke(txId: String) = observe(txId).first() fun observe(txId: String) = metadataRepository.observeTransactionMetadataByTxId(txId) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveWalletAccountsUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetWalletAccountsUseCase.kt similarity index 61% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveWalletAccountsUseCase.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetWalletAccountsUseCase.kt index da584c13f..900ebfe30 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveWalletAccountsUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetWalletAccountsUseCase.kt @@ -3,8 +3,8 @@ package co.electriccoin.zcash.ui.common.usecase import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import kotlinx.coroutines.flow.filterNotNull -class ObserveWalletAccountsUseCase(private val accountDataSource: AccountDataSource) { - operator fun invoke() = accountDataSource.allAccounts +class GetWalletAccountsUseCase(private val accountDataSource: AccountDataSource) { + fun observe() = accountDataSource.allAccounts fun require() = accountDataSource.allAccounts.filterNotNull() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToCoinbaseUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToCoinbaseUseCase.kt index b7b86dd6f..27e9c3efe 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToCoinbaseUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToCoinbaseUseCase.kt @@ -10,7 +10,7 @@ class NavigateToCoinbaseUseCase( private val navigationRouter: NavigationRouter ) { suspend operator fun invoke(replaceCurrentScreen: Boolean) { - val transparent = accountDataSource.getZashiAccount().transparent + val transparent = accountDataSource.getSelectedAccount().transparent val url = getUrl(transparent.address.address) if (replaceCurrentScreen) { navigationRouter.replace(ExternalUrl(url)) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToSeedRecoveryUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToSeedRecoveryUseCase.kt new file mode 100644 index 000000000..904e6b109 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/NavigateToSeedRecoveryUseCase.kt @@ -0,0 +1,34 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.NavigationRouter +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.repository.BiometricRepository +import co.electriccoin.zcash.ui.common.repository.BiometricRequest +import co.electriccoin.zcash.ui.common.repository.BiometricsCancelledException +import co.electriccoin.zcash.ui.common.repository.BiometricsFailureException +import co.electriccoin.zcash.ui.design.util.stringRes +import co.electriccoin.zcash.ui.screen.seed.SeedRecovery + +class NavigateToSeedRecoveryUseCase( + private val navigationRouter: NavigationRouter, + private val biometricRepository: BiometricRepository +) { + suspend operator fun invoke() { + try { + biometricRepository.requestBiometrics( + BiometricRequest( + message = + stringRes( + R.string.authentication_system_ui_subtitle, + stringRes(R.string.authentication_use_case_seed_recovery) + ) + ) + ) + navigationRouter.forward(SeedRecovery) + } catch (_: BiometricsFailureException) { + // do nothing + } catch (_: BiometricsCancelledException) { + // do nothing + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveSelectedEndpointUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveSelectedEndpointUseCase.kt index 91f0d767c..0851ab33e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveSelectedEndpointUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveSelectedEndpointUseCase.kt @@ -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 } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnAddressScannedUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnAddressScannedUseCase.kt index 282d20e7c..345a80f78 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnAddressScannedUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnAddressScannedUseCase.kt @@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.scan.Scan +import co.electriccoin.zcash.ui.screen.scan.ScanFlow import co.electriccoin.zcash.ui.screen.send.Send class OnAddressScannedUseCase( @@ -13,21 +14,21 @@ class OnAddressScannedUseCase( operator fun invoke( address: String, addressType: AddressType, - scanFlow: Scan + scanArgs: Scan ) { require(addressType is AddressType.Valid) - when (scanFlow) { - Scan.SEND -> { + when (scanArgs.flow) { + ScanFlow.SEND -> { prefillSend.request(PrefillSendData.FromAddressScan(address = address)) navigationRouter.back() } - Scan.ADDRESS_BOOK -> { + ScanFlow.ADDRESS_BOOK -> { navigationRouter.replace(AddContactArgs(address)) } - Scan.HOMEPAGE -> { + ScanFlow.HOMEPAGE -> { navigationRouter.replace( Send( address, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnZip321ScannedUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnZip321ScannedUseCase.kt index 0af05513d..0f0b4cef2 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnZip321ScannedUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/OnZip321ScannedUseCase.kt @@ -15,9 +15,9 @@ import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Z import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction import co.electriccoin.zcash.ui.screen.scan.Scan -import co.electriccoin.zcash.ui.screen.scan.Scan.ADDRESS_BOOK -import co.electriccoin.zcash.ui.screen.scan.Scan.HOMEPAGE -import co.electriccoin.zcash.ui.screen.scan.Scan.SEND +import co.electriccoin.zcash.ui.screen.scan.ScanFlow.ADDRESS_BOOK +import co.electriccoin.zcash.ui.screen.scan.ScanFlow.HOMEPAGE +import co.electriccoin.zcash.ui.screen.scan.ScanFlow.SEND import co.electriccoin.zcash.ui.screen.send.Send class OnZip321ScannedUseCase( @@ -29,20 +29,27 @@ class OnZip321ScannedUseCase( ) { suspend operator fun invoke( zip321: Zip321ParseUriValidation.Valid, - scanFlow: Scan + scanArgs: Scan ) { - if (scanFlow == ADDRESS_BOOK) { - navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value)) - } else { - createProposal(zip321, scanFlow) + when (scanArgs.flow) { + ADDRESS_BOOK -> addressBookFlow(zip321) + SEND -> + if (scanArgs.isScanZip321Enabled) { + sendFlow(zip321) + } else { + sendFlowWithDisabledZip321(zip321) + } + + HOMEPAGE -> homepageFlow(zip321) } } + private fun addressBookFlow(zip321: Zip321ParseUriValidation.Valid) { + navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value)) + } + @Suppress("TooGenericExceptionCaught") - private suspend fun createProposal( - zip321: Zip321ParseUriValidation.Valid, - scanFlow: Scan - ) { + private suspend fun homepageFlow(zip321: Zip321ParseUriValidation.Valid) { try { val proposal = when (accountDataSource.getSelectedAccount()) { @@ -57,35 +64,63 @@ class OnZip321ScannedUseCase( } } - if (scanFlow == HOMEPAGE) { - navigationRouter - .replace( - Send( - recipientAddress = proposal.destination.address, - recipientAddressType = - when (proposal.destination) { - is WalletAddress.Sapling -> SAPLING - is WalletAddress.Tex -> TEX - is WalletAddress.Transparent -> TRANSPARENT - is WalletAddress.Unified -> UNIFIED - } - ), - ReviewTransaction - ) - } else if (scanFlow == SEND) { - prefillSend.request( - PrefillSendData.All( - amount = proposal.amount, - address = proposal.destination.address, - fee = proposal.proposal.totalFeeRequired(), - memos = proposal.memo.value.takeIf { it.isNotEmpty() }?.let { listOf(it) } - ) + navigationRouter + .replace( + Send( + recipientAddress = proposal.destination.address, + recipientAddressType = + when (proposal.destination) { + is WalletAddress.Sapling -> SAPLING + is WalletAddress.Tex -> TEX + is WalletAddress.Transparent -> TRANSPARENT + is WalletAddress.Unified -> UNIFIED + } + ), + ReviewTransaction ) - navigationRouter.forward(ReviewTransaction) - } } catch (e: Exception) { keystoneProposalRepository.clear() throw e } } + + @Suppress("TooGenericExceptionCaught") + private suspend fun sendFlow(zip321: Zip321ParseUriValidation.Valid) { + try { + val proposal = + when (accountDataSource.getSelectedAccount()) { + is KeystoneAccount -> { + val result = keystoneProposalRepository.createZip321Proposal(zip321.zip321Uri) + keystoneProposalRepository.createPCZTFromProposal() + result + } + + is ZashiAccount -> { + zashiProposalRepository.createZip321Proposal(zip321.zip321Uri) + } + } + + prefillSend.request( + PrefillSendData.All( + amount = proposal.amount, + address = proposal.destination.address, + fee = proposal.proposal.totalFeeRequired(), + memos = proposal.memo.value.takeIf { it.isNotEmpty() }?.let { listOf(it) } + ) + ) + navigationRouter.forward(ReviewTransaction) + } catch (e: Exception) { + keystoneProposalRepository.clear() + throw e + } + } + + private fun sendFlowWithDisabledZip321(zip321: Zip321ParseUriValidation.Valid) { + prefillSend.request( + PrefillSendData.FromAddressScan( + address = zip321.payment.payments[0].recipientAddress.value + ) + ) + navigationRouter.back() + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/RestoreWalletUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/RestoreWalletUseCase.kt new file mode 100644 index 000000000..c46343b78 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/RestoreWalletUseCase.kt @@ -0,0 +1,24 @@ +package co.electriccoin.zcash.ui.common.usecase + +import android.content.Context +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.ui.common.repository.WalletRepository + +class RestoreWalletUseCase( + private val walletRepository: WalletRepository, + private val context: Context, +) { + operator fun invoke( + seedPhrase: SeedPhrase, + birthday: BlockHeight? + ) { + walletRepository.persistExistingWalletWithSeedPhrase( + network = ZcashNetwork.fromResources(context), + seedPhrase = seedPhrase, + birthday = birthday + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SelectWalletAccountUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SelectWalletAccountUseCase.kt index c80a29547..a039347dc 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SelectWalletAccountUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SelectWalletAccountUseCase.kt @@ -8,12 +8,8 @@ class SelectWalletAccountUseCase( private val accountDataSource: AccountDataSource, private val navigationRouter: NavigationRouter ) { - suspend operator fun invoke( - account: WalletAccount, - hideBottomSheet: suspend () -> Unit - ) { + suspend operator fun invoke(account: WalletAccount) { accountDataSource.selectAccount(account) - hideBottomSheet() navigationRouter.back() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SendTransactionAgainUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SendTransactionAgainUseCase.kt index e11b1f88e..496f6c758 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SendTransactionAgainUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SendTransactionAgainUseCase.kt @@ -9,6 +9,10 @@ class SendTransactionAgainUseCase( ) { operator fun invoke(value: DetailedTransactionData) { prefillSendUseCase.request(value) - navigationRouter.forward(Send()) + navigationRouter.forward( + Send( + isScanZip321Enabled = false + ) + ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateSeedUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateSeedUseCase.kt new file mode 100644 index 000000000..2cab036d7 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateSeedUseCase.kt @@ -0,0 +1,24 @@ +package co.electriccoin.zcash.ui.common.usecase + +import cash.z.ecc.android.bip39.Mnemonics +import cash.z.ecc.android.sdk.model.SeedPhrase +import java.util.Locale + +class ValidateSeedUseCase { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + operator fun invoke(words: List): SeedPhrase? { + return try { + val seed = words.joinToString(" ") { it.trim() }.trim() + Mnemonics.MnemonicCode(seed, Locale.ENGLISH.language).validate() + SeedPhrase(words) + } catch (e: Mnemonics.InvalidWordException) { + null + } catch (e: Mnemonics.ChecksumException) { + null + } catch (e: Mnemonics.WordCountException) { + null + } catch (e: Exception) { + null + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/AuthenticationViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/AuthenticationViewModel.kt index dd7e3cd6b..4a249c82e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/AuthenticationViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/AuthenticationViewModel.kt @@ -100,9 +100,7 @@ class AuthenticationViewModel( when { (!required || versionInfo.isRunningUnderTestService) -> AuthenticationUIState.NotRequired (state == AuthenticationUIState.Initial) -> { - if (secretState == SecretState.None || - secretState == SecretState.NeedsWarning - ) { + if (secretState == SecretState.NONE) { appAccessAuthentication.value = AuthenticationUIState.NotRequired AuthenticationUIState.NotRequired } else { @@ -146,12 +144,6 @@ class AuthenticationViewModel( val isDeleteWalletAuthenticationRequired: StateFlow = booleanStateFlow(StandardPreferenceKeys.IS_DELETE_WALLET_AUTHENTICATION) - val isSeedAuthenticationRequired: StateFlow = - booleanStateFlow(StandardPreferenceKeys.IS_SEED_AUTHENTICATION) - - val isSendFundsAuthenticationRequired: StateFlow = - booleanStateFlow(StandardPreferenceKeys.IS_SEND_FUNDS_AUTHENTICATION) - /** * Authentication framework result */ @@ -319,9 +311,6 @@ class AuthenticationViewModel( AuthenticationUseCase.ExportPrivateData -> R.string.authentication_use_case_export_data - AuthenticationUseCase.SeedRecovery -> - R.string.authentication_use_case_seed_recovery - AuthenticationUseCase.SendFunds -> R.string.authentication_use_case_send_funds } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/OldHomeViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/OldHomeViewModel.kt index 12ed81ede..2574018b6 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/OldHomeViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/OldHomeViewModel.kt @@ -3,10 +3,8 @@ package co.electriccoin.zcash.ui.common.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT -import co.electriccoin.zcash.configuration.model.map.Configuration import co.electriccoin.zcash.preference.StandardPreferenceProvider import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault -import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -16,7 +14,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn class OldHomeViewModel( - observeConfiguration: ObserveConfigurationUseCase, private val standardPreferenceProvider: StandardPreferenceProvider, ) : ViewModel() { /** @@ -30,8 +27,6 @@ class OldHomeViewModel( */ val isHideBalances: StateFlow = booleanStateFlow(StandardPreferenceKeys.IS_HIDE_BALANCES) - val configurationFlow: StateFlow = observeConfiguration() - private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow = flow { emitAll(default.observe(standardPreferenceProvider())) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt index 2b1aaa72a..e74a47b66 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt @@ -161,16 +161,10 @@ class WalletViewModel( /** * Represents the state of the wallet secret. */ -sealed class SecretState { - object Loading : SecretState() - - object None : SecretState() - - object NeedsWarning : SecretState() - - class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState() - - class Ready(val persistableWallet: PersistableWallet) : SecretState() +enum class SecretState { + LOADING, + NONE, + READY } /** diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/configuration/ConfigurationLocal.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/configuration/ConfigurationLocal.kt deleted file mode 100644 index 60800e62f..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/configuration/ConfigurationLocal.kt +++ /dev/null @@ -1,9 +0,0 @@ -package co.electriccoin.zcash.ui.configuration - -import androidx.compose.runtime.compositionLocalOf -import co.electriccoin.zcash.configuration.model.map.Configuration -import co.electriccoin.zcash.configuration.model.map.StringConfiguration -import kotlinx.collections.immutable.persistentMapOf - -@Suppress("CompositionLocalAllowlist", "CompositionLocalNaming") -val RemoteConfig = compositionLocalOf { StringConfiguration(persistentMapOf(), null) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/AndroidAccountList.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/AndroidAccountList.kt index 8686c4802..5ad2019c4 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/AndroidAccountList.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/AndroidAccountList.kt @@ -1,16 +1,13 @@ package co.electriccoin.zcash.ui.screen.accountlist import android.view.WindowManager -import androidx.activity.compose.BackHandler import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState import co.electriccoin.zcash.ui.screen.accountlist.view.AccountListView import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel import org.koin.androidx.compose.koinViewModel @@ -20,38 +17,12 @@ import org.koin.androidx.compose.koinViewModel fun AndroidAccountList() { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val parent = LocalView.current.parent - SideEffect { (parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) (parent as? DialogWindowProvider)?.window?.setDimAmount(0f) } - state?.let { - AccountListView( - state = it, - sheetState = sheetState, - onDismissRequest = { - state?.onBack?.invoke() - } - ) - - LaunchedEffect(Unit) { - sheetState.show() - } - - LaunchedEffect(Unit) { - viewModel.hideBottomSheetRequest.collect { - sheetState.hide() - state?.onBottomSheetHidden?.invoke() - } - } - - BackHandler { - state?.onBack?.invoke() - } + AccountListView(it) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/model/AccountListState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/model/AccountListState.kt index f1d58d626..bf528eeda 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/model/AccountListState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/model/AccountListState.kt @@ -2,16 +2,16 @@ package co.electriccoin.zcash.ui.screen.accountlist.model import androidx.annotation.DrawableRes import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.util.StringResource data class AccountListState( val items: List?, val isLoading: Boolean, - val onBottomSheetHidden: () -> Unit, val addWalletButton: ButtonState?, - val onBack: () -> Unit, -) + override val onBack: () -> Unit, +) : ModalBottomSheetState data class ZashiAccountListItemState( @DrawableRes val icon: Int, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/view/AccountListView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/view/AccountListView.kt index f450e8bab..3d8516a9b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/view/AccountListView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/view/AccountListView.kt @@ -33,13 +33,14 @@ import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.LottieProgress import co.electriccoin.zcash.ui.design.component.ZashiButton import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults -import co.electriccoin.zcash.ui.design.component.ZashiModalBottomSheet +import co.electriccoin.zcash.ui.design.component.ZashiScreenModalBottomSheet import co.electriccoin.zcash.ui.design.component.listitem.BaseListItem import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemColors import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemDefaults import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemDesignType import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState +import co.electriccoin.zcash.ui.design.component.rememberScreenModalBottomSheetState 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 @@ -54,16 +55,15 @@ import kotlinx.collections.immutable.persistentListOf @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun AccountListView( - onDismissRequest: () -> Unit, - sheetState: SheetState, - state: AccountListState + state: AccountListState, + sheetState: SheetState = rememberScreenModalBottomSheetState(), ) { - ZashiModalBottomSheet( + ZashiScreenModalBottomSheet( + state = state, sheetState = sheetState, content = { BottomSheetContent(state) }, - onDismissRequest = onDismissRequest ) } @@ -111,7 +111,10 @@ private fun BottomSheetContent(state: AccountListState) { Spacer(modifier = Modifier.height(32.dp)) ZashiButton( state = state.addWalletButton, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), colors = ZashiButtonDefaults.secondaryColors( borderColor = ZashiColors.Btns.Secondary.btnSecondaryBorder @@ -270,11 +273,9 @@ private fun Preview() = ) ), isLoading = false, - onBottomSheetHidden = {}, onBack = {}, addWalletButton = ButtonState(stringRes("Connect Hardware Wallet")) ), - onDismissRequest = {}, sheetState = rememberModalBottomSheetState( skipHiddenState = true, @@ -315,11 +316,9 @@ private fun HardwareWalletAddedPreview() = ), ), isLoading = false, - onBottomSheetHidden = {}, onBack = {}, addWalletButton = null ), - onDismissRequest = {}, sheetState = rememberModalBottomSheetState( skipHiddenState = true, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/viewmodel/AccountListViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/viewmodel/AccountListViewModel.kt index 07c6d6903..180a90450 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/viewmodel/AccountListViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/accountlist/viewmodel/AccountListViewModel.kt @@ -7,7 +7,7 @@ import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.common.model.KeystoneAccount import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.ZashiAccount -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase +import co.electriccoin.zcash.ui.common.usecase.GetWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.SelectWalletAccountUseCase import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.component.ButtonState @@ -19,26 +19,20 @@ import co.electriccoin.zcash.ui.screen.accountlist.model.AccountListState import co.electriccoin.zcash.ui.screen.accountlist.model.ZashiAccountListItemState import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.ADDRESS_MAX_LENGTH import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class AccountListViewModel( - observeWalletAccounts: ObserveWalletAccountsUseCase, + getWalletAccounts: GetWalletAccountsUseCase, private val selectWalletAccount: SelectWalletAccountUseCase, private val navigationRouter: NavigationRouter, ) : ViewModel() { - val hideBottomSheetRequest = MutableSharedFlow() - - private val bottomSheetHiddenResponse = MutableSharedFlow() - @Suppress("SpreadOperator") val state = - observeWalletAccounts().map { accounts -> + getWalletAccounts.observe().map { accounts -> val items = listOfNotNull( *accounts.orEmpty() @@ -77,7 +71,6 @@ class AccountListViewModel( AccountListState( items = items, isLoading = accounts == null, - onBottomSheetHidden = ::onBottomSheetHidden, onBack = ::onBack, addWalletButton = ButtonState( @@ -94,35 +87,14 @@ class AccountListViewModel( ) private fun onShowKeystonePromoClicked() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.replace(ExternalUrl("https://keyst.one/shop/products/keystone-3-pro?discount=Zashi")) - } - - private suspend fun hideBottomSheet() { - hideBottomSheetRequest.emit(Unit) - bottomSheetHiddenResponse.first() - } - - private fun onBottomSheetHidden() = - viewModelScope.launch { - bottomSheetHiddenResponse.emit(Unit) - } + navigationRouter.replace(ExternalUrl("https://keyst.one/shop/products/keystone-3-pro?discount=Zashi")) private fun onAccountClicked(account: WalletAccount) = viewModelScope.launch { - selectWalletAccount(account) { hideBottomSheet() } + selectWalletAccount(account) } - private fun onAddWalletButtonClicked() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.forward(ConnectKeystone) - } + private fun onAddWalletButtonClicked() = navigationRouter.forward(ConnectKeystone) - private fun onBack() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.back() - } + private fun onBack() = navigationRouter.back() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt index 6b3528ce1..310e791e9 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt @@ -16,6 +16,7 @@ import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.contact.UpdateContactArgs import co.electriccoin.zcash.ui.screen.scan.Scan +import co.electriccoin.zcash.ui.screen.scan.ScanFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed @@ -85,7 +86,7 @@ class AddressBookViewModel( private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) - private fun onScanContactClick() = navigationRouter.forward(Scan(Scan.ADDRESS_BOOK)) + private fun onScanContactClick() = navigationRouter.forward(Scan(ScanFlow.ADDRESS_BOOK)) } internal const val ADDRESS_MAX_LENGTH = 20 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/SelectRecipientViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/SelectRecipientViewModel.kt index 187348f2b..356a2ddc4 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/SelectRecipientViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/SelectRecipientViewModel.kt @@ -9,9 +9,9 @@ import co.electriccoin.zcash.ui.common.model.AddressBookContact import co.electriccoin.zcash.ui.common.model.KeystoneAccount import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.ZashiAccount +import co.electriccoin.zcash.ui.common.usecase.GetWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.listitem.ZashiContactListItemState import co.electriccoin.zcash.ui.design.util.ImageResource @@ -21,6 +21,7 @@ import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookItem import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.scan.Scan +import co.electriccoin.zcash.ui.screen.scan.ScanFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed @@ -31,12 +32,12 @@ import kotlinx.coroutines.launch class SelectRecipientViewModel( observeAddressBookContacts: ObserveAddressBookContactsUseCase, - observeWalletAccountsUseCase: ObserveWalletAccountsUseCase, + getWalletAccountsUseCase: GetWalletAccountsUseCase, private val observeContactPicked: ObserveContactPickedUseCase, private val navigationRouter: NavigationRouter ) : ViewModel() { val state = - combine(observeAddressBookContacts(), observeWalletAccountsUseCase()) { contacts, accounts -> + combine(observeAddressBookContacts(), getWalletAccountsUseCase.observe()) { contacts, accounts -> if (accounts != null && accounts.size > 1) { createStateWithAccounts(contacts, accounts) } else { @@ -174,5 +175,5 @@ class SelectRecipientViewModel( private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) - private fun onScanContactClick() = navigationRouter.forward(Scan(Scan.ADDRESS_BOOK)) + private fun onScanContactClick() = navigationRouter.forward(Scan(ScanFlow.ADDRESS_BOOK)) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/model/AdvancedSettingsState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt similarity index 84% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/model/AdvancedSettingsState.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt index cd81d3ac9..0b3cf2b38 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/model/AdvancedSettingsState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt @@ -1,4 +1,4 @@ -package co.electriccoin.zcash.ui.screen.advancedsettings.model +package co.electriccoin.zcash.ui.screen.advancedsettings import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsView.kt similarity index 96% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsView.kt index af5c45e4f..1003d0c93 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsView.kt @@ -1,4 +1,4 @@ -package co.electriccoin.zcash.ui.screen.advancedsettings.view +package co.electriccoin.zcash.ui.screen.advancedsettings import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -40,8 +40,6 @@ import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsTag -import co.electriccoin.zcash.ui.screen.advancedsettings.model.AdvancedSettingsState import kotlinx.collections.immutable.persistentListOf // TODO [#1271]: Add AdvancedSettingsView Tests diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsViewModel.kt similarity index 91% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsViewModel.kt index 7be2b82a0..c032e3b0f 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsViewModel.kt @@ -1,4 +1,4 @@ -package co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel +package co.electriccoin.zcash.ui.screen.advancedsettings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,11 +8,11 @@ import co.electriccoin.zcash.ui.NavigationTargets import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase +import co.electriccoin.zcash.ui.common.usecase.NavigateToSeedRecoveryUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToTaxExportUseCase import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.advancedsettings.model.AdvancedSettingsState import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -25,6 +25,7 @@ class AdvancedSettingsViewModel( getWalletRestoringState: GetWalletRestoringStateUseCase, private val navigationRouter: NavigationRouter, private val navigateToTaxExport: NavigateToTaxExportUseCase, + private val navigateToSeedRecovery: NavigateToSeedRecoveryUseCase ) : ViewModel() { val state: StateFlow = getWalletRestoringState.observe() @@ -45,7 +46,7 @@ class AdvancedSettingsViewModel( ZashiListItemState( title = stringRes(R.string.advanced_settings_recovery), icon = R.drawable.ic_advanced_settings_recovery, - onClick = {} + onClick = ::onSeedRecoveryClick ), ZashiListItemState( title = stringRes(R.string.advanced_settings_export), @@ -93,4 +94,9 @@ class AdvancedSettingsViewModel( viewModelScope.launch { navigateToTaxExport() } + + private fun onSeedRecoveryClick() = + viewModelScope.launch { + navigateToSeedRecovery() + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AndroidAdvancedSettings.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AndroidAdvancedSettings.kt index 578b34973..3af9b4aba 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AndroidAdvancedSettings.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AndroidAdvancedSettings.kt @@ -7,8 +7,6 @@ import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.electriccoin.zcash.di.koinActivityViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel -import co.electriccoin.zcash.ui.screen.advancedsettings.view.AdvancedSettings -import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettingsViewModel import kotlinx.collections.immutable.toImmutableList import org.koin.androidx.compose.koinViewModel @@ -16,7 +14,6 @@ import org.koin.androidx.compose.koinViewModel internal fun WrapAdvancedSettings( goDeleteWallet: () -> Unit, goExportPrivateData: () -> Unit, - goSeedRecovery: () -> Unit, ) { val walletViewModel = koinActivityViewModel() val viewModel = koinViewModel() @@ -28,7 +25,6 @@ internal fun WrapAdvancedSettings( items = originalState.items.mapIndexed { index, item -> when (index) { - 0 -> item.copy(onClick = goSeedRecovery) 1 -> item.copy(onClick = goExportPrivateData) else -> item } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/authentication/AndroidAuthentication.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/authentication/AndroidAuthentication.kt index efc818793..4b28ae86d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/authentication/AndroidAuthentication.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/authentication/AndroidAuthentication.kt @@ -20,7 +20,6 @@ import kotlin.time.Duration.Companion.milliseconds private const val APP_ACCESS_TRIGGER_DELAY = 0 private const val DELETE_WALLET_TRIGGER_DELAY = 0 private const val EXPORT_PRIVATE_DATA_TRIGGER_DELAY = 0 -private const val SEED_RECOVERY_TRIGGER_DELAY = 0 private const val SEND_FUNDS_DELAY = 0 internal const val RETRY_TRIGGER_DELAY = 0 @@ -82,16 +81,6 @@ private fun WrapAuthenticationUseCases( onFailed = onFailed ) } - AuthenticationUseCase.SeedRecovery -> { - Twig.debug { "Seed Recovery Authentication" } - WrapSeedRecoveryAuth( - activity = activity, - goSeedRecovery = onSuccess, - goSupport = goSupport ?: {}, - onCancel = onCancel, - onFailed = onFailed - ) - } AuthenticationUseCase.SendFunds -> { Twig.debug { "Send Funds Authentication" } WrapSendFundsAuth( @@ -251,79 +240,6 @@ private fun WrapAppExportPrivateDataAuth( } } -@Composable -private fun WrapSeedRecoveryAuth( - activity: MainActivity, - goSupport: () -> Unit, - goSeedRecovery: () -> Unit, - onCancel: () -> Unit, - onFailed: () -> Unit, -) { - val authenticationViewModel = koinActivityViewModel() - - val authenticationResult = - authenticationViewModel.authenticationResult - .collectAsStateWithLifecycle(initialValue = AuthenticationResult.None).value - - when (authenticationResult) { - AuthenticationResult.None -> { - Twig.info { "Authentication result: initiating" } - // Initial state - } - AuthenticationResult.Success -> { - Twig.info { "Authentication result: successful" } - authenticationViewModel.resetAuthenticationResult() - goSeedRecovery() - } - AuthenticationResult.Canceled -> { - Twig.info { "Authentication result: canceled" } - authenticationViewModel.resetAuthenticationResult() - onCancel() - } - AuthenticationResult.Failed -> { - Twig.warn { "Authentication result: failed" } - authenticationViewModel.resetAuthenticationResult() - onFailed() - Toast.makeText(activity, stringResource(id = R.string.authentication_toast_failed), Toast.LENGTH_SHORT) - .show() - } - is AuthenticationResult.Error -> { - Twig.error { - "Authentication result: error: ${authenticationResult.errorCode}: ${authenticationResult.errorMessage}" - } - AuthenticationErrorDialog( - onDismiss = { - // Reset authentication states - authenticationViewModel.resetAuthenticationResult() - onCancel() - }, - onRetry = { - authenticationViewModel.resetAuthenticationResult() - authenticationViewModel.authenticate( - activity = activity, - initialAuthSystemWindowDelay = RETRY_TRIGGER_DELAY.milliseconds, - useCase = AuthenticationUseCase.SeedRecovery - ) - }, - onSupport = { - authenticationViewModel.resetAuthenticationResult() - goSupport() - }, - reason = authenticationResult - ) - } - } - - // Starting authentication - LaunchedEffect(key1 = true) { - authenticationViewModel.authenticate( - activity = activity, - initialAuthSystemWindowDelay = SEED_RECOVERY_TRIGGER_DELAY.milliseconds, - useCase = AuthenticationUseCase.SeedRecovery - ) - } -} - @Composable @Suppress("LongMethod") private fun WrapSendFundsAuth( @@ -472,8 +388,6 @@ private fun WrapAppAccessAuth( sealed class AuthenticationUseCase { data object AppAccess : AuthenticationUseCase() - data object SeedRecovery : AuthenticationUseCase() - data object DeleteWallet : AuthenticationUseCase() data object ExportPrivateData : AuthenticationUseCase() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/balances/BalanceViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/balances/BalanceViewModel.kt index 810953fb2..868831bbe 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/balances/BalanceViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/balances/BalanceViewModel.kt @@ -2,8 +2,10 @@ package co.electriccoin.zcash.ui.screen.balances import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import co.electriccoin.zcash.ui.common.datasource.AccountDataSource +import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import kotlinx.coroutines.flow.SharingStarted @@ -22,30 +24,41 @@ class BalanceViewModel( accountDataSource.selectedAccount.filterNotNull(), exchangeRateRepository.state, ) { account, exchangeRateUsd -> - when { - ( - account.spendableBalance.value == 0L && - account.totalBalance.value > 0L && - (account.hasChangePending || account.hasValuePending) - ) -> { - BalanceState.Loading( - totalBalance = account.totalBalance, - spendableBalance = account.spendableBalance, - exchangeRate = exchangeRateUsd, - ) - } - - else -> { - BalanceState.Available( - totalBalance = account.totalBalance, - spendableBalance = account.spendableBalance, - exchangeRate = exchangeRateUsd, - ) - } - } + createState(account, exchangeRateUsd) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - BalanceState.None(ExchangeRateState.OptedOut) + createState( + account = accountDataSource.allAccounts.value?.firstOrNull { it.isSelected }, + exchangeRateUsd = exchangeRateRepository.state.value + ) ) + + private fun createState( + account: WalletAccount?, + exchangeRateUsd: ExchangeRateState + ): BalanceState { + return when { + ( + account != null && + account.spendableBalance.value == 0L && + account.totalBalance.value > 0L && + (account.hasChangePending || account.hasValuePending) + ) -> { + BalanceState.Loading( + totalBalance = account.totalBalance, + spendableBalance = account.spendableBalance, + exchangeRate = exchangeRateUsd, + ) + } + + else -> { + BalanceState.Available( + totalBalance = account?.totalBalance ?: Zatoshi(0), + spendableBalance = account?.spendableBalance ?: Zatoshi(0), + exchangeRate = exchangeRateUsd, + ) + } + } + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeState.kt index a2588090a..8eb2b6811 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeState.kt @@ -1,12 +1,14 @@ package co.electriccoin.zcash.ui.screen.home import co.electriccoin.zcash.ui.design.component.BigIconButtonState +import co.electriccoin.zcash.ui.screen.home.messages.HomeMessageState data class HomeState( - val receiveButton: BigIconButtonState, - val sendButton: BigIconButtonState, - val scanButton: BigIconButtonState, - val moreButton: BigIconButtonState, + val firstButton: BigIconButtonState, + val secondButton: BigIconButtonState, + val thirdButton: BigIconButtonState, + val fourthButton: BigIconButtonState, + val message: HomeMessageState? ) data class HomeRestoreDialogState( diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeView.kt index d8484a537..c2548d208 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeView.kt @@ -1,9 +1,5 @@ package co.electriccoin.zcash.ui.screen.home -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,7 +18,6 @@ import androidx.compose.ui.unit.dp import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarWithAccountSelection -import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.design.component.BigIconButtonState import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.ZashiBigIconButton @@ -34,7 +29,7 @@ import co.electriccoin.zcash.ui.fixture.BalanceStateFixture import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture import co.electriccoin.zcash.ui.screen.balances.BalanceState import co.electriccoin.zcash.ui.screen.balances.BalanceWidget -import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeOptIn +import co.electriccoin.zcash.ui.screen.home.messages.HomeMessage import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetState import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransactionHistoryWidgets @@ -74,7 +69,6 @@ private fun Content( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) - BalanceWidget( modifier = Modifier @@ -84,39 +78,10 @@ private fun Content( ), balanceState = balanceState, ) - Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) - - Row( - modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ZashiBigIconButton( - modifier = - Modifier - .weight(1f) - .testTag(HomeTags.RECEIVE), - state = state.receiveButton, - ) - ZashiBigIconButton( - modifier = - Modifier - .weight(1f) - .testTag(HomeTags.SEND), - state = state.sendButton, - ) - ZashiBigIconButton( - modifier = Modifier.weight(1f), - state = state.scanButton, - ) - ZashiBigIconButton( - modifier = Modifier.weight(1f), - state = state.moreButton, - ) - } - - Spacer(Modifier.height(32.dp)) - + NavButtons(paddingValues, state) + Spacer(Modifier.height(16.dp)) + HomeMessage(state.message) LazyColumn( modifier = Modifier @@ -128,29 +93,46 @@ private fun Content( ) } } + } +} - AnimatedVisibility( - visible = balanceState.exchangeRate is ExchangeRateState.OptIn, - enter = EnterTransition.None, - exit = fadeOut() + slideOutVertically(), - ) { - Column { - Spacer(modifier = Modifier.height(66.dp + paddingValues.calculateTopPadding())) - StyledExchangeOptIn( - modifier = Modifier.padding(horizontal = 24.dp), - state = - (balanceState.exchangeRate as? ExchangeRateState.OptIn) ?: ExchangeRateState.OptIn( - onDismissClick = {}, - ) - ) - } - } +@Composable +private fun NavButtons( + paddingValues: PaddingValues, + state: HomeState +) { + Row( + modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ZashiBigIconButton( + modifier = + Modifier + .weight(1f) + .testTag(HomeTags.RECEIVE), + state = state.firstButton, + ) + ZashiBigIconButton( + modifier = + Modifier + .weight(1f) + .testTag(HomeTags.SEND), + state = state.secondButton, + ) + ZashiBigIconButton( + modifier = Modifier.weight(1f), + state = state.thirdButton, + ) + ZashiBigIconButton( + modifier = Modifier.weight(1f), + state = state.fourthButton, + ) } } @PreviewScreens @Composable -private fun Preview() = +private fun Preview() { ZcashTheme { HomeView( appBarState = ZashiMainTopAppBarStateFixture.new(), @@ -158,30 +140,32 @@ private fun Preview() = transactionWidgetState = TransactionHistoryWidgetStateFixture.new(), state = HomeState( - receiveButton = + firstButton = BigIconButtonState( text = stringRes("Text"), icon = R.drawable.ic_warning, onClick = {} ), - sendButton = + secondButton = BigIconButtonState( text = stringRes("Text"), icon = R.drawable.ic_warning, onClick = {} ), - scanButton = + thirdButton = BigIconButtonState( text = stringRes("Text"), icon = R.drawable.ic_warning, onClick = {} ), - moreButton = + fourthButton = BigIconButtonState( text = stringRes("Text"), icon = R.drawable.ic_warning, onClick = {} ), + message = null ) ) } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeViewModel.kt index 01cd7361a..478314b66 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/HomeViewModel.kt @@ -4,28 +4,44 @@ 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.NavigationTargets import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.model.DistributionDimension +import co.electriccoin.zcash.ui.common.model.KeystoneAccount +import co.electriccoin.zcash.ui.common.model.WalletAccount +import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider +import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase +import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase import co.electriccoin.zcash.ui.design.component.BigIconButtonState import co.electriccoin.zcash.ui.design.util.stringRes +import co.electriccoin.zcash.ui.screen.home.messages.WalletBackupMessageState import co.electriccoin.zcash.ui.screen.integrations.DialogIntegrations import co.electriccoin.zcash.ui.screen.receive.Receive +import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.scan.Scan +import co.electriccoin.zcash.ui.screen.scan.ScanFlow +import co.electriccoin.zcash.ui.screen.seed.backup.SeedBackup import co.electriccoin.zcash.ui.screen.send.Send import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class HomeViewModel( + getVersionInfoProvider: GetVersionInfoProvider, + getSelectedWalletAccountUseCase: GetSelectedWalletAccountUseCase, private val navigationRouter: NavigationRouter, - private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase + private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase, + private val navigateToCoinbase: NavigateToCoinbaseUseCase ) : ViewModel() { + private val isMessageVisible = MutableStateFlow(true) + private val isRestoreDialogVisible: Flow = isRestoreSuccessDialogVisible.observe() .stateIn( @@ -48,36 +64,72 @@ class HomeViewModel( ) val state: StateFlow = - MutableStateFlow( - HomeState( - receiveButton = + combine(getSelectedWalletAccountUseCase.observe(), isMessageVisible) { selectedAccount, isMessageVisible -> + createState(getVersionInfoProvider, selectedAccount, isMessageVisible) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = null + ) + + private fun createState( + getVersionInfoProvider: GetVersionInfoProvider, + selectedAccount: WalletAccount?, + isMessageVisible: Boolean + ) = HomeState( + firstButton = + BigIconButtonState( + text = stringRes(R.string.home_button_receive), + icon = R.drawable.ic_home_receive, + onClick = ::onReceiveButtonClick, + ), + secondButton = + BigIconButtonState( + text = stringRes(R.string.home_button_send), + icon = R.drawable.ic_home_send, + onClick = ::onSendButtonClick, + ), + thirdButton = + BigIconButtonState( + text = stringRes(R.string.home_button_scan), + icon = R.drawable.ic_home_scan, + onClick = ::onScanButtonClick, + ), + fourthButton = + when { + getVersionInfoProvider().distributionDimension == DistributionDimension.FOSS -> BigIconButtonState( - text = stringRes("Receive"), - icon = R.drawable.ic_home_receive, - onClick = ::onReceiveButtonClick, - ), - sendButton = + text = stringRes(R.string.home_button_request), + icon = R.drawable.ic_home_request, + onClick = ::onRequestClick, + ) + + selectedAccount is KeystoneAccount -> BigIconButtonState( - text = stringRes("Send"), - icon = R.drawable.ic_home_send, - onClick = ::onSendButtonClick, - ), - scanButton = + text = stringRes(R.string.home_button_buy), + icon = R.drawable.ic_home_buy, + onClick = ::onBuyClick, + ) + + else -> BigIconButtonState( - text = stringRes("Scan"), - icon = R.drawable.ic_home_scan, - onClick = ::onScanButtonClick, - ), - moreButton = - BigIconButtonState( - text = stringRes("More"), + text = stringRes(R.string.home_button_more), icon = R.drawable.ic_home_more, onClick = ::onMoreButtonClick, - ), - ) - ).asStateFlow() + ) + }, + message = createWalletBackupMessageState().takeIf { isMessageVisible } + ) - fun onRestoreDialogSeenClick() = + private fun createWalletBackupMessageState(): WalletBackupMessageState { + return WalletBackupMessageState( + onClick = { + navigationRouter.forward(SeedBackup) + } + ) + } + + private fun onRestoreDialogSeenClick() = viewModelScope.launch { isRestoreSuccessDialogVisible.setSeen() } @@ -95,6 +147,15 @@ class HomeViewModel( } private fun onScanButtonClick() { - navigationRouter.forward(Scan(Scan.HOMEPAGE)) + navigationRouter.forward(Scan(ScanFlow.HOMEPAGE)) + } + + private fun onBuyClick() = + viewModelScope.launch { + navigateToCoinbase(replaceCurrentScreen = false) + } + + private fun onRequestClick() { + navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}") } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessage.kt new file mode 100644 index 000000000..ff389bee2 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessage.kt @@ -0,0 +1,171 @@ +package co.electriccoin.zcash.ui.screen.home.messages + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.DefaultShadowColor +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.addOutline +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors +import kotlinx.coroutines.delay + +@Suppress("MagicNumber") +@Composable +fun HomeMessage(state: HomeMessageState?) { + val cutoutHeight = 16.dp + var normalizedState: HomeMessageState? by remember { mutableStateOf(state) } + var isVisible by remember { mutableStateOf(state != null) } + val bottomCornerSize by animateDpAsState( + if (isVisible) cutoutHeight else 0.dp, + animationSpec = tween(350) + ) + + Box( + modifier = + Modifier + .background(Color.Gray) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(cutoutHeight) + .zIndex(2f) + .bottomOnlyShadow( + elevation = 2.dp, + shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), + backgroundColor = ZashiColors.Surfaces.bgPrimary + ), + ) + + AnimatedVisibility( + modifier = + Modifier + .fillMaxWidth() + .zIndex(0f), + visible = isVisible, + enter = expandIn(animationSpec = tween(350)), + exit = shrinkOut(animationSpec = tween(350)) + ) { + when (normalizedState) { + is WalletBackupMessageState -> + WalletBackupMessage( + state = normalizedState as WalletBackupMessageState, + contentPadding = + PaddingValues( + vertical = cutoutHeight + ) + ) + + null -> { + // do nothing + } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(cutoutHeight) + .zIndex(1f) + .align(Alignment.BottomCenter) + .topOnlyShadow( + elevation = 2.dp, + shape = RoundedCornerShape(topStart = bottomCornerSize, topEnd = bottomCornerSize), + backgroundColor = ZashiColors.Surfaces.bgPrimary + ), + ) + } + + LaunchedEffect(state) { + if (state != null) { + normalizedState = state + isVisible = true + } else { + isVisible = false + delay(350) + normalizedState = null + } + } +} + +private fun Modifier.bottomOnlyShadow( + elevation: Dp, + shape: Shape, + backgroundColor: Color, + clip: Boolean = elevation > 0.dp, + ambientColor: Color = DefaultShadowColor, + spotColor: Color = DefaultShadowColor, +): Modifier = + this + .drawWithCache { + // bottom shadow offset in Px based on elevation + val bottomOffsetPx = elevation.toPx() + // Adjust the size to extend the bottom by the bottom shadow offset + val adjustedSize = Size(size.width, size.height + bottomOffsetPx) + val outline = shape.createOutline(adjustedSize, layoutDirection, this) + val path = Path().apply { addOutline(outline) } + onDrawWithContent { + clipPath(path, ClipOp.Intersect) { + this@onDrawWithContent.drawContent() + } + } + } + .shadow(elevation, shape, clip, ambientColor, spotColor) + .background( + backgroundColor, + shape + ) + +private fun Modifier.topOnlyShadow( + elevation: Dp, + shape: Shape, + backgroundColor: Color, + clip: Boolean = elevation > 0.dp, + ambientColor: Color = DefaultShadowColor, + spotColor: Color = DefaultShadowColor, +): Modifier = + this + .drawWithCache { + // Adjust the size to extend the bottom by the bottom shadow offset + val adjustedSize = Size(size.width, size.height) + val outline = shape.createOutline(adjustedSize, layoutDirection, this) + val path = Path().apply { addOutline(outline) } + onDrawWithContent { + clipPath(path, ClipOp.Intersect) { + this@onDrawWithContent.drawContent() + } + } + } + .shadow(elevation, shape, clip, ambientColor, spotColor) + .background( + backgroundColor, + shape + ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageState.kt new file mode 100644 index 000000000..8fc1524eb --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageState.kt @@ -0,0 +1,3 @@ +package co.electriccoin.zcash.ui.screen.home.messages + +sealed interface HomeMessageState diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageWrapper.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageWrapper.kt new file mode 100644 index 000000000..b0b8115bb --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/HomeMessageWrapper.kt @@ -0,0 +1,38 @@ +package co.electriccoin.zcash.ui.screen.home.messages + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun HomeMessageWrapper( + color: Color, + contentPadding: PaddingValues, + content: @Composable RowScope.() -> Unit, +) { + Surface( + color = color, + ) { + Box( + modifier = Modifier.padding(contentPadding) + ) { + Row( + modifier = + Modifier.padding( + horizontal = 16.dp, + vertical = 14.dp + ), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/WalletBackupMessageState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/WalletBackupMessageState.kt new file mode 100644 index 000000000..f16c3493a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/messages/WalletBackupMessageState.kt @@ -0,0 +1,87 @@ +package co.electriccoin.zcash.ui.screen.home.messages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.design.component.BlankSurface +import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.HorizontalSpacer +import co.electriccoin.zcash.ui.design.component.VerticalSpacer +import co.electriccoin.zcash.ui.design.component.ZashiButton +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.stringRes + +@Composable +fun WalletBackupMessage( + state: WalletBackupMessageState, + contentPadding: PaddingValues +) { + HomeMessageWrapper( + color = ZashiColors.Utility.Espresso.utilityEspresso100, + contentPadding = contentPadding, + ) { + Image( + painter = painterResource(R.drawable.ic_warning_triangle), + contentDescription = null + ) + HorizontalSpacer(16.dp) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + stringResource(R.string.home_message_backup_required_title), + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium, + color = ZashiColors.Utility.Espresso.utilityEspresso900 + ) + VerticalSpacer(2.dp) + Text( + text = stringResource(R.string.home_message_backup_required_subtitle), + style = ZashiTypography.textXs, + fontWeight = FontWeight.Medium, + color = ZashiColors.Utility.Espresso.utilityEspresso700 + ) + } + ZashiButton( + modifier = Modifier.height(36.dp), + state = + ButtonState( + onClick = state.onClick, + text = stringRes("Start") + ) + ) + } +} + +@Immutable +data class WalletBackupMessageState( + val onClick: () -> Unit, +) : HomeMessageState + +@PreviewScreens +@Composable +private fun Preview() = + ZcashTheme { + BlankSurface { + WalletBackupMessage( + state = + WalletBackupMessageState( + onClick = {} + ), + contentPadding = PaddingValues() + ) + } + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidDialogIntegrations.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidDialogIntegrations.kt index c6b2d051a..e212dce51 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidDialogIntegrations.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidDialogIntegrations.kt @@ -1,18 +1,13 @@ package co.electriccoin.zcash.ui.screen.integrations import android.view.WindowManager -import androidx.activity.compose.BackHandler import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState -import co.electriccoin.zcash.ui.screen.integrations.view.IntegrationsDialogView -import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -20,15 +15,10 @@ import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun AndroidDialogIntegrations() { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val parent = LocalView.current.parent val viewModel = koinViewModel { parametersOf(true) } val state by viewModel.state.collectAsStateWithLifecycle() - BackHandler(enabled = state != null) { - state?.onBack?.invoke() - } - SideEffect { (parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) (parent as? DialogWindowProvider)?.window?.setDimAmount(0f) @@ -37,22 +27,7 @@ fun AndroidDialogIntegrations() { state?.let { IntegrationsDialogView( state = it, - sheetState = sheetState, - onDismissRequest = { - it.onBack() - } ) - - LaunchedEffect(Unit) { - sheetState.show() - } - - LaunchedEffect(Unit) { - viewModel.hideBottomSheetRequest.collect { - sheetState.hide() - state?.onBottomSheetHidden?.invoke() - } - } } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidIntegrations.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidIntegrations.kt index 1735790a1..a2eeb916e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidIntegrations.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/AndroidIntegrations.kt @@ -5,8 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel -import co.electriccoin.zcash.ui.screen.integrations.view.Integrations -import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsDialogView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsDialogView.kt similarity index 70% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsDialogView.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsDialogView.kt index df4822d52..a3c95ac1b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsDialogView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsDialogView.kt @@ -1,9 +1,12 @@ -package co.electriccoin.zcash.ui.screen.integrations.view +package co.electriccoin.zcash.ui.screen.integrations +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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 @@ -14,34 +17,37 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.design.component.ZashiModalBottomSheet +import co.electriccoin.zcash.ui.design.component.HorizontalSpacer +import co.electriccoin.zcash.ui.design.component.ZashiScreenModalBottomSheet import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState +import co.electriccoin.zcash.ui.design.component.rememberScreenModalBottomSheetState 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.stringRes -import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState import kotlinx.collections.immutable.persistentListOf @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun IntegrationsDialogView( - onDismissRequest: () -> Unit, - sheetState: SheetState, - state: IntegrationsState + state: IntegrationsState, + sheetState: SheetState = rememberScreenModalBottomSheetState(), ) { - ZashiModalBottomSheet( + ZashiScreenModalBottomSheet( + state = state, sheetState = sheetState, content = { BottomSheetContent(state) }, - onDismissRequest = onDismissRequest ) } @@ -56,8 +62,33 @@ fun BottomSheetContent(state: IntegrationsState) { color = ZashiColors.Text.textPrimary ) Spacer(Modifier.height(8.dp)) - IntegrationItems(state, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)) - Spacer(modifier = Modifier.height(24.dp)) + IntegrationItems(state, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + ) { + Image( + modifier = Modifier, + painter = painterResource(R.drawable.ic_info), + contentDescription = null, + colorFilter = ColorFilter.tint(ZashiColors.Text.textTertiary) + ) + HorizontalSpacer(8.dp) + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.integrations_info), + textAlign = TextAlign.Start, + style = ZashiTypography.textXs, + color = ZashiColors.Text.textTertiary + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } @@ -68,7 +99,6 @@ fun BottomSheetContent(state: IntegrationsState) { private fun IntegrationSettings() = ZcashTheme { IntegrationsDialogView( - onDismissRequest = {}, sheetState = rememberModalBottomSheetState( skipHiddenState = true, @@ -101,7 +131,6 @@ private fun IntegrationSettings() = onClick = {} ), ), - onBottomSheetHidden = {} ), ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/model/IntegrationsState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsState.kt similarity index 62% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/model/IntegrationsState.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsState.kt index 942af8fd3..c49a77c7d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/model/IntegrationsState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsState.kt @@ -1,12 +1,12 @@ -package co.electriccoin.zcash.ui.screen.integrations.model +package co.electriccoin.zcash.ui.screen.integrations +import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.util.StringResource import kotlinx.collections.immutable.ImmutableList data class IntegrationsState( val disabledInfo: StringResource?, - val onBack: () -> Unit, + override val onBack: () -> Unit, val items: ImmutableList, - val onBottomSheetHidden: () -> Unit, -) +) : ModalBottomSheetState diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsView.kt similarity index 97% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsView.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsView.kt index 0f68a92d3..ee9d5399a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/view/IntegrationsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsView.kt @@ -1,4 +1,4 @@ -package co.electriccoin.zcash.ui.screen.integrations.view +package co.electriccoin.zcash.ui.screen.integrations import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -44,7 +44,6 @@ import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.getValue import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState import co.electriccoin.zcash.ui.screen.settings.SettingsTag import kotlinx.collections.immutable.persistentListOf @@ -198,7 +197,6 @@ private fun IntegrationSettings() = onClick = {} ), ), - onBottomSheetHidden = {} ), topAppBarSubTitleState = TopAppBarSubTitleState.None, ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsViewModel.kt new file mode 100644 index 000000000..cbf90aa3e --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/IntegrationsViewModel.kt @@ -0,0 +1,141 @@ +package co.electriccoin.zcash.ui.screen.integrations + +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.common.model.KeystoneAccount +import co.electriccoin.zcash.ui.common.model.WalletAccount +import co.electriccoin.zcash.ui.common.model.WalletRestoringState +import co.electriccoin.zcash.ui.common.model.ZashiAccount +import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider +import co.electriccoin.zcash.ui.common.usecase.GetCoinbaseStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetFlexaStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetKeystoneStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase +import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase +import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase +import co.electriccoin.zcash.ui.common.usecase.Status +import co.electriccoin.zcash.ui.common.usecase.Status.DISABLED +import co.electriccoin.zcash.ui.common.usecase.Status.ENABLED +import co.electriccoin.zcash.ui.common.usecase.Status.UNAVAILABLE +import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState +import co.electriccoin.zcash.ui.design.util.stringRes +import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone +import co.electriccoin.zcash.ui.screen.flexa.Flexa +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class IntegrationsViewModel( + getZcashCurrency: GetZcashCurrencyProvider, + getWalletRestoringState: GetWalletRestoringStateUseCase, + getSelectedWalletAccount: GetSelectedWalletAccountUseCase, + getCoinbaseStatus: GetCoinbaseStatusUseCase, + getFlexaStatus: GetFlexaStatusUseCase, + getKeystoneStatus: GetKeystoneStatusUseCase, + private val isDialog: Boolean, + private val navigationRouter: NavigationRouter, + private val navigateToCoinbase: NavigateToCoinbaseUseCase, +) : ViewModel() { + private val isRestoring = getWalletRestoringState.observe().map { it == WalletRestoringState.RESTORING } + + val state = + combine( + isRestoring, + getSelectedWalletAccount.observe(), + getCoinbaseStatus.observe(), + getFlexaStatus.observe(), + getKeystoneStatus.observe(), + ) { isRestoring, selectedAccount, coinbaseStatus, flexaStatus, keystoneStatus -> + createState( + isRestoring = isRestoring, + getZcashCurrency = getZcashCurrency, + selectedAccount = selectedAccount, + flexaStatus = flexaStatus, + coinbaseStatus = coinbaseStatus, + keystoneStatus = keystoneStatus, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = null + ) + + private fun createState( + isRestoring: Boolean, + getZcashCurrency: GetZcashCurrencyProvider, + selectedAccount: WalletAccount?, + flexaStatus: Status, + coinbaseStatus: Status, + keystoneStatus: Status + ) = IntegrationsState( + disabledInfo = + when { + isRestoring -> stringRes(R.string.integrations_disabled_info) + selectedAccount is KeystoneAccount -> stringRes(R.string.integrations_disabled_info_flexa) + else -> null + }, + onBack = ::onBack, + items = + listOfNotNull( + ZashiListItemState( + // Set the wallet currency by app build is more future-proof, although we hide it from + // the UI in the Testnet build + icon = R.drawable.ic_integrations_coinbase, + title = stringRes(R.string.integrations_coinbase, getZcashCurrency.getLocalizedName()), + subtitle = + stringRes( + R.string.integrations_coinbase_subtitle, + getZcashCurrency.getLocalizedName() + ), + onClick = ::onBuyWithCoinbaseClicked + ).takeIf { coinbaseStatus != UNAVAILABLE }, + ZashiListItemState( + // Set the wallet currency by app build is more future-proof, although we hide it from + // the UI in the Testnet build + isEnabled = isRestoring.not() && selectedAccount is ZashiAccount, + icon = + when (flexaStatus) { + ENABLED -> R.drawable.ic_integrations_flexa + DISABLED -> R.drawable.ic_integrations_flexa_disabled + UNAVAILABLE -> R.drawable.ic_integrations_flexa_disabled + }, + title = stringRes(R.string.integrations_flexa), + subtitle = stringRes(R.string.integrations_flexa_subtitle), + onClick = ::onFlexaClicked + ).takeIf { flexaStatus != UNAVAILABLE }, + ZashiListItemState( + title = stringRes(R.string.integrations_keystone), + subtitle = stringRes(R.string.integrations_keystone_subtitle), + icon = R.drawable.ic_integrations_keystone, + onClick = ::onConnectKeystoneClick + ).takeIf { keystoneStatus != UNAVAILABLE }, + ).toImmutableList(), + ) + + private fun onBack() = navigationRouter.back() + + private fun onBuyWithCoinbaseClicked() = + viewModelScope.launch { + navigateToCoinbase(isDialog) + } + + private fun onConnectKeystoneClick() = + viewModelScope.launch { + navigationRouter.replace(ConnectKeystone) + } + + private fun onFlexaClicked() { + if (isDialog) { + navigationRouter.replace(Flexa) + } else { + navigationRouter.forward(Flexa) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/viewmodel/IntegrationsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/viewmodel/IntegrationsViewModel.kt deleted file mode 100644 index f976796f1..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/integrations/viewmodel/IntegrationsViewModel.kt +++ /dev/null @@ -1,138 +0,0 @@ -package co.electriccoin.zcash.ui.screen.integrations.viewmodel - -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.common.model.KeystoneAccount -import co.electriccoin.zcash.ui.common.model.WalletRestoringState -import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider -import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase -import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase -import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase -import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase -import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState -import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone -import co.electriccoin.zcash.ui.screen.flexa.Flexa -import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class IntegrationsViewModel( - getZcashCurrency: GetZcashCurrencyProvider, - getWalletRestoringState: GetWalletRestoringStateUseCase, - isFlexaAvailableUseCase: IsFlexaAvailableUseCase, - isCoinbaseAvailable: IsCoinbaseAvailableUseCase, - observeWalletAccounts: ObserveWalletAccountsUseCase, - private val isDialog: Boolean, - private val navigationRouter: NavigationRouter, - private val navigateToCoinbase: NavigateToCoinbaseUseCase, -) : ViewModel() { - val hideBottomSheetRequest = MutableSharedFlow() - - private val bottomSheetHiddenResponse = MutableSharedFlow() - - private val isRestoring = getWalletRestoringState.observe().map { it == WalletRestoringState.RESTORING } - - val state = - combine( - isFlexaAvailableUseCase.observe(), - isCoinbaseAvailable.observe(), - isRestoring, - observeWalletAccounts() - ) { isFlexaAvailable, isCoinbaseAvailable, isRestoring, accounts -> - IntegrationsState( - disabledInfo = - stringRes(R.string.integrations_disabled_info) - .takeIf { isRestoring }, - onBack = ::onBack, - items = - listOfNotNull( - ZashiListItemState( - // Set the wallet currency by app build is more future-proof, although we hide it from - // the UI in the Testnet build - icon = R.drawable.ic_integrations_coinbase, - title = stringRes(R.string.integrations_coinbase, getZcashCurrency.getLocalizedName()), - subtitle = - stringRes( - R.string.integrations_coinbase_subtitle, - getZcashCurrency.getLocalizedName() - ), - onClick = ::onBuyWithCoinbaseClicked - ).takeIf { isCoinbaseAvailable == true }, - ZashiListItemState( - // Set the wallet currency by app build is more future-proof, although we hide it from - // the UI in the Testnet build - isEnabled = isRestoring.not(), - icon = - if (isRestoring.not()) { - R.drawable.ic_integrations_flexa - } else { - R.drawable.ic_integrations_flexa_disabled - }, - title = stringRes(R.string.integrations_flexa), - subtitle = stringRes(R.string.integrations_flexa_subtitle), - onClick = ::onFlexaClicked - ).takeIf { isFlexaAvailable == true }, - ZashiListItemState( - title = stringRes(R.string.integrations_keystone), - subtitle = stringRes(R.string.integrations_keystone_subtitle), - icon = R.drawable.ic_integrations_keystone, - onClick = ::onConnectKeystoneClick - ).takeIf { accounts.orEmpty().none { it is KeystoneAccount } }, - ).toImmutableList(), - onBottomSheetHidden = ::onBottomSheetHidden - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - initialValue = null - ) - - private fun onBack() = navigationRouter.back() - - private suspend fun hideBottomSheet() { - if (isDialog) { - hideBottomSheetRequest.emit(Unit) - bottomSheetHiddenResponse.first() - } - } - - private fun onBottomSheetHidden() = - viewModelScope.launch { - bottomSheetHiddenResponse.emit(Unit) - } - - private fun onBuyWithCoinbaseClicked() = - viewModelScope.launch { - hideBottomSheet() - navigateToCoinbase(isDialog) - } - - private fun onConnectKeystoneClick() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.replace(ConnectKeystone) - } - - private fun onFlexaClicked() = - viewModelScope.launch { - if (isDialog) { - hideBottomSheet() - navigationRouter.replace(Flexa) - } else { - hideBottomSheet() - navigationRouter.forward(Flexa) - } - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt deleted file mode 100644 index 1b6b07d87..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/AndroidOnboarding.kt +++ /dev/null @@ -1,99 +0,0 @@ -@file:Suppress("ktlint:standard:filename") - -package co.electriccoin.zcash.ui.screen.onboarding - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.common.compose.LocalActivity -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.screen.onboarding.view.Onboarding -import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel -import co.electriccoin.zcash.ui.screen.restore.WrapRestore - -@Suppress("LongMethod") -@Composable -internal fun WrapOnboarding() { - val activity = LocalActivity.current - val walletViewModel = koinActivityViewModel() - val onboardingViewModel = koinActivityViewModel() - - 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 -> - persistExistingWalletWithSeedPhrase( - activity.applicationContext, - walletViewModel, - SeedPhrase.new(seed), - birthday = WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(activity.applicationContext)) - ) - } - - Onboarding( - isDebugMenuEnabled = versionInfo.isDebuggable && !versionInfo.isRunningUnderTestService, - onImportWallet = onImportWallet, - onCreateWallet = onCreateWallet, - onFixtureWallet = onFixtureWallet - ) - - activity.reportFullyDrawn() - } else { - WrapRestore() - } -} - -/** - * Persists existing wallet together with the backup complete flag to disk. Be aware of that, it - * triggers navigation changes, as we observe the WalletViewModel.secretState. - * - * Write the backup complete flag first, then the seed phrase. That avoids the UI flickering to - * the backup screen. Assume if a user is restoring from a backup, then the user has a valid backup. - * - * @param seedPhrase to be persisted as part of the wallet. - * @param birthday optional user provided birthday to be persisted as part of the wallet. - */ -internal fun persistExistingWalletWithSeedPhrase( - context: Context, - walletViewModel: WalletViewModel, - seedPhrase: SeedPhrase, - birthday: BlockHeight? -) { - walletViewModel.persistExistingWalletWithSeedPhrase( - network = ZcashNetwork.fromResources(context), - seedPhrase = seedPhrase, - birthday = birthday - ) -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/Onboarding.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/Onboarding.kt new file mode 100644 index 000000000..5ebe459a5 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/Onboarding.kt @@ -0,0 +1,6 @@ +package co.electriccoin.zcash.ui.screen.onboarding + +import kotlinx.serialization.Serializable + +@Serializable +data object Onboarding diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingNavigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingNavigation.kt new file mode 100644 index 000000000..1edb0e846 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/OnboardingNavigation.kt @@ -0,0 +1,170 @@ +@file:Suppress("ktlint:standard:filename") + +package co.electriccoin.zcash.ui.screen.onboarding + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.toRoute +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.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.WalletRestoringState +import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel +import co.electriccoin.zcash.ui.design.LocalKeyboardManager +import co.electriccoin.zcash.ui.design.LocalSheetStateManager +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.restore.date.AndroidRestoreBDDate +import co.electriccoin.zcash.ui.screen.restore.date.RestoreBDDate +import co.electriccoin.zcash.ui.screen.restore.estimation.AndroidRestoreBDEstimation +import co.electriccoin.zcash.ui.screen.restore.estimation.RestoreBDEstimation +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.info.AndroidSeedInfo +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +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 + +@Composable +fun MainActivity.OnboardingNavigation() { + val activity = LocalActivity.current + val navigationRouter = koinInject() + val navController = LocalNavController.current + val keyboardManager = LocalKeyboardManager.current + val flexaViewModel = koinViewModel() + val sheetStateManager = LocalSheetStateManager.current + + val navigator: Navigator = + remember( + navController, + flexaViewModel, + keyboardManager, + sheetStateManager + ) { + NavigatorImpl( + activity = this@OnboardingNavigation, + navController = navController, + flexaViewModel = flexaViewModel, + keyboardManager = keyboardManager, + sheetStateManager = sheetStateManager + ) + } + + LaunchedEffect(Unit) { + navigationRouter.observePipeline().collect { + navigator.executeCommand(it) + } + } + NavHost( + navController = navController, + startDestination = Onboarding, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() } + ) { + composable { + Onboarding( + 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), + WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(activity.applicationContext)) + ) + } else { + navigationRouter.forward(RestoreSeed) + } + }, + onCreateWallet = { + if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) { + persistExistingWalletWithSeedPhrase( + applicationContext, + walletViewModel, + SeedPhrase.new(WalletFixture.Alice.seedPhrase), + WalletFixture.Alice.getBirthday( + ZcashNetwork.fromResources( + applicationContext + ) + ) + ) + } else { + walletViewModel.persistOnboardingState(OnboardingState.READY) + walletViewModel.persistNewWalletAndRestoringState(WalletRestoringState.INITIATING) + } + } + ) + } + composable { + AndroidRestoreSeed() + } + composable { + AndroidRestoreBDHeight(it.toRoute()) + } + composable { + AndroidRestoreBDDate() + } + composable { + AndroidRestoreBDEstimation() + } + dialog( + dialogProperties = + DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + ) { + AndroidSeedInfo() + } + } +} + +/** + * Persists existing wallet together with the backup complete flag to disk. Be aware of that, it + * triggers navigation changes, as we observe the WalletViewModel.secretState. + * + * Write the backup complete flag first, then the seed phrase. That avoids the UI flickering to + * the backup screen. Assume if a user is restoring from a backup, then the user has a valid backup. + * + * @param seedPhrase to be persisted as part of the wallet. + * @param birthday optional user provided birthday to be persisted as part of the wallet. + */ +internal fun persistExistingWalletWithSeedPhrase( + context: Context, + walletViewModel: WalletViewModel, + seedPhrase: SeedPhrase, + birthday: BlockHeight? +) { + walletViewModel.persistExistingWalletWithSeedPhrase( + network = ZcashNetwork.fromResources(context), + seedPhrase = seedPhrase, + birthday = birthday + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/view/OnboardingView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/view/OnboardingView.kt index 6f2d2cf45..241daea75 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/view/OnboardingView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/view/OnboardingView.kt @@ -4,7 +4,6 @@ package co.electriccoin.zcash.ui.screen.onboarding.view import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +29,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import cash.z.ecc.android.sdk.fixture.WalletFixture import cash.z.ecc.sdk.type.ZcashCurrency import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT @@ -49,10 +47,8 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography private fun OnboardingComposablePreview() { ZcashTheme { Onboarding( - isDebugMenuEnabled = true, onImportWallet = {}, - onCreateWallet = {}, - onFixtureWallet = {} + onCreateWallet = {} ) } } @@ -63,10 +59,8 @@ private fun OnboardingComposablePreview() { */ @Composable fun Onboarding( - isDebugMenuEnabled: Boolean, onImportWallet: () -> Unit, - onCreateWallet: () -> Unit, - onFixtureWallet: (String) -> Unit + onCreateWallet: () -> Unit ) { Scaffold { paddingValues -> Box( @@ -90,10 +84,8 @@ fun Onboarding( ) ) { OnboardingMainContent( - isDebugMenuEnabled = isDebugMenuEnabled, - onCreateWallet = onCreateWallet, - onFixtureWallet = onFixtureWallet, onImportWallet = onImportWallet, + onCreateWallet = onCreateWallet, modifier = Modifier .padding( @@ -111,8 +103,6 @@ fun Onboarding( private fun OnboardingMainContent( onImportWallet: () -> Unit, onCreateWallet: () -> Unit, - onFixtureWallet: (String) -> Unit, - isDebugMenuEnabled: Boolean, modifier: Modifier = Modifier, ) { Column( @@ -123,18 +113,10 @@ private fun OnboardingMainContent( .then(modifier), horizontalAlignment = Alignment.CenterHorizontally ) { - var imageModifier = + val imageModifier = Modifier .height(ZcashTheme.dimens.inScreenZcashLogoHeight) .width(ZcashTheme.dimens.inScreenZcashLogoWidth) - if (isDebugMenuEnabled) { - imageModifier = - imageModifier.then( - Modifier.clickable { - onFixtureWallet(WalletFixture.Alice.seedPhrase) - } - ) - } Spacer(Modifier.weight(1f)) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt deleted file mode 100644 index a5199f748..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/onboarding/viewmodel/OnboardingViewModel.kt +++ /dev/null @@ -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 - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt index 2f5379607..62e417abb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt @@ -218,7 +218,7 @@ private fun QrCodeBottomBar( ZashiButton( text = stringResource(id = R.string.qr_code_copy_btn), - icon = R.drawable.ic_copy, + icon = R.drawable.ic_qr_copy, onClick = { state.onAddressCopy(state.walletAddress.address) }, colors = ZashiButtonDefaults.secondaryColors(), modifier = diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt index 5657f677e..41cd93afa 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt @@ -196,9 +196,21 @@ private fun AddressPanel( .fillMaxWidth() .padding(top = ZcashTheme.dimens.spacingDefault) ) { + val containerColor = + if (state.isShielded) { + ZashiColors.Utility.Purple.utilityPurple100 + } else { + ZashiColors.Surfaces.bgTertiary + } + val contentColor = + if (state.isShielded) { + ZashiColors.Utility.Purple.utilityPurple800 + } else { + ZashiColors.Text.textPrimary + } ReceiveIconButton( - containerColor = ZashiColors.Utility.Purple.utilityPurple100, - contentColor = ZashiColors.Utility.Purple.utilityPurple800, + containerColor = containerColor, + contentColor = contentColor, iconPainter = painterResource(id = R.drawable.ic_copy_shielded), onClick = state.onCopyClicked, text = stringResource(id = R.string.receive_copy), @@ -208,8 +220,8 @@ private fun AddressPanel( Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall)) ReceiveIconButton( - containerColor = ZashiColors.Utility.Purple.utilityPurple100, - contentColor = ZashiColors.Utility.Purple.utilityPurple800, + containerColor = containerColor, + contentColor = contentColor, iconPainter = painterResource(id = R.drawable.ic_qr_code_shielded), onClick = state.onQrClicked, text = stringResource(id = R.string.receive_qr_code), @@ -219,8 +231,8 @@ private fun AddressPanel( Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall)) ReceiveIconButton( - containerColor = ZashiColors.Utility.Purple.utilityPurple100, - contentColor = ZashiColors.Utility.Purple.utilityPurple800, + containerColor = containerColor, + contentColor = contentColor, iconPainter = painterResource(id = R.drawable.ic_request_shielded), onClick = state.onRequestClicked, text = stringResource(id = R.string.receive_request), diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt deleted file mode 100644 index 041f9b1de..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/AndroidRestore.kt +++ /dev/null @@ -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() - val onboardingViewModel = koinActivityViewModel() - val restoreViewModel = koinActivityViewModel() - - 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) - } - ) - } - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/AndroidRestoreBDDate.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/AndroidRestoreBDDate.kt new file mode 100644 index 000000000..5a3d40ff9 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/AndroidRestoreBDDate.kt @@ -0,0 +1,19 @@ +package co.electriccoin.zcash.ui.screen.restore.date + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel + +@Composable +fun AndroidRestoreBDDate() { + val vm = koinViewModel() + val state by vm.state.collectAsStateWithLifecycle() + RestoreBDDateView(state) + BackHandler { state.onBack() } +} + +@Serializable +data object RestoreBDDate diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateState.kt new file mode 100644 index 000000000..0192cf861 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateState.kt @@ -0,0 +1,10 @@ +package co.electriccoin.zcash.ui.screen.restore.date + +import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.IconButtonState + +data class RestoreBDDateState( + val next: ButtonState, + val dialogButton: IconButtonState, + val onBack: () -> Unit +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateView.kt new file mode 100644 index 000000000..45c354f1a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateView.kt @@ -0,0 +1,156 @@ +@file:Suppress("TooManyFunctions") + +package co.electriccoin.zcash.ui.screen.restore.date + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +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.graphics.ColorFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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.ZashiButton +import co.electriccoin.zcash.ui.design.component.ZashiIconButton +import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar +import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation +import co.electriccoin.zcash.ui.design.component.ZashiYearMonthWheelDatePicker +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 RestoreBDDateView(state: RestoreBDDateState) { + BlankBgScaffold( + topBar = { AppBar(state) }, + bottomBar = {}, + content = { padding -> + Content( + state = state, + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .scaffoldPadding(padding) + ) + } + ) +} + +@Composable +private fun Content( + state: RestoreBDDateState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.restore_bd_date_subtitle), + style = ZashiTypography.header6, + color = ZashiColors.Text.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.restore_bd_date_message), + style = ZashiTypography.textSm, + color = ZashiColors.Text.textPrimary + ) + Spacer(Modifier.height(24.dp)) + + ZashiYearMonthWheelDatePicker( + modifier = Modifier.fillMaxWidth() + ) {} + + Spacer(Modifier.height(24.dp)) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Image( + painterResource(R.drawable.ic_info), + contentDescription = "", + colorFilter = ColorFilter.tint(color = ZashiColors.Utility.Indigo.utilityIndigo700) + ) + Spacer(Modifier.width(8.dp)) + Text( + modifier = Modifier.padding(top = 2.dp), + text = stringResource(R.string.restore_bd_date_note), + style = ZashiTypography.textXs, + fontWeight = FontWeight.Medium, + color = ZashiColors.Utility.Indigo.utilityIndigo700 + ) + } + + Spacer(Modifier.height(24.dp)) + + ZashiButton( + state.next, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun AppBar(state: RestoreBDDateState) { + 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 { + RestoreBDDateView( + state = + RestoreBDDateState( + next = ButtonState(stringRes("Estimate")) {}, + dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {}, + onBack = {} + ) + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateViewModel.kt new file mode 100644 index 000000000..5a2079473 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/date/RestoreBDDateViewModel.kt @@ -0,0 +1,38 @@ +package co.electriccoin.zcash.ui.screen.restore.date + +import androidx.lifecycle.ViewModel +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.util.stringRes +import co.electriccoin.zcash.ui.screen.restore.estimation.RestoreBDEstimation +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class RestoreBDDateViewModel( + private val navigationRouter: NavigationRouter +) : ViewModel() { + val state: StateFlow = MutableStateFlow(createState()).asStateFlow() + + private fun createState() = + RestoreBDDateState( + next = ButtonState(stringRes(R.string.restore_bd_height_btn), onClick = ::onEstimateClick), + dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick), + onBack = ::onBack, + ) + + private fun onEstimateClick() { + navigationRouter.forward(RestoreBDEstimation) + } + + private fun onBack() { + navigationRouter.back() + } + + private fun onInfoButtonClick() { + navigationRouter.forward(SeedInfo) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/AndroidRestoreBDEstimation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/AndroidRestoreBDEstimation.kt new file mode 100644 index 000000000..815b75cf7 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/AndroidRestoreBDEstimation.kt @@ -0,0 +1,19 @@ +package co.electriccoin.zcash.ui.screen.restore.estimation + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel + +@Composable +fun AndroidRestoreBDEstimation() { + val vm = koinViewModel() + val state by vm.state.collectAsStateWithLifecycle() + RestoreBDEstimationView(state) + BackHandler { state.onBack() } +} + +@Serializable +data object RestoreBDEstimation diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationState.kt new file mode 100644 index 000000000..7ccdf2b9b --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationState.kt @@ -0,0 +1,13 @@ +package co.electriccoin.zcash.ui.screen.restore.estimation + +import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.IconButtonState +import co.electriccoin.zcash.ui.design.util.StringResource + +data class RestoreBDEstimationState( + val text: StringResource, + val onBack: () -> Unit, + val dialogButton: IconButtonState, + val copy: ButtonState, + val restore: ButtonState, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationView.kt new file mode 100644 index 000000000..321941f8d --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationView.kt @@ -0,0 +1,141 @@ +@file:Suppress("TooManyFunctions") + +package co.electriccoin.zcash.ui.screen.restore.estimation + +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.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.Alignment +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.text.style.TextAlign +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.VerticalSpacer +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.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.getValue +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 RestoreBDEstimationView(state: RestoreBDEstimationState) { + BlankBgScaffold( + topBar = { AppBar(state) }, + bottomBar = {}, + content = { padding -> + Content( + state = state, + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .scaffoldPadding(padding) + ) + } + ) +} + +@Composable +private fun Content( + state: RestoreBDEstimationState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.restore_bd_estimation_subtitle), + style = ZashiTypography.header6, + color = ZashiColors.Text.textPrimary, + fontWeight = FontWeight.SemiBold + ) + VerticalSpacer(8.dp) + Text( + text = stringResource(R.string.restore_bd_estimation_message), + style = ZashiTypography.textSm, + color = ZashiColors.Text.textPrimary + ) + VerticalSpacer(56.dp) + Text( + modifier = Modifier.fillMaxWidth(), + text = state.text.getValue(), + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.header2, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + VerticalSpacer(12.dp) + ZashiButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + state = state.copy, + colors = ZashiButtonDefaults.tertiaryColors() + ) + VerticalSpacer(24.dp) + VerticalSpacer(1f) + ZashiButton( + state = state.restore, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun AppBar(state: RestoreBDEstimationState) { + 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 { + RestoreBDEstimationView( + state = + RestoreBDEstimationState( + restore = ButtonState(stringRes("Estimate")) {}, + dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {}, + onBack = {}, + text = stringRes("123456"), + copy = ButtonState(stringRes("Copy"), icon = R.drawable.ic_copy) {} + ) + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationViewModel.kt new file mode 100644 index 000000000..59bae604b --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/estimation/RestoreBDEstimationViewModel.kt @@ -0,0 +1,39 @@ +package co.electriccoin.zcash.ui.screen.restore.estimation + +import androidx.lifecycle.ViewModel +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.util.stringRes +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class RestoreBDEstimationViewModel( + private val navigationRouter: NavigationRouter +) : ViewModel() { + val state: StateFlow = MutableStateFlow(createState()).asStateFlow() + + private fun createState() = + RestoreBDEstimationState( + dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick), + onBack = ::onBack, + text = stringRes("123456"), + copy = ButtonState(stringRes(R.string.restore_bd_estimation_copy), icon = R.drawable.ic_copy) {}, + restore = ButtonState(stringRes(R.string.restore_bd_estimation_restore), onClick = ::onRestoreClick), + ) + + private fun onRestoreClick() { + // do nothing + } + + private fun onBack() { + navigationRouter.back() + } + + private fun onInfoButtonClick() { + navigationRouter.forward(SeedInfo) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/AndroidRestoreBDHeight.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/AndroidRestoreBDHeight.kt new file mode 100644 index 000000000..056b1acdb --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/AndroidRestoreBDHeight.kt @@ -0,0 +1,22 @@ +package co.electriccoin.zcash.ui.screen.restore.height + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun AndroidRestoreBDHeight(args: RestoreBDHeight) { + val vm = koinViewModel { parametersOf(args) } + val state by vm.state.collectAsStateWithLifecycle() + RestoreBDHeightView(state) + BackHandler { + state.onBack() + } +} + +@Serializable +data class RestoreBDHeight(val seed: String) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightState.kt new file mode 100644 index 000000000..73b53191a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightState.kt @@ -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 +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightTags.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightTags.kt new file mode 100644 index 000000000..6701f9136 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightTags.kt @@ -0,0 +1,5 @@ +package co.electriccoin.zcash.ui.screen.restore.height + +object RestoreBDHeightTags { + const val RESTORE_BTN = "RESTORE_BTN" +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightView.kt new file mode 100644 index 000000000..d67dd9a4a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightView.kt @@ -0,0 +1,218 @@ +@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() + .testTag(RestoreBDHeightTags.RESTORE_BTN), + ) + } +} + +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")) {} + ) + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightViewModel.kt new file mode 100644 index 000000000..00c3ea557 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/height/RestoreBDHeightViewModel.kt @@ -0,0 +1,94 @@ +package co.electriccoin.zcash.ui.screen.restore.height + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.ANDROID_STATE_FLOW_TIMEOUT +import cash.z.ecc.sdk.type.fromResources +import co.electriccoin.zcash.ui.NavigationRouter +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.usecase.RestoreWalletUseCase +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.date.RestoreBDDate +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +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 restoreBDHeight: RestoreBDHeight, + private val navigationRouter: NavigationRouter, + private val context: Context, + private val restoreWallet: RestoreWalletUseCase +) : ViewModel() { + private val blockHeightText = MutableStateFlow("") + + val state: StateFlow = + 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 { + val isHigherThanSaplingActivationHeight = + blockHeight.toLongOrNull() + ?.let { it >= ZcashNetwork.fromResources(context).saplingActivationHeight.value } ?: false + val isValid = blockHeight.isEmpty() || isHigherThanSaplingActivationHeight + + return RestoreBDHeightState( + onBack = ::onBack, + dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick), + restore = + ButtonState( + stringRes(R.string.restore_bd_restore_btn), + onClick = ::onRestoreClick, + isEnabled = isValid + ), + estimate = ButtonState(stringRes(R.string.restore_bd_height_btn), onClick = ::onEstimateClick), + blockHeight = + TextFieldState( + value = stringRes(blockHeight), + onValueChange = ::onValueChanged, + error = stringRes("").takeIf { !isValid } + ) + ) + } + + private fun onEstimateClick() { + navigationRouter.forward(RestoreBDDate) + } + + private fun onRestoreClick() { + restoreWallet( + seedPhrase = SeedPhrase.new(restoreBDHeight.seed), + birthday = blockHeightText.value.toLongOrNull()?.let { BlockHeight.new(it) } + ) + } + + private fun onBack() { + navigationRouter.back() + } + + private fun onInfoButtonClick() { + navigationRouter.forward(SeedInfo) + } + + private fun onValueChanged(string: String) { + blockHeightText.update { string } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/AndroidSeedInfo.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/AndroidSeedInfo.kt new file mode 100644 index 000000000..de7bdb20b --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/AndroidSeedInfo.kt @@ -0,0 +1,31 @@ +package co.electriccoin.zcash.ui.screen.restore.info + +import android.view.WindowManager +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.DialogWindowProvider +import co.electriccoin.zcash.ui.NavigationRouter +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AndroidSeedInfo() { + val parent = LocalView.current.parent + val navigationRouter = koinInject() + + SideEffect { + (parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + (parent as? DialogWindowProvider)?.window?.setDimAmount(0f) + } + + SeedInfoView( + state = remember { SeedInfoState(onBack = { navigationRouter.back() }) }, + ) +} + +@Serializable +object SeedInfo diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoState.kt new file mode 100644 index 000000000..c06133a23 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoState.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.restore.info + +import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState + +data class SeedInfoState( + override val onBack: () -> Unit +) : ModalBottomSheetState diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoView.kt new file mode 100644 index 000000000..0ec12f507 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/info/SeedInfoView.kt @@ -0,0 +1,132 @@ +package co.electriccoin.zcash.ui.screen.restore.info + +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.ZashiScreenModalBottomSheet +import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState +import co.electriccoin.zcash.ui.design.component.rememberScreenModalBottomSheetState +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 SeedInfoView( + state: SeedInfoState, + sheetState: SheetState = rememberScreenModalBottomSheetState(), +) { + ZashiScreenModalBottomSheet( + state = state, + sheetState = sheetState, + content = { + Content(state) + }, + ) +} + +@Composable +private fun Content(state: SeedInfoState) { + 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 { + SeedInfoView( + sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + skipHiddenState = true, + initialValue = SheetValue.Expanded, + ), + state = SeedInfoState { }, + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/ParseResult.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/ParseResult.kt deleted file mode 100644 index 33c5915c4..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/ParseResult.kt +++ /dev/null @@ -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) : ParseResult() { - // Override to prevent logging of user secrets - override fun toString() = "Add" - } - - data class Autocomplete(val suggestions: List) : ParseResult() { - // Override to prevent logging of user secrets - override fun toString() = "Autocomplete" - } - - data class Warn(val suggestions: List) : ParseResult() { - // Override to prevent logging of user secrets - override fun toString() = "Warn" - } - - companion object { - @Suppress("ReturnCount") - fun new( - completeWordList: Set, - 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 -): List { - return if (input.isBlank()) { - emptyList() - } else { - completeWordList.filter { it.startsWith(input) }.ifEmpty { - findSuggestions(input.dropLast(1), completeWordList) - } - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt deleted file mode 100644 index 4aa14768d..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/model/RestoreStage.kt +++ /dev/null @@ -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)] -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/AndroidRestore.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/AndroidRestore.kt new file mode 100644 index 000000000..16404c4f9 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/AndroidRestore.kt @@ -0,0 +1,23 @@ +package co.electriccoin.zcash.ui.screen.restore.seed + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel + +@Composable +fun AndroidRestoreSeed() { + val vm = koinViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val suggestionsState = vm.suggestionsState.collectAsStateWithLifecycle().value + if (state != null && suggestionsState != null) { + state?.let { RestoreSeedView(it, suggestionsState) } + } + + BackHandler { state?.onBack?.invoke() } +} + +@Serializable +data object RestoreSeed diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedState.kt new file mode 100644 index 000000000..72904e9d0 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedState.kt @@ -0,0 +1,17 @@ +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 + +data class RestoreSeedState( + val seed: SeedTextFieldState, + val onBack: () -> Unit, + val dialogButton: IconButtonState, + val nextButton: ButtonState? +) + +data class RestoreSeedSuggestionsState( + val isVisible: Boolean, + val suggestions: List, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedTag.kt similarity index 80% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedTag.kt index 9cb6f4e62..06f1abf68 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/RestoreTag.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedTag.kt @@ -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" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedView.kt new file mode 100644 index 000000000..3f2b37618 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedView.kt @@ -0,0 +1,308 @@ +@file:Suppress("TooManyFunctions") + +package co.electriccoin.zcash.ui.screen.restore.seed + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.verticalScroll +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.LocalKeyboardManager +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.SeedTextFieldHandle +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.ZashiChipButton +import co.electriccoin.zcash.ui.design.component.ZashiChipButtonState +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.component.rememberSeedTextFieldHandle +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.util.Locale + +@Composable +fun RestoreSeedView( + state: RestoreSeedState, + suggestionsState: RestoreSeedSuggestionsState +) { + val handle = rememberSeedTextFieldHandle(state.seed) + + val focusManager = LocalFocusManager.current + var wasKeyboardOpen by remember { mutableStateOf(false) } + val keyboardManager = LocalKeyboardManager.current + val isKeyboardOpen = keyboardManager.isOpen + LaunchedEffect(isKeyboardOpen) { + if (wasKeyboardOpen && !isKeyboardOpen) { + focusManager.clearFocus(true) + } + wasKeyboardOpen = isKeyboardOpen + } + + BlankBgScaffold( + topBar = { AppBar(state) }, + bottomBar = { BottomBar(state, suggestionsState, handle) }, + content = { padding -> + Content( + state = state, + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .scaffoldPadding(padding), + handle = handle + ) + } + ) +} + +@Composable +private fun Content( + state: RestoreSeedState, + handle: SeedTextFieldHandle, + 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, + handle = handle, + wordModifier = { Modifier.testTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD) } + ) + Spacer(Modifier.weight(1f)) + state.nextButton?.let { + Spacer(Modifier.height(24.dp)) + ZashiButton( + state = 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 + ), + ) +} + +@Suppress("ComplexCondition") +@Composable +private fun BottomBar( + state: RestoreSeedState, + suggestionsState: RestoreSeedSuggestionsState, + handle: SeedTextFieldHandle, + modifier: Modifier = Modifier, +) { + val suggestions by remember(suggestionsState, handle) { + derivedStateOf { getFilteredSuggestions(suggestionsState, handle) } + } + var previousIndex by remember { mutableIntStateOf(handle.selectedIndex) } + var previousText by remember { mutableStateOf(handle.selectedText) } + LaunchedEffect(handle.selectedIndex, handle.selectedText) { + if ( + previousIndex == handle.selectedIndex && + previousText != handle.selectedText && + suggestions.contains(handle.selectedText) && suggestions.size == 1 + ) { + val nextIndex = + state.seed.values + .withIndex() + .indexOfFirst { (index, field) -> + index >= handle.selectedIndex && (field.value.isBlank() || field.isError) + } + + handle.setSelectedIndex(nextIndex) + } + previousIndex = handle.selectedIndex + previousText = handle.selectedText + } + + if (suggestionsState.isVisible && + handle.selectedIndex >= 0 && + !handle.selectedText.isNullOrEmpty() && + !(suggestions.size == 1 && suggestions.contains(handle.selectedText)) + ) { + if (suggestions.isEmpty()) { + Warn( + modifier = modifier + ) + } else { + Surface( + Modifier.padding(top = 8.dp), + color = ZashiColors.Surfaces.bgPrimary + ) { + LazyRow( + modifier = + Modifier + .testTag(RestoreSeedTag.AUTOCOMPLETE_LAYOUT) + .fillMaxWidth(), + contentPadding = PaddingValues(ZcashTheme.dimens.spacingSmall), + horizontalArrangement = spacedBy(6.dp) + ) { + items(suggestions) { + ZashiChipButton( + state = + ZashiChipButtonState( + text = stringRes(it), + onClick = { + if (handle.selectedIndex >= 0) { + state.seed.values[handle.selectedIndex].onValueChange(it) + } + } + ), + modifier = Modifier.testTag(RestoreSeedTag.AUTOCOMPLETE_ITEM) + ) + } + } + } + } + } +} + +@Composable +private fun Warn(modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Surface( + shape = RoundedCornerShape(size = ZcashTheme.dimens.tinyRippleEffectCorner), + color = ZcashTheme.colors.primaryColor, + border = + BorderStroke( + width = ZcashTheme.dimens.chipStroke, + color = ZcashTheme.colors.layoutStrokeSecondary + ), + shadowElevation = ZcashTheme.dimens.chipShadowElevation + ) { + Text( + color = ZcashTheme.colors.textPrimary, + modifier = + Modifier + .fillMaxWidth() + .padding(ZcashTheme.dimens.spacingSmall), + textAlign = TextAlign.Center, + text = stringResource(R.string.restore_seed_warning_suggestions) + ) + } + } +} + +private fun getFilteredSuggestions( + suggestionsState: RestoreSeedSuggestionsState, + handle: SeedTextFieldHandle, +): List { + val trimmed = handle.selectedText?.lowercase(Locale.US)?.trim().orEmpty() + val autocomplete = suggestionsState.suggestions.filter { it.startsWith(trimmed) } + return when { + trimmed.isBlank() -> suggestionsState.suggestions + suggestionsState.suggestions.contains(trimmed) && autocomplete.size == 1 -> autocomplete + else -> autocomplete + } +} + +@PreviewScreens +@Composable +private fun Preview() = + ZcashTheme { + RestoreSeedView( + state = + RestoreSeedState( + seed = + SeedTextFieldState( + values = + (1..24).map { + SeedWordTextFieldState( + value = "Word", + onValueChange = { }, + isError = false + ) + } + ), + onBack = {}, + dialogButton = IconButtonState(R.drawable.ic_restore_dialog) {}, + nextButton = + ButtonState( + text = stringRes("Next"), + onClick = {} + ) + ), + suggestionsState = + RestoreSeedSuggestionsState( + isVisible = true, + suggestions = listOf("Word 1", "Word 2"), + ) + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedViewModel.kt new file mode 100644 index 000000000..a50692301 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/seed/RestoreSeedViewModel.kt @@ -0,0 +1,189 @@ +package co.electriccoin.zcash.ui.screen.restore.seed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.bip39.Mnemonics +import cash.z.ecc.android.sdk.model.SeedPhrase +import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT +import co.electriccoin.zcash.ui.BuildConfig +import co.electriccoin.zcash.ui.NavigationRouter +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.usecase.ValidateSeedUseCase +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.height.RestoreBDHeight +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import java.util.Locale + +class RestoreSeedViewModel( + private val navigationRouter: NavigationRouter, + private val validateSeed: ValidateSeedUseCase +) : ViewModel() { + private val suggestions = + flow { + val result = withContext(Dispatchers.IO) { Mnemonics.getCachedWords(Locale.ENGLISH.language) } + emit(result) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = null + ) + + @Suppress("MagicNumber") + private val seedWords = + MutableStateFlow( + (0..23).map { index -> + SeedWordTextFieldState( + value = "", + onValueChange = { onValueChange(index, it) }, + isError = false + ) + } + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private val seedValidations = + combine(seedWords, suggestions) { seedWords, suggestions -> + seedWords to suggestions.orEmpty() + }.mapLatest { (seedWords, suggestions) -> + withContext(Dispatchers.Default) { + seedWords.map { field -> + val trimmed = field.value.lowercase(Locale.US).trim() + val autocomplete = suggestions.filter { it.startsWith(trimmed) } + val validSuggestions = + when { + trimmed.isBlank() -> suggestions + suggestions.contains(trimmed) && autocomplete.size == 1 -> suggestions + else -> autocomplete + } + validSuggestions.isNotEmpty() + } + } + } + + private val validSeed = + seedWords + .map { fields -> + validateSeed(fields.map { it.value }) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = null + ) + + val state: StateFlow = + combine(seedWords, seedValidations, validSeed) { words, seedValidations, validation -> + createState(words, seedValidations, validation) + }.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 + */ + @OptIn(ExperimentalCoroutinesApi::class) + val suggestionsState = + combine(validSeed, suggestions) { seed, suggestions -> + seed to suggestions + }.mapLatest { (seed, suggestions) -> + RestoreSeedSuggestionsState( + isVisible = seed == null && suggestions != null, + suggestions = suggestions.orEmpty() + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = null + ) + + private fun createState( + words: List, + seedValidations: List, + seedPhrase: SeedPhrase? + ) = RestoreSeedState( + seed = + SeedTextFieldState( + values = + words + .mapIndexed { index, word -> + word.copy(isError = !seedValidations[index]) + } + ), + onBack = ::onBack, + dialogButton = IconButtonState(icon = R.drawable.ic_info, onClick = ::onInfoButtonClick), + nextButton = + ButtonState( + text = stringRes(R.string.restore_button), + onClick = ::onNextClicked, + ).takeIf { seedPhrase != null } + ) + + private fun onBack() { + navigationRouter.back() + } + + private fun onInfoButtonClick() { + navigationRouter.forward(SeedInfo) + } + + private fun onNextClicked() { + val seed = validSeed.value ?: return + navigationRouter.forward(RestoreBDHeight(seed.joinToString())) + } + + private fun onValueChange( + index: Int, + value: String + ) { + if (BuildConfig.DEBUG) { + val seed = validateSeed(value.split(" ")) + if (seed != null) { + prefillSeed(seed) + } else { + updateSeedWord(index, value) + } + } else { + updateSeedWord(index, value) + } + } + + private fun updateSeedWord( + index: Int, + value: String + ) { + seedWords.update { + val newSeedWords = it.toMutableList() + newSeedWords[index] = newSeedWords[index].copy(value = value.trim()) + newSeedWords.toList() + } + } + + private fun prefillSeed(seed: SeedPhrase) { + seedWords.update { + val newSeedWords = it.toMutableList() + seed.split.forEachIndexed { index, word -> + newSeedWords[index] = newSeedWords[index].copy(value = word) + } + newSeedWords.toList() + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt deleted file mode 100644 index 384496ffa..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/RestoreState.kt +++ /dev/null @@ -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 = mutableState - - fun goNext() { - mutableState.value = current.value.getNext() - } - - fun goPrevious() { - mutableState.value = current.value.getPrevious() - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt deleted file mode 100644 index a8a48bc28..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/state/WordList.kt +++ /dev/null @@ -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 = emptyList()) { - private val mutableState: MutableStateFlow> = MutableStateFlow(initial.toPersistentList()) - - val current: StateFlow> = mutableState - - fun set(list: List) { - mutableState.value = list.toPersistentList() - } - - fun append(words: List) { - 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) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt deleted file mode 100644 index 97323effd..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/view/RestoreView.kt +++ /dev/null @@ -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, - 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() - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt deleted file mode 100644 index c7fe875e5..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restore/viewmodel/RestoreViewModel.kt +++ /dev/null @@ -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(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 { - // 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>(KEY_WORD_LIST) - } else { - null - } - - if (null == initialValue) { - WordList() - } else { - WordList(initialValue) - } - } - - val userBirthdayHeight: MutableStateFlow = - run { - val initialValue: BlockHeight? = - savedStateHandle.get(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) : CompleteWordSetState() -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restoresuccess/view/RestoreSuccessView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restoresuccess/view/RestoreSuccessView.kt index 5ae1a5b8d..2886e4b8e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restoresuccess/view/RestoreSuccessView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/restoresuccess/view/RestoreSuccessView.kt @@ -8,32 +8,42 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.rememberScrollState 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,49 +63,82 @@ 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), contentDescription = stringResource(id = R.string.restore_success_subtitle), - 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 +149,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(), diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt index 7a051f59e..dd6f8e76d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt @@ -9,21 +9,21 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.electriccoin.zcash.di.koinActivityViewModel +import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator -import co.electriccoin.zcash.ui.popBackStackJustOnce import co.electriccoin.zcash.ui.screen.scan.view.Scan import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel import co.electriccoin.zcash.ui.util.SettingsUtil import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @Composable internal fun WrapScanValidator(args: Scan) { - val navController = LocalNavController.current val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -32,9 +32,10 @@ internal fun WrapScanValidator(args: Scan) { val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value val state by viewModel.state.collectAsStateWithLifecycle() + val navigationRouter = koinInject() BackHandler { - navController.popBackStackJustOnce(Scan.ROUTE) + navigationRouter.back() } if (synchronizer == null) { @@ -46,7 +47,7 @@ internal fun WrapScanValidator(args: Scan) { Scan( snackbarHostState = snackbarHostState, validationResult = state, - onBack = { navController.popBackStackJustOnce(Scan.ROUTE) }, + onBack = { navigationRouter.back() }, onScanned = { viewModel.onScanned(it) }, @@ -72,16 +73,14 @@ internal fun WrapScanValidator(args: Scan) { } } -enum class Scan { +@Serializable +data class Scan( + val flow: ScanFlow, + val isScanZip321Enabled: Boolean = true +) + +enum class ScanFlow { HOMEPAGE, SEND, - ADDRESS_BOOK; - - companion object { - private const val PATH = "scan" - const val KEY = "mode" - const val ROUTE = "$PATH/{$KEY}" - - operator fun invoke(mode: Scan) = "$PATH/${mode.name}" - } + ADDRESS_BOOK } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/AndroidSecurityWarning.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/AndroidSecurityWarning.kt deleted file mode 100644 index 1b4c36220..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/AndroidSecurityWarning.kt +++ /dev/null @@ -1,35 +0,0 @@ -package co.electriccoin.zcash.ui.screen.securitywarning - -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import co.electriccoin.zcash.configuration.api.ConfigurationProvider -import co.electriccoin.zcash.ui.common.compose.LocalActivity -import co.electriccoin.zcash.ui.common.model.VersionInfo -import co.electriccoin.zcash.ui.screen.securitywarning.view.SecurityWarning -import org.koin.compose.koinInject - -@Composable -internal fun WrapSecurityWarning( - onBack: () -> Unit, - onConfirm: () -> Unit -) { - val activity = LocalActivity.current - val androidConfigurationProvider = koinInject() - BackHandler { - onBack() - } - - SecurityWarning( - versionInfo = VersionInfo.new(activity.applicationContext), - onBack = onBack, - onAcknowledged = { - // Needed for UI testing only - }, - onConfirm = onConfirm - ) - - LaunchedEffect(key1 = true) { - androidConfigurationProvider.hintToRefresh() - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityScreenTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityScreenTag.kt deleted file mode 100644 index e90ada506..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityScreenTag.kt +++ /dev/null @@ -1,8 +0,0 @@ -package co.electriccoin.zcash.ui.screen.securitywarning.view - -/** - * These are only used for automated testing. - */ -object SecurityScreenTag { - const val ACKNOWLEDGE_CHECKBOX_TAG = "acknowledge_checkbox" -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningView.kt deleted file mode 100644 index 5376a21a2..000000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/securitywarning/view/SecurityWarningView.kt +++ /dev/null @@ -1,172 +0,0 @@ -package co.electriccoin.zcash.ui.screen.securitywarning.view - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -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.tooling.preview.Preview -import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.common.model.VersionInfo -import co.electriccoin.zcash.ui.design.component.BlankBgScaffold -import co.electriccoin.zcash.ui.design.component.LabeledCheckBox -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.util.scaffoldPadding -import co.electriccoin.zcash.ui.fixture.VersionInfoFixture - -@Preview -@Composable -private fun SecurityWarningPreview() { - ZcashTheme(forceDarkMode = false) { - SecurityWarning( - versionInfo = VersionInfoFixture.new(), - onBack = {}, - onAcknowledged = {}, - onConfirm = {}, - ) - } -} - -@Preview -@Composable -private fun SecurityWarningDarkPreview() { - ZcashTheme(forceDarkMode = true) { - SecurityWarning( - versionInfo = VersionInfoFixture.new(), - onBack = {}, - onAcknowledged = {}, - onConfirm = {}, - ) - } -} - -@Composable -fun SecurityWarning( - versionInfo: VersionInfo, - onBack: () -> Unit, - onAcknowledged: (Boolean) -> Unit, - onConfirm: () -> Unit, -) { - BlankBgScaffold( - modifier = Modifier.fillMaxSize(), - topBar = { SecurityWarningTopAppBar(onBack = onBack) }, - ) { paddingValues -> - SecurityWarningContent( - versionInfo = versionInfo, - onAcknowledged = onAcknowledged, - onConfirm = onConfirm, - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .scaffoldPadding(paddingValues) - ) - } -} - -@Composable -private fun SecurityWarningTopAppBar(onBack: () -> Unit) { - SmallTopAppBar( - navigationAction = { - TopAppBarBackNavigation( - backText = stringResource(id = R.string.back_navigation).uppercase(), - backContentDescriptionText = stringResource(R.string.back_navigation_content_description), - onBack = onBack - ) - } - ) -} - -@Composable -private fun SecurityWarningContent( - versionInfo: VersionInfo, - onAcknowledged: (Boolean) -> Unit, - onConfirm: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - TopScreenLogoTitle( - title = stringResource(R.string.security_warning_header), - logoContentDescription = stringResource(R.string.zcash_logo_content_description) - ) - - Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge)) - - SecurityWarningContentText( - versionInfo = versionInfo - ) - - Spacer(Modifier.height(ZcashTheme.dimens.spacingLarge)) - - val checkedState = rememberSaveable { mutableStateOf(false) } - Row(Modifier.fillMaxWidth()) { - LabeledCheckBox( - checked = checkedState.value, - onCheckedChange = { - checkedState.value = it - onAcknowledged(it) - }, - text = stringResource(R.string.security_warning_acknowledge), - checkBoxTestTag = SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG - ) - } - Spacer(modifier = Modifier.weight(1f)) - - ZashiButton( - onClick = onConfirm, - text = stringResource(R.string.security_warning_confirm), - enabled = checkedState.value, - modifier = Modifier.fillMaxWidth() - ) - } -} - -@Composable -fun SecurityWarningContentText(versionInfo: VersionInfo) { - Column { - Text( - text = stringResource(id = R.string.security_warning_text, versionInfo.versionName), - color = ZcashTheme.colors.textPrimary, - style = ZcashTheme.extendedTypography.securityWarningText - ) - - Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) - - val textPart1 = stringResource(R.string.security_warning_text_footnote_part_1) - val textPart2 = stringResource(R.string.security_warning_text_footnote_part_2) - - Text( - text = - buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(textPart1) - } - append(textPart2) - }, - color = ZcashTheme.colors.textPrimary, - style = ZcashTheme.extendedTypography.footnote, - modifier = Modifier.fillMaxWidth() - ) - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/AndroidSeedRecovery.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/AndroidSeedRecovery.kt index f9999f380..e9d53950e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/AndroidSeedRecovery.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/AndroidSeedRecovery.kt @@ -8,33 +8,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.electriccoin.zcash.di.koinActivityViewModel import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel -import co.electriccoin.zcash.ui.screen.seed.view.SeedView -import co.electriccoin.zcash.ui.screen.seed.viewmodel.SeedViewModel +import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf @Composable -internal fun WrapSeed( - args: SeedNavigationArgs, - goBackOverride: (() -> Unit)? -) { +internal fun AndroidSeedRecovery() { val navController = LocalNavController.current val walletViewModel = koinActivityViewModel() val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value - val viewModel = koinViewModel { parametersOf(args) } + val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - val normalizedState = - state?.copy( - onBack = - state?.onBack?.let { - { - goBackOverride?.invoke() - it.invoke() - } - } - ) - LaunchedEffect(Unit) { viewModel.navigateBack.collect { navController.popBackStack() @@ -42,18 +26,16 @@ internal fun WrapSeed( } BackHandler { - normalizedState?.onBack?.invoke() + state?.onBack?.invoke() } - normalizedState?.let { - SeedView( - state = normalizedState, + state?.let { + SeedRecoveryView( + state = it, topAppBarSubTitleState = walletState, ) } } -enum class SeedNavigationArgs { - NEW_WALLET, - RECOVERY -} +@Serializable +object SeedRecovery diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/model/SeedState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryState.kt similarity index 65% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/model/SeedState.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryState.kt index 34257fe9a..6644c16e6 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/model/SeedState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryState.kt @@ -1,11 +1,14 @@ -package co.electriccoin.zcash.ui.screen.seed.model +package co.electriccoin.zcash.ui.screen.seed import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.IconButtonState +import co.electriccoin.zcash.ui.design.component.SeedTextState import co.electriccoin.zcash.ui.design.util.StringResource -data class SeedState( - val seed: SeedSecretState, +data class SeedRecoveryState( + val seed: SeedTextState, val birthday: SeedSecretState, + val info: IconButtonState, val button: ButtonState, val onBack: (() -> Unit)? ) @@ -14,16 +17,9 @@ data class SeedSecretState( val title: StringResource, val text: StringResource, val isRevealed: Boolean, - val isRevealPhraseVisible: Boolean, - val mode: Mode, val tooltip: SeedSecretStateTooltip?, val onClick: (() -> Unit)?, -) { - enum class Mode { - SEED, - BIRTHDAY - } -} +) data class SeedSecretStateTooltip( val title: StringResource, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/view/SeedView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryView.kt similarity index 58% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/view/SeedView.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryView.kt index 2f7f3be33..d6733c41c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/view/SeedView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryView.kt @@ -1,23 +1,14 @@ -package co.electriccoin.zcash.ui.screen.seed.view +package co.electriccoin.zcash.ui.screen.seed -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowColumn -import androidx.compose.foundation.layout.FlowColumnOverflow import androidx.compose.foundation.layout.Row 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -29,11 +20,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration @@ -42,10 +32,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed -import co.electriccoin.zcash.spackle.AndroidApiVersion import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.common.compose.SecureScreen import co.electriccoin.zcash.ui.common.compose.ZashiTooltip @@ -54,9 +41,15 @@ import co.electriccoin.zcash.ui.common.compose.drawCaretWithPath import co.electriccoin.zcash.ui.common.compose.shouldSecureScreen import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.IconButtonState +import co.electriccoin.zcash.ui.design.component.SeedTextState +import co.electriccoin.zcash.ui.design.component.VerticalSpacer import co.electriccoin.zcash.ui.design.component.ZashiButton +import co.electriccoin.zcash.ui.design.component.ZashiIconButton +import co.electriccoin.zcash.ui.design.component.ZashiSeedText import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation +import co.electriccoin.zcash.ui.design.component.blurCompat import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreenSizes import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors @@ -65,15 +58,12 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.util.getValue import co.electriccoin.zcash.ui.design.util.scaffoldPadding import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.seed.model.SeedSecretState -import co.electriccoin.zcash.ui.screen.seed.model.SeedSecretStateTooltip -import co.electriccoin.zcash.ui.screen.seed.model.SeedState import kotlinx.coroutines.launch @Composable -fun SeedView( +fun SeedRecoveryView( topAppBarSubTitleState: TopAppBarSubTitleState, - state: SeedState, + state: SeedRecoveryState, ) { if (shouldSecureScreen) { SecureScreen() @@ -96,7 +86,7 @@ fun SeedView( @Composable private fun SeedRecoveryTopAppBar( - state: SeedState, + state: SeedRecoveryState, subTitleState: TopAppBarSubTitleState, modifier: Modifier = Modifier, ) { @@ -113,13 +103,17 @@ private fun SeedRecoveryTopAppBar( if (state.onBack != null) { ZashiTopAppBarBackNavigation(onBack = state.onBack) } + }, + regularActions = { + ZashiIconButton(state.info) + Spacer(Modifier.width(20.dp)) } ) } @Composable private fun SeedRecoveryMainContent( - state: SeedState, + state: SeedRecoveryState, modifier: Modifier = Modifier, ) { Column( @@ -135,7 +129,7 @@ private fun SeedRecoveryMainContent( style = ZashiTypography.header6 ) - Spacer(Modifier.height(ZashiDimensions.Spacing.spacingMd)) + VerticalSpacer(8.dp) Text( text = stringResource(R.string.seed_recovery_description), @@ -143,36 +137,15 @@ private fun SeedRecoveryMainContent( style = ZashiTypography.textSm ) - Spacer(Modifier.height(ZashiDimensions.Spacing.spacing4xl)) + VerticalSpacer(20.dp) - SeedSecret(modifier = Modifier.fillMaxWidth(), state = state.seed) + ZashiSeedText(modifier = Modifier.fillMaxWidth(), state = state.seed) - Spacer(Modifier.height(ZashiDimensions.Spacing.spacing3xl)) + VerticalSpacer(24.dp) - SeedSecret(modifier = Modifier.fillMaxWidth(), state = state.birthday) + BDSecret(modifier = Modifier.fillMaxWidth(), state = state.birthday) - Spacer(Modifier.weight(1f)) - - Spacer(Modifier.height(ZashiDimensions.Spacing.spacing3xl)) - - Row { - Image( - painterResource(R.drawable.ic_warning), - contentDescription = null, - colorFilter = ColorFilter.tint(ZashiColors.Utility.WarningYellow.utilityOrange500) - ) - - Spacer(Modifier.width(ZashiDimensions.Spacing.spacingLg)) - - Text( - text = stringResource(R.string.seed_recovery_warning), - color = ZashiColors.Utility.WarningYellow.utilityOrange500, - style = ZashiTypography.textXs, - fontWeight = FontWeight.Medium - ) - } - - Spacer(Modifier.height(ZashiDimensions.Spacing.spacing3xl)) + VerticalSpacer(1f) ZashiButton( state = state.button, @@ -183,9 +156,9 @@ private fun SeedRecoveryMainContent( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SeedSecret( +private fun BDSecret( state: SeedSecretState, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val tooltipState = rememberTooltipState(isPersistent = true) val scope = rememberCoroutineScope() @@ -260,20 +233,19 @@ private fun SeedSecret( } } } - SecretContent(state = state) } } @Composable private fun SecretContent(state: SeedSecretState) { - val blur = animateDpAsState(if (state.isRevealed) 0.dp else 14.dp, label = "") + val blur by animateDpAsState(if (state.isRevealed) 0.dp else 14.dp, label = "") Surface( modifier = Modifier .fillMaxWidth(), shape = RoundedCornerShape(10.dp), - color = ZashiColors.Inputs.Default.bg + color = ZashiColors.Inputs.Filled.bg ) { Box( modifier = Modifier.fillMaxWidth(), @@ -281,124 +253,20 @@ private fun SecretContent(state: SeedSecretState) { ) { Box( modifier = - Modifier then - if (state.onClick != null) { - Modifier.clickable(onClick = state.onClick) - } else { - Modifier - } then - Modifier - .blurCompat(blur.value, 14.dp) - .padding(vertical = 18.dp) + Modifier + .blurCompat(blur, 14.dp) + .padding(vertical = 10.dp) ) { - if (state.mode == SeedSecretState.Mode.SEED) { - SecretSeedContent(state) - } else { - SecretBirthdayContent(state) - } - } - - AnimatedVisibility( - modifier = Modifier.fillMaxWidth(), - visible = - state.isRevealPhraseVisible && - state.isRevealed.not() && - state.mode == SeedSecretState.Mode.SEED, - enter = fadeIn(), - exit = fadeOut(), - ) { - Column( + Text( modifier = Modifier .fillMaxWidth() - .padding(vertical = 18.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(R.drawable.ic_reveal), - contentDescription = null, - colorFilter = ColorFilter.tint(ZashiColors.Text.textPrimary) - ) - - Spacer(Modifier.height(ZashiDimensions.Spacing.spacingMd)) - - Text( - text = stringResource(R.string.seed_recovery_reveal), - style = ZashiTypography.textLg, - fontWeight = FontWeight.SemiBold, - color = ZashiColors.Text.textPrimary - ) - } - } - } - } -} - -private fun Modifier.blurCompat( - radius: Dp, - max: Dp -): Modifier { - return if (AndroidApiVersion.isAtLeastS) { - this.blur(radius) - } else { - val progression = 1 - (radius.value / max.value) - this - .alpha(progression) - } -} - -@Composable -private fun SecretBirthdayContent( - state: SeedSecretState, - modifier: Modifier = Modifier -) { - Text( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - textAlign = TextAlign.Start, - text = state.text.getValue(), - style = ZashiTypography.textMd, - fontWeight = FontWeight.Medium, - color = ZashiColors.Inputs.Filled.text - ) -} - -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun SecretSeedContent(state: SeedSecretState) { - FlowColumn( - modifier = - Modifier - .fillMaxWidth() - .padding(end = 8.dp), - maxItemsInEachColumn = 8, - horizontalArrangement = Arrangement.SpaceAround, - verticalArrangement = spacedBy(6.dp), - maxLines = 8, - overflow = FlowColumnOverflow.Visible, - ) { - state.text.getValue().split(" ").fastForEachIndexed { i, s -> - Row { - Text( - modifier = Modifier.width(18.dp), - textAlign = TextAlign.End, - text = "${i + 1}", - style = ZashiTypography.textSm, - fontWeight = FontWeight.Normal, - color = ZashiColors.Text.textPrimary, - maxLines = 1 - ) - - Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingLg)) - - Text( - text = s, - style = ZashiTypography.textSm, - fontWeight = FontWeight.Normal, - color = ZashiColors.Text.textPrimary, - maxLines = 1 + .padding(horizontal = 12.dp), + textAlign = TextAlign.Start, + text = state.text.getValue(), + style = ZashiTypography.textMd, + fontWeight = FontWeight.Medium, + color = ZashiColors.Inputs.Filled.text ) } } @@ -409,30 +277,21 @@ private fun SecretSeedContent(state: SeedSecretState) { @PreviewScreenSizes private fun RevealedPreview() = ZcashTheme { - SeedView( + SeedRecoveryView( topAppBarSubTitleState = TopAppBarSubTitleState.None, state = - SeedState( + SeedRecoveryState( seed = - SeedSecretState( - title = stringRes("Seed"), - text = - stringRes( - (1..24).joinToString(" ") { "trala" } + "longer_tralala" - ), - tooltip = null, - isRevealed = true, - mode = SeedSecretState.Mode.SEED, - isRevealPhraseVisible = true - ) {}, + SeedTextState( + seed = (1..24).joinToString(" ") { "trala" } + "longer_tralala", + isRevealed = true + ), birthday = SeedSecretState( title = stringRes("Birthday"), text = stringRes(value = "asdads"), - tooltip = SeedSecretStateTooltip(title = stringRes(""), message = stringRes("")), isRevealed = true, - mode = SeedSecretState.Mode.BIRTHDAY, - isRevealPhraseVisible = false + tooltip = SeedSecretStateTooltip(title = stringRes(""), message = stringRes("")), ) {}, button = ButtonState( @@ -440,6 +299,11 @@ private fun RevealedPreview() = icon = R.drawable.ic_seed_show, onClick = {}, ), + info = + IconButtonState( + onClick = {}, + icon = R.drawable.ic_info + ), onBack = {} ) ) @@ -449,27 +313,21 @@ private fun RevealedPreview() = @PreviewScreenSizes private fun HiddenPreview() = ZcashTheme { - SeedView( + SeedRecoveryView( topAppBarSubTitleState = TopAppBarSubTitleState.None, state = - SeedState( + SeedRecoveryState( seed = - SeedSecretState( - title = stringRes("Seed"), - text = stringRes((1..24).joinToString(" ") { "trala" }), - tooltip = null, - isRevealed = false, - mode = SeedSecretState.Mode.SEED, - isRevealPhraseVisible = true - ) {}, + SeedTextState( + seed = (1..24).joinToString(" ") { "trala" } + "longer_tralala", + isRevealed = true + ), birthday = SeedSecretState( title = stringRes("Birthday"), text = stringRes(value = "asdads"), - tooltip = SeedSecretStateTooltip(title = stringRes(""), message = stringRes("")), isRevealed = false, - mode = SeedSecretState.Mode.BIRTHDAY, - isRevealPhraseVisible = false + tooltip = SeedSecretStateTooltip(title = stringRes(""), message = stringRes("")), ) {}, button = ButtonState( @@ -477,6 +335,11 @@ private fun HiddenPreview() = icon = R.drawable.ic_seed_show, onClick = {}, ), + info = + IconButtonState( + onClick = {}, + icon = R.drawable.ic_info + ), onBack = {} ) ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/viewmodel/SeedViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryViewModel.kt similarity index 54% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/viewmodel/SeedViewModel.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryViewModel.kt index 05320033c..8ce86d71d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/viewmodel/SeedViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/SeedRecoveryViewModel.kt @@ -1,19 +1,16 @@ -package co.electriccoin.zcash.ui.screen.seed.viewmodel +package co.electriccoin.zcash.ui.screen.seed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT -import co.electriccoin.zcash.spackle.AndroidApiVersion +import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.common.model.OnboardingState -import co.electriccoin.zcash.ui.common.repository.WalletRepository import co.electriccoin.zcash.ui.common.usecase.ObservePersistableWalletUseCase import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.IconButtonState +import co.electriccoin.zcash.ui.design.component.SeedTextState import co.electriccoin.zcash.ui.design.util.stringRes -import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs -import co.electriccoin.zcash.ui.screen.seed.model.SeedSecretState -import co.electriccoin.zcash.ui.screen.seed.model.SeedSecretStateTooltip -import co.electriccoin.zcash.ui.screen.seed.model.SeedState +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -23,10 +20,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class SeedViewModel( +class SeedRecoveryViewModel( observePersistableWallet: ObservePersistableWalletUseCase, - private val args: SeedNavigationArgs, - private val walletRepository: WalletRepository, + private val navigationRouter: NavigationRouter, ) : ViewModel() { private val isRevealed = MutableStateFlow(false) @@ -36,12 +32,11 @@ class SeedViewModel( val state = combine(isRevealed, observableWallet) { isRevealed, wallet -> - SeedState( + SeedRecoveryState( button = ButtonState( text = when { - args == SeedNavigationArgs.NEW_WALLET -> stringRes(R.string.seed_recovery_next_button) isRevealed -> stringRes(R.string.seed_recovery_hide_button) else -> stringRes(R.string.seed_recovery_reveal_button) }, @@ -50,26 +45,19 @@ class SeedViewModel( isLoading = wallet == null, icon = when { - args == SeedNavigationArgs.NEW_WALLET -> null isRevealed -> R.drawable.ic_seed_hide else -> R.drawable.ic_seed_show } ), + info = + IconButtonState( + onClick = ::onInfoClick, + icon = R.drawable.ic_help + ), seed = - SeedSecretState( - title = stringRes(R.string.seed_recovery_phrase_title), - text = stringRes(wallet?.seedPhrase?.joinToString().orEmpty()), + SeedTextState( + seed = wallet?.seedPhrase?.joinToString().orEmpty(), isRevealed = isRevealed, - tooltip = null, - onClick = - when (args) { - SeedNavigationArgs.NEW_WALLET -> ::onNewWalletSeedClicked - SeedNavigationArgs.RECOVERY -> - if (AndroidApiVersion.isAtLeastS) null else ::onNewWalletSeedClicked - }, - mode = SeedSecretState.Mode.SEED, - isRevealPhraseVisible = - if (AndroidApiVersion.isAtLeastS) args == SeedNavigationArgs.NEW_WALLET else true, ), birthday = SeedSecretState( @@ -82,17 +70,15 @@ class SeedViewModel( message = stringRes(R.string.seed_recovery_bday_tooltip_message) ), onClick = null, - mode = SeedSecretState.Mode.BIRTHDAY, - isRevealPhraseVisible = false, ), - onBack = - when (args) { - SeedNavigationArgs.NEW_WALLET -> null - SeedNavigationArgs.RECOVERY -> ::onBack - } + onBack = ::onBack ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null) + private fun onInfoClick() { + navigationRouter.forward(SeedInfo) + } + private fun onBack() { viewModelScope.launch { navigateBack.emit(Unit) @@ -100,15 +86,6 @@ class SeedViewModel( } private fun onPrimaryButtonClicked() { - when (args) { - SeedNavigationArgs.NEW_WALLET -> walletRepository.persistOnboardingState(OnboardingState.READY) - SeedNavigationArgs.RECOVERY -> isRevealed.update { !it } - } - } - - private fun onNewWalletSeedClicked() { - viewModelScope.launch { - isRevealed.update { !it } - } + isRevealed.update { !it } } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/AndroidSeedBackup.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/AndroidSeedBackup.kt new file mode 100644 index 000000000..715286869 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/AndroidSeedBackup.kt @@ -0,0 +1,41 @@ +package co.electriccoin.zcash.ui.screen.seed.backup + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.electriccoin.zcash.di.koinActivityViewModel +import co.electriccoin.zcash.ui.NavigationRouter +import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel +import co.electriccoin.zcash.ui.screen.restore.info.SeedInfo +import co.electriccoin.zcash.ui.screen.seed.SeedRecovery +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@Composable +fun AndroidSeedBackup() { + val viewModel = koinActivityViewModel() + val appBarState by viewModel.walletStateInformation.collectAsStateWithLifecycle() + val navigationRouter = koinInject() + val state = + remember { + SeedBackupState( + onBack = { navigationRouter.back() }, + onNextClick = { navigationRouter.replace(SeedRecovery) }, + onInfoClick = { navigationRouter.forward(SeedInfo) } + ) + } + + BackHandler { + state.onBack() + } + + SeedBackupView( + state = state, + appBarState = appBarState, + ) +} + +@Serializable +object SeedBackup diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupState.kt new file mode 100644 index 000000000..ef85d0849 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupState.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.seed.backup + +data class SeedBackupState( + val onBack: () -> Unit, + val onNextClick: () -> Unit, + val onInfoClick: () -> Unit, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupView.kt new file mode 100644 index 000000000..bbf4df4f1 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/seed/backup/SeedBackupView.kt @@ -0,0 +1,213 @@ +package co.electriccoin.zcash.ui.screen.seed.backup + +import androidx.annotation.DrawableRes +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +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.model.TopAppBarSubTitleState +import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.HorizontalSpacer +import co.electriccoin.zcash.ui.design.component.IconButtonState +import co.electriccoin.zcash.ui.design.component.VerticalSpacer +import co.electriccoin.zcash.ui.design.component.ZashiButton +import co.electriccoin.zcash.ui.design.component.ZashiIconButton +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.scaffoldPadding +import co.electriccoin.zcash.ui.design.util.stringRes + +@Composable +fun SeedBackupView( + state: SeedBackupState, + appBarState: TopAppBarSubTitleState, +) { + Scaffold( + topBar = { AppBar(state = state, subTitleState = appBarState) } + ) { paddingValues -> + Content( + modifier = Modifier.scaffoldPadding(paddingValues), + state = state, + ) + } +} + +@Composable +private fun AppBar( + state: SeedBackupState, + subTitleState: TopAppBarSubTitleState, + modifier: Modifier = Modifier, +) { + ZashiSmallTopAppBar( + title = stringResource(R.string.wallet_backup_title), + subtitle = + when (subTitleState) { + TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label) + TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label) + TopAppBarSubTitleState.None -> null + }, + modifier = modifier, + navigationAction = { + ZashiTopAppBarBackNavigation(onBack = state.onBack) + }, + regularActions = { + ZashiIconButton( + state = + IconButtonState( + onClick = state.onInfoClick, + icon = R.drawable.ic_help + ) + ) + Spacer(Modifier.width(20.dp)) + } + ) +} + +@Composable +private fun Content( + state: SeedBackupState, + modifier: Modifier = Modifier, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .then(modifier), + ) { + Text( + text = stringResource(R.string.wallet_backup_subtitle), + fontWeight = FontWeight.SemiBold, + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.header6 + ) + + VerticalSpacer(10.dp) + + Text( + text = stringResource(R.string.wallet_backup_message), + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.textSm + ) + + VerticalSpacer(24.dp) + + Item( + icon = R.drawable.ic_wallet_backup_1, + title = stringResource(R.string.wallet_backup_item_1), + subtitle = stringResource(R.string.wallet_backup_item_subtitle_1) + ) + VerticalSpacer(20.dp) + Item( + icon = R.drawable.ic_wallet_backup_2, + title = stringResource(R.string.wallet_backup_item_2), + subtitle = stringResource(R.string.wallet_backup_item_subtitle_2) + ) + VerticalSpacer(20.dp) + Item( + icon = R.drawable.ic_wallet_backup_3, + title = stringResource(R.string.wallet_backup_item_3), + subtitle = stringResource(R.string.wallet_backup_item_subtitle_3) + ) + VerticalSpacer(20.dp) + Item( + icon = R.drawable.ic_wallet_backup_4, + title = stringResource(R.string.wallet_backup_item_4), + subtitle = stringResource(R.string.wallet_backup_item_subtitle_4) + ) + + VerticalSpacer(20.dp) + + VerticalSpacer(1f) + + Row { + HorizontalSpacer(20.dp) + Image( + painter = painterResource(R.drawable.ic_info), + contentDescription = null, + colorFilter = ColorFilter.tint(ZashiColors.Utility.WarningYellow.utilityOrange700) + ) + HorizontalSpacer(12.dp) + Text( + text = stringResource(R.string.wallet_backup_info), + color = ZashiColors.Utility.WarningYellow.utilityOrange700, + style = ZashiTypography.textXs, + fontWeight = FontWeight.Medium + ) + } + + VerticalSpacer(24.dp) + + ZashiButton( + state = + ButtonState( + text = stringRes(stringResource(R.string.wallet_backup_btn)), + onClick = state.onNextClick + ), + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun Item( + @DrawableRes icon: Int, + title: String, + subtitle: String, +) { + Row { + Image( + painterResource(icon), + contentDescription = null + ) + HorizontalSpacer(16.dp) + Column { + VerticalSpacer(2.dp) + Text( + text = title, + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + VerticalSpacer(4.dp) + Text( + text = subtitle, + color = ZashiColors.Text.textTertiary, + style = ZashiTypography.textSm + ) + } + } +} + +@Composable +@PreviewScreens +private fun Preview() = + ZcashTheme { + SeedBackupView( + appBarState = TopAppBarSubTitleState.None, + state = + SeedBackupState( + onBack = {}, + onNextClick = {}, + onInfoClick = {} + ) + ) + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt index 71dd19cd3..69ca82111 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt @@ -34,6 +34,7 @@ import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator import co.electriccoin.zcash.ui.screen.balances.BalanceState import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel import co.electriccoin.zcash.ui.screen.scan.Scan +import co.electriccoin.zcash.ui.screen.scan.ScanFlow import co.electriccoin.zcash.ui.screen.send.ext.Saver import co.electriccoin.zcash.ui.screen.send.model.AmountState import co.electriccoin.zcash.ui.screen.send.model.MemoState @@ -72,7 +73,14 @@ internal fun WrapSend(args: Send) { WrapSend( balanceState = balanceState, exchangeRateState = exchangeRateState, - goToQrScanner = { navigationRouter.forward(Scan(Scan.SEND)) }, + goToQrScanner = { + navigationRouter.forward( + Scan( + ScanFlow.SEND, + isScanZip321Enabled = args.isScanZip321Enabled + ) + ) + }, goBack = { navigationRouter.back() }, hasCameraFeature = hasCameraFeature, monetarySeparators = monetarySeparators, @@ -241,6 +249,7 @@ internal fun WrapSend( ) setMemoState(MemoState.new(it.memos?.firstOrNull().orEmpty())) } + is PrefillSendData.FromAddressScan -> { val type = synchronizer?.validateAddress(it.address) setSendStage(SendStage.Form) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/Send.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/Send.kt index 0da005175..234076c05 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/Send.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/Send.kt @@ -7,4 +7,5 @@ import kotlinx.serialization.Serializable data class Send( val recipientAddress: String? = null, val recipientAddressType: AddressType? = null, + val isScanZip321Enabled: Boolean = true ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt index 42860da86..eee63d97c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt @@ -6,10 +6,10 @@ import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase +import co.electriccoin.zcash.ui.common.usecase.GetWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState @@ -33,7 +33,7 @@ class SendViewModel( private val observeContactByAddress: ObserveContactByAddressUseCase, private val observeContactPicked: ObserveContactPickedUseCase, private val createProposal: CreateProposalUseCase, - private val observeWalletAccounts: ObserveWalletAccountsUseCase, + private val observeWalletAccounts: GetWalletAccountsUseCase, private val navigateToAddressBook: NavigateToAddressBookUseCase ) : ViewModel() { val recipientAddressState = MutableStateFlow(RecipientAddressState.new("", null)) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt index d236e73c6..5afaf1f63 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/AndroidSettings.kt @@ -21,8 +21,10 @@ internal fun WrapSettings() { settingsViewModel.onBack() } - Settings( - state = state, - topAppBarSubTitleState = walletState, - ) + state?.let { + Settings( + state = it, + topAppBarSubTitleState = walletState, + ) + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt index 5c82865d9..907ca0ff1 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt @@ -256,7 +256,6 @@ private fun IntegrationsDisabledPreview() { ZashiListItemState( title = stringRes(R.string.settings_integrations), icon = R.drawable.ic_settings_integrations_disabled, - subtitle = stringRes(R.string.settings_integrations_subtitle_disabled), onClick = { }, isEnabled = false, titleIcons = diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt index 2082dc1c7..f13b48560 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt @@ -11,16 +11,14 @@ import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.common.model.KeystoneAccount -import co.electriccoin.zcash.ui.common.model.WalletAccount -import co.electriccoin.zcash.ui.common.model.ZashiAccount import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider -import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase -import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase +import co.electriccoin.zcash.ui.common.usecase.GetCoinbaseStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetConfigurationUseCase +import co.electriccoin.zcash.ui.common.usecase.GetFlexaStatusUseCase +import co.electriccoin.zcash.ui.common.usecase.GetKeystoneStatusUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase -import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase +import co.electriccoin.zcash.ui.common.usecase.Status import co.electriccoin.zcash.ui.configuration.ConfigurationEntries import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.util.stringRes @@ -42,15 +40,15 @@ import kotlinx.coroutines.launch @Suppress("TooManyFunctions") class SettingsViewModel( - observeConfiguration: ObserveConfigurationUseCase, - observeWalletAccounts: ObserveWalletAccountsUseCase, - isFlexaAvailable: IsFlexaAvailableUseCase, - isCoinbaseAvailable: IsCoinbaseAvailableUseCase, + getConfiguration: GetConfigurationUseCase, + getCoinbaseStatus: GetCoinbaseStatusUseCase, + getFlexaStatus: GetFlexaStatusUseCase, + getKeystoneStatus: GetKeystoneStatusUseCase, private val standardPreferenceProvider: StandardPreferenceProvider, private val getVersionInfo: GetVersionInfoProvider, private val rescanBlockchain: RescanBlockchainUseCase, private val navigationRouter: NavigationRouter, - private val navigateToAddressBook: NavigateToAddressBookUseCase + private val navigateToAddressBook: NavigateToAddressBookUseCase, ) : ViewModel() { private val versionInfo by lazy { getVersionInfo() } @@ -62,7 +60,7 @@ class SettingsViewModel( @Suppress("ComplexCondition") private val troubleshootingState = combine( - observeConfiguration(), + getConfiguration.observe(), isAnalyticsEnabled, isBackgroundSyncEnabled, isKeepScreenOnWhileSyncingEnabled, @@ -98,39 +96,30 @@ class SettingsViewModel( } } - val state: StateFlow = + val state: StateFlow = combine( troubleshootingState, - observeWalletAccounts(), - isFlexaAvailable.observe(), - isCoinbaseAvailable.observe(), - ) { troubleshootingState, accounts, isFlexaAvailable, isCoinbaseAvailable -> + getCoinbaseStatus.observe(), + getFlexaStatus.observe(), + getKeystoneStatus.observe(), + ) { troubleshootingState, coinbaseStatus, flexaStatus, keystoneStatus -> createState( - selectedAccount = accounts?.firstOrNull { it.isSelected }, troubleshootingState = troubleshootingState, - isFlexaAvailable = isFlexaAvailable == true, - isCoinbaseAvailable = isCoinbaseAvailable == true, - isKeystoneAvailable = accounts?.none { it is KeystoneAccount } == true + flexaStatus = flexaStatus, + coinbaseStatus = coinbaseStatus, + keystoneStatus = keystoneStatus, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - initialValue = - createState( - selectedAccount = observeWalletAccounts().value?.firstOrNull { it.isSelected }, - troubleshootingState = null, - isFlexaAvailable = isFlexaAvailable.observe().value == true, - isCoinbaseAvailable = isCoinbaseAvailable.observe().value == true, - isKeystoneAvailable = observeWalletAccounts().value?.none { it is KeystoneAccount } == true - ) + initialValue = null ) private fun createState( - selectedAccount: WalletAccount?, troubleshootingState: SettingsTroubleshootingState?, - isFlexaAvailable: Boolean, - isCoinbaseAvailable: Boolean, - isKeystoneAvailable: Boolean + flexaStatus: Status, + coinbaseStatus: Status, + keystoneStatus: Status ) = SettingsState( debugMenu = troubleshootingState, onBack = ::onBack, @@ -143,34 +132,31 @@ class SettingsViewModel( ), ZashiListItemState( title = stringRes(R.string.settings_integrations), - icon = - when (selectedAccount) { - is KeystoneAccount -> R.drawable.ic_settings_integrations_disabled - is ZashiAccount -> R.drawable.ic_settings_integrations - null -> R.drawable.ic_settings_integrations - }, + icon = R.drawable.ic_settings_integrations, onClick = ::onIntegrationsClick, - isEnabled = selectedAccount is ZashiAccount, - subtitle = - stringRes(R.string.settings_integrations_subtitle_disabled).takeIf { - selectedAccount !is ZashiAccount - }, titleIcons = listOfNotNull( - when (selectedAccount) { - is KeystoneAccount -> R.drawable.ic_integrations_coinbase_disabled - is ZashiAccount -> R.drawable.ic_integrations_coinbase - null -> R.drawable.ic_integrations_coinbase - }.takeIf { isCoinbaseAvailable }, - when (selectedAccount) { - is KeystoneAccount -> R.drawable.ic_integrations_flexa_disabled - is ZashiAccount -> R.drawable.ic_integrations_flexa - null -> R.drawable.ic_integrations_flexa - }.takeIf { isFlexaAvailable }, - R.drawable.ic_integrations_keystone - .takeIf { isKeystoneAvailable } + when (coinbaseStatus) { + Status.ENABLED -> R.drawable.ic_integrations_coinbase + Status.DISABLED -> R.drawable.ic_integrations_coinbase_disabled + Status.UNAVAILABLE -> null + }, + when (flexaStatus) { + Status.ENABLED -> R.drawable.ic_integrations_flexa + Status.DISABLED -> R.drawable.ic_integrations_flexa_disabled + Status.UNAVAILABLE -> null + }, + when (keystoneStatus) { + Status.ENABLED -> R.drawable.ic_integrations_keystone + Status.DISABLED -> null + Status.UNAVAILABLE -> null + } ).toImmutableList() - ).takeIf { isFlexaAvailable || isCoinbaseAvailable || isKeystoneAvailable }, + ).takeIf { + coinbaseStatus != Status.UNAVAILABLE || + flexaStatus != Status.UNAVAILABLE || + keystoneStatus != Status.UNAVAILABLE + }, ZashiListItemState( title = stringRes(R.string.settings_advanced_settings), icon = R.drawable.ic_advanced_settings, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailViewModel.kt index f85a9613a..200cb5a25 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/TransactionDetailViewModel.kt @@ -75,7 +75,7 @@ class TransactionDetailViewModel( secondaryButton = ButtonState( text = - if (transaction.metadata?.note != null) { + if (transaction.metadata.note != null) { stringRes(R.string.transaction_detail_edit_note) } else { stringRes(R.string.transaction_detail_add_a_note) @@ -85,7 +85,7 @@ class TransactionDetailViewModel( bookmarkButton = IconButtonState( icon = - if (transaction.metadata?.isBookmarked == true) { + if (transaction.metadata.isBookmarked) { R.drawable.ic_transaction_detail_bookmark } else { R.drawable.ic_transaction_detail_no_bookmark @@ -114,6 +114,7 @@ class TransactionDetailViewModel( navigationRouter.forward(TransactionNote(transactionDetail.transactionId)) } + @Suppress("CyclomaticComplexMethod") private fun createTransactionInfoState(transaction: DetailedTransactionData): TransactionDetailInfoState { return when (transaction.transaction) { is SendTransaction -> { @@ -133,7 +134,7 @@ class TransactionDetailViewModel( onTransactionAddressClick = { onCopyToClipboard(transaction.recipientAddress.address) }, fee = createFeeStringRes(transaction), completedTimestamp = createTimestampStringRes(transaction), - note = transaction.metadata?.note?.let { stringRes(it) } + note = transaction.metadata.note?.let { stringRes(it) } ) } else { SendShieldedState( @@ -154,16 +155,17 @@ class TransactionDetailViewModel( fee = createFeeStringRes(transaction), completedTimestamp = createTimestampStringRes(transaction), memo = - TransactionDetailMemosState( - transaction.memos.orEmpty() - .map { memo -> + transaction.memos?.let { + TransactionDetailMemosState( + it.map { memo -> TransactionDetailMemoState( content = stringRes(memo), onClick = { onCopyToClipboard(memo) } ) } - ), - note = transaction.metadata?.note?.let { stringRes(it) } + ) + }, + note = transaction.metadata.note?.let { stringRes(it) } ) } } @@ -180,7 +182,7 @@ class TransactionDetailViewModel( onCopyToClipboard(transaction.transaction.id.txIdString()) }, completedTimestamp = createTimestampStringRes(transaction), - note = transaction.metadata?.note?.let { stringRes(it) } + note = transaction.metadata.note?.let { stringRes(it) } ) } else { ReceiveShieldedState( @@ -194,16 +196,17 @@ class TransactionDetailViewModel( }, completedTimestamp = createTimestampStringRes(transaction), memo = - TransactionDetailMemosState( - transaction.memos.orEmpty() - .map { memo -> + transaction.memos?.let { + TransactionDetailMemosState( + it.map { memo -> TransactionDetailMemoState( content = stringRes(memo), onClick = { onCopyToClipboard(memo) } ) } - ), - note = transaction.metadata?.note?.let { stringRes(it) } + ) + }, + note = transaction.metadata.note?.let { stringRes(it) } ) } } @@ -220,7 +223,7 @@ class TransactionDetailViewModel( }, completedTimestamp = createTimestampStringRes(transaction), fee = createFeeStringRes(transaction), - note = transaction.metadata?.note?.let { stringRes(it) } + note = transaction.metadata.note?.let { stringRes(it) } ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/ReceiveShielded.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/ReceiveShielded.kt index 950d97ac9..937e2e8b0 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/ReceiveShielded.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/ReceiveShielded.kt @@ -31,17 +31,19 @@ fun ReceiveShielded( Column( modifier = modifier ) { - TransactionDetailInfoHeader( - state = - TransactionDetailInfoHeaderState( - title = stringRes(R.string.transaction_detail_info_message) - ) - ) - Spacer(Modifier.height(8.dp)) - TransactionDetailMemo( - modifier = Modifier.fillMaxWidth(), - state = state.memo - ) + state.memo?.let { + TransactionDetailInfoHeader( + state = + TransactionDetailInfoHeaderState( + title = stringRes(R.string.transaction_detail_info_message) + ) + ) + Spacer(Modifier.height(8.dp)) + TransactionDetailMemo( + modifier = Modifier.fillMaxWidth(), + state = it + ) + } Spacer(Modifier.height(20.dp)) TransactionDetailInfoHeader( state = diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/SendShielded.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/SendShielded.kt index a0b00bdf3..a7d4563df 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/SendShielded.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/SendShielded.kt @@ -144,17 +144,19 @@ fun SendShielded( ) } Spacer(Modifier.height(20.dp)) - TransactionDetailInfoHeader( - state = - TransactionDetailInfoHeaderState( - title = stringRes(R.string.transaction_detail_info_message) - ) - ) - Spacer(Modifier.height(8.dp)) - TransactionDetailMemo( - modifier = Modifier.fillMaxWidth(), - state = state.memo - ) + state.memo?.let { + TransactionDetailInfoHeader( + state = + TransactionDetailInfoHeaderState( + title = stringRes(R.string.transaction_detail_info_message) + ) + ) + Spacer(Modifier.height(8.dp)) + TransactionDetailMemo( + modifier = Modifier.fillMaxWidth(), + state = it + ) + } } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/TransactionDetailInfoState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/TransactionDetailInfoState.kt index 5df4d3af1..7e2b2b5bb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/TransactionDetailInfoState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactiondetail/info/TransactionDetailInfoState.kt @@ -15,7 +15,7 @@ data class SendShieldedState( val onTransactionAddressClick: () -> Unit, val fee: StringResource, val completedTimestamp: StringResource, - val memo: TransactionDetailMemosState, + val memo: TransactionDetailMemosState?, val note: StringResource? ) : TransactionDetailInfoState @@ -34,7 +34,7 @@ data class SendTransparentState( @Immutable data class ReceiveShieldedState( - val memo: TransactionDetailMemosState, + val memo: TransactionDetailMemosState?, val transactionId: StringResource, val onTransactionIdClick: () -> Unit, val completedTimestamp: StringResource, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/AndroidTransactionFilters.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/AndroidTransactionFilters.kt index 3377cca4f..d5b8d600a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/AndroidTransactionFilters.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/AndroidTransactionFilters.kt @@ -1,16 +1,13 @@ package co.electriccoin.zcash.ui.screen.transactionfilters import android.view.WindowManager -import androidx.activity.compose.BackHandler import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState import co.electriccoin.zcash.ui.screen.transactionfilters.view.TransactionFiltersView import co.electriccoin.zcash.ui.screen.transactionfilters.viewmodel.TransactionFiltersViewModel import org.koin.androidx.compose.koinViewModel @@ -20,9 +17,6 @@ import org.koin.androidx.compose.koinViewModel fun AndroidTransactionFiltersList() { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val parent = LocalView.current.parent SideEffect { @@ -32,22 +26,5 @@ fun AndroidTransactionFiltersList() { TransactionFiltersView( state = state, - sheetState = sheetState, - onDismissRequest = state?.onBack ?: {} ) - - LaunchedEffect(Unit) { - sheetState.show() - } - - LaunchedEffect(Unit) { - viewModel.hideBottomSheetRequest.collect { - sheetState.hide() - state?.onBottomSheetHidden?.invoke() - } - } - - BackHandler(state != null) { - state?.onBack?.invoke() - } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/fixture/TransactionFiltersStateFixture.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/fixture/TransactionFiltersStateFixture.kt index fc5d3ff37..e40adb81e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/fixture/TransactionFiltersStateFixture.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/fixture/TransactionFiltersStateFixture.kt @@ -55,14 +55,12 @@ object TransactionFiltersStateFixture { fun new( onBack: () -> Unit = {}, - onBottomSheetHidden: () -> Unit = {}, filters: List = FILTERS, primaryButtonState: ButtonState = PRIMARY_BUTTON_STATE, secondaryButtonState: ButtonState = SECONDARY_BUTTON_STATE ) = TransactionFiltersState( filters = filters, onBack = onBack, - onBottomSheetHidden = onBottomSheetHidden, primaryButton = primaryButtonState, secondaryButton = secondaryButtonState ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/model/TransactionFiltersState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/model/TransactionFiltersState.kt index 693a5cc09..abf576957 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/model/TransactionFiltersState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/model/TransactionFiltersState.kt @@ -1,15 +1,15 @@ package co.electriccoin.zcash.ui.screen.transactionfilters.model import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState import co.electriccoin.zcash.ui.design.util.StringResource data class TransactionFiltersState( val filters: List, - val onBack: () -> Unit, - val onBottomSheetHidden: () -> Unit, + override val onBack: () -> Unit, val primaryButton: ButtonState, val secondaryButton: ButtonState -) +) : ModalBottomSheetState data class TransactionFilterState( val text: StringResource, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/view/TransactionFiltersView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/view/TransactionFiltersView.kt index 92ae7b79d..c07314513 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/view/TransactionFiltersView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/view/TransactionFiltersView.kt @@ -35,8 +35,9 @@ import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults import co.electriccoin.zcash.ui.design.component.ZashiChipButton import co.electriccoin.zcash.ui.design.component.ZashiChipButtonDefaults import co.electriccoin.zcash.ui.design.component.ZashiChipButtonState -import co.electriccoin.zcash.ui.design.component.ZashiModalBottomSheet +import co.electriccoin.zcash.ui.design.component.ZashiScreenModalBottomSheet import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState +import co.electriccoin.zcash.ui.design.component.rememberScreenModalBottomSheetState 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 @@ -47,16 +48,15 @@ import co.electriccoin.zcash.ui.screen.transactionfilters.model.TransactionFilte @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun TransactionFiltersView( - onDismissRequest: () -> Unit, - sheetState: SheetState, - state: TransactionFiltersState? + state: TransactionFiltersState?, + sheetState: SheetState = rememberScreenModalBottomSheetState(), ) { - ZashiModalBottomSheet( + ZashiScreenModalBottomSheet( + state = state, sheetState = sheetState, content = { BottomSheetContent(state) }, - onDismissRequest = onDismissRequest ) } @@ -97,6 +97,16 @@ private fun BottomSheetContent(state: TransactionFiltersState?) { onClick = filter.onClick, text = filter.text, ), + modifier = + Modifier + // Customize the chip size change animation + .animateContentSize( + animationSpec = + spring( + dampingRatio = 0.85f, + stiffness = 200f + ) + ), shape = CircleShape, border = BorderStroke(1.dp, ZashiColors.Btns.Secondary.btnSecondaryBorder) @@ -113,18 +123,7 @@ private fun BottomSheetContent(state: TransactionFiltersState?) { } else { PaddingValues(horizontal = 16.dp, vertical = 10.dp) }, - endIconSpacer = 10.dp, - hasRippleEffect = false, - modifier = - Modifier - // Customize the chip size change animation - .animateContentSize( - animationSpec = - spring( - dampingRatio = 0.85f, - stiffness = 200f - ) - ) + endIconSpacer = 10.dp ) } } @@ -169,7 +168,6 @@ private fun Preview() = ZcashTheme { TransactionFiltersView( state = TransactionFiltersStateFixture.new(), - onDismissRequest = {}, sheetState = rememberModalBottomSheetState( skipHiddenState = true, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/viewmodel/TransactionFiltersViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/viewmodel/TransactionFiltersViewModel.kt index 10a469404..230cb17de 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/viewmodel/TransactionFiltersViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionfilters/viewmodel/TransactionFiltersViewModel.kt @@ -19,26 +19,19 @@ import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.screen.transactionfilters.model.TransactionFilterState import co.electriccoin.zcash.ui.screen.transactionfilters.model.TransactionFiltersState import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch internal class TransactionFiltersViewModel( private val navigationRouter: NavigationRouter, getTransactionFilters: GetTransactionFiltersUseCase, private val applyTransactionFilters: ApplyTransactionFiltersUseCase, ) : ViewModel() { - val hideBottomSheetRequest = MutableSharedFlow() - - private val bottomSheetHiddenResponse = MutableSharedFlow() - private val selectedFilters = MutableStateFlow(getTransactionFilters()) @OptIn(ExperimentalCoroutinesApi::class) @@ -87,7 +80,6 @@ internal class TransactionFiltersViewModel( } }, onBack = ::onBack, - onBottomSheetHidden = ::onBottomSheetHidden, primaryButton = ButtonState( text = stringRes(R.string.transaction_filters_btn_apply), @@ -100,9 +92,9 @@ internal class TransactionFiltersViewModel( ), ) }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), - null + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = null ) private fun onTransactionFilterClicked(filter: TransactionFilter) { @@ -115,29 +107,9 @@ internal class TransactionFiltersViewModel( } } - private suspend fun hideBottomSheet() { - hideBottomSheetRequest.emit(Unit) - bottomSheetHiddenResponse.first() - } + private fun onBack() = navigationRouter.back() - private fun onBottomSheetHidden() = - viewModelScope.launch { - bottomSheetHiddenResponse.emit(Unit) - } + private fun onApplyTransactionFiltersClick() = applyTransactionFilters(selectedFilters.value) - private fun onBack() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.back() - } - - private fun onApplyTransactionFiltersClick() = - viewModelScope.launch { - applyTransactionFilters(selectedFilters.value) { hideBottomSheet() } - } - - private fun onResetTransactionFiltersClick() = - viewModelScope.launch { - selectedFilters.update { emptyList() } - } + private fun onResetTransactionFiltersClick() = selectedFilters.update { emptyList() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionhistory/widget/TransactionHistoryWidget.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionhistory/widget/TransactionHistoryWidget.kt index 2352f4fe5..953bf87bc 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionhistory/widget/TransactionHistoryWidget.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionhistory/widget/TransactionHistoryWidget.kt @@ -101,7 +101,7 @@ private fun LazyListScope.transactionHistoryEmptyWidget(state: TransactionHistor ), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(Modifier.height(90.dp)) + Spacer(Modifier.height(72.dp)) Image( painter = painterResource(R.drawable.ic_transaction_widget_empty), contentDescription = null, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/AndroidTransactionNote.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/AndroidTransactionNote.kt index 0598e9580..223087877 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/AndroidTransactionNote.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/AndroidTransactionNote.kt @@ -1,21 +1,15 @@ package co.electriccoin.zcash.ui.screen.transactionnote import android.view.WindowManager -import androidx.activity.compose.BackHandler import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetValue.Expanded import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState import co.electriccoin.zcash.ui.screen.transactionnote.view.TransactionNoteView import co.electriccoin.zcash.ui.screen.transactionnote.viewmodel.TransactionNoteViewModel -import kotlinx.coroutines.cancel import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -24,9 +18,6 @@ import org.koin.core.parameter.parametersOf fun AndroidTransactionNote(transactionNote: TransactionNote) { val viewModel = koinViewModel { parametersOf(transactionNote) } val state by viewModel.state.collectAsStateWithLifecycle() - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val parent = LocalView.current.parent SideEffect { @@ -34,32 +25,5 @@ fun AndroidTransactionNote(transactionNote: TransactionNote) { (parent as? DialogWindowProvider)?.window?.setDimAmount(0f) } - TransactionNoteView( - state = state, - sheetState = sheetState, - onDismissRequest = state.onBack, - ) - - LaunchedEffect(Unit) { - sheetState.show() - } - - LaunchedEffect(Unit) { - snapshotFlow { sheetState.currentValue }.collect { - if (it == Expanded) { - this.cancel() - } - } - } - - LaunchedEffect(Unit) { - viewModel.hideBottomSheetRequest.collect { - sheetState.hide() - state.onBottomSheetHidden() - } - } - - BackHandler { - state.onBack() - } + TransactionNoteView(state = state) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/model/TransactionNoteState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/model/TransactionNoteState.kt index 8bae6a07f..2a2f84e68 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/model/TransactionNoteState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/model/TransactionNoteState.kt @@ -1,17 +1,17 @@ package co.electriccoin.zcash.ui.screen.transactionnote.model import co.electriccoin.zcash.ui.design.component.ButtonState +import co.electriccoin.zcash.ui.design.component.ModalBottomSheetState import co.electriccoin.zcash.ui.design.component.TextFieldState import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.StyledStringResource data class TransactionNoteState( - val onBack: () -> Unit, - val onBottomSheetHidden: () -> Unit, + override val onBack: () -> Unit, val title: StringResource, val note: TextFieldState, val noteCharacters: StyledStringResource, val primaryButton: ButtonState?, val secondaryButton: ButtonState?, val negative: ButtonState?, -) +) : ModalBottomSheetState diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/view/TransactionNoteView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/view/TransactionNoteView.kt index 486b61101..212ad7fa8 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/view/TransactionNoteView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/view/TransactionNoteView.kt @@ -22,9 +22,10 @@ import co.electriccoin.zcash.ui.design.component.ButtonState 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.ZashiModalBottomSheet +import co.electriccoin.zcash.ui.design.component.ZashiScreenModalBottomSheet import co.electriccoin.zcash.ui.design.component.ZashiTextField import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState +import co.electriccoin.zcash.ui.design.component.rememberScreenModalBottomSheetState 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 @@ -38,16 +39,15 @@ import co.electriccoin.zcash.ui.screen.transactionnote.model.TransactionNoteStat @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun TransactionNoteView( - onDismissRequest: () -> Unit, - sheetState: SheetState, state: TransactionNoteState, + sheetState: SheetState = rememberScreenModalBottomSheetState(), ) { - ZashiModalBottomSheet( + ZashiScreenModalBottomSheet( + state = state, sheetState = sheetState, content = { BottomSheetContent(state) }, - onDismissRequest = onDismissRequest ) } @@ -137,7 +137,6 @@ private fun Preview() = state = TransactionNoteState( onBack = {}, - onBottomSheetHidden = {}, title = stringRes("Title"), note = TextFieldState(stringRes("")) {}, noteCharacters = @@ -148,7 +147,6 @@ private fun Preview() = secondaryButton = null, negative = ButtonState(stringRes("Delete note")), ), - onDismissRequest = {}, sheetState = rememberModalBottomSheetState( skipHiddenState = true, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/viewmodel/TransactionNoteViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/viewmodel/TransactionNoteViewModel.kt index af0d4c886..24f1b305c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/viewmodel/TransactionNoteViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/transactionnote/viewmodel/TransactionNoteViewModel.kt @@ -15,13 +15,11 @@ import co.electriccoin.zcash.ui.design.util.StyledStringResource import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.screen.transactionnote.TransactionNote import co.electriccoin.zcash.ui.screen.transactionnote.model.TransactionNoteState -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,10 +31,6 @@ internal class TransactionNoteViewModel( private val createOrUpdateTransactionNote: CreateOrUpdateTransactionNoteUseCase, private val deleteTransactionNote: DeleteTransactionNoteUseCase ) : ViewModel() { - val hideBottomSheetRequest = MutableSharedFlow() - - private val bottomSheetHiddenResponse = MutableSharedFlow() - private val noteText = MutableStateFlow("") private val foundNote = MutableStateFlow(null) @@ -54,12 +48,9 @@ internal class TransactionNoteViewModel( foundNote: String? ): TransactionNoteState { val noteTextNormalized = noteText.trim() - val isNoteTextTooLong = noteText.length > MAX_NOTE_LENGTH - return TransactionNoteState( onBack = ::onBack, - onBottomSheetHidden = ::onBottomSheetHidden, title = if (foundNote == null) { stringRes(R.string.transaction_note_add_note_title) @@ -107,37 +98,19 @@ internal class TransactionNoteViewModel( private fun onAddOrUpdateNoteClick() = viewModelScope.launch { - createOrUpdateTransactionNote(txId = transactionNote.txId, note = noteText.value) { - hideBottomSheet() - } + createOrUpdateTransactionNote(txId = transactionNote.txId, note = noteText.value) } private fun onDeleteNoteClick() = viewModelScope.launch { - deleteTransactionNote(transactionNote.txId) { - hideBottomSheet() - } + deleteTransactionNote(transactionNote.txId) } private fun onNoteTextChanged(newValue: String) { noteText.update { newValue } } - private suspend fun hideBottomSheet() { - hideBottomSheetRequest.emit(Unit) - bottomSheetHiddenResponse.first() - } - - private fun onBottomSheetHidden() = - viewModelScope.launch { - bottomSheetHiddenResponse.emit(Unit) - } - - private fun onBack() = - viewModelScope.launch { - hideBottomSheet() - navigationRouter.back() - } + private fun onBack() = navigationRouter.back() } private const val MAX_NOTE_LENGTH = 90 diff --git a/ui-lib/src/main/res/ui/common/drawable-night/ic_copy.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_copy.xml new file mode 100644 index 000000000..6df43d482 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable-night/ic_copy.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/common/drawable-night/ic_help.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_help.xml new file mode 100644 index 000000000..16cc43401 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable-night/ic_help.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/common/drawable-night/ic_info.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_info.xml new file mode 100644 index 000000000..3bc99a688 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable-night/ic_info.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/ui-lib/src/main/res/ui/common/drawable-night/ic_warning_triangle.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_warning_triangle.xml new file mode 100644 index 000000000..e734cfa74 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable-night/ic_warning_triangle.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/common/drawable/ic_copy.xml b/ui-lib/src/main/res/ui/common/drawable/ic_copy.xml new file mode 100644 index 000000000..1636fcd26 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable/ic_copy.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/common/drawable/ic_help.xml b/ui-lib/src/main/res/ui/common/drawable/ic_help.xml new file mode 100644 index 000000000..222f46289 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable/ic_help.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/common/drawable/ic_info.xml b/ui-lib/src/main/res/ui/common/drawable/ic_info.xml new file mode 100644 index 000000000..3b4ce8863 --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable/ic_info.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/ui-lib/src/main/res/ui/common/drawable/ic_warning_triangle.xml b/ui-lib/src/main/res/ui/common/drawable/ic_warning_triangle.xml new file mode 100644 index 000000000..80586b08b --- /dev/null +++ b/ui-lib/src/main/res/ui/common/drawable/ic_warning_triangle.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/home/drawable-night/ic_home_buy.xml b/ui-lib/src/main/res/ui/home/drawable-night/ic_home_buy.xml new file mode 100644 index 000000000..8c675c98b --- /dev/null +++ b/ui-lib/src/main/res/ui/home/drawable-night/ic_home_buy.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/home/drawable-night/ic_home_request.xml b/ui-lib/src/main/res/ui/home/drawable-night/ic_home_request.xml new file mode 100644 index 000000000..43648fa9f --- /dev/null +++ b/ui-lib/src/main/res/ui/home/drawable-night/ic_home_request.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/home/drawable/ic_home_buy.xml b/ui-lib/src/main/res/ui/home/drawable/ic_home_buy.xml new file mode 100644 index 000000000..32a4ccbc1 --- /dev/null +++ b/ui-lib/src/main/res/ui/home/drawable/ic_home_buy.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/home/drawable/ic_home_request.xml b/ui-lib/src/main/res/ui/home/drawable/ic_home_request.xml new file mode 100644 index 000000000..9c16b9304 --- /dev/null +++ b/ui-lib/src/main/res/ui/home/drawable/ic_home_request.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/home/values-es/strings.xml b/ui-lib/src/main/res/ui/home/values-es/strings.xml new file mode 100644 index 000000000..ec3396ab2 --- /dev/null +++ b/ui-lib/src/main/res/ui/home/values-es/strings.xml @@ -0,0 +1,11 @@ + + + Wallet Backup Required + Prevent potential loss of funds + Receive + Send + Scan + Request + Buy + More + \ No newline at end of file diff --git a/ui-lib/src/main/res/ui/home/values/strings.xml b/ui-lib/src/main/res/ui/home/values/strings.xml new file mode 100644 index 000000000..ec3396ab2 --- /dev/null +++ b/ui-lib/src/main/res/ui/home/values/strings.xml @@ -0,0 +1,11 @@ + + + Wallet Backup Required + Prevent potential loss of funds + Receive + Send + Scan + Request + Buy + More + \ No newline at end of file diff --git a/ui-lib/src/main/res/ui/integrations/values-es/strings.xml b/ui-lib/src/main/res/ui/integrations/values-es/strings.xml index 8a26cca61..a58568e55 100644 --- a/ui-lib/src/main/res/ui/integrations/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/integrations/values-es/strings.xml @@ -10,4 +10,6 @@ Autentícate para pagar con Flexa Conectar Dispositivo Keystone Empareja tu billetera de hardware Keystone con Zashi para firmar transacciones. + Switch from Keystone to Zashi to use Flexa. + More options diff --git a/ui-lib/src/main/res/ui/integrations/values/strings.xml b/ui-lib/src/main/res/ui/integrations/values/strings.xml index 296672804..539762166 100644 --- a/ui-lib/src/main/res/ui/integrations/values/strings.xml +++ b/ui-lib/src/main/res/ui/integrations/values/strings.xml @@ -10,5 +10,6 @@ Authenticate yourself to pay with Flexa Connect Keystone Device Pair your Keystone hardware wallet with Zashi to sign transactions. + Switch from Keystone to Zashi to use Flexa. More options diff --git a/ui-lib/src/main/res/ui/qr_code/drawable-night/ic_copy.xml b/ui-lib/src/main/res/ui/qr_code/drawable-night/ic_qr_copy.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable-night/ic_copy.xml rename to ui-lib/src/main/res/ui/qr_code/drawable-night/ic_qr_copy.xml diff --git a/ui-lib/src/main/res/ui/qr_code/drawable/ic_copy.xml b/ui-lib/src/main/res/ui/qr_code/drawable/ic_qr_copy.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable/ic_copy.xml rename to ui-lib/src/main/res/ui/qr_code/drawable/ic_qr_copy.xml diff --git a/ui-lib/src/main/res/ui/restore/drawable/ic_restore_dialog.xml b/ui-lib/src/main/res/ui/restore/drawable/ic_restore_dialog.xml new file mode 100644 index 000000000..222f46289 --- /dev/null +++ b/ui-lib/src/main/res/ui/restore/drawable/ic_restore_dialog.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/restore/values-es/strings.xml b/ui-lib/src/main/res/ui/restore/values-es/strings.xml index 98450d7d2..c135d602a 100644 --- a/ui-lib/src/main/res/ui/restore/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/restore/values-es/strings.xml @@ -1,16 +1,42 @@ - Borrar Semilla + Restore + Seed Recovery Phrase + Please type in your 24-word secret recovery phrase in the correct order. + Next - Ingresa la frase secreta de recuperación - Ingresa tu frase de semilla de 24 palabras para restaurar la billetera asociada. - privacidad dignidad libertad … - Siguiente + Need to know more? + The Secret Recovery Phrase + 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 Zashi’s Advanced Settings. + The Wallet Birthday Height + 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. + + Got it! - Esta palabra no está en el diccionario de frases semilla. Por favor, selecciona la correcta de las sugerencias. - Esta palabra no está en el diccionario de frases semilla. + Estimate my block height + Restore + + Wallet Birthday Height + Entering your Wallet Birthday Height helps speed up the restore process. + Block Height + Enter number + Wallet Birthday Height is the point in time when your wallet was created. + + First Wallet Transaction + Entering the block height at which your wallet was created reduces the + number of blocks that need to be scanned to recover your wallet. + If you’re not sure, choose an earlier date. + Next + + Estimated Block Height + Zashi will scan and recover all transactions made after the following block number. + Copy + Restore + + This word is not in the seed phrase dictionary. Please select the correct one from the suggestions. - Altura de cumpleaños de la billetera - (opcional) - Restaurar diff --git a/ui-lib/src/main/res/ui/restore/values/strings.xml b/ui-lib/src/main/res/ui/restore/values/strings.xml index fcc694277..c135d602a 100644 --- a/ui-lib/src/main/res/ui/restore/values/strings.xml +++ b/ui-lib/src/main/res/ui/restore/values/strings.xml @@ -1,16 +1,42 @@ - Clear Seed + Restore + Seed Recovery Phrase + Please type in your 24-word secret recovery phrase in the correct order. + Next - Enter secret recovery phrase - Enter your 24-word seed phrase to restore the associated wallet. - privacy dignity freedom … - Next + Need to know more? + The Secret Recovery Phrase + 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 Zashi’s Advanced Settings. + The Wallet Birthday Height + 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. + + Got it! + + Estimate my block height + Restore + + Wallet Birthday Height + Entering your Wallet Birthday Height helps speed up the restore process. + Block Height + Enter number + Wallet Birthday Height is the point in time when your wallet was created. + + First Wallet Transaction + Entering the block height at which your wallet was created reduces the + number of blocks that need to be scanned to recover your wallet. + If you’re not sure, choose an earlier date. + Next + + Estimated Block Height + Zashi will scan and recover all transactions made after the following block number. + Copy + Restore This word is not in the seed phrase dictionary. Please select the correct one from the suggestions. - This word is not in the seed phrase dictionary. - Wallet birthday height - (optional) - Restore diff --git a/ui-lib/src/main/res/ui/restore_success/drawable-night/img_success_dialog.xml b/ui-lib/src/main/res/ui/restore_success/drawable-night/img_success_dialog.xml new file mode 100644 index 000000000..a534953ad --- /dev/null +++ b/ui-lib/src/main/res/ui/restore_success/drawable-night/img_success_dialog.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-lib/src/main/res/ui/restore_success/drawable/img_success_dialog.xml b/ui-lib/src/main/res/ui/restore_success/drawable/img_success_dialog.xml index f88ad7720..b9a5f9d0d 100644 --- a/ui-lib/src/main/res/ui/restore_success/drawable/img_success_dialog.xml +++ b/ui-lib/src/main/res/ui/restore_success/drawable/img_success_dialog.xml @@ -1,54 +1,65 @@ - - - - + android:width="148dp" + android:height="90dp" + android:viewportWidth="148" + android:viewportHeight="90"> + + + android:pathData="M3.12,2.53C9.3,2.17 15.49,2.01 21.68,2.04C27.84,2.06 34,2.28 40.15,2.69C43.23,2.89 46.32,3.15 49.4,3.45C50.06,3.51 50.82,3.49 51.43,3.69C51.83,3.82 51.89,4.15 51.98,4.59C52.27,5.98 52.37,7.43 52.51,8.84C53.13,15 53.31,21.19 53.57,27.37C53.84,33.58 54.1,39.78 54.35,45.99C54.6,52.22 54.84,58.46 55.06,64.7C55.29,70.93 55.49,77.17 55.66,83.41C55.66,83.51 55.7,84.12 55.66,84.12C55.66,84.12 55.77,83.99 55.77,83.99C55.78,83.95 55.88,84.02 55.74,83.99C55.63,83.96 55.38,84.05 55.25,84.07C54.67,84.16 54.09,84.23 53.51,84.3C52.21,84.46 50.91,84.58 49.61,84.69C46.96,84.9 44.3,85.03 41.64,85.1C36.09,85.26 30.54,85.23 25,85.3C19.45,85.36 14.21,85.49 8.85,86.05C7.57,86.19 6.3,86.35 5.03,86.53L6.31,87.5C6.11,81.7 5.96,75.9 5.85,70.1C5.75,64.61 5.67,59.13 5.61,53.64C5.55,48.24 5.49,42.84 5.41,37.43C5.32,31.98 5.21,26.52 5.05,21.07C4.88,15.33 4.65,9.6 4.35,3.86C4.31,3.12 4.27,2.39 4.23,1.65C4.2,1.1 3.79,0.64 3.22,0.64C2.7,0.64 2.18,1.1 2.21,1.65C2.53,7.43 2.78,13.21 2.96,18.99C3.14,24.46 3.26,29.94 3.35,35.41C3.44,40.81 3.5,46.22 3.57,51.62C3.63,57.08 3.7,62.55 3.79,68.01C3.89,73.76 4.03,79.52 4.22,85.27C4.24,86.02 4.27,86.76 4.3,87.49C4.32,88.23 4.92,88.57 5.58,88.47C10.87,87.69 16.24,87.46 21.58,87.35C27.23,87.24 32.89,87.28 38.55,87.18C43.96,87.08 49.39,86.86 54.77,86.15C55.41,86.06 56.21,86.06 56.79,85.72C57.52,85.3 57.71,84.52 57.7,83.74C57.67,82.16 57.61,80.59 57.56,79.01C57.47,75.84 57.36,72.66 57.26,69.49C57.04,63.13 56.8,56.79 56.55,50.44C56.3,44.09 56.04,37.74 55.77,31.39C55.5,25.08 55.33,18.76 54.85,12.46C54.74,10.91 54.6,9.36 54.43,7.81C54.34,7.03 54.25,6.24 54.14,5.46C54.06,4.79 54.01,4.08 53.76,3.45C53.49,2.74 53.05,2.17 52.34,1.87C51.69,1.59 50.95,1.57 50.26,1.5C48.67,1.34 47.09,1.19 45.5,1.06C39.22,0.53 32.93,0.2 26.62,0.07C20.32,-0.07 14.01,0 7.72,0.27C6.18,0.33 4.65,0.41 3.12,0.5C2.58,0.53 2.11,0.94 2.11,1.51C2.11,2.03 2.57,2.55 3.12,2.52L3.12,2.53Z" + android:fillColor="#282622"/> + android:pathData="M29,32.99C29.75,32.99 30.36,32.39 30.36,31.64V30.41C30.36,30.27 30.24,30.16 30.11,30.16H27.77C27.63,30.16 27.52,30.27 27.52,30.41V31.51C27.52,32.33 28.19,32.99 29,32.99Z" + android:strokeWidth="1.37413" + android:fillColor="#282622" + android:strokeColor="#282622"/> + android:pathData="M70.51,35.26C70.8,35.22 70.95,34.86 70.88,34.6C70.8,34.3 70.51,34.18 70.22,34.23C68.99,34.42 67.21,34.68 66.37,33.48C65.94,32.88 66.06,32.1 66.22,31.43C66.29,31.12 66.35,30.78 66.49,30.49C66.61,30.23 66.76,30.14 67.05,30.14C68,30.11 68.96,30.15 69.91,30.16C70.6,30.16 70.6,29.09 69.91,29.09C69.39,29.09 68.89,29.08 68.37,29.07C67.89,29.07 67.4,29.04 66.91,29.07C66.4,29.12 65.97,29.31 65.68,29.74C65.44,30.07 65.34,30.49 65.24,30.88C64.99,31.82 64.84,32.81 65.26,33.73C65.62,34.5 66.39,35.01 67.17,35.27C68.25,35.62 69.41,35.44 70.5,35.27V35.26L70.51,35.26Z" + android:fillColor="#282622"/> + android:pathData="M131.99,32.6C129.62,32.8 117.14,33.6 114.76,33.68C114.41,33.68 114.05,33.68 113.69,33.69C115.48,33.58 117.26,33.44 119.05,33.26C119.72,33.19 119.73,32.12 119.05,32.19C107.73,33.37 112.55,32.6 101.2,32.75C100.28,32.75 99.36,32.77 98.44,32.79C96.98,32.83 95.53,32.91 94.07,33.01C91.93,33.14 89.8,33.32 87.67,33.57C87.66,33.57 87.65,33.58 87.64,33.58C86.52,27.02 80.55,22 73.35,22C72.43,22 71.52,22.08 70.62,22.25C70.43,22.19 70.2,22.23 70.07,22.36C69.99,22.38 69.91,22.39 69.83,22.41L69.92,22.76L69.95,22.96C70.24,28.67 71.14,34.34 71.69,40.03C70.68,40.13 69.67,40.24 68.66,40.34C68.14,40.4 67.58,40.4 67.17,40.76C66.8,41.08 66.68,41.56 66.62,42.02C66.46,43.14 66.46,44.28 66.64,45.4C66.71,45.85 66.88,46.31 67.29,46.55C67.75,46.81 68.35,46.78 68.85,46.79C69.94,46.83 71.03,46.76 72.11,46.64C72.13,47.54 72.14,48.43 72.12,49.33C72.11,50.02 73.18,50.02 73.19,49.33C73.2,48.9 73.2,48.48 73.2,48.04L73.35,49.19V49.55C81.34,49.55 87.83,43.37 87.84,35.77C100.75,34.18 97.57,35.05 110.54,34.86C111.85,34.84 113.16,34.81 114.47,34.76C114.02,34.93 113.58,35.11 113.13,35.28C112.49,35.53 112.77,36.57 113.42,36.32L117.64,34.67C117.64,34.67 117.7,34.63 117.72,34.62C119.1,34.54 130.6,33.78 131.98,33.66C132.66,33.61 132.67,32.53 131.98,32.59L131.99,32.6ZM68.85,45.71C68.57,45.71 68.27,45.71 67.99,45.67C67.82,45.64 67.78,45.59 67.73,45.42C67.57,44.92 67.58,44.33 67.57,43.81C67.56,43.24 67.58,42.67 67.68,42.11C67.71,41.94 67.73,41.69 67.86,41.56C68,41.44 68.26,41.44 68.44,41.42C69.56,41.3 70.68,41.19 71.8,41.07C71.94,42.56 72.04,44.06 72.09,45.57C71.02,45.69 69.94,45.75 68.86,45.72H68.85L68.85,45.71ZM93.36,34.15V34.12C94.06,34.08 94.76,34.04 95.46,34.01C94.76,34.05 94.06,34.1 93.36,34.15Z" + android:fillColor="#282622"/> + android:pathData="M21.01,14.89C26.19,15.08 31.38,15.1 36.56,15.01L35.91,14.64C37.61,18.1 39.63,21.39 41.95,24.47L41.87,23.88C40.13,28.23 38.22,32.51 36.15,36.71C35.96,37.1 35.77,37.74 35.44,38.04C35.1,38.34 34.46,38.27 34.05,38.31C32.83,38.43 31.61,38.54 30.39,38.64C28.01,38.83 25.62,39.01 23.23,39.12C22.84,39.14 22.44,39.19 22.12,38.93C21.84,38.71 21.64,38.37 21.44,38.07C21.08,37.53 20.75,36.96 20.44,36.39C19.71,35.03 19.05,33.65 18.4,32.25C17.82,30.99 17.25,29.71 16.67,28.45C16.48,28.03 16.29,27.61 16.09,27.2C16.02,27.08 15.96,26.97 15.89,26.85C15.8,26.69 15.64,26.53 15.58,26.35C15.52,26.15 15.62,26.03 15.73,25.85C16.87,24 17.99,22.15 19.02,20.23C20.05,18.31 21.14,16.37 22.35,14.51C22.88,13.7 21.57,12.93 21.04,13.75C19.73,15.75 18.59,17.84 17.46,19.94C16.92,20.93 16.37,21.92 15.8,22.89C15.47,23.45 15.12,24.01 14.77,24.56C14.39,25.14 13.96,25.72 14.06,26.45C14.15,27.1 14.64,27.68 14.93,28.27C15.22,28.86 15.51,29.51 15.79,30.14C16.55,31.83 17.31,33.53 18.13,35.19C18.89,36.75 19.67,38.44 20.82,39.76C21.81,40.89 23.23,40.63 24.57,40.55C26.04,40.47 27.51,40.37 28.98,40.26C30.45,40.15 31.98,40.02 33.47,39.87C34.15,39.81 34.87,39.8 35.53,39.63C36.19,39.46 36.68,39 37.01,38.39C38.33,35.85 39.51,33.22 40.67,30.61C41.26,29.26 41.85,27.9 42.42,26.54C42.69,25.89 42.96,25.23 43.22,24.58C43.39,24.16 43.4,23.88 43.12,23.51C42.91,23.22 42.7,22.93 42.48,22.64C40.89,20.43 39.45,18.12 38.17,15.72C37.84,15.11 37.52,14.49 37.22,13.87C37.1,13.63 36.82,13.49 36.57,13.49C31.39,13.58 26.19,13.56 21.02,13.37C20.04,13.34 20.04,14.85 21.02,14.89L21.01,14.89Z" + android:fillColor="#282622"/> + android:pathData="M33.98,73.22C30.8,73.54 27.6,73.42 24.4,73.57C24.02,73.58 23.61,73.65 23.23,73.59C22.91,73.55 22.7,73.33 22.51,73.09C22.21,72.73 21.97,72.32 21.72,71.91C21.16,70.97 20.66,69.99 20.17,69C19.18,67 18.29,64.95 17.36,62.92C17.11,62.38 16.88,61.81 16.56,61.3C16.44,61.09 16.19,60.86 16.27,60.6C16.38,60.23 16.73,59.85 16.93,59.53C17.38,58.84 17.8,58.14 18.21,57.43C19.83,54.62 21.26,51.71 23.03,48.99C23.57,48.17 22.26,47.41 21.72,48.23C20.07,50.77 18.71,53.46 17.22,56.09C16.83,56.8 16.42,57.49 15.99,58.18C15.56,58.87 14.81,59.7 14.73,60.56C14.68,61.22 15.05,61.69 15.35,62.24C15.5,62.5 15.62,62.77 15.75,63.04C16.69,65.04 17.56,67.09 18.53,69.09C19.05,70.17 19.59,71.25 20.19,72.29C20.79,73.34 21.46,74.65 22.63,75.01C23.54,75.29 24.64,75.07 25.58,75.05C26.51,75.02 27.42,75.01 28.34,75C30.23,74.98 32.11,74.93 33.99,74.74C34.39,74.7 34.74,74.42 34.74,73.99C34.74,73.61 34.4,73.19 33.99,73.23L33.98,73.22Z" + android:fillColor="#282622"/> + android:pathData="M22.38,49.37C27.33,49.54 32.29,49.57 37.24,49.49L36.59,49.11C38.29,52.57 40.31,55.86 42.63,58.94L42.55,58.36C41.51,60.96 40.4,63.55 39.24,66.11C38.65,67.39 38.05,68.67 37.44,69.94C37.13,70.58 36.82,71.21 36.51,71.84C36.4,72.06 36.3,72.35 36.12,72.51C35.93,72.68 35.64,72.7 35.4,72.72C34.99,72.77 34.64,73.04 34.64,73.48C34.64,73.86 34.99,74.28 35.4,74.24C35.85,74.19 36.3,74.14 36.71,73.93C37.15,73.71 37.46,73.31 37.68,72.87C38.14,71.99 38.56,71.09 38.99,70.19C39.87,68.35 40.72,66.51 41.54,64.64C42.36,62.78 43.13,60.93 43.89,59.06C44.01,58.76 44.11,58.49 43.93,58.18C43.83,58 43.68,57.84 43.56,57.68C43.25,57.27 42.96,56.85 42.66,56.43C42.1,55.63 41.56,54.82 41.04,53.99C39.89,52.17 38.84,50.28 37.89,48.35C37.77,48.12 37.48,47.97 37.23,47.97C32.28,48.06 27.32,48.03 22.37,47.85C21.39,47.82 21.4,49.34 22.37,49.37L22.38,49.37Z" + android:fillColor="#282622"/> + android:pathData="M26.83,65C27,66.04 27.81,66.68 28.8,67.13C29.01,67.23 29.27,67.33 29.54,67.4C29.81,67.46 29.92,67.45 30.13,67.28C30.71,66.78 31.23,66.2 31.78,65.66C32.47,64.98 33.54,66.05 32.85,66.74C32.5,67.08 32.16,67.41 31.81,67.75C31.5,68.06 31.21,68.39 30.85,68.63C30.01,69.2 29.05,68.87 28.2,68.51C26.89,67.94 25.61,66.88 25.37,65.4C25.31,64.99 25.48,64.58 25.9,64.47C26.26,64.37 26.77,64.59 26.83,64.99V65Z" + android:fillColor="#282622"/> + android:pathData="M27.29,56.45C26.79,56.12 26.07,56.37 25.71,56.84C25.34,57.31 25.24,57.94 25.16,58.53C25.1,58.94 25.06,59.36 25.16,59.76C25.25,60.16 25.53,60.55 25.92,60.67C26.46,60.82 27.04,60.42 27.28,59.91C27.52,59.41 27.5,58.82 27.48,58.26C27.46,57.69 27.44,57.13 27.42,56.56" + android:fillColor="#282622"/> + android:pathData="M27.54,56.01C26.8,55.55 25.82,55.88 25.31,56.54C24.76,57.24 24.63,58.25 24.59,59.12C24.56,59.98 24.95,61.01 25.9,61.18C26.86,61.35 27.62,60.62 27.86,59.78C28.01,59.29 28,58.77 27.98,58.26C27.96,57.69 27.94,57.13 27.92,56.56C27.9,55.91 26.89,55.91 26.91,56.56C26.94,57.31 26.99,58.06 26.98,58.81C26.97,59.11 26.95,59.42 26.83,59.69C26.73,59.9 26.54,60.09 26.36,60.16C26.16,60.23 26.02,60.2 25.88,60.08C25.72,59.92 25.63,59.71 25.61,59.46C25.57,59.12 25.62,58.77 25.67,58.43C25.72,58.12 25.77,57.8 25.89,57.52C26.01,57.23 26.18,57.02 26.43,56.89C26.5,56.86 26.5,56.86 26.58,56.84C26.65,56.82 26.71,56.81 26.73,56.81C26.76,56.81 26.79,56.81 26.81,56.81C26.98,56.82 26.78,56.8 26.87,56.82C26.96,56.84 26.94,56.84 27.03,56.89C27.58,57.24 28.09,56.36 27.54,56.02L27.54,56.01Z" + android:fillColor="#282622"/> + android:pathData="M31.5,60.29C32.03,60.58 32.72,60.26 33.04,59.76C33.37,59.25 33.41,58.63 33.44,58.03C33.45,57.61 33.46,57.19 33.33,56.8C33.19,56.41 32.89,56.05 32.48,55.97C31.93,55.86 31.39,56.31 31.2,56.84C31.02,57.37 31.08,57.95 31.15,58.51C31.22,59.08 31.29,59.64 31.36,60.2" + android:fillColor="#282622"/> + + + + + diff --git a/ui-lib/src/main/res/ui/restore_success/values-es/strings.xml b/ui-lib/src/main/res/ui/restore_success/values-es/strings.xml index 4f036d782..ed5f420f1 100644 --- a/ui-lib/src/main/res/ui/restore_success/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/restore_success/values-es/strings.xml @@ -1,10 +1,12 @@ ¡Mantén Zashi abierto! - Tu billetera ha sido restaurada con éxito y ahora se está sincronizando - Para evitar interrupciones, mantén la pantalla encendida, conecta tu dispositivo a una fuente de energía y guárdalo en un lugar seguro. + Your wallet is being restored. + Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption: Mantener la pantalla encendida mientras se restaura. Nota: - 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. + Your funds cannot be spent with Zashi until your wallet is fully restored. ¡Entendido! + Keep the Zashi app open on an active phone screen. + To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in. diff --git a/ui-lib/src/main/res/ui/restore_success/values/strings.xml b/ui-lib/src/main/res/ui/restore_success/values/strings.xml index a8e1b9f85..e353bd127 100644 --- a/ui-lib/src/main/res/ui/restore_success/values/strings.xml +++ b/ui-lib/src/main/res/ui/restore_success/values/strings.xml @@ -1,11 +1,12 @@ Keep Zashi open! - Your wallet has been successfully restored and is now syncing - To prevent interruption, keep your screen awake, plug your device into a power source, and keep it in a secure place. + Your wallet is being restored. + Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption: Keep screen on while restoring. Note: - 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. + Your funds cannot be spent with Zashi until your wallet is fully restored. Got it! + Keep the Zashi app open on an active phone screen. + To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in. diff --git a/ui-lib/src/main/res/ui/security_warning/values-es/strings.xml b/ui-lib/src/main/res/ui/security_warning/values-es/strings.xml deleted file mode 100644 index a92132bbe..000000000 --- a/ui-lib/src/main/res/ui/security_warning/values-es/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - Advertencia de seguridad: - - Zashi %1$s es una billetera exclusiva para Zcash, con protección de privacidad — creada por Zcashers para Zcashers. Zashi ha sido diseñada para tu privacidad y seguridad. Al instalar y usar Zashi, aceptas compartir informes de fallos con Electric Coin Co. (el desarrollador de la billetera), lo que nos ayudará a mejorar la experiencia del usuario de Zashi.*\n\nPor favor, confirma y acepta a continuación para continuar. - *Nota: - \u0020Los informes de fallos podrían revelar la fecha y hora del fallo y los eventos que ocurrieron, pero no revelarían las claves de gasto o de visualización. - - Confirmar - Acepto - diff --git a/ui-lib/src/main/res/ui/security_warning/values/strings.xml b/ui-lib/src/main/res/ui/security_warning/values/strings.xml deleted file mode 100644 index d7b1bfbd3..000000000 --- a/ui-lib/src/main/res/ui/security_warning/values/strings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - Security warning: - - Zashi %1$s - is a Zcash-only, shielded wallet — built by Zcashers for Zcashers. Zashi has been engineered for your - privacy and safety. By installing and using Zashi, you consent to share crash reports with Electric Coin Co. - (the wallet developer), which will help us improve the Zashi user experience.*\n\nPlease acknowledge and - confirm below to proceed. - *Note: - \u0020Crash reports might reveal the timing of the crash and - what events occurred, but it would not reveal spending or viewing keys. - - Confirm - I acknowledge - \ No newline at end of file diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_1.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_1.xml new file mode 100644 index 000000000..e7fc16830 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_1.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_2.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_2.xml new file mode 100644 index 000000000..47465bc8c --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_2.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_3.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_3.xml new file mode 100644 index 000000000..624c55755 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_3.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_4.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_4.xml new file mode 100644 index 000000000..1243139e5 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable-night/ic_wallet_backup_4.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_1.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_1.xml new file mode 100644 index 000000000..98d912d17 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_1.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_2.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_2.xml new file mode 100644 index 000000000..664addec5 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_2.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_3.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_3.xml new file mode 100644 index 000000000..f12ce23d6 --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_3.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_4.xml b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_4.xml new file mode 100644 index 000000000..34fa09f3b --- /dev/null +++ b/ui-lib/src/main/res/ui/seed_recovery/drawable/ic_wallet_backup_4.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/seed_recovery/values-es/strings.xml b/ui-lib/src/main/res/ui/seed_recovery/values-es/strings.xml index cae710365..f6d70dd97 100644 --- a/ui-lib/src/main/res/ui/seed_recovery/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/seed_recovery/values-es/strings.xml @@ -1,15 +1,25 @@ Frase de Recuperación - Asegura tu Billetera Zashi - Las siguientes 24 palabras son las claves de acceso a tus fondos y la única forma de recuperarlos si pierdes acceso o cambias de dispositivo. + Secret Recovery Phrase + These words are the only way to recover your funds! Make sure to save them in the correct order. Mostrar detalles de seguridad Ocultar detalles de seguridad Siguiente - Frase de Recuperación Altura de Cumpleaños de la Billetera Altura de Cumpleaños de la Billetera La Altura de Cumpleaños de la Billetera determina la altura (cadena) de nacimiento de tu billetera y facilita un proceso de restauración más rápido. Guarda este número junto con tu frase de recuperación en un lugar seguro. - ¡Protege tu ZEC almacenando esta frase en un lugar confiable y nunca la compartas con nadie! - Mostrar frase de recuperación + Wallet backup + Your Secret Recovery Phrase + "Your secret recovery phrase is a unique set of 24 words, appearing in a precise order. It protects access to your funds. " + Keep this phrase securely hidden. Don’t take screenshots of it or store it on your phone! + Next + Control your wallet + Keep it private + Store it securely + Wallet birthday height + Use it to access your funds from other devices and Zcash wallets. + Don\'t share it with anyone! It can be used to gain full control of your funds. + We don’t have access to it and will never ask for it. If you lose it, it cannot be recovered. + The block height at which your wallet was created can significantly speed up the wallet recovery process. diff --git a/ui-lib/src/main/res/ui/seed_recovery/values/strings.xml b/ui-lib/src/main/res/ui/seed_recovery/values/strings.xml index d21a9f916..c960ba6a4 100644 --- a/ui-lib/src/main/res/ui/seed_recovery/values/strings.xml +++ b/ui-lib/src/main/res/ui/seed_recovery/values/strings.xml @@ -1,19 +1,27 @@ Recovery Phrase - Secure Your Zashi Wallet - The following 24 words are the keys to your funds and are the only way - to recover your funds if you get locked out or get a new device. + Secret Recovery Phrase + These words are the only way to recover your funds! Make sure to save them in the correct order. Reveal security details Hide security details Next - Recovery Phrase Wallet Birthday Height Wallet Birthday Height Wallet Birthday Height determines the birth (chain) height of your wallet and facilitates faster wallet restore process. Save this number together with your seed phrase in a safe place. - Protect your ZEC by storing this phrase in a place you trust and never share - it with anyone! - Reveal recovery phrase + Wallet backup + Your Secret Recovery Phrase + "Your secret recovery phrase is a unique set of 24 words, appearing in a precise order. It protects access to your funds. " + Keep this phrase securely hidden. Don’t take screenshots of it or store it on your phone! + Next + Control your wallet + Keep it private + Store it securely + Wallet birthday height + Use it to access your funds from other devices and Zcash wallets. + Don\'t share it with anyone! It can be used to gain full control of your funds. + We don’t have access to it and will never ask for it. If you lose it, it cannot be recovered. + The block height at which your wallet was created can significantly speed up the wallet recovery process. diff --git a/ui-lib/src/main/res/ui/settings/values-es/strings.xml b/ui-lib/src/main/res/ui/settings/values-es/strings.xml index aec7aa2f7..b458eb4ba 100644 --- a/ui-lib/src/main/res/ui/settings/values-es/strings.xml +++ b/ui-lib/src/main/res/ui/settings/values-es/strings.xml @@ -3,7 +3,6 @@ Configuración Configuración Avanzada Integraciones - Cambia de Keystone a Zashi para usar Integraciones. Sobre Nosotros Envíanos tus Comentarios Versión %s diff --git a/ui-lib/src/main/res/ui/settings/values/strings.xml b/ui-lib/src/main/res/ui/settings/values/strings.xml index 79a6bbc2e..c46054170 100644 --- a/ui-lib/src/main/res/ui/settings/values/strings.xml +++ b/ui-lib/src/main/res/ui/settings/values/strings.xml @@ -3,7 +3,6 @@ Settings Advanced Settings Integrations - Switch from Keystone to Zashi to use Integrations. About Us Send Us Feedback Version %s diff --git a/ui-lib/src/main/res/ui/transaction_history/drawable-night/ic_transaction_widget_empty.xml b/ui-lib/src/main/res/ui/transaction_history/drawable-night/ic_transaction_widget_empty.xml index bd168dea7..6a45e5e05 100644 --- a/ui-lib/src/main/res/ui/transaction_history/drawable-night/ic_transaction_widget_empty.xml +++ b/ui-lib/src/main/res/ui/transaction_history/drawable-night/ic_transaction_widget_empty.xml @@ -1,64 +1,40 @@ + android:width="121dp" + android:height="120dp" + android:viewportWidth="121" + android:viewportHeight="120"> - - - - - - - - diff --git a/ui-lib/src/main/res/ui/transaction_history/drawable/ic_transaction_widget_empty.xml b/ui-lib/src/main/res/ui/transaction_history/drawable/ic_transaction_widget_empty.xml index 8f198c62a..af0d1d5b3 100644 --- a/ui-lib/src/main/res/ui/transaction_history/drawable/ic_transaction_widget_empty.xml +++ b/ui-lib/src/main/res/ui/transaction_history/drawable/ic_transaction_widget_empty.xml @@ -1,64 +1,40 @@ + android:width="121dp" + android:height="120dp" + android:viewportWidth="121" + android:viewportHeight="120"> + android:pathData="M77.21,9.27C76.64,7.59 74.69,6.09 71.84,6.09H33.71C31.61,6.09 29.9,7.8 29.9,9.9V86.55C29.9,89.01 31.64,91.02 33.5,91.65C34.13,92.04 34.88,92.25 35.66,92.25H73.58V90.9H35.66C35.45,90.9 35.27,90.9 35.06,90.84L76.46,11.55V59.37H77.81V11.4C77.81,10.62 77.6,9.9 77.24,9.27H77.21Z" + android:fillColor="#282622"/> + android:pathData="M54.53,15.99C55.49,15.99 56.27,15.21 56.27,14.25C56.27,13.29 55.49,12.51 54.53,12.51C53.57,12.51 52.79,13.29 52.79,14.25C52.79,15.21 53.57,15.99 54.53,15.99Z" + android:fillColor="#282622"/> + android:pathData="M54.53,16.29C53.42,16.29 52.52,15.39 52.52,14.28C52.52,13.17 53.42,12.27 54.53,12.27C55.64,12.27 56.54,13.17 56.54,14.28C56.54,15.39 55.64,16.29 54.53,16.29ZM54.53,12.78C53.72,12.78 53.06,13.44 53.06,14.25C53.06,15.06 53.72,15.72 54.53,15.72C55.34,15.72 56,15.06 56,14.25C56,13.44 55.34,12.78 54.53,12.78Z" + android:fillColor="#282622"/> + android:pathData="M55.77,26.49C54.48,25.86 52.95,25.89 51.72,26.58C51.69,26.43 51.63,26.28 51.6,26.1C51.48,25.62 50.82,25.29 50.4,25.62C49.98,25.95 49.53,26.25 49.11,26.58C49.17,26.25 49.23,25.89 49.26,25.56C49.32,25.11 49.14,24.66 48.69,24.54C48.3,24.42 47.73,24.66 47.67,25.11C47.52,26.13 47.34,27.18 47.19,28.2C47.13,28.53 47.19,28.86 47.49,29.07C47.73,29.25 48.15,29.34 48.42,29.13C49.08,28.65 49.71,28.17 50.37,27.69C50.43,27.87 50.46,28.05 50.52,28.2C50.67,28.77 51.48,29.04 51.9,28.56C52.68,27.69 53.97,27.39 54.99,27.9C55.38,28.11 55.89,28.02 56.13,27.6C56.34,27.24 56.22,26.67 55.83,26.46L55.77,26.49Z" + android:fillColor="#282622"/> + android:pathData="M70.22,48.57C67.91,48.18 65.57,48 63.23,47.64C65.15,45.96 67.07,44.28 68.99,42.6C69.29,42.33 68.99,41.76 68.6,41.91C66.2,42.81 63.83,43.68 61.43,44.58C62.21,43.74 62.99,42.9 63.77,42.09C64.07,41.76 63.68,41.16 63.26,41.43C61.22,42.84 59.12,44.16 56.96,45.39C57.14,43.86 57.2,42.33 57.14,40.77C57.14,40.29 56.48,40.23 56.33,40.65C55.88,41.94 55.43,43.2 54.89,44.43C54.59,42.96 54.17,41.55 53.57,40.17C53.45,39.87 52.85,39.93 52.82,40.26C52.55,42.3 52.13,44.31 51.62,46.29C50.63,44.52 49.61,42.75 48.62,40.95C48.44,40.62 47.81,40.74 47.84,41.16C48.02,43.26 48.2,45.33 48.35,47.43C46.76,45.96 45.14,44.58 43.46,43.23C43.1,42.93 42.59,43.29 42.8,43.74C43.61,45.51 44.6,47.19 45.68,48.78C44.57,48.72 43.49,48.69 42.38,48.72C41.99,48.72 41.81,49.29 42.17,49.5C43.55,50.25 44.99,50.91 46.43,51.48C45.14,52.26 43.88,53.01 42.59,53.79C42.17,54.03 42.44,54.63 42.92,54.54C44.87,54.12 46.79,53.7 48.71,53.25C47.18,55.02 45.44,56.61 43.49,57.87C43.1,58.11 43.34,58.77 43.82,58.62C46.4,57.84 48.98,57.09 51.56,56.31C50.15,58.41 49.01,60.72 48.2,63.12C48.08,63.51 48.62,63.84 48.89,63.51C50.93,61.08 53,58.65 55.04,56.19C55.43,59.1 56.03,61.95 56.93,64.74C57.05,65.13 57.62,65.16 57.74,64.74C58.49,61.86 58.88,58.92 58.91,55.95C61.25,58.32 63.83,60.42 66.65,62.19C67.01,62.43 67.4,61.98 67.22,61.62C65.78,58.8 64.52,55.86 63.08,53.01C63.71,53.19 64.37,53.4 64.97,53.67C65.45,53.88 65.87,53.16 65.39,52.95C64.43,52.53 63.44,52.2 62.42,52.02C62.06,51.96 61.79,52.29 61.94,52.62C63.29,55.26 64.49,57.99 65.78,60.66C63.23,58.92 60.92,56.91 58.76,54.66C58.52,54.39 58.04,54.6 58.07,54.96C58.07,57.69 57.83,60.39 57.26,63.03C56.54,60.51 56.03,57.93 55.73,55.35C55.7,55.05 55.43,54.93 55.22,54.96C55.1,54.96 55.01,54.96 54.89,55.08C53.21,57.09 51.53,59.07 49.85,61.05C50.66,59.19 51.68,57.42 52.88,55.77C53.09,55.47 52.73,55.08 52.4,55.17C50.21,55.83 48.02,56.46 45.83,57.12C47.39,55.86 48.8,54.45 50.03,52.86C50.27,52.53 50.06,52.08 49.64,52.17C48.14,52.5 46.64,52.83 45.17,53.16C45.98,52.68 46.76,52.2 47.57,51.72C47.9,51.54 47.81,51.09 47.48,50.97C46.31,50.55 45.17,50.07 44.06,49.53C44.87,49.53 45.68,49.59 46.49,49.65C46.79,49.65 47.03,49.29 46.85,49.02C45.92,47.76 45.08,46.41 44.36,45.03C45.8,46.2 47.18,47.43 48.53,48.72C48.77,48.96 49.28,48.81 49.25,48.42C49.1,46.59 48.95,44.79 48.8,42.96C49.67,44.49 50.51,46.02 51.38,47.52C51.56,47.82 52.04,47.76 52.13,47.43C52.64,45.63 53.06,43.77 53.36,41.91C53.78,43.17 54.11,44.49 54.26,45.81C54.32,46.17 54.83,46.44 55.04,46.02C55.49,45.03 55.88,44.04 56.27,43.02C56.24,44.01 56.15,45 56.03,45.96C55.97,46.29 56.3,46.62 56.63,46.41C58.04,45.63 59.42,44.79 60.77,43.92C60.38,44.34 59.99,44.76 59.6,45.18C59.33,45.45 59.6,46.02 59.99,45.87C62.21,45.03 64.46,44.22 66.68,43.38C65.12,44.76 63.53,46.14 61.97,47.52C61.76,47.73 61.85,48.15 62.15,48.21C63.95,48.51 65.81,48.69 67.61,48.93C67.37,49.17 67.46,49.65 67.88,49.65C68.63,49.59 69.32,49.32 70.07,49.29C70.52,49.29 70.67,48.57 70.19,48.48L70.22,48.57Z" + android:fillColor="#282622"/> + android:pathData="M53.9,48.36C53.9,48.81 53.99,49.26 54.14,49.68C54.17,49.77 54.23,49.86 54.32,49.92C54.41,49.98 54.53,49.98 54.65,49.95C54.86,49.89 55.01,49.65 54.95,49.44C54.86,49.17 54.8,48.9 54.74,48.63V48.75C54.74,48.63 54.74,48.48 54.74,48.36C54.74,48.15 54.56,47.94 54.32,47.94C54.11,47.94 53.9,48.12 53.9,48.36Z" + android:fillColor="#282622"/> + android:pathData="M55.16,48C55.16,48.57 55.28,49.14 55.46,49.68C55.52,49.89 55.76,50.04 55.97,49.98C56.18,49.92 56.33,49.68 56.27,49.47C56.18,49.23 56.12,48.96 56.09,48.69C56.09,48.63 56.09,48.57 56.06,48.51C56.06,48.42 56.06,48.6 56.06,48.51C56.06,48.48 56.06,48.45 56.06,48.39C56.06,48.27 56.06,48.12 56.06,48C56.06,47.79 55.88,47.58 55.64,47.58C55.4,47.58 55.22,47.76 55.22,48H55.16Z" + android:fillColor="#282622"/> + android:pathData="M52.64,50.75C53.27,51.83 54.17,52.76 55.31,53.36C55.82,53.63 56.48,53.75 56.78,53.15C57.08,52.55 57.14,51.77 57.14,51.11C57.14,50.57 57.95,50.57 57.98,51.11C57.98,51.92 57.89,52.91 57.47,53.63C57.05,54.35 56.18,54.56 55.4,54.29C54.62,54.02 53.99,53.54 53.42,53.03C52.85,52.52 52.34,51.86 51.92,51.17C51.65,50.72 52.37,50.3 52.64,50.75Z" + android:fillColor="#282622"/> - - - - - - - - + android:pathData="M70.16,73.02L67.04,59.85L65.21,50.34C65.09,49.77 65.42,49.2 65.99,49.08C66.5,48.96 67.04,49.23 67.25,49.71L67.64,50.58L74.9,67.83C74.9,67.83 74.9,67.83 74.9,67.86C74.72,68.43 74.63,69.09 74.57,69.78C74.57,70.11 74.81,70.41 75.14,70.44C75.2,70.44 75.26,70.44 75.32,70.44C75.59,70.38 75.77,70.14 75.8,69.87C75.89,68.4 76.28,67.29 77.03,66.54C77.27,66.3 77.27,65.91 77.03,65.67C76.79,65.43 76.4,65.43 76.16,65.67C75.95,65.88 75.77,66.12 75.59,66.36L75.35,65.79L75.29,62.7C75.29,61.56 76.01,60.54 77.15,60.27C77.96,60.06 78.8,60.51 79.07,61.29L82.19,70.11C82.19,70.11 82.19,70.11 82.19,70.14V70.32C82.31,70.65 82.64,70.86 82.97,70.8C83.3,70.74 83.51,70.41 83.45,70.08L81.86,62.52C81.74,61.89 82.1,61.29 82.73,61.14C83.36,60.99 83.99,61.35 84.17,61.95L86.78,70.8C86.87,71.13 87.2,71.31 87.53,71.22C87.86,71.13 88.04,70.77 87.95,70.44L87.65,69.45L86.84,64.17C86.78,63.75 87.05,63.33 87.47,63.21C87.92,63.09 88.34,63.33 88.49,63.78L89.72,67.53C90.2,69 90.38,70.56 90.2,72.09L88.97,84.09C88.88,84.9 88.85,85.71 88.85,86.49C88.85,86.82 89.12,87.12 89.48,87.12C89.84,87.12 89.6,87.12 89.63,87.12C89.9,87.06 90.08,86.82 90.11,86.52C90.11,85.77 90.14,84.99 90.23,84.24L91.46,72.24C91.64,70.53 91.46,68.82 90.92,67.17L89.69,63.42C89.36,62.37 88.25,61.77 87.17,62.04C86.54,62.22 86.06,62.64 85.82,63.18L85.37,61.62C85.01,60.36 83.72,59.64 82.43,59.94C81.41,60.21 80.72,61.05 80.6,62.04L80.21,60.93C79.7,59.52 78.26,58.74 76.82,59.13C75.14,59.55 74.03,61.05 74.03,62.76L71.54,56.82L68.27,49.35C67.85,48.36 66.74,47.79 65.69,48.06C64.52,48.36 63.8,49.53 64.07,50.67L68.93,73.41C68.99,73.68 68.81,73.86 68.75,73.89C68.69,73.95 68.48,74.07 68.24,73.92C66.77,73.11 64.88,71.46 62.87,69.69C62.33,69.21 61.79,68.73 61.22,68.25C59.75,66.99 57.65,66.81 56,67.8C55.52,68.07 55.22,68.52 55.1,69.06C54.98,69.6 55.13,70.14 55.46,70.56L63.53,80.91C64.79,82.5 66.26,83.94 67.91,85.11L68.99,85.89C71.15,87.45 72.77,89.67 73.58,92.19L73.91,93.24C73.1,93.69 72.65,94.68 72.89,95.61L73.7,98.82C73.79,99.12 73.91,99.39 74.12,99.63H74C72.92,99.93 72.29,101.04 72.56,102.09L75.83,114.93C75.92,115.26 76.25,115.47 76.58,115.38C76.91,115.29 77.12,114.96 77.03,114.63L73.76,101.79C73.67,101.37 73.91,100.98 74.3,100.86L93.71,95.91C94.13,95.82 94.52,96.06 94.64,96.45L97.91,109.29C98,109.62 98.33,109.83 98.66,109.74C98.99,109.65 99.2,109.32 99.11,108.99L95.84,96.15C95.57,95.07 94.49,94.44 93.41,94.71H93.29C93.35,94.44 93.35,94.14 93.26,93.84L92.45,90.63C92.15,89.49 90.98,88.8 89.87,89.1L75.14,92.85L74.81,91.8C74.3,90.24 73.52,88.8 72.53,87.51C73.46,87.78 74.39,87.87 75.2,87.66C75.26,87.66 75.32,87.63 75.38,87.6C75.71,87.51 75.89,87.15 75.8,86.82C75.71,86.49 75.35,86.31 75.02,86.4C73.97,86.73 72.23,86.28 70.16,85.11C70.04,85.02 69.92,84.93 69.8,84.84L68.72,84.06C67.16,82.95 65.78,81.6 64.61,80.1L56.54,69.75C56.42,69.6 56.39,69.42 56.42,69.24C56.45,69.06 56.57,68.91 56.72,68.82C57.92,68.1 59.45,68.25 60.5,69.15C61.07,69.63 61.61,70.11 62.15,70.56C64.22,72.36 66.17,74.04 67.73,74.94C68.33,75.27 69.05,75.21 69.59,74.82C70.13,74.4 70.37,73.74 70.22,73.05" + android:fillColor="#282622"/> diff --git a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt index fe5700d8c..336181dbd 100644 --- a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt +++ b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt @@ -5,7 +5,6 @@ package co.electroniccoin.zcash.ui.screenshot import android.content.Context import android.os.Build import android.os.LocaleList -import androidx.activity.viewModels import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsEnabled @@ -14,6 +13,7 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -30,7 +30,6 @@ import androidx.test.filters.LargeTest import androidx.test.filters.SdkSuppress import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.model.MonetarySeparators -import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.SeedPhraseFixture import cash.z.ecc.sdk.type.ZcashCurrency @@ -46,9 +45,9 @@ 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.securitywarning.view.SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG +import co.electriccoin.zcash.ui.screen.restore.height.RestoreBDHeightTags +import co.electriccoin.zcash.ui.screen.restore.seed.RestoreSeedTag +import co.electriccoin.zcash.ui.screen.seed.SeedRecovery import co.electriccoin.zcash.ui.screen.send.SendTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -102,6 +101,13 @@ class ScreenshotTest : UiTestPrerequisites() { } } + private fun navigateTo(route: Any) = + runBlocking { + withContext(Dispatchers.Main) { + composeTestRule.activity.navControllerForTesting.navigate(route) + } + } + private fun runWith( uiMode: UiMode, locale: String, @@ -179,7 +185,7 @@ class ScreenshotTest : UiTestPrerequisites() { } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { - composeTestRule.activity.walletViewModel.secretState.value is SecretState.None + composeTestRule.activity.walletViewModel.secretState.value == SecretState.NONE } composeTestRule.waitUntilDoesNotExist(hasTestTag(WELCOME_ANIM_TEST_TAG), DEFAULT_TIMEOUT_MILLISECONDS) @@ -198,19 +204,22 @@ class ScreenshotTest : UiTestPrerequisites() { // To ensure that the new screen is available, or wait until it is composeTestRule.waitUntilAtLeastOneExists( - hasText(resContext.getString(R.string.restore_title)), + hasText(resContext.getString(R.string.restore_title), ignoreCase = true), DEFAULT_TIMEOUT_MILLISECONDS ) - composeTestRule.onNodeWithText(resContext.getString(R.string.restore_title)).also { - it.assertExists() - } + composeTestRule + .onNodeWithText( + resContext.getString(R.string.restore_title), + ignoreCase = true + ) + .assertExists() takeScreenshot(tag, "Import 1") val seedPhraseSplitLength = SeedPhraseFixture.new().split.size SeedPhraseFixture.new().split.forEachIndexed { index, string -> - composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + composeTestRule.onAllNodesWithTag(RestoreSeedTag.SEED_WORD_TEXT_FIELD)[index].also { it.performTextInput(string) // Take a screenshot half-way through filling in the seed phrase @@ -220,16 +229,12 @@ class ScreenshotTest : UiTestPrerequisites() { } } - composeTestRule.waitUntil { - composeTestRule.activity.viewModels().value.userWordList.current.value.size == - SeedPhrase.SEED_PHRASE_SIZE - } - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.restore_seed_button_next), + text = resContext.getString(R.string.restore_button), ignoreCase = true ).also { - // Even with waiting for the word list in the view model, there's some latency before the button is enabled + // Even with waiting for the word list in the view model, + // there's some latency before the button is enabled composeTestRule.waitUntil(5.seconds.inWholeMilliseconds) { runCatching { it.assertIsEnabled() }.isSuccess } @@ -237,16 +242,23 @@ class ScreenshotTest : UiTestPrerequisites() { it.performClick() } - composeTestRule.onNodeWithText(resContext.getString(R.string.restore_birthday_header)).also { - it.assertExists() - } + composeTestRule + .onNodeWithText( + resContext.getString(R.string.restore_bd_subtitle), + ignoreCase = true + ) + .also { + it.assertExists() + } takeScreenshot(tag, "Import 3") - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.restore_birthday_button_restore), - ignoreCase = true - ).also { + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(RestoreBDHeightTags.RESTORE_BTN), + timeoutMillis = DEFAULT_TIMEOUT_MILLISECONDS + ) + + composeTestRule.onNodeWithTag(RestoreBDHeightTags.RESTORE_BTN).also { it.performScrollTo() it.performClick() } @@ -266,7 +278,7 @@ class ScreenshotTest : UiTestPrerequisites() { it.performClick() } - composeTestRule.waitUntilDoesNotExist(hasTestTag(ACKNOWLEDGE_CHECKBOX_TAG), DEFAULT_TIMEOUT_MILLISECONDS) + // composeTestRule.waitUntilDoesNotExist(hasTestTag(ACKNOWLEDGE_CHECKBOX_TAG), DEFAULT_TIMEOUT_MILLISECONDS) } @Test @@ -361,7 +373,7 @@ class ScreenshotTest : UiTestPrerequisites() { // These are the Settings screen items // We could manually click on each one, which is a better integration test but a worse screenshot test - navigateTo(NavigationTargets.SEED_RECOVERY) + navigateTo(SeedRecovery) seedScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.SUPPORT) @@ -382,7 +394,7 @@ private fun onboardingScreenshots( composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { - composeTestRule.activity.walletViewModel.secretState.value is SecretState.None + composeTestRule.activity.walletViewModel.secretState.value == SecretState.NONE } // Welcome screen @@ -403,41 +415,6 @@ private fun onboardingScreenshots( ).also { it.performClick() } - - // Security Warning screen - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.security_warning_acknowledge), - ignoreCase = true, - useUnmergedTree = true - ).also { - it.assertExists() - it.performClick() - ScreenshotTest.takeScreenshot(tag, "Security Warning") - } - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.security_warning_confirm), - ignoreCase = true, - useUnmergedTree = true - ).performClick() - - composeTestRule.waitForIdle() - - composeTestRule.waitUntil { - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.seed_recovery_next_button), - ignoreCase = true, - useUnmergedTree = true - ).exists() - } - - composeTestRule.onNodeWithText( - text = resContext.getString(R.string.seed_recovery_next_button), - ignoreCase = true, - useUnmergedTree = true - ).also { - it.performScrollTo() - it.performClick() - } } private fun accountScreenshots( @@ -445,7 +422,7 @@ private fun accountScreenshots( composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { - composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready + composeTestRule.activity.walletViewModel.secretState.value == SecretState.READY } composeTestRule.onNodeWithTag(BalanceTag.BALANCE_VIEWS).also { it.assertExists()