Feature/address book (#1614)

* [#1564] Send screen redesign (#1601)

* [#1564] Send screen redesign

Closes #1564
Closes #1580

* [#1564] Test hotfix

Closes #1564
Closes #1580

* [#1564] Test hotfix

* [#1564] Bugfixes and code cleanup

* [#1564] Focus handling

* Address Book UI (#1606)

* Address Book UI

* Design hotfix

* Code cleanup

* Test hotfix

* Confirmation screen redesign (#1602)

* Confirmation screen redesign

* Documentation update

* Design hotfixes

* History item redesign (#1603)

* History item redesign

* Empty Memo message removed

* Hidden fee for a receiving transaction

* Address Book, Add Contact & Update Contact logic (#1610)

* Address Book Screen logic

* Add New Contact screen logic

* Update Contact screen logic

* Code cleanup

* Code cleanup
This commit is contained in:
Milan 2024-10-03 17:40:19 +02:00 committed by GitHub
parent 519b48b524
commit 1fedce1cff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 2595 additions and 659 deletions

View File

@ -8,6 +8,9 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
### Changed ### Changed
- The Receive screen UI has been redesigned - The Receive screen UI has been redesigned
- Send screen redesigned
- Confirmation screen redesigned
- History item redesigned
## [1.2 (739)] - 2024-09-27 ## [1.2 (739)] - 2024-09-27

View File

@ -11,6 +11,9 @@ directly impact users rather than highlighting other key architectural updates.*
### Changed ### Changed
- The Receive screen UI has been redesigned - The Receive screen UI has been redesigned
- Send screen redesigned
- Confirmation screen redesigned
- History item redesigned
## [1.2 (739)] - 2024-09-27 ## [1.2 (739)] - 2024-09-27

View File

@ -31,6 +31,19 @@ style:
excludes: [ '**/*.kts' ] excludes: [ '**/*.kts' ]
ignoreAnnotated: ignoreAnnotated:
- 'Preview' - 'Preview'
- 'PreviewScreens'
complexity:
LongMethod:
active: true
ignoreAnnotated:
- 'Preview'
- 'PreviewScreens'
LongParameterList:
active: true
ignoreAnnotated:
- 'Preview'
- 'PreviewScreens'
Compose: Compose:
ModifierMissing: ModifierMissing:

View File

@ -218,6 +218,7 @@ fun TextWithIcon(
textAlign: TextAlign = TextAlign.Start, textAlign: TextAlign = TextAlign.Start,
style: TextStyle = LocalTextStyle.current, style: TextStyle = LocalTextStyle.current,
color: Color = ZcashTheme.colors.textPrimary, color: Color = ZcashTheme.colors.textPrimary,
fontWeight: FontWeight? = null,
) { ) {
Row( Row(
modifier = modifier =
@ -248,6 +249,7 @@ fun TextWithIcon(
overflow = overflow, overflow = overflow,
textAlign = textAlign, textAlign = textAlign,
style = style, style = style,
fontWeight = fontWeight
) )
} }
} }

View File

@ -137,4 +137,6 @@ data class TextFieldState(
val error: StringResource? = null, val error: StringResource? = null,
val isEnabled: Boolean = true, val isEnabled: Boolean = true,
val onValueChange: (String) -> Unit, val onValueChange: (String) -> Unit,
) ) {
val isError = error != null
}

View File

@ -0,0 +1,37 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.graphics.Color
@Composable
internal fun TextFieldColors.textColor(
enabled: Boolean,
isError: Boolean,
interactionSource: InteractionSource
): State<Color> {
val focused by interactionSource.collectIsFocusedAsState()
val targetValue =
when {
!enabled -> disabledTextColor
isError -> errorTextColor
focused -> focusedTextColor
else -> unfocusedTextColor
}
return rememberUpdatedState(targetValue)
}
internal val TextFieldColors.selectionColors: TextSelectionColors
@Composable get() = textSelectionColors
@Composable
internal fun TextFieldColors.cursorColor(isError: Boolean): State<Color> {
return rememberUpdatedState(if (isError) errorCursorColor else cursorColor)
}

View File

@ -38,7 +38,6 @@ fun ZashiBottomBar(
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun BottomBarPreview() = private fun BottomBarPreview() =

View File

@ -4,8 +4,12 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -26,100 +30,192 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun ZashiSettingsListItem(
state: ButtonState,
@DrawableRes icon: Int,
trailing: @Composable () -> Unit = {
Image(
painter = painterResource(R.drawable.ic_chevron_right orDark R.drawable.ic_chevron_right_dark),
contentDescription = state.text.getValue(),
)
}
) {
ZashiSettingsListItem(
text = state.text.getValue(),
icon = icon,
trailing = trailing,
onClick = state.onClick
)
}
@Composable @Composable
fun ZashiSettingsListItem( fun ZashiSettingsListItem(
text: String, text: String,
@DrawableRes icon: Int, @DrawableRes icon: Int,
trailing: @Composable () -> Unit = { subtitle: String? = null,
Image( isEnabled: Boolean = true,
painter = painterResource(R.drawable.ic_chevron_right orDark R.drawable.ic_chevron_right_dark),
contentDescription = text,
)
},
onClick: () -> Unit onClick: () -> Unit
) { ) {
ZashiSettingsListItem( ZashiSettingsListItem(
leading = { state =
ZashiSettingsListItemState(
text = stringRes(text),
subtitle = subtitle?.let { stringRes(it) },
isEnabled = isEnabled,
onClick = onClick
),
icon = icon,
)
}
@Composable
fun ZashiSettingsListItem(
state: ZashiSettingsListItemState,
@DrawableRes icon: Int
) {
ZashiSettingsListItem(
leading = { modifier ->
ZashiSettingsListLeadingItem(
modifier = modifier,
icon = icon,
contentDescription = state.text.getValue()
)
},
content = { modifier ->
ZashiSettingsListContentItem(
modifier = modifier,
text = state.text.getValue(),
subtitle = state.subtitle?.getValue()
)
},
trailing = { modifier ->
ZashiSettingsListTrailingItem(
modifier = modifier,
isEnabled = state.isEnabled,
contentDescription = state.text.getValue()
)
},
onClick = state.onClick.takeIf { state.isEnabled }
)
}
@Composable
fun ZashiSettingsListLeadingItem(
icon: Int,
contentDescription: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Image( Image(
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
painter = painterResource(icon), painter = painterResource(icon),
contentDescription = text contentDescription = contentDescription,
) )
}, }
content = { }
@Composable
fun ZashiSettingsListTrailingItem(
isEnabled: Boolean,
contentDescription: String,
modifier: Modifier = Modifier
) {
if (isEnabled) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.ic_chevron_right orDark R.drawable.ic_chevron_right_dark),
contentDescription = contentDescription,
)
}
}
}
@Composable
fun ZashiSettingsListContentItem(
text: String,
subtitle: String?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text( Text(
text = text, text = text,
style = ZashiTypography.textMd, style = ZashiTypography.textMd,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary color = ZashiColors.Text.textPrimary
) )
}, subtitle?.let {
trailing = trailing, Spacer(modifier = Modifier.height(2.dp))
onClick = onClick Text(
text = it,
style = ZashiTypography.textXs,
color = ZashiColors.Text.textTertiary
) )
} }
}
}
@Composable @Composable
fun ZashiSettingsListItem( fun ZashiSettingsListItem(
leading: @Composable () -> Unit, leading: @Composable (Modifier) -> Unit,
content: @Composable () -> Unit, content: @Composable (Modifier) -> Unit,
trailing: @Composable () -> Unit, trailing: @Composable (Modifier) -> Unit,
onClick: () -> Unit contentPadding: PaddingValues = PaddingValues(vertical = 12.dp),
onClick: (() -> Unit)?
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp)) then
.clickable( if (onClick != null) {
Modifier.clickable(
indication = rememberRipple(), indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = onClick, onClick = onClick,
role = Role.Button, role = Role.Button,
) )
.padding(vertical = 12.dp), } else {
Modifier
} then Modifier.padding(contentPadding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.width(20.dp)) Spacer(modifier = Modifier.width(20.dp))
leading() leading(Modifier)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
content() content(Modifier.weight(1f))
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(16.dp))
trailing() trailing(Modifier)
Spacer(modifier = Modifier.width(20.dp)) Spacer(modifier = Modifier.width(20.dp))
} }
} }
data class ZashiSettingsListItemState(
val text: StringResource,
val subtitle: StringResource? = null,
val isEnabled: Boolean = true,
val onClick: () -> Unit = {},
)
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun EnabledPreview() =
ZcashTheme {
BlankSurface {
ZashiSettingsListItem(
text = "Test",
subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked,
onClick = {}
)
}
}
@Suppress("UnusedPrivateMember") @Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun ZashiSettingsListItemPreview() = private fun DisabledPreview() =
ZcashTheme { ZcashTheme {
BlankSurface { BlankSurface {
ZashiSettingsListItem( ZashiSettingsListItem(
text = "Test", text = "Test",
subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked, icon = R.drawable.ic_radio_button_checked,
isEnabled = false,
onClick = {} onClick = {}
) )
} }

View File

@ -1,19 +1,20 @@
package co.electriccoin.zcash.ui.design.component package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldColors import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -38,11 +39,71 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
@Suppress("LongParameterList")
@Composable
fun ZashiTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
error: String? = null,
isEnabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = ZashiTextFieldDefaults.shape,
colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) {
ZashiTextField(
state =
TextFieldState(
value = stringRes(value),
error = error?.let { stringRes(it) },
isEnabled = isEnabled,
onValueChange = onValueChange,
),
modifier = modifier,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
interactionSource = interactionSource,
shape = shape,
colors = colors,
innerModifier = innerModifier
)
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
@Composable @Composable
fun ZashiTextField( fun ZashiTextField(
state: TextFieldState, state: TextFieldState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
readOnly: Boolean = false, readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium), textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
@ -63,16 +124,8 @@ fun ZashiTextField(
colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors() colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) { ) {
TextFieldInternal( TextFieldInternal(
value = state.value.getValue(), state = state,
onValueChange = state.onValueChange, modifier = modifier,
modifier =
modifier then
Modifier.border(
width = 1.dp,
color = colors.borderColor,
shape = ZashiTextFieldDefaults.shape
),
enabled = state.isEnabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = textStyle, textStyle = textStyle,
label = label, label = label,
@ -82,7 +135,6 @@ fun ZashiTextField(
prefix = prefix, prefix = prefix,
suffix = suffix, suffix = suffix,
supportingText = supportingText, supportingText = supportingText,
isError = state.error != null,
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
@ -91,17 +143,16 @@ fun ZashiTextField(
minLines = minLines, minLines = minLines,
interactionSource = interactionSource, interactionSource = interactionSource,
shape = shape, shape = shape,
colors = colors.toTextFieldColors(), colors = colors,
innerModifier = innerModifier
) )
} }
@Suppress("LongParameterList") @Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TextFieldInternal( private fun TextFieldInternal(
value: String, state: TextFieldState,
onValueChange: (String) -> Unit,
enabled: Boolean,
readOnly: Boolean, readOnly: Boolean,
textStyle: TextStyle, textStyle: TextStyle,
label: @Composable (() -> Unit)?, label: @Composable (() -> Unit)?,
@ -111,7 +162,6 @@ private fun TextFieldInternal(
prefix: @Composable (() -> Unit)?, prefix: @Composable (() -> Unit)?,
suffix: @Composable (() -> Unit)?, suffix: @Composable (() -> Unit)?,
supportingText: @Composable (() -> Unit)?, supportingText: @Composable (() -> Unit)?,
isError: Boolean,
visualTransformation: VisualTransformation, visualTransformation: VisualTransformation,
keyboardOptions: KeyboardOptions, keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions, keyboardActions: KeyboardActions,
@ -120,27 +170,41 @@ private fun TextFieldInternal(
minLines: Int, minLines: Int,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
shape: Shape, shape: Shape,
colors: TextFieldColors, colors: ZashiTextFieldColors,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
) { ) {
val borderColor by colors.borderColor(state)
val androidColors = colors.toTextFieldColors()
// If color is not provided via the text style, use content color as a default // If color is not provided via the text style, use content color as a default
val textColor = val textColor =
textStyle.color.takeOrElse { textStyle.color.takeOrElse {
colors.textColor(enabled, isError, interactionSource).value androidColors.textColor(state.isEnabled, state.isError, interactionSource).value
} }
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.selectionColors) { CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) {
Column(
modifier = modifier,
) {
BasicTextField( BasicTextField(
value = value, value = state.value.getValue(),
modifier = modifier =
modifier innerModifier.fillMaxWidth() then
.defaultMinSize(minWidth = TextFieldDefaults.MinWidth), if (borderColor == Color.Unspecified) {
onValueChange = onValueChange, Modifier
enabled = enabled, } else {
Modifier.border(
width = 1.dp,
color = borderColor,
shape = ZashiTextFieldDefaults.shape
)
} then Modifier.defaultMinSize(minWidth = TextFieldDefaults.MinWidth),
onValueChange = state.onValueChange,
enabled = state.isEnabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = mergedTextStyle, textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor(isError).value), cursorBrush = SolidColor(androidColors.cursorColor(state.isError).value),
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
@ -151,7 +215,7 @@ private fun TextFieldInternal(
decorationBox = @Composable { innerTextField -> decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon // places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox( TextFieldDefaults.DecorationBox(
value = value, value = state.value.getValue(),
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
innerTextField = innerTextField, innerTextField = innerTextField,
placeholder = placeholder, placeholder = placeholder,
@ -163,41 +227,31 @@ private fun TextFieldInternal(
supportingText = supportingText, supportingText = supportingText,
shape = shape, shape = shape,
singleLine = singleLine, singleLine = singleLine,
enabled = enabled, enabled = state.isEnabled,
isError = isError, isError = state.isError,
interactionSource = interactionSource, interactionSource = interactionSource,
colors = colors, colors = androidColors,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) contentPadding =
PaddingValues(
start = if (leadingIcon != null) 8.dp else 12.dp,
end = 12.dp,
top = if (trailingIcon != null || leadingIcon != null) 12.dp else 8.dp,
bottom = if (trailingIcon != null || leadingIcon != null) 12.dp else 8.dp,
)
) )
} }
) )
if (state.error != null && state.error.getValue().isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = state.error.getValue(),
style = ZashiTypography.textSm,
color = colors.hintColor(state).value
)
} }
} }
@Composable
private fun TextFieldColors.textColor(
enabled: Boolean,
isError: Boolean,
interactionSource: InteractionSource
): State<Color> {
val focused by interactionSource.collectIsFocusedAsState()
val targetValue =
when {
!enabled -> disabledTextColor
isError -> errorTextColor
focused -> focusedTextColor
else -> unfocusedTextColor
} }
return rememberUpdatedState(targetValue)
}
private val TextFieldColors.selectionColors: TextSelectionColors
@Composable get() = textSelectionColors
@Composable
private fun TextFieldColors.cursorColor(isError: Boolean): State<Color> {
return rememberUpdatedState(if (isError) errorCursorColor else cursorColor)
} }
@Immutable @Immutable
@ -206,26 +260,58 @@ data class ZashiTextFieldColors(
val hintColor: Color, val hintColor: Color,
val borderColor: Color, val borderColor: Color,
val containerColor: Color, val containerColor: Color,
) val placeholderColor: Color,
val disabledTextColor: Color,
val disabledHintColor: Color,
val disabledBorderColor: Color,
val disabledContainerColor: Color,
val disabledPlaceholderColor: Color,
val errorTextColor: Color,
val errorHintColor: Color,
val errorBorderColor: Color,
val errorContainerColor: Color,
val errorPlaceholderColor: Color,
) {
@Composable
internal fun borderColor(state: TextFieldState): State<Color> {
val targetValue =
when {
!state.isEnabled -> disabledBorderColor
state.isError -> errorBorderColor
else -> borderColor
}
return rememberUpdatedState(targetValue)
}
@Composable @Composable
private fun ZashiTextFieldColors.toTextFieldColors() = internal fun hintColor(state: TextFieldState): State<Color> {
val targetValue =
when {
!state.isEnabled -> disabledHintColor
state.isError -> errorHintColor
else -> hintColor
}
return rememberUpdatedState(targetValue)
}
@Composable
internal fun toTextFieldColors() =
TextFieldDefaults.colors( TextFieldDefaults.colors(
focusedTextColor = textColor, focusedTextColor = textColor,
unfocusedTextColor = textColor, unfocusedTextColor = textColor,
disabledTextColor = textColor, disabledTextColor = disabledTextColor,
errorTextColor = Color.Unspecified, errorTextColor = errorTextColor,
focusedContainerColor = containerColor, focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor, unfocusedContainerColor = containerColor,
disabledContainerColor = containerColor, disabledContainerColor = disabledContainerColor,
errorContainerColor = Color.Unspecified, errorContainerColor = errorContainerColor,
cursorColor = Color.Unspecified, cursorColor = Color.Unspecified,
errorCursorColor = Color.Unspecified, errorCursorColor = Color.Unspecified,
selectionColors = null, selectionColors = null,
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Unspecified, errorIndicatorColor = Color.Transparent,
focusedLeadingIconColor = Color.Unspecified, focusedLeadingIconColor = Color.Unspecified,
unfocusedLeadingIconColor = Color.Unspecified, unfocusedLeadingIconColor = Color.Unspecified,
disabledLeadingIconColor = Color.Unspecified, disabledLeadingIconColor = Color.Unspecified,
@ -238,14 +324,14 @@ private fun ZashiTextFieldColors.toTextFieldColors() =
unfocusedLabelColor = Color.Unspecified, unfocusedLabelColor = Color.Unspecified,
disabledLabelColor = Color.Unspecified, disabledLabelColor = Color.Unspecified,
errorLabelColor = Color.Unspecified, errorLabelColor = Color.Unspecified,
focusedPlaceholderColor = hintColor, focusedPlaceholderColor = placeholderColor,
unfocusedPlaceholderColor = hintColor, unfocusedPlaceholderColor = placeholderColor,
disabledPlaceholderColor = hintColor, disabledPlaceholderColor = disabledPlaceholderColor,
errorPlaceholderColor = Color.Unspecified, errorPlaceholderColor = errorPlaceholderColor,
focusedSupportingTextColor = hintColor, focusedSupportingTextColor = hintColor,
unfocusedSupportingTextColor = hintColor, unfocusedSupportingTextColor = hintColor,
disabledSupportingTextColor = hintColor, disabledSupportingTextColor = disabledHintColor,
errorSupportingTextColor = Color.Unspecified, errorSupportingTextColor = errorHintColor,
focusedPrefixColor = Color.Unspecified, focusedPrefixColor = Color.Unspecified,
unfocusedPrefixColor = Color.Unspecified, unfocusedPrefixColor = Color.Unspecified,
disabledPrefixColor = Color.Unspecified, disabledPrefixColor = Color.Unspecified,
@ -255,29 +341,52 @@ private fun ZashiTextFieldColors.toTextFieldColors() =
disabledSuffixColor = Color.Unspecified, disabledSuffixColor = Color.Unspecified,
errorSuffixColor = Color.Unspecified, errorSuffixColor = Color.Unspecified,
) )
}
object ZashiTextFieldDefaults { object ZashiTextFieldDefaults {
val shape: Shape val shape: Shape
get() = RoundedCornerShape(8.dp) get() = RoundedCornerShape(8.dp)
@Suppress("LongParameterList")
@Composable @Composable
fun defaultColors( fun defaultColors(
textColor: Color = ZashiColors.Inputs.Default.text, textColor: Color = ZashiColors.Inputs.Filled.text,
hintColor: Color = ZashiColors.Inputs.Default.hint, hintColor: Color = ZashiColors.Inputs.Default.hint,
borderColor: Color = ZashiColors.Inputs.Default.stroke, borderColor: Color = Color.Unspecified,
containerColor: Color = ZashiColors.Inputs.Default.bg containerColor: Color = ZashiColors.Inputs.Default.bg,
placeholderColor: Color = ZashiColors.Inputs.Default.text,
disabledTextColor: Color = ZashiColors.Inputs.Disabled.text,
disabledHintColor: Color = ZashiColors.Inputs.Disabled.hint,
disabledBorderColor: Color = ZashiColors.Inputs.Disabled.stroke,
disabledContainerColor: Color = ZashiColors.Inputs.Disabled.bg,
disabledPlaceholderColor: Color = ZashiColors.Inputs.Disabled.text,
errorTextColor: Color = ZashiColors.Inputs.ErrorFilled.text,
errorHintColor: Color = ZashiColors.Inputs.ErrorDefault.hint,
errorBorderColor: Color = ZashiColors.Inputs.ErrorDefault.stroke,
errorContainerColor: Color = ZashiColors.Inputs.ErrorDefault.bg,
errorPlaceholderColor: Color = ZashiColors.Inputs.ErrorDefault.text,
) = ZashiTextFieldColors( ) = ZashiTextFieldColors(
textColor = textColor, textColor = textColor,
hintColor = hintColor, hintColor = hintColor,
borderColor = borderColor, borderColor = borderColor,
containerColor = containerColor containerColor = containerColor,
placeholderColor = placeholderColor,
disabledTextColor = disabledTextColor,
disabledHintColor = disabledHintColor,
disabledBorderColor = disabledBorderColor,
disabledContainerColor = disabledContainerColor,
disabledPlaceholderColor = disabledPlaceholderColor,
errorTextColor = errorTextColor,
errorHintColor = errorHintColor,
errorBorderColor = errorBorderColor,
errorContainerColor = errorContainerColor,
errorPlaceholderColor = errorPlaceholderColor,
) )
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun ZashiTextFieldPreview() = private fun DefaultPreview() =
ZcashTheme { ZcashTheme {
ZashiTextField( ZashiTextField(
state = state =
@ -286,3 +395,16 @@ private fun ZashiTextFieldPreview() =
) {} ) {}
) )
} }
@PreviewScreens
@Composable
private fun ErrorPreview() =
ZcashTheme {
ZashiTextField(
state =
TextFieldState(
value = stringRes("Text"),
error = stringRes("Error"),
) {}
)
}

