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> {
return if (configurationProviders.isEmpty()) {
flowOf(MergingConfiguration(persistentListOf<Configuration>()))
flowOf(MergingConfiguration(persistentListOf()))
} else {
combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations ->
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@ -66,7 +68,9 @@ fun LabeledCheckBox(
text: String,
modifier: Modifier = Modifier,
checked: Boolean = false,
checkBoxTestTag: String? = null
checkBoxTestTag: String? = null,
color: Color = ZcashTheme.colors.textPrimary,
style: TextStyle = ZcashTheme.extendedTypography.checkboxText
) {
val (checkedState, setCheckedState) = rememberSaveable { mutableStateOf(checked) }
@ -114,8 +118,8 @@ fun LabeledCheckBox(
)
Text(
text = AnnotatedString(text),
color = ZcashTheme.colors.textPrimary,
style = ZcashTheme.extendedTypography.checkboxText
color = color,
style = style
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -90,7 +90,7 @@ fun rememberModalBottomSheetState(
@Composable
@ExperimentalMaterial3Api
private fun rememberSheetState(
fun rememberSheetState(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
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.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -22,23 +23,32 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getString
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
@ -48,9 +58,18 @@ fun ZashiTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
error: String? = null,
isEnabled: Boolean = true,
handle: ZashiTextFieldHandle =
rememberZashiTextFieldHandle(
TextFieldState(
value = stringRes(value),
error = error?.let { stringRes(it) },
isEnabled = isEnabled,
onValueChange = onValueChange,
)
),
readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null,
@ -97,7 +116,8 @@ fun ZashiTextField(
interactionSource = interactionSource,
shape = shape,
colors = colors,
innerModifier = innerModifier
innerModifier = innerModifier,
handle = handle,
)
}
@ -106,7 +126,8 @@ fun ZashiTextField(
fun ZashiTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
innerModifier: Modifier = ZashiTextFieldDefaults.innerModifier,
handle: ZashiTextFieldHandle = rememberZashiTextFieldHandle(state),
readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null,
@ -124,6 +145,13 @@ fun ZashiTextField(
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = ZashiTextFieldDefaults.shape,
contentPadding: PaddingValues =
PaddingValues(
start = if (leadingIcon != null) 8.dp else 14.dp,
end = if (suffix != null) 4.dp else 12.dp,
top = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
bottom = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
),
colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) {
TextFieldInternal(
@ -147,10 +175,39 @@ fun ZashiTextField(
interactionSource = interactionSource,
shape = shape,
colors = colors,
innerModifier = innerModifier
contentPadding = contentPadding,
innerModifier = innerModifier,
handle = handle
)
}
@Composable
fun ZashiTextFieldPlaceholder(res: StringResource) {
Text(
text = res.getValue(),
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
}
@Stable
class ZashiTextFieldHandle(text: String) {
var textFieldValueState by mutableStateOf(TextFieldValue(text = text))
fun moveCursorToEnd() {
textFieldValueState =
textFieldValueState.copy(
selection = TextRange(textFieldValueState.text.length),
)
}
}
@Composable
fun rememberZashiTextFieldHandle(state: TextFieldState): ZashiTextFieldHandle {
val context = LocalContext.current
return remember { ZashiTextFieldHandle(state.value.getString(context)) }
}
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -174,10 +231,32 @@ private fun TextFieldInternal(
interactionSource: MutableInteractionSource,
shape: Shape,
colors: ZashiTextFieldColors,
contentPadding: PaddingValues,
handle: ZashiTextFieldHandle,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
) {
val borderColor by colors.borderColor(state)
val context = LocalContext.current
val value = remember(state.value) { state.value.getString(context) }
// Holds the latest internal TextFieldValue state. We need to keep it to have the correct value
// of the composition.
val textFieldValueState = handle.textFieldValueState
// Holds the latest TextFieldValue that BasicTextField was recomposed with. We couldn't simply
// pass `TextFieldValue(text = value)` to the CoreTextField because we need to preserve the
// composition.
val textFieldValue = textFieldValueState.copy(text = value, selection = textFieldValueState.selection)
SideEffect {
if (textFieldValue.text != textFieldValueState.text ||
textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
handle.textFieldValueState = textFieldValue
}
}
val isFocused by interactionSource.collectIsFocusedAsState()
val borderColor by colors.borderColor(state, isFocused)
val androidColors = colors.toTextFieldColors()
// If color is not provided via the text style, use content color as a default
val textColor =
@ -186,24 +265,35 @@ private fun TextFieldInternal(
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
var lastTextValue by remember(value) { mutableStateOf(value) }
CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) {
Column(
modifier = modifier,
) {
BasicTextField(
value = state.value.getValue(),
value = textFieldValue,
modifier =
innerModifier.fillMaxWidth() then
innerModifier then
if (borderColor == Color.Unspecified) {
Modifier
} else {
Modifier.border(
width = 1.dp,
color = borderColor,
shape = ZashiTextFieldDefaults.shape
shape = shape
)
} then Modifier.defaultMinSize(minWidth = TextFieldDefaults.MinWidth),
onValueChange = state.onValueChange,
},
onValueChange = { newTextFieldValueState ->
handle.textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
state.onValueChange(newTextFieldValueState.text)
}
},
enabled = state.isEnabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
@ -215,7 +305,7 @@ private fun TextFieldInternal(
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
decorationBox = @Composable { innerTextField ->
) { innerTextField: @Composable () -> Unit ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = state.value.getValue(),
@ -243,16 +333,9 @@ private fun TextFieldInternal(
isError = state.isError,
interactionSource = interactionSource,
colors = androidColors,
contentPadding =
PaddingValues(
start = if (leadingIcon != null) 8.dp else 14.dp,
end = if (suffix != null) 4.dp else 12.dp,
top = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
bottom = getVerticalPadding(trailingIcon, leadingIcon, suffix, prefix),
)
contentPadding = contentPadding
)
}
)
if (state.error != null && state.error.getValue().isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
@ -303,7 +386,9 @@ data class ZashiTextFieldColors(
val textColor: Color,
val hintColor: Color,
val borderColor: Color,
val focusedBorderColor: Color,
val containerColor: Color,
val focusedContainerColor: Color,
val placeholderColor: Color,
val disabledTextColor: Color,
val disabledHintColor: Color,
@ -317,11 +402,15 @@ data class ZashiTextFieldColors(
val errorPlaceholderColor: Color,
) {
@Composable
internal fun borderColor(state: TextFieldState): State<Color> {
internal fun borderColor(
state: TextFieldState,
isFocused: Boolean
): State<Color> {
val targetValue =
when {
!state.isEnabled -> disabledBorderColor
state.isError -> errorBorderColor
isFocused -> focusedBorderColor.takeOrElse { borderColor }
else -> borderColor
}
return rememberUpdatedState(targetValue)
@ -345,7 +434,7 @@ data class ZashiTextFieldColors(
unfocusedTextColor = textColor,
disabledTextColor = disabledTextColor,
errorTextColor = errorTextColor,
focusedContainerColor = containerColor,
focusedContainerColor = focusedContainerColor.takeOrElse { containerColor },
unfocusedContainerColor = containerColor,
disabledContainerColor = disabledContainerColor,
errorContainerColor = errorContainerColor,
@ -391,13 +480,21 @@ object ZashiTextFieldDefaults {
val shape: Shape
get() = RoundedCornerShape(8.dp)
val innerModifier: Modifier
get() =
Modifier
.defaultMinSize(minWidth = TextFieldDefaults.MinWidth)
.fillMaxWidth()
@Suppress("LongParameterList")
@Composable
fun defaultColors(
textColor: Color = ZashiColors.Inputs.Filled.text,
hintColor: Color = ZashiColors.Inputs.Default.hint,
borderColor: Color = Color.Unspecified,
focusedBorderColor: Color = ZashiColors.Inputs.Focused.stroke,
containerColor: Color = ZashiColors.Inputs.Default.bg,
focusedContainerColor: Color = ZashiColors.Inputs.Focused.bg,
placeholderColor: Color = ZashiColors.Inputs.Default.text,
disabledTextColor: Color = ZashiColors.Inputs.Disabled.text,
disabledHintColor: Color = ZashiColors.Inputs.Disabled.hint,
@ -413,7 +510,9 @@ object ZashiTextFieldDefaults {
textColor = textColor,
hintColor = hintColor,
borderColor = borderColor,
focusedBorderColor = focusedBorderColor,
containerColor = containerColor,
focusedContainerColor = focusedContainerColor,
placeholderColor = placeholderColor,
disabledTextColor = disabledTextColor,
disabledHintColor = disabledHintColor,
@ -428,6 +527,16 @@ object ZashiTextFieldDefaults {
)
}
@Immutable
data class TextFieldState(
val value: StringResource,
val error: StringResource? = null,
val isEnabled: Boolean = true,
val onValueChange: (String) -> Unit,
) {
val isError = error != null
}
@PreviewScreens
@Composable
private fun DefaultPreview() =

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

View File

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

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.combine

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -23,22 +22,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavHostController
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.compose.BindCompLocalProvider
import co.electriccoin.zcash.ui.common.extension.setContentCompat
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
import co.electriccoin.zcash.ui.design.component.Override
@ -48,11 +39,7 @@ import co.electriccoin.zcash.ui.screen.authentication.RETRY_TRIGGER_DELAY
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
import co.electriccoin.zcash.ui.screen.authentication.view.AnimationConstants
import co.electriccoin.zcash.ui.screen.authentication.view.WelcomeAnimationAutostart
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
import co.electriccoin.zcash.ui.screen.onboarding.persistExistingWalletWithSeedPhrase
import co.electriccoin.zcash.ui.screen.securitywarning.WrapSecurityWarning
import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs
import co.electriccoin.zcash.ui.screen.seed.WrapSeed
import co.electriccoin.zcash.ui.screen.onboarding.OnboardingNavigation
import co.electriccoin.zcash.ui.screen.warning.viewmodel.StorageCheckViewModel
import co.electriccoin.zcash.work.WorkIds
import kotlinx.coroutines.delay
@ -131,8 +118,7 @@ class MainActivity : FragmentActivity() {
}
}
// Note this condition needs to be kept in sync with the condition in MainContent()
oldHomeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value
SecretState.LOADING == walletViewModel.secretState.value
}
}
@ -235,58 +221,20 @@ class MainActivity : FragmentActivity() {
@Composable
private fun MainContent() {
val configuration = oldHomeViewModel.configurationFlow.collectAsStateWithLifecycle().value
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
val secretState by walletViewModel.secretState.collectAsStateWithLifecycle()
// Note this condition needs to be kept in sync with the condition in setupSplashScreen()
if (null == configuration || secretState == SecretState.Loading) {
// For now, keep displaying splash screen using condition above.
// In the future, we might consider displaying something different here.
} else {
// Note that the deeply nested child views will probably receive arguments derived from
// the configuration. The CompositionLocalProvider is helpful for passing the configuration
// to the "platform" layer, which is where the arguments will be derived from.
CompositionLocalProvider(RemoteConfig provides configuration) {
when (secretState) {
SecretState.None -> {
WrapOnboarding()
SecretState.NONE -> {
OnboardingNavigation()
}
is SecretState.NeedsWarning -> {
WrapSecurityWarning(
onBack = { walletViewModel.persistOnboardingState(OnboardingState.NONE) },
onConfirm = {
walletViewModel.persistOnboardingState(OnboardingState.NEEDS_BACKUP)
if (FirebaseTestLabUtil.isFirebaseTestLab(applicationContext)) {
persistExistingWalletWithSeedPhrase(
applicationContext,
walletViewModel,
SeedPhrase.new(WalletFixture.Alice.seedPhrase),
WalletFixture.Alice.getBirthday(ZcashNetwork.fromResources(applicationContext))
)
} else {
walletViewModel.persistNewWalletAndRestoringState(WalletRestoringState.INITIATING)
}
}
)
}
is SecretState.NeedsBackup -> {
WrapSeed(
args = SeedNavigationArgs.NEW_WALLET,
goBackOverride = null
)
}
is SecretState.Ready -> {
SecretState.READY -> {
Navigation()
}
else -> {
error("Unhandled secret state: $secretState")
}
}
SecretState.LOADING -> {
// For now, keep displaying splash screen using condition above.
// In the future, we might consider displaying something different here.
}
}
}
@ -294,7 +242,7 @@ class MainActivity : FragmentActivity() {
private fun monitorForBackgroundSync() {
val isEnableBackgroundSyncFlow =
run {
val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready }
val isSecretReadyFlow = walletViewModel.secretState.map { it == SecretState.READY }
val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull()
isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled ->

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

View File

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

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

View File

@ -49,7 +49,7 @@ class TransactionHistoryMapper {
false
} else {
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),
initialValue = null
)
override val isCoinbaseAvailable: StateFlow<Boolean?> =
flow {
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.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.common.wallet.RefreshLock
import co.electriccoin.zcash.ui.common.wallet.StaleLock
@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
interface ExchangeRateRepository {
val isExchangeRateUsdOptedIn: StateFlow<Boolean?>
@ -48,7 +49,7 @@ interface ExchangeRateRepository {
}
class ExchangeRateRepositoryImpl(
private val walletRepository: WalletRepository,
private val synchronizerProvider: SynchronizerProvider,
private val standardPreferenceProvider: StandardPreferenceProvider,
private val navigationRouter: NavigationRouter,
) : ExchangeRateRepository {
@ -61,7 +62,8 @@ class ExchangeRateRepositoryImpl(
private val exchangeRateUsdInternal =
isExchangeRateUsdOptedIn.flatMapLatest { optedIn ->
if (optedIn == true) {
walletRepository.synchronizer
synchronizerProvider
.synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
synchronizer.exchangeRateUsd
@ -105,6 +107,35 @@ class ExchangeRateRepositoryImpl(
staleExchangeRateUsdLock.state,
refreshExchangeRateUsdLock.state,
) { isOptedIn, exchangeRate, isStale, isRefreshEnabled ->
createState(isOptedIn, exchangeRate, isStale, isRefreshEnabled)
}.distinctUntilChanged()
.onEach {
Twig.info { "[USD] $it" }
send(it)
}
.launchIn(this)
awaitClose {
// do nothing
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, 5.seconds),
initialValue =
createState(
isOptedIn = isExchangeRateUsdOptedIn.value,
exchangeRate = exchangeRateUsdInternal.value,
isStale = false,
isRefreshEnabled = false
)
)
private fun createState(
isOptedIn: Boolean?,
exchangeRate: ObserveFiatCurrencyResult,
isStale: Boolean,
isRefreshEnabled: Boolean
): ExchangeRateState {
lastExchangeRateUsdValue =
when (isOptedIn) {
true ->
@ -144,22 +175,8 @@ class ExchangeRateRepositoryImpl(
)
}
lastExchangeRateUsdValue
}.distinctUntilChanged()
.onEach {
Twig.info { "[USD] $it" }
send(it)
return lastExchangeRateUsdValue
}
.launchIn(this)
awaitClose {
// do nothing
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(),
initialValue = ExchangeRateState.OptedOut
)
override fun refreshExchangeRateUsd() {
refreshExchangeRateUsdInternal()
@ -167,7 +184,7 @@ class ExchangeRateRepositoryImpl(
private fun refreshExchangeRateUsdInternal() =
scope.launch {
val synchronizer = walletRepository.synchronizer.filterNotNull().first()
val synchronizer = synchronizerProvider.getSynchronizer()
val value = state.value
if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) {
synchronizer.refreshExchangeRateUsd()

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
class GetPersistableWalletUseCase(
private val walletRepository: WalletRepository
private val persistableWalletProvider: PersistableWalletProvider
) {
suspend operator fun invoke() = walletRepository.getPersistableWallet()
suspend operator fun invoke() = persistableWalletProvider.getPersistableWallet()
}

View File

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

View File

@ -1,13 +1,12 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
class GetTransactionMetadataUseCase(
private val metadataRepository: MetadataRepository,
) {
suspend operator fun invoke(txId: String) = observe(txId).filterNotNull().first()
suspend operator fun invoke(txId: String) = observe(txId).first()
fun observe(txId: String) = metadataRepository.observeTransactionMetadataByTxId(txId)
}

View File

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

View File

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

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

View File

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

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.reviewtransaction.ReviewTransaction
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.Scan.ADDRESS_BOOK
import co.electriccoin.zcash.ui.screen.scan.Scan.HOMEPAGE
import co.electriccoin.zcash.ui.screen.scan.Scan.SEND
import co.electriccoin.zcash.ui.screen.scan.ScanFlow.ADDRESS_BOOK
import co.electriccoin.zcash.ui.screen.scan.ScanFlow.HOMEPAGE
import co.electriccoin.zcash.ui.screen.scan.ScanFlow.SEND
import co.electriccoin.zcash.ui.screen.send.Send
class OnZip321ScannedUseCase(
@ -29,20 +29,27 @@ class OnZip321ScannedUseCase(
) {
suspend operator fun invoke(
zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan
scanArgs: Scan
) {
if (scanFlow == ADDRESS_BOOK) {
navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value))
when (scanArgs.flow) {
ADDRESS_BOOK -> addressBookFlow(zip321)
SEND ->
if (scanArgs.isScanZip321Enabled) {
sendFlow(zip321)
} else {
createProposal(zip321, scanFlow)
sendFlowWithDisabledZip321(zip321)
}
HOMEPAGE -> homepageFlow(zip321)
}
}
private fun addressBookFlow(zip321: Zip321ParseUriValidation.Valid) {
navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value))
}
@Suppress("TooGenericExceptionCaught")
private suspend fun createProposal(
zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan
) {
private suspend fun homepageFlow(zip321: Zip321ParseUriValidation.Valid) {
try {
val proposal =
when (accountDataSource.getSelectedAccount()) {
@ -57,7 +64,6 @@ class OnZip321ScannedUseCase(
}
}
if (scanFlow == HOMEPAGE) {
navigationRouter
.replace(
Send(
@ -72,7 +78,28 @@ class OnZip321ScannedUseCase(
),
ReviewTransaction
)
} else if (scanFlow == SEND) {
} catch (e: Exception) {
keystoneProposalRepository.clear()
throw e
}
}
@Suppress("TooGenericExceptionCaught")
private suspend fun sendFlow(zip321: Zip321ParseUriValidation.Valid) {
try {
val proposal =
when (accountDataSource.getSelectedAccount()) {
is KeystoneAccount -> {
val result = keystoneProposalRepository.createZip321Proposal(zip321.zip321Uri)
keystoneProposalRepository.createPCZTFromProposal()
result
}
is ZashiAccount -> {
zashiProposalRepository.createZip321Proposal(zip321.zip321Uri)
}
}
prefillSend.request(
PrefillSendData.All(
amount = proposal.amount,
@ -82,10 +109,18 @@ class OnZip321ScannedUseCase(
)
)
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 navigationRouter: NavigationRouter
) {
suspend operator fun invoke(
account: WalletAccount,
hideBottomSheet: suspend () -> Unit
) {
suspend operator fun invoke(account: WalletAccount) {
accountDataSource.selectAccount(account)
hideBottomSheet()
navigationRouter.back()
}
}

View File

@ -9,6 +9,10 @@ class SendTransactionAgainUseCase(
) {
operator fun invoke(value: DetailedTransactionData) {
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 {
(!required || versionInfo.isRunningUnderTestService) -> AuthenticationUIState.NotRequired
(state == AuthenticationUIState.Initial) -> {
if (secretState == SecretState.None ||
secretState == SecretState.NeedsWarning
) {
if (secretState == SecretState.NONE) {
appAccessAuthentication.value = AuthenticationUIState.NotRequired
AuthenticationUIState.NotRequired
} else {
@ -146,12 +144,6 @@ class AuthenticationViewModel(
val isDeleteWalletAuthenticationRequired: StateFlow<Boolean?> =
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
*/
@ -319,9 +311,6 @@ class AuthenticationViewModel(
AuthenticationUseCase.ExportPrivateData ->
R.string.authentication_use_case_export_data
AuthenticationUseCase.SeedRecovery ->
R.string.authentication_use_case_seed_recovery
AuthenticationUseCase.SendFunds ->
R.string.authentication_use_case_send_funds
}

View File

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

View File

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

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

View File

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

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

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

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

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.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.layout.Arrangement
@ -40,8 +40,6 @@ import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsTag
import co.electriccoin.zcash.ui.screen.advancedsettings.model.AdvancedSettingsState
import kotlinx.collections.immutable.persistentListOf
// TODO [#1271]: Add AdvancedSettingsView Tests

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

View File

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

View File

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

View File

@ -2,8 +2,10 @@ package co.electriccoin.zcash.ui.screen.balances
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.flow.SharingStarted
@ -22,8 +24,23 @@ class BalanceViewModel(
accountDataSource.selectedAccount.filterNotNull(),
exchangeRateRepository.state,
) { account, exchangeRateUsd ->
when {
createState(account, exchangeRateUsd)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
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)
@ -37,15 +54,11 @@ class BalanceViewModel(
else -> {
BalanceState.Available(
totalBalance = account.totalBalance,
spendableBalance = account.spendableBalance,
totalBalance = account?.totalBalance ?: Zatoshi(0),
spendableBalance = account?.spendableBalance ?: Zatoshi(0),
exchangeRate = exchangeRateUsd,
)
}
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None(ExchangeRateState.OptedOut)
)
}
}

View File

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

View File

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

View File

@ -4,28 +4,44 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.DistributionDimension
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.home.messages.WalletBackupMessageState
import co.electriccoin.zcash.ui.screen.integrations.DialogIntegrations
import co.electriccoin.zcash.ui.screen.receive.Receive
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
import co.electriccoin.zcash.ui.screen.seed.backup.SeedBackup
import co.electriccoin.zcash.ui.screen.send.Send
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class HomeViewModel(
getVersionInfoProvider: GetVersionInfoProvider,
getSelectedWalletAccountUseCase: GetSelectedWalletAccountUseCase,
private val navigationRouter: NavigationRouter,
private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase
private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase,
private val navigateToCoinbase: NavigateToCoinbaseUseCase
) : ViewModel() {
private val isMessageVisible = MutableStateFlow(true)
private val isRestoreDialogVisible: Flow<Boolean?> =
isRestoreSuccessDialogVisible.observe()
.stateIn(
@ -48,36 +64,72 @@ class HomeViewModel(
)
val state: StateFlow<HomeState?> =
MutableStateFlow(
HomeState(
receiveButton =
combine(getSelectedWalletAccountUseCase.observe(), isMessageVisible) { selectedAccount, isMessageVisible ->
createState(getVersionInfoProvider, selectedAccount, isMessageVisible)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
private fun createState(
getVersionInfoProvider: GetVersionInfoProvider,
selectedAccount: WalletAccount?,
isMessageVisible: Boolean
) = HomeState(
firstButton =
BigIconButtonState(
text = stringRes("Receive"),
text = stringRes(R.string.home_button_receive),
icon = R.drawable.ic_home_receive,
onClick = ::onReceiveButtonClick,
),
sendButton =
secondButton =
BigIconButtonState(
text = stringRes("Send"),
text = stringRes(R.string.home_button_send),
icon = R.drawable.ic_home_send,
onClick = ::onSendButtonClick,
),
scanButton =
thirdButton =
BigIconButtonState(
text = stringRes("Scan"),
text = stringRes(R.string.home_button_scan),
icon = R.drawable.ic_home_scan,
onClick = ::onScanButtonClick,
),
moreButton =
fourthButton =
when {
getVersionInfoProvider().distributionDimension == DistributionDimension.FOSS ->
BigIconButtonState(
text = stringRes("More"),
text = stringRes(R.string.home_button_request),
icon = R.drawable.ic_home_request,
onClick = ::onRequestClick,
)
selectedAccount is KeystoneAccount ->
BigIconButtonState(
text = stringRes(R.string.home_button_buy),
icon = R.drawable.ic_home_buy,
onClick = ::onBuyClick,
)
else ->
BigIconButtonState(
text = stringRes(R.string.home_button_more),
icon = R.drawable.ic_home_more,
onClick = ::onMoreButtonClick,
),
)
).asStateFlow()
},
message = createWalletBackupMessageState().takeIf { isMessageVisible }
)
fun onRestoreDialogSeenClick() =
private fun createWalletBackupMessageState(): WalletBackupMessageState {
return WalletBackupMessageState(
onClick = {
navigationRouter.forward(SeedBackup)
}
)
}
private fun onRestoreDialogSeenClick() =
viewModelScope.launch {
isRestoreSuccessDialogVisible.setSeen()
}
@ -95,6 +147,15 @@ class HomeViewModel(
}
private fun onScanButtonClick() {
navigationRouter.forward(Scan(Scan.HOMEPAGE))
navigationRouter.forward(Scan(ScanFlow.HOMEPAGE))
}
private fun onBuyClick() =
viewModelScope.launch {
navigateToCoinbase(replaceCurrentScreen = false)
}
private fun onRequestClick() {
navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}")
}
}

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

View File

@ -5,8 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.integrations.view.Integrations
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf

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

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.util.StringResource
import kotlinx.collections.immutable.ImmutableList
data class IntegrationsState(
val disabledInfo: StringResource?,
val onBack: () -> Unit,
override val onBack: () -> Unit,
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.layout.Column
@ -44,7 +44,6 @@ import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import co.electriccoin.zcash.ui.screen.settings.SettingsTag
import kotlinx.collections.immutable.persistentListOf
@ -198,7 +197,6 @@ private fun IntegrationSettings() =
onClick = {}
),
),
onBottomSheetHidden = {}
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

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

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