Merge pull request #1819 from Electric-Coin-Company/feature/restore-redesign

Restore redesign
This commit is contained in:
Milan 2025-04-02 12:05:13 +02:00 committed by GitHub
commit 8842ee10d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
204 changed files with 5585 additions and 3782 deletions

View File

@ -19,7 +19,7 @@ class MergingConfigurationProvider(
override fun getConfigurationFlow(): Flow<Configuration> { override fun getConfigurationFlow(): Flow<Configuration> {
return if (configurationProviders.isEmpty()) { return if (configurationProviders.isEmpty()) {
flowOf(MergingConfiguration(persistentListOf<Configuration>())) flowOf(MergingConfiguration(persistentListOf()))
} else { } else {
combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations -> combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations ->
MergingConfiguration(configurations.toList().toPersistentList()) MergingConfiguration(configurations.toList().toPersistentList())

View File

@ -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<String>): 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
}
}
}
}

View File

@ -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<Boolean> {
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
return rememberUpdatedState(isImeVisible)
}
@Suppress("CompositionLocalAllowlist")
val LocalKeyboardManager =
compositionLocalOf<KeyboardManager> {
error("Keyboard manager not provided")
}

View File

@ -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<SheetStateManager> {
error("Sheet state manager not provided")
}

View File

@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -41,30 +39,17 @@ fun ZashiChipButton(
border: BorderStroke? = ZashiChipButtonDefaults.border, border: BorderStroke? = ZashiChipButtonDefaults.border,
color: Color = ZashiChipButtonDefaults.color, color: Color = ZashiChipButtonDefaults.color,
contentPadding: PaddingValues = ZashiChipButtonDefaults.contentPadding, contentPadding: PaddingValues = ZashiChipButtonDefaults.contentPadding,
hasRippleEffect: Boolean = true,
textStyle: TextStyle = ZashiChipButtonDefaults.textStyle, textStyle: TextStyle = ZashiChipButtonDefaults.textStyle,
endIconSpacer: Dp = ZashiChipButtonDefaults.endIconSpacer, 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( Surface(
modifier = clickableModifier, modifier = modifier,
shape = shape, shape = shape,
border = border, border = border,
color = color, color = color,
) { ) {
Row( Row(
modifier = Modifier.padding(contentPadding), modifier = Modifier.clickable(onClick = state.onClick) then Modifier.padding(contentPadding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (state.startIcon != null) { if (state.startIcon != null) {

View File

@ -90,7 +90,7 @@ fun rememberModalBottomSheetState(
@Composable @Composable
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
private fun rememberSheetState( fun rememberSheetState(
skipPartiallyExpanded: Boolean, skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean, confirmValueChange: (SheetValue) -> Boolean,
initialValue: SheetValue, initialValue: SheetValue,

View File

@ -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 <T : ModalBottomSheetState> 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
}

View File

@ -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,
)
)
}
}

View File

@ -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<MutableInteractionSource>.observeSelectedIndex() =
this
.map { interaction ->
interaction.isFocused()
}
.combineToFlow()
.map {
it.indexOfFirst { isFocused -> isFocused }
}
private fun InteractionSource.isFocused(): Flow<Boolean> =
channelFlow {
val focusInteractions = mutableListOf<FocusInteraction.Focus>()
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<SeedWordTextFieldState>,
)
@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<String>
)
@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
)
}
)
)
}
}

View File

@ -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",
)
)
}
}

View File

@ -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 = {},
)
)
}
}

View File