View File

@ -4,9 +4,6 @@ import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import kotlin.annotation.AnnotationRetention.SOURCE import kotlin.annotation.AnnotationRetention.SOURCE
// TODO [#1580]: Suppress compilation warning on PreviewScreens
// https://github.com/Electric-Coin-Company/zashi-android/issues/1580
@Suppress("UnusedPrivateMember")
@Preview(name = "1: Light preview", showBackground = true) @Preview(name = "1: Light preview", showBackground = true)
@Preview(name = "2: Dark preview", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "2: Dark preview", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Retention(SOURCE) @Retention(SOURCE)

View File

@ -32,6 +32,10 @@ android {
setOf( setOf(
"src/main/res/ui/about", "src/main/res/ui/about",
"src/main/res/ui/account", "src/main/res/ui/account",
"src/main/res/ui/address_book",
"src/main/res/ui/contact",
"src/main/res/ui/add_contact",
"src/main/res/ui/update_contact",
"src/main/res/ui/advanced_settings", "src/main/res/ui/advanced_settings",
"src/main/res/ui/authentication", "src/main/res/ui/authentication",
"src/main/res/ui/balances", "src/main/res/ui/balances",

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.screen.send
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@ -49,11 +50,8 @@ internal fun ComposeContentTestRule.setValidAmount() {
} }
internal fun ComposeContentTestRule.setAmount(amount: String) { internal fun ComposeContentTestRule.setAmount(amount: String) {
onNodeWithText( onNode(
getStringResourceWithArgs( hasTestTag(SendTag.SEND_AMOUNT_FIELD)
R.string.send_amount_hint,
ZcashCurrency.fromResources(getAppContext()).name
)
).also { ).also {
it.performTextClearance() it.performTextClearance()
it.performTextInput(amount) it.performTextInput(amount)

View File

@ -26,6 +26,7 @@ class SettingsViewTestSetup(
private val onBackgroundSyncChangedCount = AtomicInteger(0) private val onBackgroundSyncChangedCount = AtomicInteger(0)
private val onKeepScreenOnChangedCount = AtomicInteger(0) private val onKeepScreenOnChangedCount = AtomicInteger(0)
private val onAnalyticsChangedCount = AtomicInteger(0) private val onAnalyticsChangedCount = AtomicInteger(0)
private val onAddressBookCount = AtomicInteger(0)
private val settingsTroubleshootingState = private val settingsTroubleshootingState =
if (isTroubleshootingEnabled) { if (isTroubleshootingEnabled) {
@ -91,6 +92,11 @@ class SettingsViewTestSetup(
return onAnalyticsChangedCount.get() return onAnalyticsChangedCount.get()
} }
fun getAddressBookCount(): Int {
composeTestRule.waitForIdle()
return onAddressBookCount.get()
}
init { init {
composeTestRule.setContent { composeTestRule.setContent {
ZcashTheme { ZcashTheme {
@ -112,6 +118,9 @@ class SettingsViewTestSetup(
onAboutUsClick = { onAboutUsClick = {
onAboutCount.incrementAndGet() onAboutCount.incrementAndGet()
}, },
onAddressBookClick = {
onAddressBookCount.incrementAndGet()
}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )

View File

@ -1,5 +1,7 @@
package co.electriccoin.zcash.di package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepositoryImpl import co.electriccoin.zcash.ui.common.repository.ConfigurationRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.WalletRepository import co.electriccoin.zcash.ui.common.repository.WalletRepository
@ -12,4 +14,5 @@ val repositoryModule =
module { module {
singleOf(::WalletRepositoryImpl) bind WalletRepository::class singleOf(::WalletRepositoryImpl) bind WalletRepository::class
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::class
} }

View File

@ -1,9 +1,12 @@
package co.electriccoin.zcash.di package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.usecase.DeleteContactUseCase
import co.electriccoin.zcash.ui.common.usecase.GetContactUseCase
import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase
@ -11,6 +14,10 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase
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.ValidateEndpointUseCase
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
@ -29,4 +36,11 @@ val useCaseModule =
singleOf(::ObserveConfigurationUseCase) singleOf(::ObserveConfigurationUseCase)
singleOf(::RescanBlockchainUseCase) singleOf(::RescanBlockchainUseCase)
singleOf(::GetTransparentAddressUseCase) singleOf(::GetTransparentAddressUseCase)
singleOf(::ObserveAddressBookContactsUseCase)
singleOf(::ValidateContactAddressUseCase)
singleOf(::ValidateContactNameUseCase)
singleOf(::SaveContactUseCase)
singleOf(::UpdateContactUseCase)
singleOf(::DeleteContactUseCase)
singleOf(::GetContactUseCase)
} }

View File

@ -5,8 +5,11 @@ import co.electriccoin.zcash.ui.common.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.account.viewmodel.TransactionHistoryViewModel import co.electriccoin.zcash.ui.screen.account.viewmodel.TransactionHistoryViewModel
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel
import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettingsViewModel import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettingsViewModel
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
@ -39,4 +42,7 @@ val viewModelModule =
viewModelOf(::WhatsNewViewModel) viewModelOf(::WhatsNewViewModel)
viewModelOf(::UpdateViewModel) viewModelOf(::UpdateViewModel)
viewModelOf(::ChooseServerViewModel) viewModelOf(::ChooseServerViewModel)
viewModelOf(::AddressBookViewModel)
viewModelOf(::AddContactViewModel)
viewModelOf(::UpdateContactViewModel)
} }

View File

@ -12,11 +12,14 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationArgs.UPDATE_CONTACT_ID
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE
@ -25,6 +28,8 @@ import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADDRESS_BOOK
import co.electriccoin.zcash.ui.NavigationTargets.ADD_NEW_CONTACT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET
@ -38,6 +43,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.SEND_CONFIRMATION
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.UPDATE_CONTACT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.model.SerializableAddress import co.electriccoin.zcash.ui.common.model.SerializableAddress
@ -48,10 +54,13 @@ import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransition import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
import co.electriccoin.zcash.ui.screen.about.WrapAbout import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.addressbook.WrapAddressBook
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
import co.electriccoin.zcash.ui.screen.contact.WrapAddContact
import co.electriccoin.zcash.ui.screen.contact.WrapUpdateContact
import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet import co.electriccoin.zcash.ui.screen.deletewallet.WrapDeleteWallet
import co.electriccoin.zcash.ui.screen.disconnected.WrapDisconnected import co.electriccoin.zcash.ui.screen.disconnected.WrapDisconnected
import co.electriccoin.zcash.ui.screen.exchangerate.optin.AndroidExchangeRateOptIn import co.electriccoin.zcash.ui.screen.exchangerate.optin.AndroidExchangeRateOptIn
@ -263,6 +272,19 @@ internal fun MainActivity.Navigation() {
goSettings = { navController.navigateJustOnce(SETTINGS) } goSettings = { navController.navigateJustOnce(SETTINGS) }
) )
} }
composable(ADDRESS_BOOK) {
WrapAddressBook()
}
composable(ADD_NEW_CONTACT) {
WrapAddContact()
}
composable(
route = "$UPDATE_CONTACT/{$UPDATE_CONTACT_ID}",
arguments = listOf(navArgument(UPDATE_CONTACT_ID) { type = NavType.StringType })
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString(UPDATE_CONTACT_ID).orEmpty()
WrapUpdateContact(contactId)
}
} }
} }
@ -449,4 +471,11 @@ object NavigationTargets {
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in" const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support" const val SUPPORT = "support"
const val WHATS_NEW = "whats_new" const val WHATS_NEW = "whats_new"
const val ADDRESS_BOOK = "address_book"
const val ADD_NEW_CONTACT = "add_new_contact"
const val UPDATE_CONTACT = "update_contact"
}
object NavigationArgs {
const val UPDATE_CONTACT_ID = "contactId"
} }

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.common.model
import java.util.UUID
data class AddressBookContact(
val name: String,
val address: String,
val id: String = UUID.randomUUID().toString(),
)

View File

@ -0,0 +1,4 @@
package co.electriccoin.zcash.ui.common.model
@JvmInline
value class ValidContactName(val value: String)

View File

@ -0,0 +1,65 @@
package co.electriccoin.zcash.ui.common.repository
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface AddressBookRepository {
val contacts: StateFlow<List<AddressBookContact>>
suspend fun saveContact(
name: String,
address: String
)
suspend fun updateContact(
contact: AddressBookContact,
name: String,
address: String
)
suspend fun deleteContact(contact: AddressBookContact)
suspend fun getContact(id: String): AddressBookContact?
}
class AddressBookRepositoryImpl : AddressBookRepository {
override val contacts = MutableStateFlow(emptyList<AddressBookContact>())
override suspend fun saveContact(
name: String,
address: String
) {
contacts.update { it + AddressBookContact(name = name.trim(), address = address.trim()) }
}
override suspend fun updateContact(
contact: AddressBookContact,
name: String,
address: String
) {
contacts.update {
it.toMutableList()
.apply {
set(
it.indexOf(contact),
AddressBookContact(name = name.trim(), address = address.trim())
)
}
.toList()
}
}
override suspend fun deleteContact(contact: AddressBookContact) {
contacts.update {
contacts.value.toMutableList()
.apply {
remove(contact)
}
.toList()
}
}
override suspend fun getContact(id: String): AddressBookContact? = contacts.value.find { it.id == id }
}

View File

@ -61,6 +61,8 @@ interface WalletRepository {
suspend fun getSelectedServer(): LightWalletEndpoint suspend fun getSelectedServer(): LightWalletEndpoint
suspend fun getAllServers(): List<LightWalletEndpoint> suspend fun getAllServers(): List<LightWalletEndpoint>
suspend fun getSynchronizer(): Synchronizer
} }
class WalletRepositoryImpl( class WalletRepositoryImpl(
@ -238,4 +240,6 @@ class WalletRepositoryImpl(
defaultServers + selectedServer defaultServers + selectedServer
} }
} }
override suspend fun getSynchronizer(): Synchronizer = synchronizer.filterNotNull().first()
} }

View File

@ -0,0 +1,12 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class DeleteContactUseCase(
private val addressBookRepository: AddressBookRepository
) {
suspend operator fun invoke(contact: AddressBookContact) {
addressBookRepository.deleteContact(contact)
}
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class GetContactUseCase(
private val addressBookRepository: AddressBookRepository
) {
suspend operator fun invoke(id: String) = addressBookRepository.getContact(id)
}

View File

@ -1,11 +1,9 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository import co.electriccoin.zcash.ui.common.repository.WalletRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
class GetSynchronizerUseCase( class GetSynchronizerUseCase(
private val walletRepository: WalletRepository private val walletRepository: WalletRepository
) { ) {
suspend operator fun invoke() = walletRepository.synchronizer.filterNotNull().first() suspend operator fun invoke() = walletRepository.getSynchronizer()
} }

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class ObserveAddressBookContactsUseCase(
private val addressBookRepository: AddressBookRepository
) {
operator fun invoke() = addressBookRepository.contacts
}

View File

@ -0,0 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class SaveContactUseCase(
private val addressBookRepository: AddressBookRepository
) {
suspend operator fun invoke(
name: String,
address: String
) {
addressBookRepository.saveContact(name = name, address = address)
}
}

View File

@ -0,0 +1,16 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class UpdateContactUseCase(
private val addressBookRepository: AddressBookRepository
) {
suspend operator fun invoke(
contact: AddressBookContact,
name: String,
address: String
) {
addressBookRepository.updateContact(contact = contact, name = name, address = address)
}
}

View File

@ -0,0 +1,35 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
class ValidateContactAddressUseCase(
private val addressBookRepository: AddressBookRepository,
private val walletRepository: WalletRepository,
) {
suspend operator fun invoke(
address: String,
exclude: AddressBookContact? = null
): Result {
val result = walletRepository.getSynchronizer().validateAddress(address)
return when {
result.isNotValid -> Result.Invalid
addressBookRepository.contacts.value
.filter {
if (exclude == null) true else it != exclude
}
.any { it.address == address.trim() } -> Result.NotUnique
else -> Result.Valid
}
}
sealed interface Result {
data object Valid : Result
data object Invalid : Result
data object NotUnique : Result
}
}

View File

@ -0,0 +1,32 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
class ValidateContactNameUseCase(
private val addressBookRepository: AddressBookRepository
) {
operator fun invoke(
name: String,
exclude: AddressBookContact? = null
) = when {
name.length > CONTACT_NAME_MAX_LENGTH -> Result.TooLong
addressBookRepository.contacts.value
.filter {
if (exclude == null) true else it != exclude
}
.any { it.name == name.trim() } -> Result.NotUnique
else -> Result.Valid
}
sealed interface Result {
data object Valid : Result
data object TooLong : Result
data object NotUnique : Result
}
}
private const val CONTACT_NAME_MAX_LENGTH = 32

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -18,12 +19,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.DividerDefaults import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -34,8 +36,11 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -45,7 +50,6 @@ import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionState import cash.z.ecc.android.sdk.model.TransactionState
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.extension.DEFAULT_FEE import cash.z.ecc.sdk.extension.DEFAULT_FEE
import cash.z.ecc.sdk.extension.toZecStringAbbreviated import cash.z.ecc.sdk.extension.toZecStringAbbreviated
import cash.z.ecc.sdk.extension.toZecStringFull import cash.z.ecc.sdk.extension.toZecStringFull
@ -61,7 +65,11 @@ import co.electriccoin.zcash.ui.design.component.CircularMidProgressIndicator
import co.electriccoin.zcash.ui.design.component.StyledBalance import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.TextWithIcon import co.electriccoin.zcash.ui.design.component.TextWithIcon
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.account.HistoryTag import co.electriccoin.zcash.ui.screen.account.HistoryTag
import co.electriccoin.zcash.ui.screen.account.fixture.TransactionUiFixture import co.electriccoin.zcash.ui.screen.account.fixture.TransactionUiFixture
@ -257,7 +265,7 @@ private fun ComposableHistoryListItemPreview() {
} }
@Composable @Composable
@Preview("History List Item Expanded") @PreviewScreens
private fun ComposableHistoryListItemExpandedPreview() { private fun ComposableHistoryListItemExpandedPreview() {
ZcashTheme(forceDarkMode = false) { ZcashTheme(forceDarkMode = false) {
BlankSurface { BlankSurface {
@ -321,43 +329,49 @@ private fun HistoryItem(
TransactionExtendedState.SENT -> { TransactionExtendedState.SENT -> {
typeText = stringResource(id = R.string.account_history_item_sent) typeText = stringResource(id = R.string.account_history_item_sent)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = MaterialTheme.colorScheme.onBackground textColor = ZashiColors.Text.textPrimary
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRegular textStyle = ZashiTypography.textSm
} }
TransactionExtendedState.SENDING -> { TransactionExtendedState.SENDING -> {
typeText = stringResource(id = R.string.account_history_item_sending) typeText = stringResource(id = R.string.account_history_item_sending)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = ZcashTheme.colors.textDescription textColor = ZashiColors.Text.textPrimary
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRunning textStyle = ZashiTypography.textSm
} }
TransactionExtendedState.SEND_FAILED -> { TransactionExtendedState.SEND_FAILED -> {
typeText = stringResource(id = R.string.account_history_item_send_failed) typeText = stringResource(id = R.string.account_history_item_send_failed)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = ZcashTheme.colors.historyRedColor textColor = ZashiColors.Text.textError
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleFailed textStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
} }
TransactionExtendedState.RECEIVED -> { TransactionExtendedState.RECEIVED -> {
typeText = stringResource(id = R.string.account_history_item_received) typeText = stringResource(id = R.string.account_history_item_received)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = MaterialTheme.colorScheme.onBackground textColor = ZashiColors.Text.textPrimary
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRegular textStyle = ZashiTypography.textSm
} }
TransactionExtendedState.RECEIVING -> { TransactionExtendedState.RECEIVING -> {
typeText = stringResource(id = R.string.account_history_item_receiving) typeText = stringResource(id = R.string.account_history_item_receiving)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = ZcashTheme.colors.textDescription textColor = ZashiColors.Text.textPrimary
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRunning textStyle = ZashiTypography.textSm
} }
TransactionExtendedState.RECEIVE_FAILED -> { TransactionExtendedState.RECEIVE_FAILED -> {
typeText = stringResource(id = R.string.account_history_item_receive_failed) typeText = stringResource(id = R.string.account_history_item_receive_failed)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon) typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = ZcashTheme.colors.historyRedColor textColor = ZashiColors.Text.textError
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleFailed textStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
} }
} }
@ -374,15 +388,22 @@ private fun HistoryItem(
TrxItemState.EXPANDED TrxItemState.EXPANDED
) )
) )
} else {
onAction(
TrxItemAction.ExpandableStateChange(
transaction.overview.rawId,
TrxItemState.COLLAPSED
)
)
} }
} }
.padding(all = ZcashTheme.dimens.spacingLarge) .padding(24.dp)
.animateContentSize() .animateContentSize()
) )
) { ) {
Image( Image(
imageVector = typeIcon, imageVector = typeIcon,
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor), colorFilter = ColorFilter.tint(ZashiColors.Text.textPrimary),
contentDescription = typeText, contentDescription = typeText,
modifier = Modifier.padding(top = ZcashTheme.dimens.spacingTiny) modifier = Modifier.padding(top = ZcashTheme.dimens.spacingTiny)
) )
@ -399,12 +420,10 @@ private fun HistoryItem(
onAction = onAction onAction = onAction
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXtiny)) Spacer(modifier = Modifier.height(2.dp))
// To add an extra spacing at the end // To add an extra spacing at the end
Column( Column {
modifier = Modifier.padding(end = ZcashTheme.dimens.spacingUpLarge)
) {
val isInExpectedState = val isInExpectedState =
transaction.expandableState == TrxItemState.EXPANDED_ADDRESS || transaction.expandableState == TrxItemState.EXPANDED_ADDRESS ||
transaction.expandableState == TrxItemState.EXPANDED_ALL transaction.expandableState == TrxItemState.EXPANDED_ALL
@ -415,13 +434,13 @@ private fun HistoryItem(
) { ) {
HistoryItemExpandedAddressPart(onAction, transaction.recipient) HistoryItemExpandedAddressPart(onAction, transaction.recipient)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(16.dp))
} }
HistoryItemDatePart(transaction) HistoryItemDatePart(transaction)
if (transaction.expandableState.isInAnyExtendedState()) { if (transaction.expandableState.isInAnyExtendedState()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(32.dp))
HistoryItemExpandedPart(onAction, transaction) HistoryItemExpandedPart(onAction, transaction)
} }
@ -431,7 +450,7 @@ private fun HistoryItem(
} }
@Composable @Composable
@Suppress("LongParameterList") @Suppress("LongParameterList", "LongMethod")
private fun HistoryItemCollapsedMainPart( private fun HistoryItemCollapsedMainPart(
transaction: TransactionUi, transaction: TransactionUi,
typeText: String, typeText: String,
@ -454,6 +473,7 @@ private fun HistoryItemCollapsedMainPart(
text = typeText, text = typeText,
style = textStyle, style = textStyle,
color = textColor, color = textColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.testTag(HistoryTag.TRANSACTION_ITEM_TITLE) modifier = Modifier.testTag(HistoryTag.TRANSACTION_ITEM_TITLE)
) )
@ -468,15 +488,18 @@ private fun HistoryItemCollapsedMainPart(
val valueTextStyle: TextStyle val valueTextStyle: TextStyle
val valueTextColor: Color val valueTextColor: Color
if (transaction.overview.getExtendedState().isFailed()) { if (transaction.overview.getExtendedState().isFailed()) {
valueTextStyle = ZcashTheme.extendedTypography.transactionItemStyles.contentLineThrough valueTextStyle =
valueTextColor = ZcashTheme.colors.historyRedColor ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
valueTextColor = ZashiColors.Text.textError
} else { } else {
valueTextStyle = ZcashTheme.extendedTypography.transactionItemStyles.valueFirstPart valueTextStyle = ZashiTypography.textSm
valueTextColor = valueTextColor =
if (transaction.overview.isSentTransaction) { if (transaction.overview.isSentTransaction) {
ZcashTheme.colors.historyRedColor ZashiColors.Text.textError
} else { } else {
ZcashTheme.colors.textPrimary ZashiColors.Text.textPrimary
} }
} }
@ -500,7 +523,7 @@ private fun HistoryItemCollapsedMainPart(
textStyle = textStyle =
StyledBalanceDefaults.textStyles( StyledBalanceDefaults.textStyles(
mostSignificantPart = valueTextStyle, mostSignificantPart = valueTextStyle,
leastSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.valueSecondPart leastSignificantPart = ZashiTypography.textXxs
), ),
textColor = valueTextColor, textColor = valueTextColor,
) )
@ -545,8 +568,8 @@ private fun HistoryItemCollapsedAddressPart(
Text( Text(
text = transaction.recipient.addressValue, text = transaction.recipient.addressValue,
style = ZcashTheme.extendedTypography.transactionItemStyles.addressCollapsed, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier =
@ -580,26 +603,29 @@ private fun HistoryItemExpandedAddressPart(
) { ) {
Text( Text(
text = recipient.addressValue, text = recipient.addressValue,
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textPrimary, color = ZashiColors.Text.textTertiary,
modifier = modifier =
Modifier Modifier
.fillMaxWidth(EXPANDED_ADDRESS_WIDTH_RATIO) .fillMaxWidth(EXPANDED_ADDRESS_WIDTH_RATIO)
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.height(16.dp))
TextWithIcon( TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy), text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy), iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor, iconTintColor = ZashiColors.Text.textTertiary,
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner)) .clickable(
.clickable { onAction(TrxItemAction.AddressClick(recipient)) } role = Role.Button,
.padding(all = ZcashTheme.dimens.spacingTiny) indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.AddressClick(recipient)) }
) )
} }
} }
@ -619,8 +645,8 @@ private fun HistoryItemDatePart(
if (formattedDate != null) { if (formattedDate != null) {
Text( Text(
text = formattedDate, text = formattedDate,
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
@ -643,8 +669,8 @@ private fun HistoryItemExpandedPart(
id = R.plurals.account_history_item_message, id = R.plurals.account_history_item_message,
count = transaction.messages!!.size count = transaction.messages!!.size
), ),
style = ZcashTheme.extendedTypography.transactionItemStyles.contentMedium, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textPrimary color = ZashiColors.Text.textTertiary,
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
@ -662,51 +688,17 @@ private fun HistoryItemExpandedPart(
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
} }
} else if (transaction.recipientAddressType == null || }
transaction.recipientAddressType == AddressType.Shielded
HistoryItemTransactionIdPart(transaction = transaction, onAction = onAction)
if (transaction.overview.getExtendedState() !in
listOf(TransactionExtendedState.RECEIVING, TransactionExtendedState.RECEIVED)
) { ) {
Text( Spacer(modifier = Modifier.height(16.dp))
text = stringResource(id = R.string.account_history_item_no_message),
style = ZcashTheme.extendedTypography.transactionItemStyles.contentItalic,
color = ZcashTheme.colors.textPrimary,
modifier = Modifier.fillMaxWidth(EXPANDED_TRANSACTION_WIDTH_RATIO)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
HistoryItemTransactionIdPart(
transaction = transaction,
onAction = onAction
)
Spacer(modifier = (Modifier.height(ZcashTheme.dimens.spacingDefault)))
HistoryItemTransactionFeePart(fee = transaction.overview.feePaid) HistoryItemTransactionFeePart(fee = transaction.overview.feePaid)
Spacer(modifier = (Modifier.height(ZcashTheme.dimens.spacingLarge)))
TextWithIcon(
text = stringResource(id = R.string.account_history_item_collapse_transaction),
style = ZcashTheme.extendedTypography.transactionItemStyles.contentUnderline,
color = ZcashTheme.colors.textDescription,
iconVector = ImageVector.vectorResource(id = R.drawable.ic_trx_collapse),
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable {
if (transaction.expandableState >= TrxItemState.EXPANDED) {
onAction(
TrxItemAction.ExpandableStateChange(
transaction.overview.rawId,
TrxItemState.COLLAPSED
)
)
} }
} }
.padding(all = ZcashTheme.dimens.spacingTiny)
)
}
} }
private fun List<String>?.containsValidMemo(): Boolean { private fun List<String>?.containsValidMemo(): Boolean {
@ -735,35 +727,38 @@ private fun HistoryItemTransactionIdPart(
) { ) {
Text( Text(
text = stringResource(id = R.string.account_history_item_transaction_id), text = stringResource(id = R.string.account_history_item_transaction_id),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = txIdString, text = txIdString,
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textPrimary, color = ZashiColors.Text.textTertiary,
modifier = modifier =
Modifier Modifier
.fillMaxWidth(EXPANDED_TRANSACTION_WIDTH_RATIO) .fillMaxWidth(EXPANDED_TRANSACTION_WIDTH_RATIO)
.testTag(HistoryTag.TRANSACTION_ID) .testTag(HistoryTag.TRANSACTION_ID)
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.height(16.dp))
TextWithIcon( TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy), text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy), iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor, iconTintColor = ZashiColors.Text.textTertiary,
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner)) .clickable(
.clickable { onAction(TrxItemAction.TransactionIdClick(txIdString)) } role = Role.Button,
.padding(all = ZcashTheme.dimens.spacingTiny) indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.TransactionIdClick(txIdString)) }
) )
} else { } else {
Row( Row(
@ -782,21 +777,21 @@ private fun HistoryItemTransactionIdPart(
) )
) )
} }
.padding(all = ZcashTheme.dimens.spacingTiny)
) { ) {
Text( Text(
text = stringResource(id = R.string.account_history_item_transaction_id), text = stringResource(id = R.string.account_history_item_transaction_id),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
) )
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = txIdString, text = txIdString,
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
maxLines = 1, maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier =
Modifier Modifier
@ -813,14 +808,14 @@ private fun HistoryItemTransactionFeePart(
fee: Zatoshi?, fee: Zatoshi?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier) { Row(modifier = modifier) {
Text( Text(
text = stringResource(id = R.string.account_history_item_transaction_fee), text = stringResource(id = R.string.account_history_item_transaction_fee),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.weight(1f))
if (fee == null) { if (fee == null) {
Text( Text(
@ -829,8 +824,8 @@ private fun HistoryItemTransactionFeePart(
id = R.string.account_history_item_transaction_fee_typical, id = R.string.account_history_item_transaction_fee_typical,
DEFAULT_FEE DEFAULT_FEE
), ),
style = ZcashTheme.extendedTypography.transactionItemStyles.feeFirstPart, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Text.textTertiary,
) )
} else { } else {
StyledBalance( StyledBalance(
@ -839,10 +834,10 @@ private fun HistoryItemTransactionFeePart(
isHideBalances = false, isHideBalances = false,
textStyle = textStyle =
StyledBalanceDefaults.textStyles( StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.feeFirstPart, mostSignificantPart = ZashiTypography.textSm,
leastSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.feeSecondPart leastSignificantPart = ZashiTypography.textXxs
), ),
textColor = ZcashTheme.colors.textDescription textColor = ZashiColors.Text.textTertiary
) )
} }
} }
@ -858,11 +853,14 @@ private fun HistoryItemMessagePart(
val textStyle: TextStyle val textStyle: TextStyle
val textColor: Color val textColor: Color
if (state.isFailed()) { if (state.isFailed()) {
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.contentLineThrough textStyle =
textColor = ZcashTheme.colors.historyRedColor ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
textColor = ZashiColors.Text.textError
} else { } else {
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.content textStyle = ZashiTypography.textSm
textColor = ZcashTheme.colors.textPrimary textColor = ZashiColors.Text.textPrimary
} }
Column(modifier = modifier.then(Modifier.fillMaxWidth())) { Column(modifier = modifier.then(Modifier.fillMaxWidth())) {
@ -870,12 +868,12 @@ private fun HistoryItemMessagePart(
val bubbleStroke: BorderStroke val bubbleStroke: BorderStroke
val arrowAlignment: BubbleArrowAlignment val arrowAlignment: BubbleArrowAlignment
if (state.isSendType()) { if (state.isSendType()) {
bubbleBackgroundColor = Color.Transparent bubbleBackgroundColor = ZashiColors.Utility.Gray.utilityGray200 orDark Color.Transparent
bubbleStroke = BorderStroke(1.dp, ZcashTheme.colors.textFieldFrame) bubbleStroke = BorderStroke(1.dp, ZashiColors.Text.textPrimary)
arrowAlignment = BubbleArrowAlignment.BottomLeft arrowAlignment = BubbleArrowAlignment.BottomLeft
} else { } else {
bubbleBackgroundColor = ZcashTheme.colors.historyMessageBubbleColor bubbleBackgroundColor = ZashiColors.Utility.Gray.utilityGray200 orDark Color.Transparent
bubbleStroke = BorderStroke(1.dp, ZcashTheme.colors.historyMessageBubbleStrokeColor) bubbleStroke = BorderStroke(1.dp, ZashiColors.Text.textPrimary)
arrowAlignment = BubbleArrowAlignment.BottomRight arrowAlignment = BubbleArrowAlignment.BottomRight
} }
@ -893,19 +891,23 @@ private fun HistoryItemMessagePart(
) )
} }
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.height(16.dp))
TextWithIcon( TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy), text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content, style = ZashiTypography.textSm,
color = ZcashTheme.colors.textDescription, color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy), iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor, iconTintColor = ZashiColors.Text.textTertiary,
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner)) .clickable(
.clickable { onAction(TrxItemAction.MessageClick(message)) } onClick = { onAction(TrxItemAction.MessageClick(message)) },
.padding(all = ZcashTheme.dimens.spacingTiny) role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
)
) )
} }
} }