@ -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))
}

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -22,23 +23,32 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse 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.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography 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.getValue
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
@ -48,9 +58,18 @@ fun ZashiTextField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier, innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
error: String? = null, error: String? = null,
isEnabled: Boolean = true, isEnabled: Boolean = true,
handle: ZashiTextFieldHandle =
rememberZashiTextFieldHandle(
TextFieldState(
value = stringRes(value),
error = error?.let { stringRes(it) },
isEnabled = isEnabled,
onValueChange = onValueChange,
)
),
readOnly: Boolean = false, readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium), textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
@ -97,7 +116,8 @@ fun ZashiTextField(
interactionSource = interactionSource, interactionSource = interactionSource,
shape = shape, shape = shape,
colors = colors, colors = colors,
innerModifier = innerModifier innerModifier = innerModifier,
handle = handle,
) )
} }
@ -106,7 +126,8 @@ fun ZashiTextField(
fun ZashiTextField( fun ZashiTextField(
state: TextFieldState, state: TextFieldState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier, innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
handle: ZashiTextFieldHandle = rememberZashiTextFieldHandle(state),
readOnly: Boolean = false, readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium), textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
@ -124,6 +145,13 @@ fun ZashiTextField(
minLines: Int = 1, minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = ZashiTextFieldDefaults.shape, 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() colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) { ) {
TextFieldInternal( TextFieldInternal(
@ -147,10 +175,39 @@ fun ZashiTextField(
interactionSource = interactionSource, interactionSource = interactionSource,
shape = shape, shape = shape,
colors = colors, 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") @Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -174,10 +231,32 @@ private fun TextFieldInternal(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
shape: Shape, shape: Shape,
colors: ZashiTextFieldColors, colors: ZashiTextFieldColors,
contentPadding: PaddingValues,
handle: ZashiTextFieldHandle,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: 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() val androidColors = colors.toTextFieldColors()
// If color is not provided via the text style, use content color as a default // If color is not provided via the text style, use content color as a default
val textColor = val textColor =
@ -186,24 +265,35 @@ private fun TextFieldInternal(
} }
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
var lastTextValue by remember(value) { mutableStateOf(value) }
CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) { CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) {
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
BasicTextField( BasicTextField(
value = state.value.getValue(), value = textFieldValue,
modifier = modifier =
innerModifier.fillMaxWidth() then innerModifier then
if (borderColor == Color.Unspecified) { if (borderColor == Color.Unspecified) {
Modifier Modifier
} else { } else {
Modifier.border( Modifier.border(
width = 1.dp, width = 1.dp,
color = borderColor, 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, enabled = state.isEnabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = mergedTextStyle, textStyle = mergedTextStyle,
@ -215,44 +305,37 @@ private fun TextFieldInternal(
singleLine = singleLine, singleLine = singleLine,
maxLines = maxLines, maxLines = maxLines,
minLines = minLines, minLines = minLines,
decorationBox = @Composable { innerTextField -> ) { innerTextField: @Composable () -> Unit ->
// places leading icon, text field with label and placeholder, trailing icon // places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox( TextFieldDefaults.DecorationBox(
value = state.value.getValue(), value = state.value.getValue(),
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
innerTextField = { innerTextField = {
DecorationBox(prefix = prefix, suffix = suffix, content = innerTextField) DecorationBox(prefix = prefix, suffix = suffix, content = innerTextField)
},
placeholder =
if (placeholder != null) {
{
DecorationBox(prefix, suffix, placeholder)
}
} else {
null
}, },
placeholder = label = label,
if (placeholder != null) { leadingIcon = leadingIcon,
{ trailingIcon = trailingIcon,
DecorationBox(prefix, suffix, placeholder) prefix = prefix,
} suffix = suffix,
} else { supportingText = supportingText,
null shape = shape,
}, singleLine = singleLine,
label = label, enabled = state.isEnabled,
leadingIcon = leadingIcon, isError = state.isError,
trailingIcon = trailingIcon, interactionSource = interactionSource,
prefix = prefix, colors = androidColors,
suffix = suffix, contentPadding = contentPadding
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),
)
)
}
)
if (state.error != null && state.error.getValue().isNotEmpty()) { if (state.error != null && state.error.getValue().isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@ -303,7 +386,9 @@ data class ZashiTextFieldColors(
val textColor: Color, val textColor: Color,
val hintColor: Color, val hintColor: Color,
val borderColor: Color, val borderColor: Color,
val focusedBorderColor: Color,
val containerColor: Color, val containerColor: Color,
val focusedContainerColor: Color,
val placeholderColor: Color, val placeholderColor: Color,
val disabledTextColor: Color, val disabledTextColor: Color,
val disabledHintColor: Color, val disabledHintColor: Color,
@ -317,11 +402,15 @@ data class ZashiTextFieldColors(
val errorPlaceholderColor: Color, val errorPlaceholderColor: Color,
) { ) {
@Composable @Composable
internal fun borderColor(state: TextFieldState): State<Color> { internal fun borderColor(
state: TextFieldState,
isFocused: Boolean
): State<Color> {
val targetValue = val targetValue =
when { when {
!state.isEnabled -> disabledBorderColor !state.isEnabled -> disabledBorderColor
state.isError -> errorBorderColor state.isError -> errorBorderColor
isFocused -> focusedBorderColor.takeOrElse { borderColor }
else -> borderColor else -> borderColor
} }
return rememberUpdatedState(targetValue) return rememberUpdatedState(targetValue)
@ -345,7 +434,7 @@ data class ZashiTextFieldColors(
unfocusedTextColor = textColor, unfocusedTextColor = textColor,
disabledTextColor = disabledTextColor, disabledTextColor = disabledTextColor,
errorTextColor = errorTextColor, errorTextColor = errorTextColor,
focusedContainerColor = containerColor, focusedContainerColor = focusedContainerColor.takeOrElse { containerColor },
unfocusedContainerColor = containerColor, unfocusedContainerColor = containerColor,
disabledContainerColor = disabledContainerColor, disabledContainerColor = disabledContainerColor,
errorContainerColor = errorContainerColor, errorContainerColor = errorContainerColor,
@ -391,13 +480,21 @@ object ZashiTextFieldDefaults {
val shape: Shape val shape: Shape
get() = RoundedCornerShape(8.dp) get() = RoundedCornerShape(8.dp)
val innerModifier: Modifier
get() =
Modifier
.defaultMinSize(minWidth = TextFieldDefaults.MinWidth)
.fillMaxWidth()
@Suppress("LongParameterList") @Suppress("LongParameterList")
@Composable @Composable
fun defaultColors( fun defaultColors(
textColor: Color = ZashiColors.Inputs.Filled.text, textColor: Color = ZashiColors.Inputs.Filled.text,
hintColor: Color = ZashiColors.Inputs.Default.hint, hintColor: Color = ZashiColors.Inputs.Default.hint,
borderColor: Color = Color.Unspecified, borderColor: Color = Color.Unspecified,
focusedBorderColor: Color = ZashiColors.Inputs.Focused.stroke,
containerColor: Color = ZashiColors.Inputs.Default.bg, containerColor: Color = ZashiColors.Inputs.Default.bg,
focusedContainerColor: Color = ZashiColors.Inputs.Focused.bg,
placeholderColor: Color = ZashiColors.Inputs.Default.text, placeholderColor: Color = ZashiColors.Inputs.Default.text,
disabledTextColor: Color = ZashiColors.Inputs.Disabled.text, disabledTextColor: Color = ZashiColors.Inputs.Disabled.text,
disabledHintColor: Color = ZashiColors.Inputs.Disabled.hint, disabledHintColor: Color = ZashiColors.Inputs.Disabled.hint,
@ -413,7 +510,9 @@ object ZashiTextFieldDefaults {
textColor = textColor, textColor = textColor,
hintColor = hintColor, hintColor = hintColor,
borderColor = borderColor, borderColor = borderColor,
focusedBorderColor = focusedBorderColor,
containerColor = containerColor, containerColor = containerColor,
focusedContainerColor = focusedContainerColor,
placeholderColor = placeholderColor, placeholderColor = placeholderColor,
disabledTextColor = disabledTextColor, disabledTextColor = disabledTextColor,
disabledHintColor = disabledHintColor, 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 @PreviewScreens
@Composable @Composable
private fun DefaultPreview() = private fun DefaultPreview() =

View File

@ -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
}

View File

@ -9,6 +9,10 @@ import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.RippleDefaults import androidx.compose.material3.RippleDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.balances.LocalBalancesAvailable
import co.electriccoin.zcash.ui.design.theme.colors.DarkZashiColorsInternal import co.electriccoin.zcash.ui.design.theme.colors.DarkZashiColorsInternal
import co.electriccoin.zcash.ui.design.theme.colors.LightZashiColorsInternal import co.electriccoin.zcash.ui.design.theme.colors.LightZashiColorsInternal
@ -49,7 +53,9 @@ fun ZcashTheme(
LocalZashiColors provides zashiColors, LocalZashiColors provides zashiColors,
LocalZashiTypography provides ZashiTypographyInternal, LocalZashiTypography provides ZashiTypographyInternal,
LocalRippleConfiguration provides MaterialRippleConfig, LocalRippleConfiguration provides MaterialRippleConfig,
LocalBalancesAvailable provides balancesAvailable LocalBalancesAvailable provides balancesAvailable,
LocalKeyboardManager provides rememberKeyboardManager(),
LocalSheetStateManager provides rememberSheetStateManager()
) { ) {
ProvideDimens { ProvideDimens {
MaterialTheme( MaterialTheme(

View File

@ -551,8 +551,8 @@ val DarkZashiColorsInternal =
utilityEspresso600 = Espresso.`300`, utilityEspresso600 = Espresso.`300`,
utilityEspresso500 = Espresso.`400`, utilityEspresso500 = Espresso.`400`,
utilityEspresso200 = Espresso.`700`, utilityEspresso200 = Espresso.`700`,
utilityEspresso50 = Espresso.`900`, utilityEspresso50 = Espresso.`950`,
utilityEspresso100 = Espresso.`800`, utilityEspresso100 = Espresso.`900`,
utilityEspresso400 = Espresso.`500`, utilityEspresso400 = Espresso.`500`,
utilityEspresso300 = Espresso.`600`, utilityEspresso300 = Espresso.`600`,
utilityEspresso900 = Espresso.`50`, utilityEspresso900 = Espresso.`50`,

View File

@ -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.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine

View File

@ -3,4 +3,5 @@
<string name="hide_balance_placeholder">-----</string> <string name="hide_balance_placeholder">-----</string>
<string name="back_navigation_content_description">Atrás</string> <string name="back_navigation_content_description">Atrás</string>
<string name="triple_dots"></string> <string name="triple_dots"></string>
<string name="seed_recovery_reveal">Mostrar frase de recuperación</string>
</resources> </resources>

View File

@ -3,4 +3,5 @@
<string name="hide_balance_placeholder">-----</string> <string name="hide_balance_placeholder">-----</string>
<string name="back_navigation_content_description">Back</string> <string name="back_navigation_content_description">Back</string>
<string name="triple_dots"></string> <string name="triple_dots"></string>
<string name="seed_recovery_reveal">Reveal recovery phrase</string>
</resources> </resources>

View File

@ -28,10 +28,8 @@ class OnboardingTestSetup(
ZcashTheme { ZcashTheme {
Onboarding( Onboarding(
// Debug only UI state does not need to be tested // Debug only UI state does not need to be tested
isDebugMenuEnabled = false,
onImportWallet = { onImportWalletCallbackCount.incrementAndGet() }, onImportWallet = { onImportWalletCallbackCount.incrementAndGet() },
onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() }, onCreateWallet = { onCreateWalletCallbackCount.incrementAndGet() }
onFixtureWallet = {}
) )
} }
} }

View File

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

View File

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

View File

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

View File

@ -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()
}
}

View File

@ -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()
}
}
}
}

View File

@ -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.DeriveKeystoneAccountUnifiedAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ExportTaxUseCase import co.electriccoin.zcash.ui.common.usecase.ExportTaxUseCase
import co.electriccoin.zcash.ui.common.usecase.FlipTransactionBookmarkUseCase 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.GetContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetExchangeRateUseCase 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.GetMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetProposalUseCase 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.GetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase 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.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiAccountUseCase 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.MarkTxMemoAsReadUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase 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.NavigateToTaxExportUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveClearSendUseCase 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.ObserveContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase 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.ObserveSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveTransactionSubmitStateUseCase 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.ObserveZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.OnAddressScannedUseCase import co.electriccoin.zcash.ui.common.usecase.OnAddressScannedUseCase
import co.electriccoin.zcash.ui.common.usecase.OnZip321ScannedUseCase 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.ResetInMemoryDataUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetSharedPrefsDataUseCase import co.electriccoin.zcash.ui.common.usecase.ResetSharedPrefsDataUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetTransactionFiltersUseCase 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.SaveContactUseCase
import co.electriccoin.zcash.ui.common.usecase.SelectWalletAccountUseCase import co.electriccoin.zcash.ui.common.usecase.SelectWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.SendEmailUseCase 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.ValidateContactAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase 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.ViewTransactionDetailAfterSuccessfulProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.ViewTransactionsAfterSuccessfulProposalUseCase import co.electriccoin.zcash.ui.common.usecase.ViewTransactionsAfterSuccessfulProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase
@ -102,7 +107,7 @@ val useCaseModule =
factoryOf(::ValidateEndpointUseCase) factoryOf(::ValidateEndpointUseCase)
factoryOf(::GetPersistableWalletUseCase) factoryOf(::GetPersistableWalletUseCase)
factoryOf(::GetSelectedEndpointUseCase) factoryOf(::GetSelectedEndpointUseCase)
factoryOf(::ObserveConfigurationUseCase) factoryOf(::GetConfigurationUseCase)
factoryOf(::RescanBlockchainUseCase) factoryOf(::RescanBlockchainUseCase)
factoryOf(::GetTransparentAddressUseCase) factoryOf(::GetTransparentAddressUseCase)
factoryOf(::ValidateContactAddressUseCase) factoryOf(::ValidateContactAddressUseCase)
@ -121,12 +126,11 @@ val useCaseModule =
factoryOf(::IsCoinbaseAvailableUseCase) factoryOf(::IsCoinbaseAvailableUseCase)
factoryOf(::GetZashiSpendingKeyUseCase) factoryOf(::GetZashiSpendingKeyUseCase)
factoryOf(::ObservePersistableWalletUseCase) factoryOf(::ObservePersistableWalletUseCase)
factoryOf(::GetBackupPersistableWalletUseCase)
factoryOf(::GetSupportUseCase) factoryOf(::GetSupportUseCase)
factoryOf(::SendEmailUseCase) factoryOf(::SendEmailUseCase)
factoryOf(::SendSupportEmailUseCase) factoryOf(::SendSupportEmailUseCase)
factoryOf(::IsFlexaAvailableUseCase) factoryOf(::IsFlexaAvailableUseCase)
factoryOf(::ObserveWalletAccountsUseCase) factoryOf(::GetWalletAccountsUseCase)
factoryOf(::SelectWalletAccountUseCase) factoryOf(::SelectWalletAccountUseCase)
factoryOf(::ObserveSelectedWalletAccountUseCase) factoryOf(::ObserveSelectedWalletAccountUseCase)
factoryOf(::ObserveZashiAccountUseCase) factoryOf(::ObserveZashiAccountUseCase)
@ -178,4 +182,10 @@ val useCaseModule =
factoryOf(::NavigateToTaxExportUseCase) factoryOf(::NavigateToTaxExportUseCase)
factoryOf(::CreateFlexaTransactionUseCase) factoryOf(::CreateFlexaTransactionUseCase)
factoryOf(::IsRestoreSuccessDialogVisibleUseCase) factoryOf(::IsRestoreSuccessDialogVisibleUseCase)
factoryOf(::ValidateSeedUseCase)
factoryOf(::RestoreWalletUseCase)
factoryOf(::NavigateToSeedRecoveryUseCase)
factoryOf(::GetKeystoneStatusUseCase)
factoryOf(::GetCoinbaseStatusUseCase)
factoryOf(::GetFlexaStatusUseCase)
} }

View File

@ -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.accountlist.viewmodel.AccountListViewModel
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.SelectRecipientViewModel 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.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel 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.feedback.viewmodel.FeedbackViewModel
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.home.HomeViewModel import co.electriccoin.zcash.ui.screen.home.HomeViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import co.electriccoin.zcash.ui.screen.integrations.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel 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.restoresuccess.viewmodel.RestoreSuccessViewModel
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransactionViewModel import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransactionViewModel
import co.electriccoin.zcash.ui.screen.scan.Scan import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel 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.ScanKeystonePCZTViewModel
import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel
import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs import co.electriccoin.zcash.ui.screen.seed.SeedRecoveryViewModel
import co.electriccoin.zcash.ui.screen.seed.viewmodel.SeedViewModel
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.viewmodel.SelectKeystoneAccountViewModel import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.viewmodel.SelectKeystoneAccountViewModel
import co.electriccoin.zcash.ui.screen.send.SendViewModel import co.electriccoin.zcash.ui.screen.send.SendViewModel
@ -57,9 +58,8 @@ val viewModelModule =
viewModelOf(::WalletViewModel) viewModelOf(::WalletViewModel)
viewModelOf(::AuthenticationViewModel) viewModelOf(::AuthenticationViewModel)
viewModelOf(::OldHomeViewModel) viewModelOf(::OldHomeViewModel)
viewModelOf(::OnboardingViewModel)
viewModelOf(::StorageCheckViewModel) viewModelOf(::StorageCheckViewModel)
viewModelOf(::RestoreViewModel) viewModelOf(::RestoreSeedViewModel)
viewModelOf(::ScreenBrightnessViewModel) viewModelOf(::ScreenBrightnessViewModel)
viewModelOf(::SettingsViewModel) viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AdvancedSettingsViewModel)
@ -92,27 +92,10 @@ val viewModelModule =
} }
viewModelOf(::ScanKeystoneSignInRequestViewModel) viewModelOf(::ScanKeystoneSignInRequestViewModel)
viewModelOf(::ScanKeystonePCZTViewModel) viewModelOf(::ScanKeystonePCZTViewModel)
viewModel { (isDialog: Boolean) -> viewModelOf(::IntegrationsViewModel)
IntegrationsViewModel(
isDialog = isDialog,
getZcashCurrency = get(),
isFlexaAvailableUseCase = get(),
isCoinbaseAvailable = get(),
observeWalletAccounts = get(),
navigationRouter = get(),
navigateToCoinbase = get(),
getWalletRestoringState = get()
)
}
viewModelOf(::FlexaViewModel) viewModelOf(::FlexaViewModel)
viewModelOf(::SendViewModel) viewModelOf(::SendViewModel)
viewModel { (args: SeedNavigationArgs) -> viewModelOf(::SeedRecoveryViewModel)
SeedViewModel(
observePersistableWallet = get(),
args = args,
walletRepository = get(),
)
}
viewModelOf(::FeedbackViewModel) viewModelOf(::FeedbackViewModel)
viewModelOf(::SignKeystoneTransactionViewModel) viewModelOf(::SignKeystoneTransactionViewModel)
viewModelOf(::AccountListViewModel) viewModelOf(::AccountListViewModel)
@ -156,4 +139,7 @@ val viewModelModule =
viewModelOf(::TaxExportViewModel) viewModelOf(::TaxExportViewModel)
viewModelOf(::BalanceViewModel) viewModelOf(::BalanceViewModel)
viewModelOf(::HomeViewModel) viewModelOf(::HomeViewModel)
viewModelOf(::RestoreBDHeightViewModel)
viewModelOf(::RestoreBDDateViewModel)
viewModelOf(::RestoreBDEstimationViewModel)
} }

View File

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -23,22 +22,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavHostController 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.spackle.Twig
import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider
import co.electriccoin.zcash.ui.common.extension.setContentCompat 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.AuthenticationUIState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.SecretState import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel 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.BlankSurface
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
import co.electriccoin.zcash.ui.design.component.Override 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.WrapAuthentication
import co.electriccoin.zcash.ui.screen.authentication.view.AnimationConstants import co.electriccoin.zcash.ui.screen.authentication.view.AnimationConstants
import co.electriccoin.zcash.ui.screen.authentication.view.WelcomeAnimationAutostart import co.electriccoin.zcash.ui.screen.authentication.view.WelcomeAnimationAutostart
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding import co.electriccoin.zcash.ui.screen.onboarding.OnboardingNavigation
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.warning.viewmodel.StorageCheckViewModel import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
import co.electriccoin.zcash.work.WorkIds import co.electriccoin.zcash.work.WorkIds
import kotlinx.coroutines.delay 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() SecretState.LOADING == walletViewModel.secretState.value
oldHomeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value
} }
} }
@ -235,58 +221,20 @@ class MainActivity : FragmentActivity() {
@Composable @Composable
private fun MainContent() { private fun MainContent() {
val configuration = oldHomeViewModel.configurationFlow.collectAsStateWithLifecycle().value val secretState by walletViewModel.secretState.collectAsStateWithLifecycle()
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
// Note this condition needs to be kept in sync with the condition in setupSplashScreen() when (secretState) {
if (null == configuration || secretState == SecretState.Loading) { SecretState.NONE -> {
// For now, keep displaying splash screen using condition above. OnboardingNavigation()
// 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()
}
is SecretState.NeedsWarning -> { SecretState.READY -> {
WrapSecurityWarning( Navigation()
onBack = { walletViewModel.persistOnboardingState(OnboardingState.NONE) }, }
onConfirm = {
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_BACKUP)
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) { SecretState.LOADING -> {
persistExistingWalletWithSeedPhrase( // For now, keep displaying splash screen using condition above.
applicationContext, // In the future, we might consider displaying something different here.
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")
}
}
} }
} }
} }
@ -294,7 +242,7 @@ class MainActivity : FragmentActivity() {
private fun monitorForBackgroundSync() { private fun monitorForBackgroundSync() {
val isEnableBackgroundSyncFlow = val isEnableBackgroundSyncFlow =
run { run {
val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready } val isSecretReadyFlow = walletViewModel.secretState.map { it == SecretState.READY }
val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull() val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull()
isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled -> isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled ->

View File

@ -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.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST 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
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT 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.compose.LocalNavController
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.isInForeground 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.enterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition 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.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.Receive
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest 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.AndroidReviewTransaction
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction
import co.electriccoin.zcash.ui.screen.scan.Scan 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.ScanKeystoneSignInRequest
import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystonePCZTRequest import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystonePCZTRequest
import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystoneSignInRequest import co.electriccoin.zcash.ui.screen.scankeystone.WrapScanKeystoneSignInRequest
import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs import co.electriccoin.zcash.ui.screen.seed.AndroidSeedRecovery
import co.electriccoin.zcash.ui.screen.seed.WrapSeed 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.AndroidSelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.send.Send import co.electriccoin.zcash.ui.screen.send.Send
@ -120,18 +125,32 @@ import org.koin.compose.koinInject
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
internal fun MainActivity.Navigation() { internal fun MainActivity.Navigation() {
val navController = LocalNavController.current val navController = LocalNavController.current
val keyboardManager = LocalKeyboardManager.current
val flexaViewModel = koinViewModel<FlexaViewModel>() val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigationRouter = koinInject<NavigationRouter>() val navigationRouter = koinInject<NavigationRouter>()
val sheetStateManager = LocalSheetStateManager.current
// Helper properties for triggering the system security UI from callbacks // Helper properties for triggering the system security UI from callbacks
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) = val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
rememberSaveable { mutableStateOf(false) } rememberSaveable { mutableStateOf(false) }
val (seedRecoveryAuthentication, setSeedRecoveryAuthentication) =
rememberSaveable { mutableStateOf(false) }
val (deleteWalletAuthentication, setDeleteWalletAuthentication) = val (deleteWalletAuthentication, setDeleteWalletAuthentication) =
rememberSaveable { mutableStateOf(false) } 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) { LaunchedEffect(Unit) {
navigationRouter.observePipeline().collect { navigationRouter.observePipeline().collect {
@ -163,14 +182,6 @@ internal fun MainActivity.Navigation() {
unProtectedDestination = EXPORT_PRIVATE_DATA unProtectedDestination = EXPORT_PRIVATE_DATA
) )
}, },
goSeedRecovery = {
navController.checkProtectedDestination(
scope = lifecycleScope,
propertyToCheck = authenticationViewModel.isSeedAuthenticationRequired,
setCheckedProperty = setSeedRecoveryAuthentication,
unProtectedDestination = SEED_RECOVERY
)
},
goDeleteWallet = { goDeleteWallet = {
navController.checkProtectedDestination( navController.checkProtectedDestination(
scope = lifecycleScope, scope = lifecycleScope,
@ -199,27 +210,13 @@ internal fun MainActivity.Navigation() {
setCheckedProperty = setExportPrivateDataAuthentication setCheckedProperty = setExportPrivateDataAuthentication
) )
} }
seedRecoveryAuthentication -> {
ShowSystemAuthentication(
navHostController = navController,
protectedDestination = SEED_RECOVERY,
protectedUseCase = AuthenticationUseCase.SeedRecovery,
setCheckedProperty = setSeedRecoveryAuthentication
)
}
} }
} }
composable(CHOOSE_SERVER) { composable(CHOOSE_SERVER) {
WrapChooseServer() WrapChooseServer()
} }
composable(SEED_RECOVERY) { composable<SeedRecovery> {
WrapSeed( AndroidSeedRecovery()
args = SeedNavigationArgs.RECOVERY,
goBackOverride = {
setSeedRecoveryAuthentication(false)
}
)
} }
composable(SUPPORT) { composable(SUPPORT) {
// Pop back stack won't be right if we deep link into support // Pop back stack won't be right if we deep link into support
@ -275,21 +272,8 @@ internal fun MainActivity.Navigation() {
) { ) {
AndroidAccountList() AndroidAccountList()
} }
composable( composable<Scan> {
route = Scan.ROUTE, WrapScanValidator(it.toRoute())
arguments =
listOf(
navArgument(Scan.KEY) {
type = NavType.EnumType(Scan::class.java)
defaultValue = Scan.SEND
}
)
) { backStackEntry ->
val mode =
backStackEntry.arguments
?.getSerializableCompat<Scan>(Scan.KEY) ?: Scan.SEND
WrapScanValidator(args = mode)
} }
composable(EXPORT_PRIVATE_DATA) { composable(EXPORT_PRIVATE_DATA) {
WrapExportPrivateData( WrapExportPrivateData(
@ -405,6 +389,18 @@ internal fun MainActivity.Navigation() {
composable<Send> { composable<Send> {
WrapSend(it.toRoute()) WrapSend(it.toRoute())
} }
dialog<SeedInfo>(
dialogProperties =
DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
)
) {
AndroidSeedInfo()
}
composable<SeedBackup> {
AndroidSeedBackup()
}
} }
} }
@ -530,7 +526,6 @@ object NavigationTargets {
const val NOT_ENOUGH_SPACE = "not_enough_space" const val NOT_ENOUGH_SPACE = "not_enough_space"
const val QR_CODE = "qr_code" const val QR_CODE = "qr_code"
const val REQUEST = "request" const val REQUEST = "request"
const val SEED_RECOVERY = "seed_recovery"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in" const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support" const val SUPPORT = "support"

View File

@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.serialization.generateHashCode 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.ExternalUrl
import co.electriccoin.zcash.ui.screen.about.util.WebBrowserUtil import co.electriccoin.zcash.ui.screen.about.util.WebBrowserUtil
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
@ -14,15 +16,19 @@ import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
interface Navigator { interface Navigator {
fun executeCommand(command: NavigationCommand) suspend fun executeCommand(command: NavigationCommand)
} }
class NavigatorImpl( class NavigatorImpl(
private val activity: ComponentActivity, private val activity: ComponentActivity,
private val navController: NavHostController, private val navController: NavHostController,
private val flexaViewModel: FlexaViewModel, private val flexaViewModel: FlexaViewModel,
private val keyboardManager: KeyboardManager,
private val sheetStateManager: SheetStateManager,
) : Navigator { ) : Navigator {
override fun executeCommand(command: NavigationCommand) { override suspend fun executeCommand(command: NavigationCommand) {
keyboardManager.close()
sheetStateManager.hide()
when (command) { when (command) {
is NavigationCommand.Forward -> forward(command) is NavigationCommand.Forward -> forward(command)
is NavigationCommand.Replace -> replace(command) is NavigationCommand.Replace -> replace(command)

View File

@ -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.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount 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.GetWalletStateInformationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.IconButtonState import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
@ -30,7 +31,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ZashiTopAppBarViewModel( class ZashiTopAppBarViewModel(
observeSelectedWalletAccount: ObserveSelectedWalletAccountUseCase, getWalletAccountUseCase: GetWalletAccountsUseCase,
getSelectedWalletAccount: GetSelectedWalletAccountUseCase,
getWalletStateInformation: GetWalletStateInformationUseCase, getWalletStateInformation: GetWalletStateInformationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
@ -39,7 +41,7 @@ class ZashiTopAppBarViewModel(
val state = val state =
combine( combine(
observeSelectedWalletAccount.require(), getSelectedWalletAccount.observe().filterNotNull(),
isHideBalances, isHideBalances,
getWalletStateInformation.observe() getWalletStateInformation.observe()
) { currentAccount, isHideBalances, walletState -> ) { currentAccount, isHideBalances, walletState ->
@ -47,11 +49,16 @@ class ZashiTopAppBarViewModel(
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), 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( private fun createState(
currentAccount: WalletAccount, currentAccount: WalletAccount?,
isHideBalances: Boolean?, isHideBalances: Boolean?,
topAppBarSubTitleState: TopAppBarSubTitleState topAppBarSubTitleState: TopAppBarSubTitleState
) = ZashiMainTopAppBarState( ) = ZashiMainTopAppBarState(
@ -61,6 +68,7 @@ class ZashiTopAppBarViewModel(
when (currentAccount) { when (currentAccount) {
is KeystoneAccount -> ZashiMainTopAppBarState.AccountType.KEYSTONE is KeystoneAccount -> ZashiMainTopAppBarState.AccountType.KEYSTONE
is ZashiAccount -> ZashiMainTopAppBarState.AccountType.ZASHI is ZashiAccount -> ZashiMainTopAppBarState.AccountType.ZASHI
else -> ZashiMainTopAppBarState.AccountType.ZASHI
}, },
onAccountTypeClick = ::onAccountTypeClicked, onAccountTypeClick = ::onAccountTypeClicked,
), ),

View File

@ -49,7 +49,7 @@ class TransactionHistoryMapper {
false false
} else { } else {
val transactionMetadata = data.metadata val transactionMetadata = data.metadata
hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not()) hasMemo && transactionMetadata.isRead.not()
} }
} }

View File

@ -61,6 +61,7 @@ class ConfigurationRepositoryImpl(
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null initialValue = null
) )
override val isCoinbaseAvailable: StateFlow<Boolean?> = override val isCoinbaseAvailable: StateFlow<Boolean?> =
flow { flow {
val versionInfo = getVersionInfo() val versionInfo = getVersionInfo()

View File

@ -7,6 +7,7 @@ import co.electriccoin.zcash.preference.model.entry.NullableBooleanPreferenceDef
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN 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.ExchangeRateState
import co.electriccoin.zcash.ui.common.wallet.RefreshLock import co.electriccoin.zcash.ui.common.wallet.RefreshLock
import co.electriccoin.zcash.ui.common.wallet.StaleLock 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.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
interface ExchangeRateRepository { interface ExchangeRateRepository {
val isExchangeRateUsdOptedIn: StateFlow<Boolean?> val isExchangeRateUsdOptedIn: StateFlow<Boolean?>
@ -48,7 +49,7 @@ interface ExchangeRateRepository {
} }
class ExchangeRateRepositoryImpl( class ExchangeRateRepositoryImpl(
private val walletRepository: WalletRepository, private val synchronizerProvider: SynchronizerProvider,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
) : ExchangeRateRepository { ) : ExchangeRateRepository {
@ -61,7 +62,8 @@ class ExchangeRateRepositoryImpl(
private val exchangeRateUsdInternal = private val exchangeRateUsdInternal =
isExchangeRateUsdOptedIn.flatMapLatest { optedIn -> isExchangeRateUsdOptedIn.flatMapLatest { optedIn ->
if (optedIn == true) { if (optedIn == true) {
walletRepository.synchronizer synchronizerProvider
.synchronizer
.filterNotNull() .filterNotNull()
.flatMapLatest { synchronizer -> .flatMapLatest { synchronizer ->
synchronizer.exchangeRateUsd synchronizer.exchangeRateUsd
@ -105,46 +107,7 @@ class ExchangeRateRepositoryImpl(
staleExchangeRateUsdLock.state, staleExchangeRateUsdLock.state,
refreshExchangeRateUsdLock.state, refreshExchangeRateUsdLock.state,
) { isOptedIn, exchangeRate, isStale, isRefreshEnabled -> ) { isOptedIn, exchangeRate, isStale, isRefreshEnabled ->
lastExchangeRateUsdValue = createState(isOptedIn, exchangeRate, isStale, isRefreshEnabled)
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
}.distinctUntilChanged() }.distinctUntilChanged()
.onEach { .onEach {
Twig.info { "[USD] $it" } Twig.info { "[USD] $it" }
@ -157,17 +120,71 @@ class ExchangeRateRepositoryImpl(
} }
}.stateIn( }.stateIn(
scope = scope, scope = scope,
started = SharingStarted.WhileSubscribed(), started = SharingStarted.WhileSubscribed(5.seconds, 5.seconds),
initialValue = ExchangeRateState.OptedOut 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() { override fun refreshExchangeRateUsd() {
refreshExchangeRateUsdInternal() refreshExchangeRateUsdInternal()
} }
private fun refreshExchangeRateUsdInternal() = private fun refreshExchangeRateUsdInternal() =
scope.launch { scope.launch {
val synchronizer = walletRepository.synchronizer.filterNotNull().first() val synchronizer = synchronizerProvider.getSynchronizer()
val value = state.value val value = state.value
if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) { if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) {
synchronizer.refreshExchangeRateUsd() synchronizer.refreshExchangeRateUsd()

View File

@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -39,7 +38,7 @@ interface MetadataRepository {
suspend fun markTxMemoAsRead(txId: String) suspend fun markTxMemoAsRead(txId: String)
fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata>
} }
class MetadataRepositoryImpl( class MetadataRepositoryImpl(
@ -161,9 +160,9 @@ class MetadataRepositoryImpl(
} }
} }
override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> = override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata> =
metadata metadata
.map<Metadata?, TransactionMetadata?> { metadata -> .map { metadata ->
val accountMetadata = metadata?.accountMetadata val accountMetadata = metadata?.accountMetadata
TransactionMetadata( TransactionMetadata(
@ -173,7 +172,6 @@ class MetadataRepositoryImpl(
) )
} }
.distinctUntilChanged() .distinctUntilChanged()
.onStart { emit(null) }
private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey { private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey {
val key = metadataKeyStorageProvider.get(selectedAccount) val key = metadataKeyStorageProvider.get(selectedAccount)

View File

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

View File

@ -8,12 +8,8 @@ class ApplyTransactionFiltersUseCase(
private val transactionFilterRepository: TransactionFilterRepository, private val transactionFilterRepository: TransactionFilterRepository,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
) { ) {
suspend operator fun invoke( operator fun invoke(filters: List<TransactionFilter>) {
filters: List<TransactionFilter>,
hideBottomSheet: suspend () -> Unit
) {
transactionFilterRepository.apply(filters) transactionFilterRepository.apply(filters)
hideBottomSheet()
navigationRouter.back() navigationRouter.back()
} }
} }

View File

@ -9,11 +9,9 @@ class CreateOrUpdateTransactionNoteUseCase(
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
txId: String, txId: String,
note: String, note: String
closeBottomSheet: suspend () -> Unit
) { ) {
metadataRepository.createOrUpdateTxNote(txId, note.trim()) metadataRepository.createOrUpdateTxNote(txId, note.trim())
closeBottomSheet()
navigationRouter.back() navigationRouter.back()
} }
} }

View File

@ -7,12 +7,8 @@ class DeleteTransactionNoteUseCase(
private val metadataRepository: MetadataRepository, private val metadataRepository: MetadataRepository,
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
suspend operator fun invoke( suspend operator fun invoke(txId: String) {
txId: String,
closeBottomSheet: suspend () -> Unit
) {
metadataRepository.deleteTxNote(txId) metadataRepository.deleteTxNote(txId)
closeBottomSheet()
navigationRouter.back() navigationRouter.back()
} }
} }

View File

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

View File

@ -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()
}

View File

@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
class ObserveConfigurationUseCase( class GetConfigurationUseCase(
private val configurationRepository: ConfigurationRepository private val configurationRepository: ConfigurationRepository
) { ) {
operator fun invoke() = configurationRepository.configurationFlow fun observe() = configurationRepository.configurationFlow
} }

View File

@ -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.TransactionFilterRepository
import co.electriccoin.zcash.ui.common.repository.TransactionMetadata import co.electriccoin.zcash.ui.common.repository.TransactionMetadata
import co.electriccoin.zcash.ui.common.repository.TransactionRepository 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.getString
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.util.CloseableScopeHolder import co.electriccoin.zcash.ui.util.CloseableScopeHolder
import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl
import co.electriccoin.zcash.ui.util.combineToFlow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -232,23 +232,23 @@ class GetCurrentFilteredTransactionsUseCase(
} else { } else {
val transactionMetadata = transaction.transactionMetadata val transactionMetadata = transaction.transactionMetadata
hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not()) hasMemo && transactionMetadata.isRead.not()
} }
} }
private fun isBookmark(transaction: FilterTransactionData): Boolean { private fun isBookmark(transaction: FilterTransactionData): Boolean {
return transaction.transactionMetadata?.isBookmarked ?: false return transaction.transactionMetadata.isBookmarked
} }
private fun hasNotes(transaction: FilterTransactionData): Boolean { private fun hasNotes(transaction: FilterTransactionData): Boolean {
return transaction.transactionMetadata?.note != null return transaction.transactionMetadata.note != null
} }
private fun hasNotesWithFulltext( private fun hasNotesWithFulltext(
transaction: FilterTransactionData, transaction: FilterTransactionData,
fulltextFilter: String fulltextFilter: String
): Boolean { ): Boolean {
return transaction.transactionMetadata?.note return transaction.transactionMetadata.note
?.contains( ?.contains(
fulltextFilter, fulltextFilter,
ignoreCase = true ignoreCase = true
@ -288,7 +288,7 @@ private data class FilterTransactionData(
val transaction: Transaction, val transaction: Transaction,
val contact: AddressBookContact?, val contact: AddressBookContact?,
val recipientAddress: String?, val recipientAddress: String?,
val transactionMetadata: TransactionMetadata? val transactionMetadata: TransactionMetadata
) )
private const val MIN_TEXT_FILTER_LENGTH = 3 private const val MIN_TEXT_FILTER_LENGTH = 3

View File

@ -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.Transaction
import co.electriccoin.zcash.ui.common.repository.TransactionMetadata import co.electriccoin.zcash.ui.common.repository.TransactionMetadata
import co.electriccoin.zcash.ui.common.repository.TransactionRepository 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -40,5 +40,5 @@ class GetCurrentTransactionsUseCase(
data class ListTransactionData( data class ListTransactionData(
val transaction: Transaction, val transaction: Transaction,
val metadata: TransactionMetadata? val metadata: TransactionMetadata
) )

View File

@ -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
}

View File

@ -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()
}

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.common.usecase 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( class GetPersistableWalletUseCase(
private val walletRepository: WalletRepository private val persistableWalletProvider: PersistableWalletProvider
) { ) {
suspend operator fun invoke() = walletRepository.getPersistableWallet() suspend operator fun invoke() = persistableWalletProvider.getPersistableWallet()
} }

View File

@ -12,15 +12,16 @@ import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class GetTransactionDetailByIdUseCase( class GetTransactionDetailByIdUseCase(
private val transactionRepository: TransactionRepository, private val transactionRepository: TransactionRepository,
@ -30,43 +31,56 @@ class GetTransactionDetailByIdUseCase(
) { ) {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun observe(txId: String) = fun observe(txId: String) =
transactionRepository channelFlow {
.observeTransaction(txId).filterNotNull().flatMapLatest { transaction -> val transactionFlow =
channelFlow { transactionRepository
launch { .observeTransaction(txId)
combine( .filterNotNull()
flow { .stateIn(this)
emit(null)
emit(getWalletAddress(transactionRepository.getRecipients(transaction))) val addressFlow =
}, transactionFlow
flow { .mapLatest { getWalletAddress(transactionRepository.getRecipients(it)) }
emit(null) .onStart { emit(null) }
emit(transaction.let { transactionRepository.getMemos(it) }) .distinctUntilChanged()
},
metadataRepository.observeTransactionMetadataByTxId(txId) val memosFlow: Flow<List<String>?> =
) { address, memos, metadata -> transactionFlow
Triple(address, memos, metadata) .mapLatest<Transaction, List<String>?> { transactionRepository.getMemos(it) }
}.flatMapLatest { (address, memos, metadata) -> .onStart { emit(null) }
addressBookRepository .distinctUntilChanged()
.observeContactByAddress(address?.address.orEmpty())
.mapLatest { contact -> val metadataFlow =
DetailedTransactionData( metadataRepository
transaction = transaction, .observeTransactionMetadataByTxId(txId)
memos = memos,
contact = contact, val contactFlow =
recipientAddress = address, addressFlow
metadata = metadata .flatMapLatest { addressBookRepository.observeContactByAddress(it?.address.orEmpty()) }
) .distinctUntilChanged()
}
}.collect { combine(
send(it) transactionFlow,
} addressFlow,
} memosFlow,
awaitClose { metadataFlow,
// do nothing contactFlow
} ) { transaction, address, memos, metadata, contact ->
} DetailedTransactionData(
}.distinctUntilChanged().flowOn(Dispatchers.Default) 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? { private suspend fun getWalletAddress(address: String?): WalletAddress? {
if (address == null) return null if (address == null) return null
@ -86,5 +100,5 @@ data class DetailedTransactionData(
val memos: List<String>?, val memos: List<String>?,
val contact: AddressBookContact?, val contact: AddressBookContact?,
val recipientAddress: WalletAddress?, val recipientAddress: WalletAddress?,
val metadata: TransactionMetadata? val metadata: TransactionMetadata
) )

View File

@ -1,13 +1,12 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.MetadataRepository import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
class GetTransactionMetadataUseCase( class GetTransactionMetadataUseCase(
private val metadataRepository: MetadataRepository, 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) fun observe(txId: String) = metadataRepository.observeTransactionMetadataByTxId(txId)
} }

View File

@ -3,8 +3,8 @@ package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
class ObserveWalletAccountsUseCase(private val accountDataSource: AccountDataSource) { class GetWalletAccountsUseCase(private val accountDataSource: AccountDataSource) {
operator fun invoke() = accountDataSource.allAccounts fun observe() = accountDataSource.allAccounts
fun require() = accountDataSource.allAccounts.filterNotNull() fun require() = accountDataSource.allAccounts.filterNotNull()
} }

View File

@ -10,7 +10,7 @@ class NavigateToCoinbaseUseCase(
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
suspend operator fun invoke(replaceCurrentScreen: Boolean) { suspend operator fun invoke(replaceCurrentScreen: Boolean) {
val transparent = accountDataSource.getZashiAccount().transparent val transparent = accountDataSource.getSelectedAccount().transparent
val url = getUrl(transparent.address.address) val url = getUrl(transparent.address.address)
if (replaceCurrentScreen) { if (replaceCurrentScreen) {
navigationRouter.replace(ExternalUrl(url)) navigationRouter.replace(ExternalUrl(url))

View File

@ -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
}
}
}

View File

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

View File

@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.scan.Scan import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
import co.electriccoin.zcash.ui.screen.send.Send import co.electriccoin.zcash.ui.screen.send.Send
class OnAddressScannedUseCase( class OnAddressScannedUseCase(
@ -13,21 +14,21 @@ class OnAddressScannedUseCase(
operator fun invoke( operator fun invoke(
address: String, address: String,
addressType: AddressType, addressType: AddressType,
scanFlow: Scan scanArgs: Scan
) { ) {
require(addressType is AddressType.Valid) require(addressType is AddressType.Valid)
when (scanFlow) { when (scanArgs.flow) {
Scan.SEND -> { ScanFlow.SEND -> {
prefillSend.request(PrefillSendData.FromAddressScan(address = address)) prefillSend.request(PrefillSendData.FromAddressScan(address = address))
navigationRouter.back() navigationRouter.back()
} }
Scan.ADDRESS_BOOK -> { ScanFlow.ADDRESS_BOOK -> {
navigationRouter.replace(AddContactArgs(address)) navigationRouter.replace(AddContactArgs(address))
} }
Scan.HOMEPAGE -> { ScanFlow.HOMEPAGE -> {
navigationRouter.replace( navigationRouter.replace(
Send( Send(
address, address,

View File

@ -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.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction
import co.electriccoin.zcash.ui.screen.scan.Scan 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.ScanFlow.ADDRESS_BOOK
import co.electriccoin.zcash.ui.screen.scan.Scan.HOMEPAGE import co.electriccoin.zcash.ui.screen.scan.ScanFlow.HOMEPAGE
import co.electriccoin.zcash.ui.screen.scan.Scan.SEND import co.electriccoin.zcash.ui.screen.scan.ScanFlow.SEND
import co.electriccoin.zcash.ui.screen.send.Send import co.electriccoin.zcash.ui.screen.send.Send
class OnZip321ScannedUseCase( class OnZip321ScannedUseCase(
@ -29,20 +29,27 @@ class OnZip321ScannedUseCase(
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
zip321: Zip321ParseUriValidation.Valid, zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan scanArgs: Scan
) { ) {
if (scanFlow == ADDRESS_BOOK) { when (scanArgs.flow) {
navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value)) ADDRESS_BOOK -> addressBookFlow(zip321)
} else { SEND ->
createProposal(zip321, scanFlow) 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") @Suppress("TooGenericExceptionCaught")
private suspend fun createProposal( private suspend fun homepageFlow(zip321: Zip321ParseUriValidation.Valid) {
zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan
) {
try { try {
val proposal = val proposal =
when (accountDataSource.getSelectedAccount()) { when (accountDataSource.getSelectedAccount()) {
@ -57,35 +64,63 @@ class OnZip321ScannedUseCase(
} }
} }
if (scanFlow == HOMEPAGE) { navigationRouter
navigationRouter .replace(
.replace( Send(
Send( recipientAddress = proposal.destination.address,
recipientAddress = proposal.destination.address, recipientAddressType =
recipientAddressType = when (proposal.destination) {
when (proposal.destination) { is WalletAddress.Sapling -> SAPLING
is WalletAddress.Sapling -> SAPLING is WalletAddress.Tex -> TEX
is WalletAddress.Tex -> TEX is WalletAddress.Transparent -> TRANSPARENT
is WalletAddress.Transparent -> TRANSPARENT is WalletAddress.Unified -> UNIFIED
is WalletAddress.Unified -> UNIFIED }
} ),
), ReviewTransaction
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.forward(ReviewTransaction)
}
} catch (e: Exception) { } catch (e: Exception) {
keystoneProposalRepository.clear() keystoneProposalRepository.clear()
throw e 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()
}
} }

View File

@ -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
)
}
}

View File

@ -8,12 +8,8 @@ class SelectWalletAccountUseCase(
private val accountDataSource: AccountDataSource, private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
suspend operator fun invoke( suspend operator fun invoke(account: WalletAccount) {
account: WalletAccount,
hideBottomSheet: suspend () -> Unit
) {
accountDataSource.selectAccount(account) accountDataSource.selectAccount(account)
hideBottomSheet()
navigationRouter.back() navigationRouter.back()
} }
} }

View File

@ -9,6 +9,10 @@ class SendTransactionAgainUseCase(
) { ) {
operator fun invoke(value: DetailedTransactionData) { operator fun invoke(value: DetailedTransactionData) {
prefillSendUseCase.request(value) prefillSendUseCase.request(value)
navigationRouter.forward(Send()) navigationRouter.forward(
Send(
isScanZip321Enabled = false
)
)
} }
} }

View File

@ -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<String>): 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
}
}
}

View File

@ -100,9 +100,7 @@ class AuthenticationViewModel(
when { when {
(!required || versionInfo.isRunningUnderTestService) -> AuthenticationUIState.NotRequired (!required || versionInfo.isRunningUnderTestService) -> AuthenticationUIState.NotRequired
(state == AuthenticationUIState.Initial) -> { (state == AuthenticationUIState.Initial) -> {
if (secretState == SecretState.None || if (secretState == SecretState.NONE) {
secretState == SecretState.NeedsWarning
) {
appAccessAuthentication.value = AuthenticationUIState.NotRequired appAccessAuthentication.value = AuthenticationUIState.NotRequired
AuthenticationUIState.NotRequired AuthenticationUIState.NotRequired
} else { } else {
@ -146,12 +144,6 @@ class AuthenticationViewModel(
val isDeleteWalletAuthenticationRequired: StateFlow<Boolean?> = val isDeleteWalletAuthenticationRequired: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_DELETE_WALLET_AUTHENTICATION) booleanStateFlow(StandardPreferenceKeys.IS_DELETE_WALLET_AUTHENTICATION)
val isSeedAuthenticationRequired: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_SEED_AUTHENTICATION)
val isSendFundsAuthenticationRequired: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_SEND_FUNDS_AUTHENTICATION)
/** /**
* Authentication framework result * Authentication framework result
*/ */
@ -319,9 +311,6 @@ class AuthenticationViewModel(
AuthenticationUseCase.ExportPrivateData -> AuthenticationUseCase.ExportPrivateData ->
R.string.authentication_use_case_export_data R.string.authentication_use_case_export_data
AuthenticationUseCase.SeedRecovery ->
R.string.authentication_use_case_seed_recovery
AuthenticationUseCase.SendFunds -> AuthenticationUseCase.SendFunds ->
R.string.authentication_use_case_send_funds R.string.authentication_use_case_send_funds
} }

View File

@ -3,10 +3,8 @@ package co.electriccoin.zcash.ui.common.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT 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.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -16,7 +14,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
class OldHomeViewModel( class OldHomeViewModel(
observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
) : ViewModel() { ) : ViewModel() {
/** /**
@ -30,8 +27,6 @@ class OldHomeViewModel(
*/ */
val isHideBalances: StateFlow<Boolean?> = booleanStateFlow(StandardPreferenceKeys.IS_HIDE_BALANCES) val isHideBalances: StateFlow<Boolean?> = booleanStateFlow(StandardPreferenceKeys.IS_HIDE_BALANCES)
val configurationFlow: StateFlow<Configuration?> = observeConfiguration()
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> = private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
flow<Boolean?> { flow<Boolean?> {
emitAll(default.observe(standardPreferenceProvider())) emitAll(default.observe(standardPreferenceProvider()))

View File

@ -161,16 +161,10 @@ class WalletViewModel(
/** /**
* Represents the state of the wallet secret. * Represents the state of the wallet secret.
*/ */
sealed class SecretState { enum class SecretState {
object Loading : SecretState() LOADING,
NONE,
object None : SecretState() READY
object NeedsWarning : SecretState()
class NeedsBackup(val persistableWallet: PersistableWallet) : SecretState()
class Ready(val persistableWallet: PersistableWallet) : SecretState()
} }
/** /**

View File

@ -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<Configuration> { StringConfiguration(persistentMapOf(), null) }

View File

@ -1,16 +1,13 @@
package co.electriccoin.zcash.ui.screen.accountlist package co.electriccoin.zcash.ui.screen.accountlist
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.view.AccountListView
import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@ -20,38 +17,12 @@ import org.koin.androidx.compose.koinViewModel
fun AndroidAccountList() { fun AndroidAccountList() {
val viewModel = koinViewModel<AccountListViewModel>() val viewModel = koinViewModel<AccountListViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val parent = LocalView.current.parent val parent = LocalView.current.parent
SideEffect { SideEffect {
(parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) (parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
(parent as? DialogWindowProvider)?.window?.setDimAmount(0f) (parent as? DialogWindowProvider)?.window?.setDimAmount(0f)
} }
state?.let { state?.let {
AccountListView( AccountListView(it)
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()
}
} }
} }

View File

@ -2,16 +2,16 @@ package co.electriccoin.zcash.ui.screen.accountlist.model
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import co.electriccoin.zcash.ui.design.component.ButtonState 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.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.StringResource
data class AccountListState( data class AccountListState(
val items: List<AccountListItem>?, val items: List<AccountListItem>?,
val isLoading: Boolean, val isLoading: Boolean,
val onBottomSheetHidden: () -> Unit,
val addWalletButton: ButtonState?, val addWalletButton: ButtonState?,
val onBack: () -> Unit, override val onBack: () -> Unit,
) ) : ModalBottomSheetState
data class ZashiAccountListItemState( data class ZashiAccountListItemState(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,

View File

@ -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.LottieProgress
import co.electriccoin.zcash.ui.design.component.ZashiButton import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults 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.BaseListItem
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemColors 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.ZashiListItemDefaults
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemDesignType 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.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState 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.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
@ -54,16 +55,15 @@ import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
internal fun AccountListView( internal fun AccountListView(
onDismissRequest: () -> Unit, state: AccountListState,
sheetState: SheetState, sheetState: SheetState = rememberScreenModalBottomSheetState(),
state: AccountListState
) { ) {
ZashiModalBottomSheet( ZashiScreenModalBottomSheet(
state = state,
sheetState = sheetState, sheetState = sheetState,
content = { content = {
BottomSheetContent(state) BottomSheetContent(state)
}, },
onDismissRequest = onDismissRequest
) )
} }
@ -111,7 +111,10 @@ private fun BottomSheetContent(state: AccountListState) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
ZashiButton( ZashiButton(
state = state.addWalletButton, state = state.addWalletButton,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
colors = colors =
ZashiButtonDefaults.secondaryColors( ZashiButtonDefaults.secondaryColors(
borderColor = ZashiColors.Btns.Secondary.btnSecondaryBorder borderColor = ZashiColors.Btns.Secondary.btnSecondaryBorder
@ -270,11 +273,9 @@ private fun Preview() =
) )
), ),
isLoading = false, isLoading = false,
onBottomSheetHidden = {},
onBack = {}, onBack = {},
addWalletButton = ButtonState(stringRes("Connect Hardware Wallet")) addWalletButton = ButtonState(stringRes("Connect Hardware Wallet"))
), ),
onDismissRequest = {},
sheetState = sheetState =
rememberModalBottomSheetState( rememberModalBottomSheetState(
skipHiddenState = true, skipHiddenState = true,
@ -315,11 +316,9 @@ private fun HardwareWalletAddedPreview() =
), ),
), ),
isLoading = false, isLoading = false,
onBottomSheetHidden = {},
onBack = {}, onBack = {},
addWalletButton = null addWalletButton = null
), ),
onDismissRequest = {},
sheetState = sheetState =
rememberModalBottomSheetState( rememberModalBottomSheetState(
skipHiddenState = true, skipHiddenState = true,

View File

@ -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.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount 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.common.usecase.SelectWalletAccountUseCase
import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.ButtonState 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.accountlist.model.ZashiAccountListItemState
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.ADDRESS_MAX_LENGTH import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.ADDRESS_MAX_LENGTH
import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AccountListViewModel( class AccountListViewModel(
observeWalletAccounts: ObserveWalletAccountsUseCase, getWalletAccounts: GetWalletAccountsUseCase,
private val selectWalletAccount: SelectWalletAccountUseCase, private val selectWalletAccount: SelectWalletAccountUseCase,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
) : ViewModel() { ) : ViewModel() {
val hideBottomSheetRequest = MutableSharedFlow<Unit>()
private val bottomSheetHiddenResponse = MutableSharedFlow<Unit>()
@Suppress("SpreadOperator") @Suppress("SpreadOperator")
val state = val state =
observeWalletAccounts().map { accounts -> getWalletAccounts.observe().map { accounts ->
val items = val items =
listOfNotNull( listOfNotNull(
*accounts.orEmpty() *accounts.orEmpty()
@ -77,7 +71,6 @@ class AccountListViewModel(
AccountListState( AccountListState(
items = items, items = items,
isLoading = accounts == null, isLoading = accounts == null,
onBottomSheetHidden = ::onBottomSheetHidden,
onBack = ::onBack, onBack = ::onBack,
addWalletButton = addWalletButton =
ButtonState( ButtonState(
@ -94,35 +87,14 @@ class AccountListViewModel(
) )
private fun onShowKeystonePromoClicked() = private fun onShowKeystonePromoClicked() =
viewModelScope.launch { navigationRouter.replace(ExternalUrl("https://keyst.one/shop/products/keystone-3-pro?discount=Zashi"))
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)
}
private fun onAccountClicked(account: WalletAccount) = private fun onAccountClicked(account: WalletAccount) =
viewModelScope.launch { viewModelScope.launch {
selectWalletAccount(account) { hideBottomSheet() } selectWalletAccount(account)
} }
private fun onAddWalletButtonClicked() = private fun onAddWalletButtonClicked() = navigationRouter.forward(ConnectKeystone)
viewModelScope.launch {
hideBottomSheet()
navigationRouter.forward(ConnectKeystone)
}
private fun onBack() = private fun onBack() = navigationRouter.back()
viewModelScope.launch {
hideBottomSheet()
navigationRouter.back()
}
} }

View File

@ -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.AddContactArgs
import co.electriccoin.zcash.ui.screen.contact.UpdateContactArgs import co.electriccoin.zcash.ui.screen.contact.UpdateContactArgs
import co.electriccoin.zcash.ui.screen.scan.Scan import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
@ -85,7 +86,7 @@ class AddressBookViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) 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 internal const val ADDRESS_MAX_LENGTH = 20

View File

@ -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.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount 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.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase 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.ButtonState
import co.electriccoin.zcash.ui.design.component.listitem.ZashiContactListItemState import co.electriccoin.zcash.ui.design.component.listitem.ZashiContactListItemState
import co.electriccoin.zcash.ui.design.util.ImageResource 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.addressbook.model.AddressBookState
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.scan.Scan import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
@ -31,12 +32,12 @@ import kotlinx.coroutines.launch
class SelectRecipientViewModel( class SelectRecipientViewModel(
observeAddressBookContacts: ObserveAddressBookContactsUseCase, observeAddressBookContacts: ObserveAddressBookContactsUseCase,
observeWalletAccountsUseCase: ObserveWalletAccountsUseCase, getWalletAccountsUseCase: GetWalletAccountsUseCase,
private val observeContactPicked: ObserveContactPickedUseCase, private val observeContactPicked: ObserveContactPickedUseCase,
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) : ViewModel() { ) : ViewModel() {
val state = val state =
combine(observeAddressBookContacts(), observeWalletAccountsUseCase()) { contacts, accounts -> combine(observeAddressBookContacts(), getWalletAccountsUseCase.observe()) { contacts, accounts ->
if (accounts != null && accounts.size > 1) { if (accounts != null && accounts.size > 1) {
createStateWithAccounts(contacts, accounts) createStateWithAccounts(contacts, accounts)
} else { } else {
@ -174,5 +175,5 @@ class SelectRecipientViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) 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))
} }

View File

@ -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.ButtonState
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState

View File

@ -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.Image
import androidx.compose.foundation.layout.Arrangement 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.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes 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 import kotlinx.collections.immutable.persistentListOf
// TODO [#1271]: Add AdvancedSettingsView Tests // TODO [#1271]: Add AdvancedSettingsView Tests

View File

@ -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.ViewModel
import androidx.lifecycle.viewModelScope 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.R
import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase 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.common.usecase.NavigateToTaxExportUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.advancedsettings.model.AdvancedSettingsState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -25,6 +25,7 @@ class AdvancedSettingsViewModel(
getWalletRestoringState: GetWalletRestoringStateUseCase, getWalletRestoringState: GetWalletRestoringStateUseCase,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
private val navigateToTaxExport: NavigateToTaxExportUseCase, private val navigateToTaxExport: NavigateToTaxExportUseCase,
private val navigateToSeedRecovery: NavigateToSeedRecoveryUseCase
) : ViewModel() { ) : ViewModel() {
val state: StateFlow<AdvancedSettingsState> = val state: StateFlow<AdvancedSettingsState> =
getWalletRestoringState.observe() getWalletRestoringState.observe()
@ -45,7 +46,7 @@ class AdvancedSettingsViewModel(
ZashiListItemState( ZashiListItemState(
title = stringRes(R.string.advanced_settings_recovery), title = stringRes(R.string.advanced_settings_recovery),
icon = R.drawable.ic_advanced_settings_recovery, icon = R.drawable.ic_advanced_settings_recovery,
onClick = {} onClick = ::onSeedRecoveryClick
), ),
ZashiListItemState( ZashiListItemState(
title = stringRes(R.string.advanced_settings_export), title = stringRes(R.string.advanced_settings_export),
@ -93,4 +94,9 @@ class AdvancedSettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
navigateToTaxExport() navigateToTaxExport()
} }
private fun onSeedRecoveryClick() =
viewModelScope.launch {
navigateToSeedRecovery()
}
} }

View File

@ -7,8 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel 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 kotlinx.collections.immutable.toImmutableList
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@ -16,7 +14,6 @@ import org.koin.androidx.compose.koinViewModel
internal fun WrapAdvancedSettings( internal fun WrapAdvancedSettings(
goDeleteWallet: () -> Unit, goDeleteWallet: () -> Unit,
goExportPrivateData: () -> Unit, goExportPrivateData: () -> Unit,
goSeedRecovery: () -> Unit,
) { ) {
val walletViewModel = koinActivityViewModel<WalletViewModel>() val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<AdvancedSettingsViewModel>() val viewModel = koinViewModel<AdvancedSettingsViewModel>()
@ -28,7 +25,6 @@ internal fun WrapAdvancedSettings(
items = items =
originalState.items.mapIndexed { index, item -> originalState.items.mapIndexed { index, item ->
when (index) { when (index) {
0 -> item.copy(onClick = goSeedRecovery)
1 -> item.copy(onClick = goExportPrivateData) 1 -> item.copy(onClick = goExportPrivateData)
else -> item else -> item
} }

View File

@ -20,7 +20,6 @@ import kotlin.time.Duration.Companion.milliseconds
private const val APP_ACCESS_TRIGGER_DELAY = 0 private const val APP_ACCESS_TRIGGER_DELAY = 0
private const val DELETE_WALLET_TRIGGER_DELAY = 0 private const val DELETE_WALLET_TRIGGER_DELAY = 0
private const val EXPORT_PRIVATE_DATA_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 private const val SEND_FUNDS_DELAY = 0
internal const val RETRY_TRIGGER_DELAY = 0 internal const val RETRY_TRIGGER_DELAY = 0
@ -82,16 +81,6 @@ private fun WrapAuthenticationUseCases(
onFailed = onFailed onFailed = onFailed
) )
} }
AuthenticationUseCase.SeedRecovery -> {
Twig.debug { "Seed Recovery Authentication" }
WrapSeedRecoveryAuth(
activity = activity,
goSeedRecovery = onSuccess,
goSupport = goSupport ?: {},
onCancel = onCancel,
onFailed = onFailed
)
}
AuthenticationUseCase.SendFunds -> { AuthenticationUseCase.SendFunds -> {
Twig.debug { "Send Funds Authentication" } Twig.debug { "Send Funds Authentication" }
WrapSendFundsAuth( 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<AuthenticationViewModel>()
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 @Composable
@Suppress("LongMethod") @Suppress("LongMethod")
private fun WrapSendFundsAuth( private fun WrapSendFundsAuth(
@ -472,8 +388,6 @@ private fun WrapAppAccessAuth(
sealed class AuthenticationUseCase { sealed class AuthenticationUseCase {
data object AppAccess : AuthenticationUseCase() data object AppAccess : AuthenticationUseCase()
data object SeedRecovery : AuthenticationUseCase()
data object DeleteWallet : AuthenticationUseCase() data object DeleteWallet : AuthenticationUseCase()
data object ExportPrivateData : AuthenticationUseCase() data object ExportPrivateData : AuthenticationUseCase()

View File

@ -2,8 +2,10 @@ package co.electriccoin.zcash.ui.screen.balances
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource 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.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -22,30 +24,41 @@ class BalanceViewModel(
accountDataSource.selectedAccount.filterNotNull(), accountDataSource.selectedAccount.filterNotNull(),
exchangeRateRepository.state, exchangeRateRepository.state,
) { account, exchangeRateUsd -> ) { account, exchangeRateUsd ->
when { createState(account, exchangeRateUsd)
(
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,
)
}
}
}.stateIn( }.stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), 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,
)
}
}
}
} }

View File

@ -1,12 +1,14 @@
package co.electriccoin.zcash.ui.screen.home package co.electriccoin.zcash.ui.screen.home
import co.electriccoin.zcash.ui.design.component.BigIconButtonState import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.screen.home.messages.HomeMessageState
data class HomeState( data class HomeState(
val receiveButton: BigIconButtonState, val firstButton: BigIconButtonState,
val sendButton: BigIconButtonState, val secondButton: BigIconButtonState,
val scanButton: BigIconButtonState, val thirdButton: BigIconButtonState,
val moreButton: BigIconButtonState, val fourthButton: BigIconButtonState,
val message: HomeMessageState?
) )
data class HomeRestoreDialogState( data class HomeRestoreDialogState(

View File

@ -1,9 +1,5 @@
package co.electriccoin.zcash.ui.screen.home 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.R
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarWithAccountSelection 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.BigIconButtonState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ZashiBigIconButton 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.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.balances.BalanceState import co.electriccoin.zcash.ui.screen.balances.BalanceState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidget 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.TransactionHistoryWidgetState
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransactionHistoryWidgets import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransactionHistoryWidgets
@ -74,7 +69,6 @@ private fun Content(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BalanceWidget( BalanceWidget(
modifier = modifier =
Modifier Modifier
@ -84,39 +78,10 @@ private fun Content(
), ),
balanceState = balanceState, balanceState = balanceState,
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
NavButtons(paddingValues, state)
Row( Spacer(Modifier.height(16.dp))
modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp), HomeMessage(state.message)
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))
LazyColumn( LazyColumn(
modifier = modifier =
Modifier Modifier
@ -128,29 +93,46 @@ private fun Content(
) )
} }
} }
}
}
AnimatedVisibility( @Composable
visible = balanceState.exchangeRate is ExchangeRateState.OptIn, private fun NavButtons(
enter = EnterTransition.None, paddingValues: PaddingValues,
exit = fadeOut() + slideOutVertically(), state: HomeState
) { ) {
Column { Row(
Spacer(modifier = Modifier.height(66.dp + paddingValues.calculateTopPadding())) modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp),
StyledExchangeOptIn( horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(horizontal = 24.dp), ) {
state = ZashiBigIconButton(
(balanceState.exchangeRate as? ExchangeRateState.OptIn) ?: ExchangeRateState.OptIn( modifier =
onDismissClick = {}, 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 @PreviewScreens
@Composable @Composable
private fun Preview() = private fun Preview() {
ZcashTheme { ZcashTheme {
HomeView( HomeView(
appBarState = ZashiMainTopAppBarStateFixture.new(), appBarState = ZashiMainTopAppBarStateFixture.new(),
@ -158,30 +140,32 @@ private fun Preview() =
transactionWidgetState = TransactionHistoryWidgetStateFixture.new(), transactionWidgetState = TransactionHistoryWidgetStateFixture.new(),
state = state =
HomeState( HomeState(
receiveButton = firstButton =
BigIconButtonState( BigIconButtonState(
text = stringRes("Text"), text = stringRes("Text"),
icon = R.drawable.ic_warning, icon = R.drawable.ic_warning,
onClick = {} onClick = {}
), ),
sendButton = secondButton =
BigIconButtonState( BigIconButtonState(
text = stringRes("Text"), text = stringRes("Text"),
icon = R.drawable.ic_warning, icon = R.drawable.ic_warning,
onClick = {} onClick = {}
), ),
scanButton = thirdButton =
BigIconButtonState( BigIconButtonState(
text = stringRes("Text"), text = stringRes("Text"),
icon = R.drawable.ic_warning, icon = R.drawable.ic_warning,
onClick = {} onClick = {}
), ),
moreButton = fourthButton =
BigIconButtonState( BigIconButtonState(
text = stringRes("Text"), text = stringRes("Text"),
icon = R.drawable.ic_warning, icon = R.drawable.ic_warning,
onClick = {} onClick = {}
), ),
message = null
) )
) )
} }
}

View File

@ -4,28 +4,44 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets
import co.electriccoin.zcash.ui.R 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.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.design.component.BigIconButtonState import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes 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.integrations.DialogIntegrations
import co.electriccoin.zcash.ui.screen.receive.Receive 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.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 co.electriccoin.zcash.ui.screen.send.Send
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class HomeViewModel( class HomeViewModel(
getVersionInfoProvider: GetVersionInfoProvider,
getSelectedWalletAccountUseCase: GetSelectedWalletAccountUseCase,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase,
private val navigateToCoinbase: NavigateToCoinbaseUseCase
) : ViewModel() { ) : ViewModel() {
private val isMessageVisible = MutableStateFlow(true)
private val isRestoreDialogVisible: Flow<Boolean?> = private val isRestoreDialogVisible: Flow<Boolean?> =
isRestoreSuccessDialogVisible.observe() isRestoreSuccessDialogVisible.observe()
.stateIn( .stateIn(
@ -48,36 +64,72 @@ class HomeViewModel(
) )
val state: StateFlow<HomeState?> = val state: StateFlow<HomeState?> =
MutableStateFlow( combine(getSelectedWalletAccountUseCase.observe(), isMessageVisible) { selectedAccount, isMessageVisible ->
HomeState( createState(getVersionInfoProvider, selectedAccount, isMessageVisible)
receiveButton = }.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( BigIconButtonState(
text = stringRes("Receive"), text = stringRes(R.string.home_button_request),
icon = R.drawable.ic_home_receive, icon = R.drawable.ic_home_request,
onClick = ::onReceiveButtonClick, onClick = ::onRequestClick,
), )
sendButton =
selectedAccount is KeystoneAccount ->
BigIconButtonState( BigIconButtonState(
text = stringRes("Send"), text = stringRes(R.string.home_button_buy),
icon = R.drawable.ic_home_send, icon = R.drawable.ic_home_buy,
onClick = ::onSendButtonClick, onClick = ::onBuyClick,
), )
scanButton =
else ->
BigIconButtonState( BigIconButtonState(
text = stringRes("Scan"), text = stringRes(R.string.home_button_more),
icon = R.drawable.ic_home_scan,
onClick = ::onScanButtonClick,
),
moreButton =
BigIconButtonState(
text = stringRes("More"),
icon = R.drawable.ic_home_more, icon = R.drawable.ic_home_more,
onClick = ::onMoreButtonClick, onClick = ::onMoreButtonClick,
), )
) },
).asStateFlow() message = createWalletBackupMessageState().takeIf { isMessageVisible }
)
fun onRestoreDialogSeenClick() = private fun createWalletBackupMessageState(): WalletBackupMessageState {
return WalletBackupMessageState(
onClick = {
navigationRouter.forward(SeedBackup)
}
)
}
private fun onRestoreDialogSeenClick() =
viewModelScope.launch { viewModelScope.launch {
isRestoreSuccessDialogVisible.setSeen() isRestoreSuccessDialogVisible.setSeen()
} }
@ -95,6 +147,15 @@ class HomeViewModel(
} }
private fun onScanButtonClick() { 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}")
} }
} }

View File

@ -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
)

View File

@ -0,0 +1,3 @@
package co.electriccoin.zcash.ui.screen.home.messages
sealed interface HomeMessageState

View File

@ -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
)
}
}
}

View File

@ -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()
)
}
}

View File

@ -1,18 +1,13 @@
package co.electriccoin.zcash.ui.screen.integrations package co.electriccoin.zcash.ui.screen.integrations
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -20,15 +15,10 @@ import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AndroidDialogIntegrations() { fun AndroidDialogIntegrations() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val parent = LocalView.current.parent val parent = LocalView.current.parent
val viewModel = koinViewModel<IntegrationsViewModel> { parametersOf(true) } val viewModel = koinViewModel<IntegrationsViewModel> { parametersOf(true) }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(enabled = state != null) {
state?.onBack?.invoke()
}
SideEffect { SideEffect {
(parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) (parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
(parent as? DialogWindowProvider)?.window?.setDimAmount(0f) (parent as? DialogWindowProvider)?.window?.setDimAmount(0f)
@ -37,22 +27,7 @@ fun AndroidDialogIntegrations() {
state?.let { state?.let {
IntegrationsDialogView( IntegrationsDialogView(
state = it, state = it,
sheetState = sheetState,
onDismissRequest = {
it.onBack()
}
) )
LaunchedEffect(Unit) {
sheetState.show()
}
LaunchedEffect(Unit) {
viewModel.hideBottomSheetRequest.collect {
sheetState.hide()
state?.onBottomSheetHidden?.invoke()
}
}
} }
} }

View File

@ -5,8 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel 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 kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf

View File

@ -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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
@ -14,34 +17,37 @@ import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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 co.electriccoin.zcash.ui.R 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.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState 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.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
internal fun IntegrationsDialogView( internal fun IntegrationsDialogView(
onDismissRequest: () -> Unit, state: IntegrationsState,
sheetState: SheetState, sheetState: SheetState = rememberScreenModalBottomSheetState(),
state: IntegrationsState
) { ) {
ZashiModalBottomSheet( ZashiScreenModalBottomSheet(
state = state,
sheetState = sheetState, sheetState = sheetState,
content = { content = {
BottomSheetContent(state) BottomSheetContent(state)
}, },
onDismissRequest = onDismissRequest
) )
} }
@ -56,8 +62,33 @@ fun BottomSheetContent(state: IntegrationsState) {
color = ZashiColors.Text.textPrimary color = ZashiColors.Text.textPrimary
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
IntegrationItems(state, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)) IntegrationItems(state, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp))
Spacer(modifier = Modifier.height(24.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)) Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
} }
} }
@ -68,7 +99,6 @@ fun BottomSheetContent(state: IntegrationsState) {
private fun IntegrationSettings() = private fun IntegrationSettings() =
ZcashTheme { ZcashTheme {
IntegrationsDialogView( IntegrationsDialogView(
onDismissRequest = {},
sheetState = sheetState =
rememberModalBottomSheetState( rememberModalBottomSheetState(
skipHiddenState = true, skipHiddenState = true,
@ -101,7 +131,6 @@ private fun IntegrationSettings() =
onClick = {} onClick = {}
), ),
), ),
onBottomSheetHidden = {}
), ),
) )
} }

View File

@ -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.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.StringResource
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class IntegrationsState( data class IntegrationsState(
val disabledInfo: StringResource?, val disabledInfo: StringResource?,
val onBack: () -> Unit, override val onBack: () -> Unit,
val items: ImmutableList<ZashiListItemState>, val items: ImmutableList<ZashiListItemState>,
val onBottomSheetHidden: () -> Unit, ) : ModalBottomSheetState
)

View File

@ -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.Image
import androidx.compose.foundation.layout.Column 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.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes 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 co.electriccoin.zcash.ui.screen.settings.SettingsTag
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -198,7 +197,6 @@ private fun IntegrationSettings() =
onClick = {} onClick = {}
), ),
), ),
onBottomSheetHidden = {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )

View File

@ -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)
}
}
}

View File

@ -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<Unit>()
private val bottomSheetHiddenResponse = MutableSharedFlow<Unit>()
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)
}
}
}

View File

@ -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<WalletViewModel>()
val onboardingViewModel = koinActivityViewModel<OnboardingViewModel>()
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
)
}

View File

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

View File

@ -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<NavigationRouter>()
val navController = LocalNavController.current
val keyboardManager = LocalKeyboardManager.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
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> {
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<RestoreSeed> {
AndroidRestoreSeed()
}
composable<RestoreBDHeight> {
AndroidRestoreBDHeight(it.toRoute())
}
composable<RestoreBDDate> {
AndroidRestoreBDDate()
}
composable<RestoreBDEstimation> {
AndroidRestoreBDEstimation()
}
dialog<SeedInfo>(
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
)
}

View File

@ -4,7 +4,6 @@ package co.electriccoin.zcash.ui.screen.onboarding.view
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.sdk.type.ZcashCurrency import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
@ -49,10 +47,8 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
private fun OnboardingComposablePreview() { private fun OnboardingComposablePreview() {
ZcashTheme { ZcashTheme {
Onboarding( Onboarding(
isDebugMenuEnabled = true,
onImportWallet = {}, onImportWallet = {},
onCreateWallet = {}, onCreateWallet = {}
onFixtureWallet = {}
) )
} }
} }
@ -63,10 +59,8 @@ private fun OnboardingComposablePreview() {
*/ */
@Composable @Composable
fun Onboarding( fun Onboarding(
isDebugMenuEnabled: Boolean,
onImportWallet: () -> Unit, onImportWallet: () -> Unit,
onCreateWallet: () -> Unit, onCreateWallet: () -> Unit
onFixtureWallet: (String) -> Unit
) { ) {
Scaffold { paddingValues -> Scaffold { paddingValues ->
Box( Box(
@ -90,10 +84,8 @@ fun Onboarding(
) )
) { ) {
OnboardingMainContent( OnboardingMainContent(
isDebugMenuEnabled = isDebugMenuEnabled,
onCreateWallet = onCreateWallet,
onFixtureWallet = onFixtureWallet,
onImportWallet = onImportWallet, onImportWallet = onImportWallet,
onCreateWallet = onCreateWallet,
modifier = modifier =
Modifier Modifier
.padding( .padding(
@ -111,8 +103,6 @@ fun Onboarding(
private fun OnboardingMainContent( private fun OnboardingMainContent(
onImportWallet: () -> Unit, onImportWallet: () -> Unit,
onCreateWallet: () -> Unit, onCreateWallet: () -> Unit,
onFixtureWallet: (String) -> Unit,
isDebugMenuEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -123,18 +113,10 @@ private fun OnboardingMainContent(
.then(modifier), .then(modifier),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
var imageModifier = val imageModifier =
Modifier Modifier
.height(ZcashTheme.dimens.inScreenZcashLogoHeight) .height(ZcashTheme.dimens.inScreenZcashLogoHeight)
.width(ZcashTheme.dimens.inScreenZcashLogoWidth) .width(ZcashTheme.dimens.inScreenZcashLogoWidth)
if (isDebugMenuEnabled) {
imageModifier =
imageModifier.then(
Modifier.clickable {
onFixtureWallet(WalletFixture.Alice.seedPhrase)
}
)
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))

View File

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

Some files were not shown because too many files have changed in this diff Show More