View File

@ -0,0 +1,5 @@
package co.electriccoin.zcash.ui.screen.addressbook
object AddressBookTag {
const val TOP_APP_BAR = "top_app_bar"
}

View File

@ -0,0 +1,45 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.addressbook
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.addressbook.view.AddressBookView
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel
import org.koin.androidx.compose.koinViewModel
@Composable
internal fun WrapAddressBook() {
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<AddressBookViewModel>()
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.navigationCommand.collect {
navController.navigate(it)
}
}
LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect {
navController.popBackStack()
}
}
BackHandler {
state.onBack()
}
AddressBookView(
state = state,
topAppBarSubTitleState = walletState,
)
}

View File

@ -0,0 +1,20 @@
package co.electriccoin.zcash.ui.screen.addressbook.model
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.StringResource
data class AddressBookState(
val contacts: List<AddressBookContactState>,
val isLoading: Boolean,
val version: StringResource,
val onBack: () -> Unit,
val addButton: ButtonState
)
data class AddressBookContactState(
val initials: StringResource,
val isShielded: Boolean,
val name: StringResource,
val address: StringResource,
val onClick: () -> Unit,
)

View File

@ -0,0 +1,343 @@
package co.electriccoin.zcash.ui.screen.addressbook.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiBottomBar
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListTrailingItem
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookTag
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookContactState
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState
@Suppress("LongMethod")
@Composable
fun AddressBookView(
state: AddressBookState,
topAppBarSubTitleState: TopAppBarSubTitleState
) {
BlankBgScaffold(
topBar = {
AddressBookTopAppBar(onBack = state.onBack, subTitleState = topAppBarSubTitleState)
}
) { paddingValues ->
when {
state.contacts.isEmpty() && state.isLoading -> {
CircularScreenProgressIndicator()
}
state.contacts.isEmpty() && !state.isLoading -> {
Empty(
state = state,
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
)
}
else -> {
Column(
modifier = Modifier.fillMaxSize(),
) {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
contentPadding =
PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = 4.dp,
end = 4.dp
)
) {
itemsIndexed(state.contacts) { index, item ->
ContactItem(state = item)
if (index != state.contacts.lastIndex) {
ZashiHorizontalDivider()
}
}
}
ZashiBottomBar {
AddContactButton(
state.addButton,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
)
}
}
}
}
}
}
@Composable
private fun ContactItem(state: AddressBookContactState) {
ZashiSettingsListItem(
leading = { modifier ->
ContactItemLeading(modifier = modifier, state = state)
},
content = { modifier ->
ContactItemContent(modifier = modifier, state = state)
},
trailing = { modifier ->
ZashiSettingsListTrailingItem(
modifier = modifier,
isEnabled = true,
contentDescription = state.name.getValue()
)
},
onClick = state.onClick,
contentPadding = PaddingValues(top = 12.dp, bottom = if (state.isShielded) 8.dp else 12.dp)
)
}
@Composable
private fun ContactItemLeading(
state: AddressBookContactState,
modifier: Modifier = Modifier,
) {
Box(
modifier.size(height = 50.dp, width = 54.dp)
) {
Text(
modifier =
Modifier
.background(ZashiColors.Avatars.avatarBg, CircleShape)
.size(40.dp)
.padding(top = 10.dp)
.align(Alignment.Center),
text = state.initials.getValue(),
style = ZashiTypography.textSm,
color = ZashiColors.Avatars.avatarTextFg,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
if (state.isShielded) {
Image(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(24.dp),
painter = painterResource(id = R.drawable.ic_address_book_shielded),
contentDescription = ""
)
}
}
}
@Composable
private fun ContactItemContent(
state: AddressBookContactState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text(
text = state.name.getValue(),
style = ZashiTypography.textMd,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = state.address.getValue(),
style = ZashiTypography.textXs,
color = ZashiColors.Text.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun Empty(
state: AddressBookState,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painter = painterResource(id = R.drawable.ic_address_book_empty), contentDescription = "")
Spacer(modifier = Modifier.height(14.dp))
Text(
text = stringResource(id = R.string.address_book_empty),
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary,
style = ZashiTypography.header6
)
}
AddContactButton(
state.addButton,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(20.dp)
)
}
}
@Composable
private fun AddContactButton(
state: ButtonState,
modifier: Modifier = Modifier
) {
ZashiButton(
modifier = modifier,
state = state
) { scope ->
Image(
painter = painterResource(id = R.drawable.ic_address_book_plus),
colorFilter = ColorFilter.tint(ZashiColors.Btns.Primary.btnPrimaryFg),
contentDescription = ""
)
Spacer(modifier = Modifier.width(8.dp))
scope.Text()
Spacer(modifier = Modifier.width(6.dp))
scope.Loading()
}
}
@Composable
private fun AddressBookTopAppBar(
onBack: () -> Unit,
subTitleState: TopAppBarSubTitleState,
) {
ZashiSmallTopAppBar(
title = stringResource(id = R.string.address_book_title),
subtitle =
when (subTitleState) {
TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label)
TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label)
TopAppBarSubTitleState.None -> null
},
modifier = Modifier.testTag(AddressBookTag.TOP_APP_BAR),
showTitleLogo = true,
navigationAction = {
ZashiTopAppBarBackNavigation(onBack = onBack)
},
)
}
@PreviewScreens
@Composable
private fun DataPreview() {
ZcashTheme {
AddressBookView(
state =
AddressBookState(
isLoading = false,
version = stringRes("Version 1.2"),
onBack = {},
contacts =
(1..10).map {
AddressBookContactState(
name = stringRes("Name Surname"),
address = stringRes("3iY5ZSkRnevzSMu4hosasdasdasdasd12312312dasd9hw2"),
initials = stringRes("NS"),
isShielded = it % 2 == 0,
onClick = {}
)
},
addButton =
ButtonState(
text = stringRes("Add New Contact"),
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}
@PreviewScreens
@Composable
private fun LoadingPreview() {
ZcashTheme {
AddressBookView(
state =
AddressBookState(
isLoading = true,
version = stringRes("Version 1.2"),
onBack = {},
contacts = emptyList(),
addButton =
ButtonState(
text = stringRes("Add New Contact"),
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}
@PreviewScreens
@Composable
private fun EmptyPreview() {
ZcashTheme {
AddressBookView(
state =
AddressBookState(
isLoading = false,
version = stringRes("Version 1.2"),
onBack = {},
contacts = emptyList(),
addButton =
ButtonState(
text = stringRes("Add New Contact"),
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}

View File

@ -0,0 +1,94 @@
package co.electriccoin.zcash.ui.screen.addressbook.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADD_NEW_CONTACT
import co.electriccoin.zcash.ui.NavigationTargets.UPDATE_CONTACT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookContactState
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class AddressBookViewModel(
observeAddressBookContacts: ObserveAddressBookContactsUseCase,
getVersionInfo: GetVersionInfoProvider,
) : ViewModel() {
private val versionInfo = getVersionInfo()
val state =
observeAddressBookContacts()
.map { contacts -> createState(contacts = contacts, isLoading = false) }
.flowOn(Dispatchers.Default)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(contacts = emptyList(), isLoading = true)
)
val navigationCommand = MutableSharedFlow<String>()
val backNavigationCommand = MutableSharedFlow<Unit>()
private fun createState(
contacts: List<AddressBookContact>,
isLoading: Boolean
) = AddressBookState(
version = stringRes(R.string.address_book_version, versionInfo.versionName),
isLoading = isLoading,
contacts =
contacts.map { contact ->
AddressBookContactState(
initials = getContactInitials(contact),
isShielded = false,
name = stringRes(contact.name),
address = stringRes(contact.address),
onClick = { onUpdateContactClick(contact) }
)
},
onBack = ::onBack,
addButton =
ButtonState(
onClick = ::onAddContactClick,
text = stringRes(R.string.address_book_add)
)
)
private fun getContactInitials(contact: AddressBookContact) =
stringRes(
contact.name
.split(" ")
.mapNotNull { part ->
part.takeIf { it.isNotEmpty() }?.first()?.toString()
}
.take(2)
.joinToString(separator = "")
)
private fun onBack() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)
}
private fun onUpdateContactClick(contact: AddressBookContact) =
viewModelScope.launch {
navigationCommand.emit("$UPDATE_CONTACT/${contact.id}")
}
private fun onAddContactClick() =
viewModelScope.launch {
navigationCommand.emit(ADD_NEW_CONTACT)
}
}

View File

@ -1,6 +1,6 @@
package co.electriccoin.zcash.ui.screen.advancedsettings package co.electriccoin.zcash.ui.screen.advancedsettings
import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
data class AdvancedSettingsState( data class AdvancedSettingsState(
val onBack: () -> Unit, val onBack: () -> Unit,
@ -9,5 +9,5 @@ data class AdvancedSettingsState(
val onChooseServerClick: () -> Unit, val onChooseServerClick: () -> Unit,
val onCurrencyConversionClick: () -> Unit, val onCurrencyConversionClick: () -> Unit,
val onDeleteZashiClick: () -> Unit, val onDeleteZashiClick: () -> Unit,
val coinbaseButton: ButtonState?, val coinbaseButton: ZashiSettingsListItemState?,
) )

View File

@ -25,11 +25,11 @@ import androidx.compose.ui.unit.sp
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.ZashiButton import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
@ -172,7 +172,7 @@ private fun AdvancedSettingsPreview() =
onCurrencyConversionClick = {}, onCurrencyConversionClick = {},
onDeleteZashiClick = {}, onDeleteZashiClick = {},
coinbaseButton = coinbaseButton =
ButtonState( ZashiSettingsListItemState(
text = stringRes("Coinbase"), text = stringRes("Coinbase"),
onClick = {} onClick = {}
) )

View File

@ -8,7 +8,7 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsState import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsState
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -33,7 +33,7 @@ class AdvancedSettingsViewModel(
onCurrencyConversionClick = ::onCurrencyConversionClick, onCurrencyConversionClick = ::onCurrencyConversionClick,
onDeleteZashiClick = {}, onDeleteZashiClick = {},
coinbaseButton = coinbaseButton =
ButtonState( ZashiSettingsListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from the UI // Set the wallet currency by app build is more future-proof, although we hide it from the UI
// in the Testnet build // in the Testnet build
text = stringRes(R.string.advanced_settings_coinbase, getZcashCurrency.getLocalizedName()), text = stringRes(R.string.advanced_settings_coinbase, getZcashCurrency.getLocalizedName()),

View File

@ -436,7 +436,8 @@ private fun CustomServerRadioButton(
colors = colors =
ZashiTextFieldDefaults.defaultColors( ZashiTextFieldDefaults.defaultColors(
containerColor = ZashiColors.Surfaces.bgPrimary, containerColor = ZashiColors.Surfaces.bgPrimary,
textColor = ZashiColors.Text.textPrimary textColor = ZashiColors.Text.textPrimary,
borderColor = ZashiColors.Inputs.Default.stroke,
) orDark ) orDark
ZashiTextFieldDefaults.defaultColors( ZashiTextFieldDefaults.defaultColors(
containerColor = ZashiColors.Surfaces.bgSecondary, containerColor = ZashiColors.Surfaces.bgSecondary,

View File

@ -0,0 +1,47 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.contact
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.contact.view.ContactView
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import org.koin.androidx.compose.koinViewModel
@Composable
internal fun WrapAddContact() {
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<AddContactViewModel>()
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.navigationCommand.collect {
navController.navigate(it)
}
}
LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect {
navController.popBackStack()
}
}
BackHandler {
state?.onBack?.invoke()
}
state?.let {
ContactView(
state = it,
topAppBarSubTitleState = walletState,
)
}
}

View File

@ -0,0 +1,48 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.contact
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.contact.view.ContactView
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun WrapUpdateContact(contactId: String) {
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<UpdateContactViewModel> { parametersOf(contactId) }
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.navigationCommand.collect {
navController.navigate(it)
}
}
LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect {
navController.popBackStack()
}
}
BackHandler {
state?.onBack?.invoke()
}
state?.let {
ContactView(
state = it,
topAppBarSubTitleState = walletState,
)
}
}

View File

@ -0,0 +1,5 @@
package co.electriccoin.zcash.ui.screen.contact
object ContactTag {
const val TOP_APP_BAR = "top_app_bar"
}

View File

@ -0,0 +1,15 @@
package co.electriccoin.zcash.ui.screen.contact.model
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.StringResource
data class ContactState(
val title: StringResource,
val isLoading: Boolean,
val walletAddress: TextFieldState,
val contactName: TextFieldState,
val negativeButton: ButtonState?,
val positiveButton: ButtonState,
val onBack: () -> Unit,
)

View File

@ -0,0 +1,200 @@
package co.electriccoin.zcash.ui.screen.contact.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.contact.ContactTag
import co.electriccoin.zcash.ui.screen.contact.model.ContactState
@Composable
fun ContactView(
state: ContactState,
topAppBarSubTitleState: TopAppBarSubTitleState
) {
BlankBgScaffold(
topBar = {
ContactTopAppBar(onBack = state.onBack, subTitleState = topAppBarSubTitleState, state = state)
}
) { paddingValues ->
if (state.isLoading) {
CircularScreenProgressIndicator()
} else {
ContactViewInternal(
state = state,
modifier =
Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding() + 24.dp,
bottom = paddingValues.calculateBottomPadding() + 24.dp,
start = 20.dp,
end = 20.dp,
)
)
}
}
}
@Composable
private fun ContactViewInternal(
state: ContactState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
Text(
text = stringResource(id = R.string.contact_address_label),
style = ZashiTypography.textSm,
fontWeight = FontWeight.Medium,
color = ZashiColors.Inputs.Filled.label
)
Spacer(modifier = Modifier.height(6.dp))
ZashiTextField(
modifier = Modifier.fillMaxWidth(),
state = state.walletAddress,
placeholder = {
Text(
text = stringResource(id = R.string.contact_address_hint),
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
}
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(id = R.string.contact_name_label),
style = ZashiTypography.textSm,
fontWeight = FontWeight.Medium,
color = ZashiColors.Inputs.Filled.label
)
Spacer(modifier = Modifier.height(6.dp))
ZashiTextField(
modifier = Modifier.fillMaxWidth(),
state = state.contactName,
placeholder = {
Text(
text = stringResource(id = R.string.contact_name_hint),
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
}
)
Spacer(modifier = Modifier.weight(1f))
ZashiButton(
state = state.positiveButton,
modifier = Modifier.fillMaxWidth()
)
state.negativeButton?.let {
ZashiButton(
state = it,
modifier = Modifier.fillMaxWidth(),
colors = ZashiButtonDefaults.destructive1Colors()
)
}
}
}
@Composable
private fun ContactTopAppBar(
onBack: () -> Unit,
subTitleState: TopAppBarSubTitleState,
state: ContactState
) {
ZashiSmallTopAppBar(
title = state.title.getValue(),
subtitle =
when (subTitleState) {
TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label)
TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label)
TopAppBarSubTitleState.None -> null
},
modifier = Modifier.testTag(ContactTag.TOP_APP_BAR),
showTitleLogo = true,
navigationAction = {
ZashiTopAppBarBackNavigation(onBack = onBack)
},
)
}
@PreviewScreens
@Composable
private fun DataPreview() {
ZcashTheme {
ContactView(
state =
ContactState(
isLoading = false,
onBack = {},
title = stringRes("Title"),
walletAddress = TextFieldState(stringRes("Address")) {},
contactName = TextFieldState(stringRes("Name")) {},
positiveButton =
ButtonState(
text = stringRes("Positive"),
),
negativeButton =
ButtonState(
text = stringRes("Negative"),
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}
@PreviewScreens
@Composable
private fun LoadingPreview() {
ZcashTheme {
ContactView(
state =
ContactState(
isLoading = true,
onBack = {},
title = stringRes("Title"),
walletAddress = TextFieldState(stringRes("Address")) {},
contactName = TextFieldState(stringRes("Name")) {},
positiveButton =
ButtonState(
text = stringRes("Add New Contact"),
),
negativeButton =
ButtonState(
text = stringRes("Add New Contact"),
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}

View File

@ -0,0 +1,129 @@
package co.electriccoin.zcash.ui.screen.contact.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateContactAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.contact.model.ContactState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AddContactViewModel(
private val validateContactAddress: ValidateContactAddressUseCase,
private val validateContactName: ValidateContactNameUseCase,
private val saveContact: SaveContactUseCase
) : ViewModel() {
private val contactAddress = MutableStateFlow("")
private val contactName = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class)
private val contactAddressState =
contactAddress.mapLatest { address ->
TextFieldState(
value = stringRes(address),
error =
if (address.isEmpty()) {
null
} else {
when (validateContactAddress(address)) {
ValidateContactAddressUseCase.Result.Invalid -> stringRes("")
ValidateContactAddressUseCase.Result.NotUnique ->
stringRes(R.string.contact_address_error_not_unique)
ValidateContactAddressUseCase.Result.Valid -> null
}
},
onValueChange = { newValue ->
contactAddress.update { newValue }
}
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private val contactNameState =
contactName.mapLatest { name ->
TextFieldState(
value = stringRes(name),
error =
if (name.isEmpty()) {
null
} else {
when (validateContactName(name)) {
ValidateContactNameUseCase.Result.TooLong ->
stringRes(R.string.contact_name_error_too_long)
ValidateContactNameUseCase.Result.NotUnique ->
stringRes(R.string.contact_name_error_not_unique)
ValidateContactNameUseCase.Result.Valid ->
null
}
},
onValueChange = { newValue ->
contactName.update { newValue }
}
)
}
private val isSavingContact = MutableStateFlow(false)
private val saveButtonState =
combine(contactAddressState, contactNameState, isSavingContact) { address, name, isSavingContact ->
ButtonState(
text = stringRes(R.string.add_new_contact_primary_btn),
isEnabled =
address.error == null &&
name.error == null &&
contactAddress.value.isNotEmpty() &&
contactName.value.isNotEmpty(),
onClick = ::onSaveButtonClick,
isLoading = isSavingContact
)
}
val state =
combine(contactAddressState, contactNameState, saveButtonState) { address, name, saveButton ->
ContactState(
title = stringRes(R.string.new_contact_title),
isLoading = false,
walletAddress = address,
contactName = name,
negativeButton = null,
positiveButton = saveButton,
onBack = ::onBack,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val navigationCommand = MutableSharedFlow<String>()
val backNavigationCommand = MutableSharedFlow<Unit>()
private fun onBack() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)
}
private fun onSaveButtonClick() =
viewModelScope.launch {
isSavingContact.update { true }
saveContact(name = contactName.value, address = contactAddress.value)
backNavigationCommand.emit(Unit)
isSavingContact.update { false }
}
}

View File

@ -0,0 +1,177 @@
package co.electriccoin.zcash.ui.screen.contact.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.usecase.DeleteContactUseCase
import co.electriccoin.zcash.ui.common.usecase.GetContactUseCase
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.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.contact.model.ContactState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class UpdateContactViewModel(
private val contactId: String,
private val validateContactAddress: ValidateContactAddressUseCase,
private val validateContactName: ValidateContactNameUseCase,
private val updateContact: UpdateContactUseCase,
private val deleteContact: DeleteContactUseCase,
private val getContact: GetContactUseCase
) : ViewModel() {
private var contact: AddressBookContact? = null
private val contactAddress = MutableStateFlow("")
private val contactName = MutableStateFlow("")
private val isUpdatingContact = MutableStateFlow(false)
private val isDeletingContact = MutableStateFlow(false)
private val isLoadingContact = MutableStateFlow(true)
@OptIn(ExperimentalCoroutinesApi::class)
private val contactAddressState =
contactAddress.mapLatest { address ->
TextFieldState(
value = stringRes(address),
error =
if (address.isEmpty()) {
null
} else {
when (validateContactAddress(address = address, exclude = contact)) {
ValidateContactAddressUseCase.Result.Invalid -> stringRes("")
ValidateContactAddressUseCase.Result.NotUnique ->
stringRes(R.string.contact_address_error_not_unique)
ValidateContactAddressUseCase.Result.Valid -> null
}
},
onValueChange = { newValue ->
contactAddress.update { newValue }
}
)
}
@OptIn(ExperimentalCoroutinesApi::class)
private val contactNameState =
contactName.mapLatest { name ->
TextFieldState(
value = stringRes(name),
error =
if (name.isEmpty()) {
null
} else {
when (validateContactName(name = name, exclude = contact)) {
ValidateContactNameUseCase.Result.TooLong ->
stringRes(R.string.contact_name_error_too_long)
ValidateContactNameUseCase.Result.NotUnique ->
stringRes(R.string.contact_name_error_not_unique)
ValidateContactNameUseCase.Result.Valid -> null
}
},
onValueChange = { newValue ->
contactName.update { newValue }
}
)
}
private val updateButtonState =
combine(contactAddressState, contactNameState, isUpdatingContact) { address, name, isUpdatingContact ->
ButtonState(
text = stringRes(R.string.update_contact_primary_btn),
isEnabled =
address.error == null &&
name.error == null &&
contactAddress.value.isNotEmpty() &&
contactName.value.isNotEmpty() &&
(contactName.value.trim() != contact?.name || contactAddress.value.trim() != contact?.address),
onClick = ::onUpdateButtonClick,
isLoading = isUpdatingContact
)
}
private val deleteButtonState =
isDeletingContact.map { isDeletingContact ->
ButtonState(
text = stringRes(R.string.update_contact_secondary_btn),
onClick = ::onDeleteButtonClick,
isLoading = isDeletingContact
)
}
val state =
combine(
contactAddressState,
contactNameState,
updateButtonState,
deleteButtonState,
isLoadingContact
) { address, name, saveButton, deleteButton, isLoadingContact ->
ContactState(
title = stringRes(R.string.update_contact_title),
isLoading = isLoadingContact,
walletAddress = address,
contactName = name,
negativeButton = deleteButton,
positiveButton = saveButton,
onBack = ::onBack,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val navigationCommand = MutableSharedFlow<String>()
val backNavigationCommand = MutableSharedFlow<Unit>()
init {
viewModelScope.launch {
getContact(contactId).let { contact ->
contactAddress.update { contact?.address.orEmpty() }
contactName.update { contact?.name.orEmpty() }
this@UpdateContactViewModel.contact = contact
}
isLoadingContact.update { false }
}
}
private fun onBack() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)
}
private fun onUpdateButtonClick() =
viewModelScope.launch {
contact?.let {
isUpdatingContact.update { true }
updateContact(contact = it, name = contactName.value, address = contactAddress.value)
backNavigationCommand.emit(Unit)
isUpdatingContact.update { false }
}
}
private fun onDeleteButtonClick() =
viewModelScope.launch {
contact?.let {
isDeletingContact.update { true }
deleteContact(it)
backNavigationCommand.emit(Unit)
isDeletingContact.update { false }
}
}
}

View File

@ -5,6 +5,5 @@ package co.electriccoin.zcash.ui.screen.send
*/ */
object SendTag { object SendTag {
const val SEND_FORM_BUTTON = "send_form_button" const val SEND_FORM_BUTTON = "send_form_button"
const val SEND_FAILED_BUTTON = "send_failed_button" const val SEND_AMOUNT_FIELD = "SEND_AMOUNT_FIELD"
const val SEND_SUCCESS_BUTTON = "send_success_button"
} }

View File

@ -5,6 +5,8 @@ package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -12,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
@ -21,15 +22,16 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@ -39,21 +41,20 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.type.AddressType import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.extension.DEFAULT_FEE
import cash.z.ecc.sdk.fixture.ZatoshiFixture import cash.z.ecc.sdk.fixture.ZatoshiFixture
import cash.z.ecc.sdk.type.ZcashCurrency import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
@ -70,16 +71,14 @@ import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.AppAlertDialog import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.BlankSurface import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.BubbleArrowAlignment
import co.electriccoin.zcash.ui.design.component.BubbleMessage
import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TopAppBarHideBalancesNavigation import co.electriccoin.zcash.ui.design.component.TopAppBarHideBalancesNavigation
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldDefaults
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.send.SendTag import co.electriccoin.zcash.ui.screen.send.SendTag
@ -87,6 +86,7 @@ import co.electriccoin.zcash.ui.screen.send.model.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@Composable @Composable
@ -161,7 +161,6 @@ private fun SendFormTransparentAddressPreview() {
// TODO [#1260]: Cover Send screens UI with tests // TODO [#1260]: Cover Send screens UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260 // TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
@Suppress("LongParameterList") @Suppress("LongParameterList")
@Composable @Composable
fun Send( fun Send(
@ -322,7 +321,7 @@ private fun SendMainContent(
// TODO [#1257]: Send.Form TextFields not persisted on a configuration change when the underlying ViewPager is on the // TODO [#1257]: Send.Form TextFields not persisted on a configuration change when the underlying ViewPager is on the
// Balances page // Balances page
// TODO [#1257]: https://github.com/Electric-Coin-Company/zashi-android/issues/1257 // TODO [#1257]: https://github.com/Electric-Coin-Company/zashi-android/issues/1257
@Suppress("LongMethod", "LongParameterList") @Suppress("LongParameterList", "LongMethod")
@Composable @Composable
private fun SendForm( private fun SendForm(
balanceState: BalanceState, balanceState: BalanceState,
@ -360,7 +359,7 @@ private fun SendForm(
onReferenceClick = goBalances onReferenceClick = goBalances
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) Spacer(modifier = Modifier.height(24.dp))
// TODO [#1256]: Consider Send.Form TextFields scrolling // TODO [#1256]: Consider Send.Form TextFields scrolling
// TODO [#1256]: https://github.com/Electric-Coin-Company/zashi-android/issues/1256 // TODO [#1256]: https://github.com/Electric-Coin-Company/zashi-android/issues/1256
@ -416,7 +415,7 @@ private fun SendForm(
.weight(MINIMAL_WEIGHT) .weight(MINIMAL_WEIGHT)
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(54.dp))
SendButton( SendButton(
amountState = amountState, amountState = amountState,
@ -425,11 +424,13 @@ private fun SendForm(
recipientAddressState = recipientAddressState, recipientAddressState = recipientAddressState,
walletSnapshot = walletSnapshot, walletSnapshot = walletSnapshot,
) )
Spacer(modifier = Modifier.height(78.dp))
} }
} }
@Suppress("CyclomaticComplexMethod")
@Composable @Composable
@Suppress("LongParameterList")
fun SendButton( fun SendButton(
amountState: AmountState, amountState: AmountState,
memoState: MemoState, memoState: MemoState,
@ -437,6 +438,7 @@ fun SendButton(
recipientAddressState: RecipientAddressState, recipientAddressState: RecipientAddressState,
walletSnapshot: WalletSnapshot, walletSnapshot: WalletSnapshot,
) { ) {
val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
// Common conditions continuously checked for validity // Common conditions continuously checked for validity
@ -449,12 +451,9 @@ fun SendButton(
// A valid memo is necessary only for non-transparent recipient // A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct) (recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
Column( ZashiButton(
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular),
horizontalAlignment = Alignment.CenterHorizontally
) {
PrimaryButton(
onClick = { onClick = {
scope.launch {
// SDK side validations // SDK side validations
val zecSendValidation = val zecSendValidation =
ZecSendExt.new( ZecSendExt.new(
@ -471,13 +470,35 @@ fun SendButton(
) )
when (zecSendValidation) { when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend) is ZecSendExt.ZecSendValidation.Valid ->
onCreateZecSend(
zecSendValidation.zecSend.copy(
destination =
when (recipientAddressState.type) {
is AddressType.Invalid ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Shielded ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Tex ->
WalletAddress.Tex.new(recipientAddressState.address)
AddressType.Transparent ->
WalletAddress.Transparent.new(recipientAddressState.address)
AddressType.Unified ->
WalletAddress.Unified.new(recipientAddressState.address)
null -> WalletAddress.Unified.new(recipientAddressState.address)
}
)
)
is ZecSendExt.ZecSendValidation.Invalid -> { is ZecSendExt.ZecSendValidation.Invalid -> {
// We do not expect this validation to fail, so logging is enough here // We do not expect this validation to fail, so logging is enough here
// An error popup could be reasonable here as well // An error popup could be reasonable here as well
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" } Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" }
} }
} }
}
}, },
text = stringResource(id = R.string.send_create), text = stringResource(id = R.string.send_create),
enabled = sendButtonEnabled, enabled = sendButtonEnabled,
@ -486,22 +507,10 @@ fun SendButton(
.testTag(SendTag.SEND_FORM_BUTTON) .testTag(SendTag.SEND_FORM_BUTTON)
.fillMaxWidth() .fillMaxWidth()
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall(
text =
stringResource(
id = R.string.send_fee,
DEFAULT_FEE
),
textFontWeight = FontWeight.SemiBold,
)
}
} }
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod") @Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SendFormAddressTextField( fun SendFormAddressTextField(
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
@ -510,8 +519,8 @@ fun SendFormAddressTextField(
setRecipientAddress: (String) -> Unit, setRecipientAddress: (String) -> Unit,
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scope = rememberCoroutineScope()
Column( Column(
modifier = modifier =
@ -521,7 +530,11 @@ fun SendFormAddressTextField(
// Scroll TextField above ime keyboard // Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
) { ) {
Small(text = stringResource(id = R.string.send_address_label)) Text(
text = stringResource(id = R.string.send_address_label),
color = ZashiColors.Inputs.Default.label,
style = ZashiTypography.textMd
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
@ -536,34 +549,42 @@ fun SendFormAddressTextField(
null null
} }
FormTextField( ZashiTextField(
value = recipientAddressValue, value = recipientAddressValue,
onValueChange = { onValueChange = {
setRecipientAddress(it) setRecipientAddress(it)
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.onFocusEvent { focusState ->
if (focusState.isFocused) {
scope.launch {
bringIntoViewRequester.bringIntoView()
}
}
},
error = recipientAddressError, error = recipientAddressError,
placeholder = { placeholder = {
Text( Text(
text = stringResource(id = R.string.send_address_hint), text = stringResource(id = R.string.send_address_hint),
style = ZcashTheme.extendedTypography.textFieldHint, style = ZashiTypography.textMd,
color = ZcashTheme.colors.textFieldHint color = ZashiColors.Inputs.Default.text
) )
}, },
trailingIcon = trailingIcon =
if (hasCameraFeature) { if (hasCameraFeature) {
{ {
IconButton( Image(
modifier =
Modifier.clickable(
onClick = onQrScannerOpen, onClick = onQrScannerOpen,
content = { role = Role.Button,
Icon( indication = rememberRipple(radius = 4.dp),
painter = painterResource(id = R.drawable.qr_code_icon), interactionSource = remember { MutableInteractionSource() }
),
painter = painterResource(R.drawable.qr_code_icon),
contentDescription = stringResource(R.string.send_scan_content_description), contentDescription = stringResource(R.string.send_scan_content_description),
tint = ZcashTheme.colors.secondaryColor,
)
}
) )
} }
} else { } else {
@ -577,16 +598,15 @@ fun SendFormAddressTextField(
keyboardActions = keyboardActions =
KeyboardActions( KeyboardActions(
onNext = { onNext = {
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Next)
} }
), ),
bringIntoViewRequester = bringIntoViewRequester,
) )
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList", "LongMethod") @Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SendFormAmountTextField( fun SendFormAmountTextField(
amountState: AmountState, amountState: AmountState,
@ -632,13 +652,16 @@ fun SendFormAmountTextField(
// Scroll TextField above ime keyboard // Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
) { ) {
Small(text = stringResource(id = R.string.send_amount_label)) Text(
text = stringResource(id = R.string.send_amount_label),
color = ZashiColors.Inputs.Default.label,
style = ZashiTypography.textMd
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Row { Row {
FormTextField( ZashiTextField(
textStyle = ZcashTheme.extendedTypography.textFieldValue.copy(fontSize = 14.sp),
value = amountState.value, value = amountState.value,
onValueChange = { newValue -> onValueChange = { newValue ->
setAmountState( setAmountState(
@ -653,6 +676,7 @@ fun SendFormAmountTextField(
) )
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
innerModifier = Modifier.testTag(SendTag.SEND_AMOUNT_FIELD),
error = amountError, error = amountError,
placeholder = { placeholder = {
Text( Text(
@ -661,8 +685,8 @@ fun SendFormAmountTextField(
id = R.string.send_amount_hint, id = R.string.send_amount_hint,
zcashCurrency zcashCurrency
), ),
style = ZcashTheme.extendedTypography.textFieldHint, style = ZashiTypography.textMd,
color = ZcashTheme.colors.textFieldHint color = ZashiColors.Inputs.Default.text
) )
}, },
keyboardOptions = keyboardOptions =
@ -676,49 +700,30 @@ fun SendFormAmountTextField(
focusManager.clearFocus(true) focusManager.clearFocus(true)
}, },
onNext = { onNext = {
if (exchangeRateState is ExchangeRateState.Data) {
focusManager.moveFocus(FocusDirection.Right)
} else {
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Down)
} }
}
), ),
bringIntoViewRequester = bringIntoViewRequester,
leadingIcon = { leadingIcon = {
Image( Image(
modifier = Modifier.requiredSize(7.dp, 13.dp),
painter = painterResource(R.drawable.ic_send_zashi), painter = painterResource(R.drawable.ic_send_zashi),
contentDescription = "", contentDescription = "",
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor), colorFilter = ColorFilter.tint(color = ZashiColors.Inputs.Default.text),
) )
} }
) )
if (exchangeRateState is ExchangeRateState.Data) { if (exchangeRateState is ExchangeRateState.Data) {
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingMin)) Spacer(modifier = Modifier.width(12.dp))
Image( Image(
modifier = Modifier.padding(top = 24.dp), modifier = Modifier.padding(top = 12.dp),
painter = painterResource(id = R.drawable.ic_send_convert), painter = painterResource(id = R.drawable.ic_send_convert),
contentDescription = "", contentDescription = "",
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor), colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor),
) )
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingMin)) Spacer(modifier = Modifier.width(12.dp))
FormTextField( ZashiTextField(
enabled = !exchangeRateState.isStale, isEnabled = !exchangeRateState.isStale,
textStyle = ZcashTheme.extendedTypography.textFieldValue.copy(fontSize = 14.sp),
value = amountState.fiatValue, value = amountState.fiatValue,
colors =
TextFieldDefaults.colors(
cursorColor = ZcashTheme.colors.textPrimary,
disabledTextColor = ZcashTheme.colors.textDisabled,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
onValueChange = { newValue -> onValueChange = { newValue ->
setAmountState( setAmountState(
AmountState.newFromFiat( AmountState.newFromFiat(
@ -738,8 +743,8 @@ fun SendFormAmountTextField(
stringResource( stringResource(
id = R.string.send_usd_amount_hint id = R.string.send_usd_amount_hint
), ),
style = ZcashTheme.extendedTypography.textFieldHint, style = ZashiTypography.textMd,
color = ZcashTheme.colors.textFieldHint color = ZashiColors.Inputs.Default.text
) )
}, },
keyboardOptions = keyboardOptions =
@ -756,17 +761,15 @@ fun SendFormAmountTextField(
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Down)
} }
), ),
bringIntoViewRequester = bringIntoViewRequester,
leadingIcon = { leadingIcon = {
Image( Image(
modifier = Modifier.requiredSize(7.dp, 13.dp), painter = painterResource(R.drawable.ic_send_usd),
painter = painterResource(R.drawable.ic_usd),
contentDescription = "", contentDescription = "",
colorFilter = colorFilter =
if (!exchangeRateState.isStale) { if (!exchangeRateState.isStale) {
ColorFilter.tint(color = ZcashTheme.colors.secondaryColor) ColorFilter.tint(color = ZashiColors.Inputs.Default.text)
} else { } else {
ColorFilter.tint(color = ZcashTheme.colors.textDisabled) ColorFilter.tint(color = ZashiColors.Inputs.Disabled.text)
} }
) )
} }
@ -776,8 +779,8 @@ fun SendFormAmountTextField(
} }
} }
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "LongParameterList")
@Composable @Composable
fun SendFormMemoTextField( fun SendFormMemoTextField(
isMemoFieldAvailable: Boolean, isMemoFieldAvailable: Boolean,
@ -794,54 +797,27 @@ fun SendFormMemoTextField(
// Scroll TextField above ime keyboard // Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Text(
Icon(
painter = painterResource(id = R.drawable.send_paper_plane),
contentDescription = null,
tint =
if (isMemoFieldAvailable) {
ZcashTheme.colors.textPrimary
} else {
ZcashTheme.colors.textDisabled
}
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Small(
text = stringResource(id = R.string.send_memo_label), text = stringResource(id = R.string.send_memo_label),
color = color = ZashiColors.Inputs.Default.label,
if (isMemoFieldAvailable) { style = ZashiTypography.textMd
ZcashTheme.colors.textPrimary
} else {
ZcashTheme.colors.textDisabled
}
) )
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BubbleMessage( ZashiTextField(
arrowAlignment = BubbleArrowAlignment.BottomLeft, minLines = if (isMemoFieldAvailable) 2 else 1,
backgroundColor = isEnabled = isMemoFieldAvailable,
if (isMemoFieldAvailable) {
Color.Transparent
} else {
ZcashTheme.colors.textDisabled
}
) {
FormTextField(
enabled = isMemoFieldAvailable,
value = value =
if (isMemoFieldAvailable) { if (isMemoFieldAvailable) {
memoState.text memoState.text
} else { } else {
"" ""
}, },
error = if (memoState is MemoState.Correct) null else "",
onValueChange = { onValueChange = {
setMemoState(MemoState.new(it)) setMemoState(MemoState.new(it))
}, },
bringIntoViewRequester = bringIntoViewRequester,
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
@ -849,34 +825,63 @@ fun SendFormMemoTextField(
capitalization = KeyboardCapitalization.Sentences capitalization = KeyboardCapitalization.Sentences
), ),
placeholder = { placeholder = {
if (isMemoFieldAvailable) {
Text( Text(
text = stringResource(id = R.string.send_memo_hint), text = stringResource(id = R.string.send_memo_hint),
style = ZcashTheme.extendedTypography.textFieldHint, style = ZashiTypography.textMd,
color = ZcashTheme.colors.textFieldHint color = ZashiColors.Inputs.Default.text
)
} else {
Text(
text = stringResource(R.string.send_transparent_memo),
style = ZashiTypography.textSm,
color = ZashiColors.Utility.Gray.utilityGray700
)
}
},
leadingIcon =
if (isMemoFieldAvailable) {
null
} else {
{
Image(
painter = painterResource(id = R.drawable.ic_confirmation_message_info),
contentDescription = "",
colorFilter = ColorFilter.tint(ZashiColors.Utility.Gray.utilityGray500)
)
}
},
colors =
if (isMemoFieldAvailable) {
ZashiTextFieldDefaults.defaultColors()
} else {
ZashiTextFieldDefaults.defaultColors(
disabledTextColor = ZashiColors.Inputs.Disabled.text,
disabledHintColor = ZashiColors.Inputs.Disabled.hint,
disabledBorderColor = Color.Unspecified,
disabledContainerColor = ZashiColors.Inputs.Disabled.bg,
disabledPlaceholderColor = ZashiColors.Inputs.Disabled.text,
) )
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minHeight = ZcashTheme.dimens.textFieldMemoPanelDefaultHeight,
withBorder = false
) )
}
if (isMemoFieldAvailable) { if (isMemoFieldAvailable) {
Body( Text(
text = text =
stringResource( stringResource(
id = R.string.send_memo_bytes_counter, id = R.string.send_memo_bytes_counter,
Memo.MAX_MEMO_LENGTH_BYTES - memoState.byteSize, Memo.MAX_MEMO_LENGTH_BYTES - memoState.byteSize,
Memo.MAX_MEMO_LENGTH_BYTES Memo.MAX_MEMO_LENGTH_BYTES
), ),
textFontWeight = FontWeight.Bold,
color = color =
if (memoState is MemoState.Correct) { if (memoState is MemoState.Correct) {
ZcashTheme.colors.textFieldHint ZashiColors.Inputs.Default.hint
} else { } else {
ZcashTheme.colors.textFieldWarning ZashiColors.Inputs.Filled.required
}, },
textAlign = TextAlign.End, textAlign = TextAlign.End,
style = ZashiTypography.textSm,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -26,14 +26,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.FirstClassByteArray import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionSubmitResult import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.extension.toZecStringFull import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.MemoFixture
@ -48,17 +52,24 @@ import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.BlankSurface import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.Body import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BubbleArrowAlignment import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.BubbleMessage
import co.electriccoin.zcash.ui.design.component.PrimaryButton import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SecondaryButton
import co.electriccoin.zcash.ui.design.component.Small import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.StyledBalance import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.Tiny import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.component.TopAppBarBackNavigation import co.electriccoin.zcash.ui.design.component.TopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldDefaults
import co.electriccoin.zcash.ui.design.component.ZecAmountTriple
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ObserveFiatCurrencyResultFixture import co.electriccoin.zcash.ui.fixture.ObserveFiatCurrencyResultFixture
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel
import co.electriccoin.zcash.ui.screen.sendconfirmation.SendConfirmationTag import co.electriccoin.zcash.ui.screen.sendconfirmation.SendConfirmationTag
@ -300,18 +311,21 @@ private fun SendConfirmationTopAppBar(
SendConfirmationStage.Confirmation, SendConfirmationStage.Confirmation,
SendConfirmationStage.Sending, SendConfirmationStage.Sending,
is SendConfirmationStage.Failure, is SendConfirmationStage.Failure,
is SendConfirmationStage.FailureGrpc, -> { is SendConfirmationStage.FailureGrpc,
SmallTopAppBar( -> {
subTitle = subTitle, ZashiSmallTopAppBar(
titleText = stringResource(id = R.string.send_stage_confirmation_title), title = stringResource(id = R.string.send_stage_confirmation_title),
subtitle = subTitle,
) )
} }
SendConfirmationStage.MultipleTrxFailure -> { SendConfirmationStage.MultipleTrxFailure -> {
SmallTopAppBar( SmallTopAppBar(
subTitle = subTitle, subTitle = subTitle,
titleText = stringResource(id = R.string.send_confirmation_multiple_error_title), titleText = stringResource(id = R.string.send_confirmation_multiple_error_title),
) )
} }
SendConfirmationStage.MultipleTrxFailureReported -> { SendConfirmationStage.MultipleTrxFailureReported -> {
SmallTopAppBar( SmallTopAppBar(
subTitle = subTitle, subTitle = subTitle,
@ -366,6 +380,7 @@ private fun SendConfirmationMainContent(
) )
} }
} }
is SendConfirmationStage.MultipleTrxFailure, SendConfirmationStage.MultipleTrxFailureReported -> { is SendConfirmationStage.MultipleTrxFailure, SendConfirmationStage.MultipleTrxFailureReported -> {
MultipleSubmissionFailure( MultipleSubmissionFailure(
onContactSupport = { onContactSupport = {
@ -397,7 +412,11 @@ private fun SendConfirmationContent(
) { ) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Small(stringResource(R.string.send_confirmation_amount)) Text(
stringResource(R.string.send_confirmation_amount),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary
)
BalanceWidgetBigLineOnly( BalanceWidgetBigLineOnly(
parts = zecSend.amount.toZecStringFull().asZecAmountTriple(), parts = zecSend.amount.toZecStringFull().asZecAmountTriple(),
@ -409,27 +428,41 @@ private fun SendConfirmationContent(
zatoshi = zecSend.amount, zatoshi = zecSend.amount,
state = exchangeRate, state = exchangeRate,
isHideBalances = false, isHideBalances = false,
style = ZashiTypography.textMd.copy(fontWeight = FontWeight.SemiBold),
textColor = ZashiColors.Text.textPrimary
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(24.dp))
Small(stringResource(R.string.send_confirmation_address)) Text(
stringResource(R.string.send_confirmation_address),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.height(8.dp))
Tiny(zecSend.destination.address) Text(
zecSend.destination.address,
style = ZashiTypography.textXs,
color = ZashiColors.Text.textPrimary
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge)) Spacer(modifier = Modifier.height(20.dp))
Small(stringResource(R.string.send_confirmation_fee)) Row {
Text(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) modifier = Modifier.weight(1f),
text = stringResource(R.string.send_confirmation_amount_item),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary
)
StyledBalance( StyledBalance(
// The not-null assertion operator is necessary here even if we check its nullability before // The not-null assertion operator is necessary here even if we check its nullability before
// due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API // due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
// property declared in different module. See more details on the Kotlin forum. // property declared in different module. See more details on the Kotlin forum.
balanceParts = zecSend.proposal!!.totalFeeRequired().toZecStringFull().asZecAmountTriple(), balanceParts = zecSend.amount.toZecStringFull().asZecAmountTriple(),
// We don't hide any balance in confirmation screen // We don't hide any balance in confirmation screen
isHideBalances = false, isHideBalances = false,
textStyle = textStyle =
@ -438,28 +471,89 @@ private fun SendConfirmationContent(
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
), ),
) )
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge)) Spacer(modifier = Modifier.height(20.dp))
if (zecSend.memo.value.isNotEmpty()) { Row {
Small(stringResource(R.string.send_confirmation_memo)) Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.send_confirmation_fee),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) StyledBalance(
// The not-null assertion operator is necessary here even if we check its nullability before
BubbleMessage( // due to: "Smart cast to 'Proposal' is impossible, because 'zecSend.proposal' is a public API
modifier = Modifier.fillMaxWidth(), // property declared in different module. See more details on the Kotlin forum.
arrowAlignment = BubbleArrowAlignment.BottomLeft, balanceParts =
backgroundColor = Color.Transparent zecSend.proposal?.totalFeeRequired()?.toZecStringFull()?.asZecAmountTriple()
) { ?: ZecAmountTriple("main", "prefix"),
Tiny( // We don't hide any balance in confirmation screen
text = zecSend.memo.value, isHideBalances = false,
modifier = textStyle =
Modifier StyledBalanceDefaults.textStyles(
.fillMaxWidth() mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
.padding(all = ZcashTheme.dimens.spacingMid) leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
) )
} }
Spacer(modifier = Modifier.height(20.dp))
val isMemoFieldAvailable =
zecSend.destination !is WalletAddress.Transparent &&
zecSend.destination !is WalletAddress.Tex
if (zecSend.memo.value.isNotEmpty() || !isMemoFieldAvailable) {
Text(
stringResource(R.string.send_confirmation_memo),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary
)
Spacer(modifier = Modifier.height(8.dp))
ZashiTextField(
state = TextFieldState(value = stringRes(zecSend.memo.value), isEnabled = false) {},
modifier =
Modifier
.fillMaxWidth(),
colors =
ZashiTextFieldDefaults.defaultColors(
disabledTextColor = ZashiColors.Inputs.Filled.text,
disabledHintColor = ZashiColors.Inputs.Disabled.hint,
disabledBorderColor = Color.Unspecified,
disabledContainerColor = ZashiColors.Inputs.Disabled.bg,
disabledPlaceholderColor = ZashiColors.Inputs.Disabled.text,
),
placeholder =
if (isMemoFieldAvailable) {
null
} else {
{
Text(
text = stringResource(R.string.send_transparent_memo),
style = ZashiTypography.textSm,
color = ZashiColors.Utility.Gray.utilityGray700
)
}
},
leadingIcon =
if (isMemoFieldAvailable) {
null
} else {
{
Image(
painter = painterResource(id = R.drawable.ic_confirmation_message_info),
contentDescription = "",
colorFilter = ColorFilter.tint(ZashiColors.Utility.Gray.utilityGray500)
)
}
}
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
} }
@ -476,12 +570,10 @@ private fun SendConfirmationContent(
onConfirmation = onConfirmation onConfirmation = onConfirmation
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge)) Spacer(modifier = Modifier.height(52.dp))
} }
} }
const val BUTTON_WIDTH_RATIO = 0.5f
@Composable @Composable
fun SendConfirmationActionButtons( fun SendConfirmationActionButtons(
onConfirmation: () -> Unit, onConfirmation: () -> Unit,
@ -489,33 +581,37 @@ fun SendConfirmationActionButtons(
isSending: Boolean, isSending: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Column(
modifier = modifier modifier = modifier
) { ) {
PrimaryButton( ZashiButton(
text = stringResource(id = R.string.send_confirmation_send_button), state =
ButtonState(
text = stringRes(R.string.send_confirmation_send_button),
onClick = onConfirmation, onClick = onConfirmation,
enabled = !isSending, isEnabled = !isSending,
showProgressBar = isSending, isLoading = isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall, ),
buttonColors = ZcashTheme.colors.tertiaryButtonColors,
modifier = modifier =
Modifier Modifier
.fillMaxWidth()
.testTag(SendConfirmationTag.SEND_CONFIRMATION_SEND_BUTTON) .testTag(SendConfirmationTag.SEND_CONFIRMATION_SEND_BUTTON)
.weight(BUTTON_WIDTH_RATIO)
) )
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingLarge)) Spacer(modifier = Modifier.height(12.dp))
SecondaryButton( ZashiButton(
text = stringResource(R.string.send_confirmation_back_button), state =
ButtonState(
text = stringRes(R.string.send_confirmation_back_button),
onClick = onBack, onClick = onBack,
enabled = !isSending, isEnabled = !isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall, ),
modifier = modifier =
Modifier Modifier
.testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON) .fillMaxWidth()
.weight(BUTTON_WIDTH_RATIO) .testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON),
colors = ZashiButtonDefaults.tertiaryColors()
) )
} }
} }

View File

@ -6,6 +6,7 @@ data class SettingsState(
val isLoading: Boolean, val isLoading: Boolean,
val version: StringResource, val version: StringResource,
val settingsTroubleshootingState: SettingsTroubleshootingState?, val settingsTroubleshootingState: SettingsTroubleshootingState?,
val onAddressBookClick: () -> Unit,
val onBack: () -> Unit, val onBack: () -> Unit,
val onAdvancedSettingsClick: () -> Unit, val onAdvancedSettingsClick: () -> Unit,
val onAboutUsClick: () -> Unit, val onAboutUsClick: () -> Unit,

View File

@ -76,6 +76,12 @@ fun Settings(
end = 4.dp end = 4.dp
), ),
) { ) {
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_address_book),
icon = R.drawable.ic_settings_address_book,
onClick = state.onAddressBookClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.settings_advanced_settings), text = stringResource(id = R.string.settings_advanced_settings),
icon = R.drawable.ic_advanced_settings orDark R.drawable.ic_advanced_settings_dark, icon = R.drawable.ic_advanced_settings orDark R.drawable.ic_advanced_settings_dark,
@ -230,6 +236,7 @@ private fun PreviewSettings() {
onAdvancedSettingsClick = {}, onAdvancedSettingsClick = {},
onAboutUsClick = {}, onAboutUsClick = {},
onSendUsFeedbackClick = {}, onSendUsFeedbackClick = {},
onAddressBookClick = {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )
@ -251,6 +258,7 @@ private fun PreviewSettingsLoading() {
onAdvancedSettingsClick = {}, onAdvancedSettingsClick = {},
onAboutUsClick = {}, onAboutUsClick = {},
onSendUsFeedbackClick = {}, onSendUsFeedbackClick = {},
onAddressBookClick = {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )

View File

@ -6,6 +6,7 @@ import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.preference.StandardPreferenceProvider import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADDRESS_BOOK
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Suppress("TooManyFunctions")
class SettingsViewModel( class SettingsViewModel(
observeConfiguration: ObserveConfigurationUseCase, observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
@ -100,6 +102,7 @@ class SettingsViewModel(
onAdvancedSettingsClick = ::onAdvancedSettingsClick, onAdvancedSettingsClick = ::onAdvancedSettingsClick,
onAboutUsClick = ::onAboutUsClick, onAboutUsClick = ::onAboutUsClick,
onSendUsFeedbackClick = ::onSendUsFeedbackClick, onSendUsFeedbackClick = ::onSendUsFeedbackClick,
onAddressBookClick = ::onAddressBookClick
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null)
@ -152,6 +155,12 @@ class SettingsViewModel(
navigationCommand.emit(SUPPORT) navigationCommand.emit(SUPPORT)
} }
private fun onAddressBookClick() {
viewModelScope.launch {
navigationCommand.emit(ADDRESS_BOOK)
}
}
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> = private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
flow<Boolean?> { flow<Boolean?> {
emitAll(default.observe(standardPreferenceProvider())) emitAll(default.observe(standardPreferenceProvider()))

View File

@ -1,16 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="11dp" android:width="17dp"
android:height="10dp" android:height="16dp"
android:viewportWidth="11" android:viewportWidth="17"
android:viewportHeight="10"> android:viewportHeight="16">
<group> <group>
<clip-path <clip-path
android:pathData="M0,0h10.25v9.75h-10.25z"/> android:pathData="M0.249,0h16v16h-16z"/>
<path <path
android:pathData="M0,9.75V1.72H8.46V9.75H0ZM7.58,8.9V2.57H0.88V8.91H7.58V8.9Z" android:pathData="M3.583,10C2.961,10 2.651,10 2.406,9.899C2.079,9.763 1.82,9.504 1.684,9.177C1.583,8.932 1.583,8.621 1.583,8V3.467C1.583,2.72 1.583,2.347 1.728,2.061C1.856,1.81 2.06,1.606 2.311,1.479C2.596,1.333 2.969,1.333 3.716,1.333H8.249C8.871,1.333 9.181,1.333 9.426,1.435C9.753,1.57 10.013,1.83 10.148,2.156C10.249,2.401 10.249,2.712 10.249,3.333M8.383,14.667H12.783C13.529,14.667 13.903,14.667 14.188,14.521C14.439,14.394 14.643,14.189 14.771,13.939C14.916,13.653 14.916,13.28 14.916,12.533V8.133C14.916,7.387 14.916,7.013 14.771,6.728C14.643,6.477 14.439,6.273 14.188,6.145C13.903,6 13.529,6 12.783,6H8.383C7.636,6 7.263,6 6.977,6.145C6.727,6.273 6.523,6.477 6.395,6.728C6.249,7.013 6.249,7.387 6.249,8.133V12.533C6.249,13.28 6.249,13.653 6.395,13.939C6.523,14.189 6.727,14.394 6.977,14.521C7.263,14.667 7.636,14.667 8.383,14.667Z"
android:fillColor="#000000"/> android:strokeLineJoin="round"
<path android:strokeWidth="1.33"
android:pathData="M9.81,7.2C9.57,7.2 9.37,7.01 9.37,6.78V0.85H3.1C2.86,0.85 2.66,0.66 2.66,0.43C2.66,0.2 2.86,0.01 3.1,0.01H10.25V6.79C10.25,7.02 10.05,7.21 9.81,7.21V7.2Z" android:fillColor="#00000000"
android:fillColor="#000000"/> android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</group> </group>
</vector> </vector>

View File

@ -16,7 +16,6 @@ n <plurals name="account_history_item_message">
<item quantity="one">Message</item> <item quantity="one">Message</item>
<item quantity="other">Messages</item> <item quantity="other">Messages</item>
</plurals> </plurals>
<string name="account_history_item_no_message">No message included in transaction</string>
<string name="account_history_item_collapse_transaction">Collapse transaction</string> <string name="account_history_item_collapse_transaction">Collapse transaction</string>
<string name="account_history_item_transaction_id">Transaction ID</string> <string name="account_history_item_transaction_id">Transaction ID</string>
<string name="account_history_item_transaction_fee">Transaction Fee</string> <string name="account_history_item_transaction_fee">Transaction Fee</string>

View File

@ -0,0 +1,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="add_new_contact_title">Add New Contact</string>
<string name="add_new_contact_primary_btn">Save</string>
</resources>

View File

@ -0,0 +1,32 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88dp"
android:height="88dp"
android:viewportWidth="88"
android:viewportHeight="88">
<path
android:pathData="M0.5,36C0.5,16.118 16.618,0 36.5,0C56.382,0 72.5,16.118 72.5,36C72.5,55.882 56.382,72 36.5,72C16.618,72 0.5,55.882 0.5,36Z"
android:fillColor="#EBEBE6"/>
<path
android:pathData="M34.499,38L48.499,24M34.669,38.438L38.174,47.448C38.482,48.242 38.637,48.639 38.859,48.755C39.052,48.855 39.282,48.855 39.474,48.755C39.697,48.639 39.852,48.243 40.161,47.449L48.949,24.932C49.228,24.216 49.368,23.858 49.291,23.629C49.225,23.43 49.069,23.274 48.87,23.208C48.641,23.132 48.283,23.271 47.567,23.551L25.05,32.338C24.257,32.648 23.86,32.803 23.744,33.025C23.644,33.218 23.644,33.448 23.745,33.64C23.861,33.863 24.257,34.017 25.051,34.326L34.062,37.83C34.223,37.893 34.304,37.924 34.371,37.972C34.432,38.015 34.484,38.068 34.527,38.128C34.576,38.196 34.607,38.276 34.669,38.438Z"
android:strokeLineJoin="round"
android:strokeWidth="2.66667"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
<group>
<clip-path
android:pathData="M51,66C51,57.163 58.163,50 67,50C75.837,50 83,57.163 83,66C83,74.837 75.837,82 67,82C58.163,82 51,74.837 51,66Z"/>
<path
android:pathData="M67,50L67,50A16,16 0,0 1,83 66L83,66A16,16 0,0 1,67 82L67,82A16,16 0,0 1,51 66L51,66A16,16 0,0 1,67 50z"
android:fillColor="#ffffff"/>
<path
android:pathData="M51,66C51,57.176 58.176,50 67,50C75.824,50 83,57.176 83,66C83,74.824 75.824,82 67,82C58.176,82 51,74.824 51,66ZM72.707,58.575V61.01L65.935,70.195H72.707V73.425H68.342V76.101H65.658V73.425H61.293V70.99L68.058,61.804H61.293V58.575H65.658V55.892H68.342V58.575H72.707Z"
android:fillColor="#FCBB1A"
android:fillType="evenOdd"/>
</group>
<path
android:pathData="M67,49C57.611,49 50,56.611 50,66C50,75.389 57.611,83 67,83C76.389,83 84,75.389 84,66C84,56.611 76.389,49 67,49Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M10,4.167V15.833M4.167,10H15.833"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,1C5.925,1 1,5.925 1,12C1,18.075 5.925,23 12,23C18.075,23 23,18.075 23,12C23,5.925 18.075,1 12,1Z"
android:fillColor="#231F20"/>
<path
android:pathData="M12,1C5.925,1 1,5.925 1,12C1,18.075 5.925,23 12,23C18.075,23 23,18.075 23,12C23,5.925 18.075,1 12,1Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
<path
android:pathData="M11.857,6.544C11.952,6.531 12.048,6.531 12.143,6.544C12.252,6.56 12.354,6.598 12.435,6.629L12.457,6.637L15.203,7.667C15.512,7.782 15.783,7.883 15.992,8.068C16.174,8.229 16.315,8.432 16.402,8.659C16.501,8.92 16.501,9.209 16.5,9.539L16.5,12C16.5,13.413 15.731,14.592 14.901,15.457C14.064,16.329 13.107,16.944 12.601,17.239L12.58,17.251C12.488,17.306 12.368,17.376 12.21,17.41C12.08,17.438 11.92,17.438 11.79,17.41C11.632,17.376 11.512,17.306 11.42,17.251L11.399,17.239C10.893,16.944 9.936,16.329 9.1,15.457C8.269,14.592 7.5,13.413 7.5,12L7.5,9.539C7.499,9.209 7.499,8.92 7.598,8.659C7.685,8.432 7.826,8.229 8.008,8.068C8.217,7.883 8.488,7.782 8.797,7.667L11.544,6.637L11.565,6.629C11.646,6.598 11.748,6.56 11.857,6.544ZM14.104,10.854C14.299,10.658 14.299,10.342 14.104,10.146C13.908,9.951 13.592,9.951 13.396,10.146L11.5,12.043L10.854,11.396C10.658,11.201 10.342,11.201 10.146,11.396C9.951,11.592 9.951,11.908 10.146,12.104L11.146,13.104C11.342,13.299 11.658,13.299 11.854,13.104L14.104,10.854Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="address_book_title">Address Book</string>
<string name="address_book_add">Add New Contact</string>
<string name="address_book_empty">Your address book is empty</string>
<string name="address_book_version">Version %s</string>
</resources>

View File

@ -0,0 +1,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="new_contact_title">Add New Contact</string>
<string name="saved_contact_title">Saved Contact</string>
<string name="contact_name_label">Contact Name</string>
<string name="contact_name_hint">Enter contact name…</string>
<string name="contact_address_label">Wallet Address</string>
<string name="contact_address_hint">Enter Wallet Address…</string>
<string name="contact_name_error_not_unique">This contact name is already in use. Please choose a different name.</string>
<string name="contact_name_error_too_long">This contact name exceeds the 32-character limit. Please shorten the name.</string>
<string name="contact_address_error_not_unique">This wallet address is already in your Address Book.</string>
</resources>

View File

@ -1,14 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="11dp" android:width="24dp"
android:height="9dp" android:height="24dp"
android:viewportWidth="11" android:viewportWidth="24"
android:viewportHeight="9"> android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0.5,0.601h10v8h-10z"/>
<path <path
android:pathData="M3.289,8.601L0.5,6.047V5.181H10.5V6.162H2.064L3.926,7.868L3.289,8.601ZM7.71,0.601L10.499,3.155V4.053L0.499,4.022V3.04H8.935L7.073,1.334L7.71,0.601Z" android:pathData="M20,17H4M4,17L8,13M4,17L8,21M4,7H20M20,7L16,3M20,7L16,11"
android:fillColor="#ffffff" android:strokeLineJoin="round"
android:fillType="evenOdd"/> android:strokeWidth="2"
</group> android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector> </vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M5,13.333C5,15.174 6.492,16.667 8.333,16.667H11.667C13.508,16.667 15,15.174 15,13.333C15,11.492 13.508,10 11.667,10H8.333C6.492,10 5,8.508 5,6.667C5,4.826 6.492,3.333 8.333,3.333H11.667C13.508,3.333 15,4.826 15,6.667M10,1.667V18.333"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#87816F"
android:strokeLineCap="round"/>
</vector>

View File

@ -1,9 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="7dp" android:width="20dp"
android:height="13dp" android:height="21dp"
android:viewportWidth="7" android:viewportWidth="20"
android:viewportHeight="13"> android:viewportHeight="21">
<path <path
android:pathData="M7.009,3.942V2.534H4.48V0.98H2.926V2.534H0.397V4.405H4.321L0.397,9.728V11.138H2.926V12.683H4.48V11.138H7.009V9.267H3.085L7.009,3.942Z" android:pathData="M15.521,5.563V3.108H11.115V0.4H8.406V3.108H4V6.369H10.837L4,15.644V18.1H8.406V20.792H11.115V18.1H15.521V14.84H8.683L15.521,5.563Z"
android:fillColor="#ffffff"/> android:fillColor="#87816F"/>
</vector> </vector>

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="7dp"
android:height="12dp"
android:viewportWidth="7"
android:viewportHeight="12">
<path
android:pathData="M3.067,2.719V0.381H3.865V2.719H3.067ZM3.067,11.343V8.921H3.865V11.343H3.067ZM3.431,9.649C2.918,9.649 2.456,9.593 2.045,9.481C1.635,9.369 1.285,9.215 0.995,9.019C0.706,8.814 0.482,8.571 0.323,8.291C0.174,8.011 0.099,7.694 0.099,7.339C0.099,7.302 0.099,7.264 0.099,7.227C0.099,7.19 0.104,7.162 0.113,7.143H1.989C1.989,7.162 1.989,7.18 1.989,7.199C1.989,7.218 1.989,7.236 1.989,7.255C1.999,7.488 2.073,7.68 2.213,7.829C2.353,7.969 2.535,8.072 2.759,8.137C2.993,8.202 3.235,8.235 3.487,8.235C3.711,8.235 3.926,8.216 4.131,8.179C4.346,8.132 4.523,8.053 4.663,7.941C4.813,7.829 4.887,7.684 4.887,7.507C4.887,7.283 4.794,7.11 4.607,6.989C4.43,6.868 4.192,6.77 3.893,6.695C3.604,6.62 3.287,6.536 2.941,6.443C2.624,6.368 2.307,6.284 1.989,6.191C1.672,6.088 1.383,5.958 1.121,5.799C0.869,5.64 0.664,5.435 0.505,5.183C0.347,4.922 0.267,4.595 0.267,4.203C0.267,3.82 0.351,3.489 0.519,3.209C0.687,2.92 0.916,2.682 1.205,2.495C1.504,2.308 1.849,2.173 2.241,2.089C2.643,1.996 3.072,1.949 3.529,1.949C3.959,1.949 4.36,1.996 4.733,2.089C5.107,2.173 5.433,2.304 5.713,2.481C5.993,2.649 6.213,2.864 6.371,3.125C6.53,3.377 6.609,3.662 6.609,3.979C6.609,4.044 6.609,4.105 6.609,4.161C6.609,4.217 6.605,4.254 6.595,4.273H4.733V4.161C4.733,3.993 4.682,3.853 4.579,3.741C4.477,3.62 4.327,3.526 4.131,3.461C3.945,3.396 3.716,3.363 3.445,3.363C3.259,3.363 3.086,3.377 2.927,3.405C2.778,3.433 2.647,3.475 2.535,3.531C2.423,3.587 2.335,3.657 2.269,3.741C2.213,3.816 2.185,3.909 2.185,4.021C2.185,4.18 2.251,4.31 2.381,4.413C2.521,4.506 2.703,4.586 2.927,4.651C3.151,4.716 3.399,4.786 3.669,4.861C4.005,4.954 4.355,5.048 4.719,5.141C5.093,5.225 5.438,5.342 5.755,5.491C6.073,5.64 6.329,5.855 6.525,6.135C6.721,6.406 6.819,6.774 6.819,7.241C6.819,7.689 6.731,8.067 6.553,8.375C6.385,8.683 6.147,8.93 5.839,9.117C5.531,9.304 5.172,9.439 4.761,9.523C4.351,9.607 3.907,9.649 3.431,9.649Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -1,9 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp" android:width="36dp"
android:height="26dp" android:height="36dp"
android:viewportWidth="26" android:viewportWidth="36"
android:viewportHeight="26"> android:viewportHeight="36">
<path <path
android:pathData="M16.727,11.88H22.906C24.644,11.88 25.5,11.01 25.5,9.218V3.148C25.5,1.355 24.644,0.5 22.906,0.5H16.727C15.003,0.5 14.134,1.355 14.134,3.148V9.218C14.134,11.011 15.003,11.88 16.727,11.88ZM3.094,11.88H9.286C11.01,11.88 11.88,11.01 11.88,9.218V3.148C11.88,1.355 11.01,0.5 9.286,0.5H3.094C1.369,0.5 0.5,1.355 0.5,3.148V9.218C0.5,11.011 1.369,11.88 3.094,11.88ZM3.121,9.965C2.645,9.965 2.415,9.72 2.415,9.218V3.148C2.415,2.659 2.645,2.415 3.121,2.415H9.245C9.72,2.415 9.965,2.659 9.965,3.148V9.218C9.965,9.721 9.72,9.965 9.245,9.965H3.121ZM16.755,9.965C16.279,9.965 16.049,9.72 16.049,9.218V3.148C16.049,2.659 16.279,2.415 16.755,2.415H22.893C23.354,2.415 23.585,2.659 23.585,3.148V9.218C23.585,9.721 23.354,9.965 22.893,9.965H16.755ZM5.022,7.616H7.344C7.548,7.616 7.629,7.534 7.629,7.304V5.049C7.629,4.832 7.548,4.75 7.344,4.75H5.022C4.819,4.75 4.764,4.832 4.764,5.049V7.304C4.764,7.534 4.819,7.616 5.022,7.616ZM18.737,7.616H21.046C21.25,7.616 21.331,7.534 21.331,7.304V5.049C21.331,4.832 21.25,4.75 21.046,4.75H18.737C18.534,4.75 18.466,4.832 18.466,5.049V7.304C18.466,7.534 18.534,7.616 18.737,7.616ZM3.094,25.5H9.286C11.01,25.5 11.88,24.645 11.88,22.852V16.768C11.88,14.99 11.01,14.12 9.286,14.12H3.094C1.369,14.12 0.5,14.99 0.5,16.768V22.852C0.5,24.645 1.369,25.5 3.094,25.5ZM15.043,17.637H17.365C17.569,17.637 17.65,17.556 17.65,17.325V15.071C17.65,14.853 17.569,14.772 17.365,14.772H15.043C14.84,14.772 14.785,14.853 14.785,15.071V17.325C14.785,17.556 14.84,17.637 15.043,17.637ZM22.254,17.637H24.576C24.78,17.637 24.861,17.556 24.861,17.325V15.071C24.861,14.853 24.78,14.772 24.576,14.772H22.254C22.051,14.772 21.983,14.853 21.983,15.071V17.325C21.983,17.556 22.05,17.637 22.254,17.637ZM3.121,23.585C2.645,23.585 2.415,23.341 2.415,22.852V16.782C2.415,16.28 2.645,16.035 3.121,16.035H9.245C9.72,16.035 9.965,16.28 9.965,16.782V22.852C9.965,23.341 9.72,23.585 9.245,23.585H3.121ZM5.022,21.25H7.344C7.548,21.25 7.629,21.168 7.629,20.924V18.683C7.629,18.465 7.548,18.384 7.344,18.384H5.022C4.819,18.384 4.764,18.465 4.764,18.683V20.924C4.764,21.168 4.819,21.25 5.022,21.25ZM18.683,21.25H21.005C21.209,21.25 21.29,21.168 21.29,20.924V18.683C21.29,18.465 21.209,18.384 21.005,18.384H18.683C18.479,18.384 18.425,18.465 18.425,18.683V20.924C18.425,21.168 18.479,21.25 18.683,21.25ZM15.043,24.848H17.365C17.569,24.848 17.65,24.766 17.65,24.535V22.281C17.65,22.064 17.569,21.983 17.365,21.983H15.043C14.84,21.983 14.785,22.064 14.785,22.281V24.535C14.785,24.766 14.84,24.848 15.043,24.848ZM22.254,24.848H24.576C24.78,24.848 24.861,24.766 24.861,24.535V22.281C24.861,22.064 24.78,21.983 24.576,21.983H22.254C22.051,21.983 21.983,22.064 21.983,22.281V24.535C21.983,24.766 22.05,24.848 22.254,24.848Z" android:pathData="M0.5,8C0.5,3.858 3.858,0.5 8,0.5H28C32.142,0.5 35.5,3.858 35.5,8V28C35.5,32.142 32.142,35.5 28,35.5H8C3.858,35.5 0.5,32.142 0.5,28V8Z"
android:fillColor="#000000"/> android:fillColor="#ffffff"/>
<path
android:strokeWidth="1"
android:pathData="M0.5,8C0.5,3.858 3.858,0.5 8,0.5H28C32.142,0.5 35.5,3.858 35.5,8V28C35.5,32.142 32.142,35.5 28,35.5H8C3.858,35.5 0.5,32.142 0.5,28V8Z"
android:fillColor="#00000000"
android:strokeColor="#D9D8CF"/>
<path
android:pathData="M13.833,18H18V22.167M10.508,18H10.5M14.675,22.167H14.667M18.008,25.5H18M25.508,18H25.5M10.5,22.167H11.75M20.917,18H22.583M10.5,25.5H14.667M18,9.667V14.667M22.667,25.5H24.167C24.633,25.5 24.867,25.5 25.045,25.409C25.202,25.329 25.329,25.202 25.409,25.045C25.5,24.867 25.5,24.633 25.5,24.167V22.667C25.5,22.2 25.5,21.967 25.409,21.788C25.329,21.632 25.202,21.504 25.045,21.424C24.867,21.333 24.633,21.333 24.167,21.333H22.667C22.2,21.333 21.967,21.333 21.788,21.424C21.632,21.504 21.504,21.632 21.424,21.788C21.333,21.967 21.333,22.2 21.333,22.667V24.167C21.333,24.633 21.333,24.867 21.424,25.045C21.504,25.202 21.632,25.329 21.788,25.409C21.967,25.5 22.2,25.5 22.667,25.5ZM22.667,14.667H24.167C24.633,14.667 24.867,14.667 25.045,14.576C25.202,14.496 25.329,14.368 25.409,14.212C25.5,14.033 25.5,13.8 25.5,13.333V11.833C25.5,11.367 25.5,11.133 25.409,10.955C25.329,10.798 25.202,10.671 25.045,10.591C24.867,10.5 24.633,10.5 24.167,10.5H22.667C22.2,10.5 21.967,10.5 21.788,10.591C21.632,10.671 21.504,10.798 21.424,10.955C21.333,11.133 21.333,11.367 21.333,11.833V13.333C21.333,13.8 21.333,14.033 21.424,14.212C21.504,14.368 21.632,14.496 21.788,14.576C21.967,14.667 22.2,14.667 22.667,14.667ZM11.833,14.667H13.333C13.8,14.667 14.033,14.667 14.212,14.576C14.368,14.496 14.496,14.368 14.576,14.212C14.667,14.033 14.667,13.8 14.667,13.333V11.833C14.667,11.367 14.667,11.133 14.576,10.955C14.496,10.798 14.368,10.671 14.212,10.591C14.033,10.5 13.8,10.5 13.333,10.5H11.833C11.367,10.5 11.133,10.5 10.955,10.591C10.798,10.671 10.671,10.798 10.591,10.955C10.5,11.133 10.5,11.367 10.5,11.833V13.333C10.5,13.8 10.5,14.033 10.591,14.212C10.671,14.368 10.798,14.496 10.955,14.576C11.133,14.667 11.367,14.667 11.833,14.667Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector> </vector>

View File

@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="16dp"
android:viewportWidth="20"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h20v16h-20z"/>
<path
android:pathData="M19.92,0.07C19.81,-0.03 19.5,0 19.31,0.06C13.04,2.2 6.77,4.34 0.5,6.5C0.31,6.56 0.17,6.75 0,6.88C0.13,7.04 0.24,7.25 0.41,7.35C1.57,8.05 2.76,8.72 3.92,9.43C4.15,9.57 4.37,9.85 4.44,10.11C4.93,11.9 5.38,13.7 5.86,15.49C5.91,15.68 6.05,15.96 6.2,15.99C6.35,16.02 6.6,15.85 6.74,15.7C7.68,14.75 8.62,13.79 9.53,12.82C9.79,12.54 9.99,12.48 10.35,12.66C11.64,13.32 12.95,13.93 14.25,14.56C14.39,14.63 14.53,14.69 14.77,14.79C14.89,14.6 15.04,14.43 15.12,14.23C16.74,9.71 18.35,5.18 19.96,0.65C20.02,0.47 20.04,0.16 19.93,0.06L19.92,0.07ZM4.26,8.81C3.28,8.21 2.28,7.64 1.2,7C6.3,5.25 11.28,3.55 16.26,1.84L16.3,1.92C16.16,2.01 16.03,2.1 15.89,2.18C12.27,4.38 8.66,6.57 5.04,8.78C4.76,8.95 4.55,8.99 4.26,8.8V8.81ZM6.71,10.97C6.53,11.87 6.39,12.77 6.12,13.68C6.02,13.3 5.92,12.93 5.82,12.55C5.6,11.69 5.38,10.84 5.15,9.98C5.09,9.76 5.06,9.58 5.32,9.43C8.56,7.48 11.79,5.51 15.03,3.55C15.07,3.53 15.12,3.53 15.3,3.49C14.16,4.44 13.13,5.3 12.09,6.16C11.63,6.54 8.6,8.99 8.14,9.38C8.07,9.44 8,9.5 7.95,9.57C7.62,9.81 7.3,10.08 7.01,10.37C6.85,10.52 6.75,10.77 6.71,10.98V10.97ZM6.81,14.61C7.01,13.42 7.19,12.38 7.38,11.25C8,11.55 8.58,11.83 9.2,12.13C8.41,12.95 7.65,13.73 6.8,14.61H6.81ZM14.48,13.9C12.24,12.81 10.04,11.75 7.77,10.65C8.09,10.38 8.39,10.18 8.62,9.92C11.01,7.93 15.96,3.88 18.35,1.89C18.49,1.77 18.63,1.66 18.86,1.6C17.41,5.68 15.95,9.77 14.48,13.9Z"
android:fillColor="#000000"/>
</group>
</vector>

View File

@ -1,19 +1,18 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_stage_send_title">Send</string> <string name="send_stage_send_title">Send</string>
<string name="send_scan_content_description">Scan</string> <string name="send_scan_content_description">Scan</string>
<string name="send_address_label">To:</string> <string name="send_address_label">Send to</string>
<string name="send_address_hint">Zcash Address</string> <string name="send_address_hint">Zcash Address</string>
<string name="send_address_invalid">Invalid address</string> <string name="send_address_invalid">Invalid address</string>
<string name="send_amount_label">Amount:</string> <string name="send_amount_label">Amount</string>
<string name="send_amount_hint"><xliff:g id="currency" example="ZEC">%1$s</xliff:g> Amount</string> <string name="send_amount_hint"><xliff:g id="currency" example="ZEC">%1$s</xliff:g></string>
<string name="send_usd_amount_hint">USD Amount</string> <string name="send_usd_amount_hint">USD</string>
<string name="send_amount_insufficient_balance">Insufficient funds</string> <string name="send_amount_insufficient_balance">Insufficient funds</string>
<string name="send_amount_invalid">Invalid amount</string> <string name="send_amount_invalid">Invalid amount</string>
<string name="send_memo_label">Message</string> <string name="send_memo_label">Message</string>
<string name="send_memo_hint">Write private message here…</string> <string name="send_memo_hint">Write encrypted message here…</string>
<string name="send_memo_bytes_counter"> <string name="send_memo_bytes_counter">
<xliff:g id="typed_bytes" example="12">%1$s</xliff:g>/ <xliff:g id="typed_bytes" example="12">%1$s</xliff:g>/<xliff:g id="max_bytes" example="512">%2$s</xliff:g>
<xliff:g id="max_bytes" example="500">%2$s</xliff:g>
</string> </string>
<string name="send_create">Review</string> <string name="send_create">Review</string>
<string name="send_fee">(Typical Fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s</xliff:g>)</string> <string name="send_fee">(Typical Fee &lt; <xliff:g id="fee_amount" example="0.001">%1$s</xliff:g>)</string>
@ -23,5 +22,6 @@
<string name="send_dialog_error_btn">OK</string> <string name="send_dialog_error_btn">OK</string>
<string name="send_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string> <string name="send_abbreviated_address_format" formatted="true"><xliff:g id="first_five" example="zs1g7">%1$s</xliff:g><xliff:g id="last_five" example="mvyzg">%2$s</xliff:g></string>
<string name="send_transparent_memo">Transparent transactions can\'t have memos</string>
</resources> </resources>

View File

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

View File

@ -1,12 +1,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_stage_confirmation_title">Confirmation</string> <string name="send_stage_confirmation_title">Confirmation</string>
<string name="send_confirmation_amount">Amount:</string> <string name="send_confirmation_amount">Total Amount</string>
<string name="send_confirmation_address">To:</string> <string name="send_confirmation_address">Sending to</string>
<string name="send_confirmation_memo">Message</string> <string name="send_confirmation_memo">Message</string>
<string name="send_confirmation_fee">Fee:</string> <string name="send_confirmation_fee">Fee</string>
<string name="send_confirmation_amount_item">Amount</string>
<string name="send_confirmation_send_button">Send</string> <string name="send_confirmation_send_button">Send</string>
<string name="send_confirmation_back_button">Go Back</string> <string name="send_confirmation_back_button">Cancel</string>
<string name="send_confirmation_dialog_error_title">Transaction Failed</string> <string name="send_confirmation_dialog_error_title">Transaction Failed</string>
<string name="send_confirmation_dialog_error_text">An error occurred and the attempt to send funds failed. Try it again, please.</string> <string name="send_confirmation_dialog_error_text">An error occurred and the attempt to send funds failed. Try it again, please.</string>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,20C0,8.954 8.954,0 20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20Z"
android:fillColor="#454243"/>
<path
android:pathData="M12.5,26.667C14.446,24.602 17.089,23.333 20,23.333C22.911,23.333 25.553,24.602 27.5,26.667M23.75,16.25C23.75,18.321 22.071,20 20,20C17.929,20 16.25,18.321 16.25,16.25C16.25,14.179 17.929,12.5 20,12.5C22.071,12.5 23.75,14.179 23.75,16.25Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#E8E8E8"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,20C0,8.954 8.954,0 20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20Z"
android:fillColor="#EBEBE6"/>
<path
android:pathData="M12.5,26.667C14.446,24.602 17.089,23.333 20,23.333C22.911,23.333 25.553,24.602 27.5,26.667M23.75,16.25C23.75,18.321 22.071,20 20,20C17.929,20 16.25,18.321 16.25,16.25C16.25,14.179 17.929,12.5 20,12.5C22.071,12.5 23.75,14.179 23.75,16.25Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector>

View File

@ -4,6 +4,7 @@
<string name="settings_about_us">About Us</string> <string name="settings_about_us">About Us</string>
<string name="settings_feedback">Send Us Feedback</string> <string name="settings_feedback">Send Us Feedback</string>
<string name="settings_version">Version %s</string> <string name="settings_version">Version %s</string>
<string name="settings_address_book">Address Book</string>
<string name="settings_troubleshooting_menu_content_description">Additional settings</string> <string name="settings_troubleshooting_menu_content_description">Additional settings</string>
<string name="settings_troubleshooting_rescan">Rescan blockchain</string> <string name="settings_troubleshooting_rescan">Rescan blockchain</string>

View File

@ -0,0 +1,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="update_contact_title">Saved Contact</string>
<string name="update_contact_primary_btn">Save</string>
<string name="update_contact_secondary_btn">Delete</string>
</resources>

View File

@ -33,7 +33,6 @@ import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.SeedPhraseFixture import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
@ -48,6 +47,7 @@ import co.electriccoin.zcash.ui.screen.home.HomeTag
import co.electriccoin.zcash.ui.screen.restore.RestoreTag import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.securitywarning.view.SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG import co.electriccoin.zcash.ui.screen.securitywarning.view.SecurityScreenTag.ACKNOWLEDGE_CHECKBOX_TAG
import co.electriccoin.zcash.ui.screen.send.SendTag
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -475,11 +475,8 @@ private fun sendZecScreenshots(
// Screenshot: Empty form // Screenshot: Empty form
ScreenshotTest.takeScreenshot(tag, "Send 1") ScreenshotTest.takeScreenshot(tag, "Send 1")
composeTestRule.onNodeWithText( composeTestRule.onNode(
resContext.getString( hasTestTag(SendTag.SEND_AMOUNT_FIELD)
R.string.send_amount_hint,
ZcashCurrency.fromResources(resContext).name
)
).also { ).also {
val separators = MonetarySeparators.current() val separators = MonetarySeparators.current()