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
- The Receive screen UI has been redesigned
- Send screen redesigned
- Confirmation screen redesigned
- History item redesigned
## [1.2 (739)] - 2024-09-27

View File

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

View File

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

View File

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

View File

@ -137,4 +137,6 @@ data class TextFieldState(
val error: StringResource? = null,
val isEnabled: Boolean = true,
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
@Composable
private fun BottomBarPreview() =

View File

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

View File

@ -1,19 +1,20 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.InteractionSource
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.Spacer
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.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.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")
@Composable
fun ZashiTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
readOnly: Boolean = false,
textStyle: TextStyle = ZashiTypography.textMd.copy(fontWeight = FontWeight.Medium),
label: @Composable (() -> Unit)? = null,
@ -63,16 +124,8 @@ fun ZashiTextField(
colors: ZashiTextFieldColors = ZashiTextFieldDefaults.defaultColors()
) {
TextFieldInternal(
value = state.value.getValue(),
onValueChange = state.onValueChange,
modifier =
modifier then
Modifier.border(
width = 1.dp,
color = colors.borderColor,
shape = ZashiTextFieldDefaults.shape
),
enabled = state.isEnabled,
state = state,
modifier = modifier,
readOnly = readOnly,
textStyle = textStyle,
label = label,
@ -82,7 +135,6 @@ fun ZashiTextField(
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
isError = state.error != null,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
@ -91,17 +143,16 @@ fun ZashiTextField(
minLines = minLines,
interactionSource = interactionSource,
shape = shape,
colors = colors.toTextFieldColors(),
colors = colors,
innerModifier = innerModifier
)
}
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TextFieldInternal(
value: String,
onValueChange: (String) -> Unit,
enabled: Boolean,
state: TextFieldState,
readOnly: Boolean,
textStyle: TextStyle,
label: @Composable (() -> Unit)?,
@ -111,7 +162,6 @@ private fun TextFieldInternal(
prefix: @Composable (() -> Unit)?,
suffix: @Composable (() -> Unit)?,
supportingText: @Composable (() -> Unit)?,
isError: Boolean,
visualTransformation: VisualTransformation,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
@ -120,27 +170,41 @@ private fun TextFieldInternal(
minLines: Int,
interactionSource: MutableInteractionSource,
shape: Shape,
colors: TextFieldColors,
colors: ZashiTextFieldColors,
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
val textColor =
textStyle.color.takeOrElse {
colors.textColor(enabled, isError, interactionSource).value
androidColors.textColor(state.isEnabled, state.isError, interactionSource).value
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.selectionColors) {
CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) {
Column(
modifier = modifier,
) {
BasicTextField(
value = value,
value = state.value.getValue(),
modifier =
modifier
.defaultMinSize(minWidth = TextFieldDefaults.MinWidth),
onValueChange = onValueChange,
enabled = enabled,
innerModifier.fillMaxWidth() then
if (borderColor == Color.Unspecified) {
Modifier
} 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,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor(isError).value),
cursorBrush = SolidColor(androidColors.cursorColor(state.isError).value),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
@ -151,7 +215,7 @@ private fun TextFieldInternal(
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value,
value = state.value.getValue(),
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
@ -163,41 +227,31 @@ private fun TextFieldInternal(
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
enabled = state.isEnabled,
isError = state.isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
colors = androidColors,
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
@ -206,26 +260,58 @@ data class ZashiTextFieldColors(
val hintColor: Color,
val borderColor: 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
private fun ZashiTextFieldColors.toTextFieldColors() =
@Composable
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(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
disabledTextColor = textColor,
errorTextColor = Color.Unspecified,
disabledTextColor = disabledTextColor,
errorTextColor = errorTextColor,
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
disabledContainerColor = containerColor,
errorContainerColor = Color.Unspecified,
disabledContainerColor = disabledContainerColor,
errorContainerColor = errorContainerColor,
cursorColor = Color.Unspecified,
errorCursorColor = Color.Unspecified,
selectionColors = null,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Unspecified,
errorIndicatorColor = Color.Transparent,
focusedLeadingIconColor = Color.Unspecified,
unfocusedLeadingIconColor = Color.Unspecified,
disabledLeadingIconColor = Color.Unspecified,
@ -238,14 +324,14 @@ private fun ZashiTextFieldColors.toTextFieldColors() =
unfocusedLabelColor = Color.Unspecified,
disabledLabelColor = Color.Unspecified,
errorLabelColor = Color.Unspecified,
focusedPlaceholderColor = hintColor,
unfocusedPlaceholderColor = hintColor,
disabledPlaceholderColor = hintColor,
errorPlaceholderColor = Color.Unspecified,
focusedPlaceholderColor = placeholderColor,
unfocusedPlaceholderColor = placeholderColor,
disabledPlaceholderColor = disabledPlaceholderColor,
errorPlaceholderColor = errorPlaceholderColor,
focusedSupportingTextColor = hintColor,
unfocusedSupportingTextColor = hintColor,
disabledSupportingTextColor = hintColor,
errorSupportingTextColor = Color.Unspecified,
disabledSupportingTextColor = disabledHintColor,
errorSupportingTextColor = errorHintColor,
focusedPrefixColor = Color.Unspecified,
unfocusedPrefixColor = Color.Unspecified,
disabledPrefixColor = Color.Unspecified,
@ -255,29 +341,52 @@ private fun ZashiTextFieldColors.toTextFieldColors() =
disabledSuffixColor = Color.Unspecified,
errorSuffixColor = Color.Unspecified,
)
}
object ZashiTextFieldDefaults {
val shape: Shape
get() = RoundedCornerShape(8.dp)
@Suppress("LongParameterList")
@Composable
fun defaultColors(
textColor: Color = ZashiColors.Inputs.Default.text,
textColor: Color = ZashiColors.Inputs.Filled.text,
hintColor: Color = ZashiColors.Inputs.Default.hint,
borderColor: Color = ZashiColors.Inputs.Default.stroke,
containerColor: Color = ZashiColors.Inputs.Default.bg
borderColor: Color = Color.Unspecified,
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(
textColor = textColor,
hintColor = hintColor,
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
@Composable
private fun ZashiTextFieldPreview() =
private fun DefaultPreview() =
ZcashTheme {
ZashiTextField(
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 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 = "2: Dark preview", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Retention(SOURCE)

View File

@ -32,6 +32,10 @@ android {
setOf(
"src/main/res/ui/about",
"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/authentication",
"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.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
@ -49,11 +50,8 @@ internal fun ComposeContentTestRule.setValidAmount() {
}
internal fun ComposeContentTestRule.setAmount(amount: String) {
onNodeWithText(
getStringResourceWithArgs(
R.string.send_amount_hint,
ZcashCurrency.fromResources(getAppContext()).name
)
onNode(
hasTestTag(SendTag.SEND_AMOUNT_FIELD)
).also {
it.performTextClearance()
it.performTextInput(amount)

View File

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

View File

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

View File

@ -1,9 +1,12 @@
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.GetSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
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.ObserveFastestServersUseCase
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.RefreshFastestServersUseCase
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 org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
@ -29,4 +36,11 @@ val useCaseModule =
singleOf(::ObserveConfigurationUseCase)
singleOf(::RescanBlockchainUseCase)
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.WalletViewModel
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.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.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
@ -39,4 +42,7 @@ val viewModelModule =
viewModelOf(::WhatsNewViewModel)
viewModelOf(::UpdateViewModel)
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.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.ZecSend
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.SEND_CONFIRM_AMOUNT
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_SCAN_RECIPIENT_ADDRESS
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.CHOOSE_SERVER
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_EXCHANGE_RATE_OPT_IN
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.common.compose.LocalNavController
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.popExitTransition
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.authentication.AuthenticationUseCase
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
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.disconnected.WrapDisconnected
import co.electriccoin.zcash.ui.screen.exchangerate.optin.AndroidExchangeRateOptIn
@ -263,6 +272,19 @@ internal fun MainActivity.Navigation() {
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 SUPPORT = "support"
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 getAllServers(): List<LightWalletEndpoint>
suspend fun getSynchronizer(): Synchronizer
}
class WalletRepositoryImpl(
@ -238,4 +240,6 @@ class WalletRepositoryImpl(
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
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
class GetSynchronizerUseCase(
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -34,8 +36,11 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.TransactionState
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.toZecStringAbbreviated
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.StyledBalanceDefaults
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.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.screen.account.HistoryTag
import co.electriccoin.zcash.ui.screen.account.fixture.TransactionUiFixture
@ -257,7 +265,7 @@ private fun ComposableHistoryListItemPreview() {
}
@Composable
@Preview("History List Item Expanded")
@PreviewScreens
private fun ComposableHistoryListItemExpandedPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface {
@ -321,43 +329,49 @@ private fun HistoryItem(
TransactionExtendedState.SENT -> {
typeText = stringResource(id = R.string.account_history_item_sent)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = MaterialTheme.colorScheme.onBackground
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRegular
textColor = ZashiColors.Text.textPrimary
textStyle = ZashiTypography.textSm
}
TransactionExtendedState.SENDING -> {
typeText = stringResource(id = R.string.account_history_item_sending)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = ZcashTheme.colors.textDescription
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRunning
textColor = ZashiColors.Text.textPrimary
textStyle = ZashiTypography.textSm
}
TransactionExtendedState.SEND_FAILED -> {
typeText = stringResource(id = R.string.account_history_item_send_failed)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_send_icon)
textColor = ZcashTheme.colors.historyRedColor
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleFailed
textColor = ZashiColors.Text.textError
textStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
}
TransactionExtendedState.RECEIVED -> {
typeText = stringResource(id = R.string.account_history_item_received)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = MaterialTheme.colorScheme.onBackground
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRegular
textColor = ZashiColors.Text.textPrimary
textStyle = ZashiTypography.textSm
}
TransactionExtendedState.RECEIVING -> {
typeText = stringResource(id = R.string.account_history_item_receiving)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = ZcashTheme.colors.textDescription
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleRunning
textColor = ZashiColors.Text.textPrimary
textStyle = ZashiTypography.textSm
}
TransactionExtendedState.RECEIVE_FAILED -> {
typeText = stringResource(id = R.string.account_history_item_receive_failed)
typeIcon = ImageVector.vectorResource(R.drawable.ic_trx_receive_icon)
textColor = ZcashTheme.colors.historyRedColor
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.titleFailed
textColor = ZashiColors.Text.textError
textStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
}
}
@ -374,15 +388,22 @@ private fun HistoryItem(
TrxItemState.EXPANDED
)
)
} else {
onAction(
TrxItemAction.ExpandableStateChange(
transaction.overview.rawId,
TrxItemState.COLLAPSED
)
)
}
}
.padding(all = ZcashTheme.dimens.spacingLarge)
.padding(24.dp)
.animateContentSize()
)
) {
Image(
imageVector = typeIcon,
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor),
colorFilter = ColorFilter.tint(ZashiColors.Text.textPrimary),
contentDescription = typeText,
modifier = Modifier.padding(top = ZcashTheme.dimens.spacingTiny)
)
@ -399,12 +420,10 @@ private fun HistoryItem(
onAction = onAction
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingXtiny))
Spacer(modifier = Modifier.height(2.dp))
// To add an extra spacing at the end
Column(
modifier = Modifier.padding(end = ZcashTheme.dimens.spacingUpLarge)
) {
Column {
val isInExpectedState =
transaction.expandableState == TrxItemState.EXPANDED_ADDRESS ||
transaction.expandableState == TrxItemState.EXPANDED_ALL
@ -415,13 +434,13 @@ private fun HistoryItem(
) {
HistoryItemExpandedAddressPart(onAction, transaction.recipient)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(modifier = Modifier.height(16.dp))
}
HistoryItemDatePart(transaction)
if (transaction.expandableState.isInAnyExtendedState()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(modifier = Modifier.height(32.dp))
HistoryItemExpandedPart(onAction, transaction)
}
@ -431,7 +450,7 @@ private fun HistoryItem(
}
@Composable
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
private fun HistoryItemCollapsedMainPart(
transaction: TransactionUi,
typeText: String,
@ -454,6 +473,7 @@ private fun HistoryItemCollapsedMainPart(
text = typeText,
style = textStyle,
color = textColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.testTag(HistoryTag.TRANSACTION_ITEM_TITLE)
)
@ -468,15 +488,18 @@ private fun HistoryItemCollapsedMainPart(
val valueTextStyle: TextStyle
val valueTextColor: Color
if (transaction.overview.getExtendedState().isFailed()) {
valueTextStyle = ZcashTheme.extendedTypography.transactionItemStyles.contentLineThrough
valueTextColor = ZcashTheme.colors.historyRedColor
valueTextStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
valueTextColor = ZashiColors.Text.textError
} else {
valueTextStyle = ZcashTheme.extendedTypography.transactionItemStyles.valueFirstPart
valueTextStyle = ZashiTypography.textSm
valueTextColor =
if (transaction.overview.isSentTransaction) {
ZcashTheme.colors.historyRedColor
ZashiColors.Text.textError
} else {
ZcashTheme.colors.textPrimary
ZashiColors.Text.textPrimary
}
}
@ -500,7 +523,7 @@ private fun HistoryItemCollapsedMainPart(
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = valueTextStyle,
leastSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.valueSecondPart
leastSignificantPart = ZashiTypography.textXxs
),
textColor = valueTextColor,
)
@ -545,8 +568,8 @@ private fun HistoryItemCollapsedAddressPart(
Text(
text = transaction.recipient.addressValue,
style = ZcashTheme.extendedTypography.transactionItemStyles.addressCollapsed,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
@ -580,26 +603,29 @@ private fun HistoryItemExpandedAddressPart(
) {
Text(
text = recipient.addressValue,
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textPrimary,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
modifier =
Modifier
.fillMaxWidth(EXPANDED_ADDRESS_WIDTH_RATIO)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(modifier = Modifier.height(16.dp))
TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor,
iconTintColor = ZashiColors.Text.textTertiary,
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onAction(TrxItemAction.AddressClick(recipient)) }
.padding(all = ZcashTheme.dimens.spacingTiny)
.clickable(
role = Role.Button,
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) {
Text(
text = formattedDate,
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
@ -643,8 +669,8 @@ private fun HistoryItemExpandedPart(
id = R.plurals.account_history_item_message,
count = transaction.messages!!.size
),
style = ZcashTheme.extendedTypography.transactionItemStyles.contentMedium,
color = ZcashTheme.colors.textPrimary
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
@ -662,51 +688,17 @@ private fun HistoryItemExpandedPart(
)
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(
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)))
Spacer(modifier = Modifier.height(16.dp))
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 {
@ -735,35 +727,38 @@ private fun HistoryItemTransactionIdPart(
) {
Text(
text = stringResource(id = R.string.account_history_item_transaction_id),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = txIdString,
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textPrimary,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
modifier =
Modifier
.fillMaxWidth(EXPANDED_TRANSACTION_WIDTH_RATIO)
.testTag(HistoryTag.TRANSACTION_ID)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(modifier = Modifier.height(16.dp))
TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor,
iconTintColor = ZashiColors.Text.textTertiary,
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onAction(TrxItemAction.TransactionIdClick(txIdString)) }
.padding(all = ZcashTheme.dimens.spacingTiny)
.clickable(
role = Role.Button,
indication = rememberRipple(radius = 2.dp, color = ZashiColors.Text.textTertiary),
interactionSource = remember { MutableInteractionSource() }
) { onAction(TrxItemAction.TransactionIdClick(txIdString)) }
)
} else {
Row(
@ -782,21 +777,21 @@ private fun HistoryItemTransactionIdPart(
)
)
}
.padding(all = ZcashTheme.dimens.spacingTiny)
) {
Text(
text = stringResource(id = R.string.account_history_item_transaction_id),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Spacer(modifier = Modifier.weight(1f))
Text(
text = txIdString,
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
@ -813,14 +808,14 @@ private fun HistoryItemTransactionFeePart(
fee: Zatoshi?,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Row(modifier = modifier) {
Text(
text = stringResource(id = R.string.account_history_item_transaction_fee),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(modifier = Modifier.weight(1f))
if (fee == null) {
Text(
@ -829,8 +824,8 @@ private fun HistoryItemTransactionFeePart(
id = R.string.account_history_item_transaction_fee_typical,
DEFAULT_FEE
),
style = ZcashTheme.extendedTypography.transactionItemStyles.feeFirstPart,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary,
)
} else {
StyledBalance(
@ -839,10 +834,10 @@ private fun HistoryItemTransactionFeePart(
isHideBalances = false,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.feeFirstPart,
leastSignificantPart = ZcashTheme.extendedTypography.transactionItemStyles.feeSecondPart
mostSignificantPart = ZashiTypography.textSm,
leastSignificantPart = ZashiTypography.textXxs
),
textColor = ZcashTheme.colors.textDescription
textColor = ZashiColors.Text.textTertiary
)
}
}
@ -858,11 +853,14 @@ private fun HistoryItemMessagePart(
val textStyle: TextStyle
val textColor: Color
if (state.isFailed()) {
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.contentLineThrough
textColor = ZcashTheme.colors.historyRedColor
textStyle =
ZashiTypography.textSm.copy(
textDecoration = TextDecoration.LineThrough
)
textColor = ZashiColors.Text.textError
} else {
textStyle = ZcashTheme.extendedTypography.transactionItemStyles.content
textColor = ZcashTheme.colors.textPrimary
textStyle = ZashiTypography.textSm
textColor = ZashiColors.Text.textPrimary
}
Column(modifier = modifier.then(Modifier.fillMaxWidth())) {
@ -870,12 +868,12 @@ private fun HistoryItemMessagePart(
val bubbleStroke: BorderStroke
val arrowAlignment: BubbleArrowAlignment
if (state.isSendType()) {
bubbleBackgroundColor = Color.Transparent
bubbleStroke = BorderStroke(1.dp, ZcashTheme.colors.textFieldFrame)
bubbleBackgroundColor = ZashiColors.Utility.Gray.utilityGray200 orDark Color.Transparent
bubbleStroke = BorderStroke(1.dp, ZashiColors.Text.textPrimary)
arrowAlignment = BubbleArrowAlignment.BottomLeft
} else {
bubbleBackgroundColor = ZcashTheme.colors.historyMessageBubbleColor
bubbleStroke = BorderStroke(1.dp, ZcashTheme.colors.historyMessageBubbleStrokeColor)
bubbleBackgroundColor = ZashiColors.Utility.Gray.utilityGray200 orDark Color.Transparent
bubbleStroke = BorderStroke(1.dp, ZashiColors.Text.textPrimary)
arrowAlignment = BubbleArrowAlignment.BottomRight
}
@ -893,19 +891,23 @@ private fun HistoryItemMessagePart(
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Spacer(modifier = Modifier.height(16.dp))
TextWithIcon(
text = stringResource(id = R.string.account_history_item_tap_to_copy),
style = ZcashTheme.extendedTypography.transactionItemStyles.content,
color = ZcashTheme.colors.textDescription,
style = ZashiTypography.textSm,
color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
fontWeight = FontWeight.SemiBold,
iconVector = ImageVector.vectorResource(R.drawable.ic_trx_copy),
iconTintColor = ZcashTheme.colors.secondaryColor,
iconTintColor = ZashiColors.Text.textTertiary,
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onAction(TrxItemAction.MessageClick(message)) }
.padding(all = ZcashTheme.dimens.spacingTiny)
.clickable(
onClick = { onAction(TrxItemAction.MessageClick(message)) },
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
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
data class AdvancedSettingsState(
val onBack: () -> Unit,
@ -9,5 +9,5 @@ data class AdvancedSettingsState(
val onChooseServerClick: () -> Unit,
val onCurrencyConversionClick: () -> 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.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.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
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.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
@ -172,7 +172,7 @@ private fun AdvancedSettingsPreview() =
onCurrencyConversionClick = {},
onDeleteZashiClick = {},
coinbaseButton =
ButtonState(
ZashiSettingsListItemState(
text = stringRes("Coinbase"),
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.GetZcashCurrencyProvider
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.screen.advancedsettings.AdvancedSettingsState
import kotlinx.coroutines.flow.MutableSharedFlow
@ -33,7 +33,7 @@ class AdvancedSettingsViewModel(
onCurrencyConversionClick = ::onCurrencyConversionClick,
onDeleteZashiClick = {},
coinbaseButton =
ButtonState(
ZashiSettingsListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from the UI
// in the Testnet build
text = stringRes(R.string.advanced_settings_coinbase, getZcashCurrency.getLocalizedName()),

View File

@ -436,7 +436,8 @@ private fun CustomServerRadioButton(
colors =
ZashiTextFieldDefaults.defaultColors(
containerColor = ZashiColors.Surfaces.bgPrimary,
textColor = ZashiColors.Text.textPrimary
textColor = ZashiColors.Text.textPrimary,
borderColor = ZashiColors.Inputs.Default.stroke,
) orDark
ZashiTextFieldDefaults.defaultColors(
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 {
const val SEND_FORM_BUTTON = "send_form_button"
const val SEND_FAILED_BUTTON = "send_failed_button"
const val SEND_SUCCESS_BUTTON = "send_success_button"
const val SEND_AMOUNT_FIELD = "SEND_AMOUNT_FIELD"
}

View File

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

View File

@ -26,14 +26,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.FirstClassByteArray
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.sdk.extension.toZecStringFull
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.BlankSurface
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BubbleArrowAlignment
import co.electriccoin.zcash.ui.design.component.BubbleMessage
import co.electriccoin.zcash.ui.design.component.ButtonState
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.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.StyledBalance
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.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.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.screen.exchangerate.widget.StyledExchangeLabel
import co.electriccoin.zcash.ui.screen.sendconfirmation.SendConfirmationTag
@ -300,18 +311,21 @@ private fun SendConfirmationTopAppBar(
SendConfirmationStage.Confirmation,
SendConfirmationStage.Sending,
is SendConfirmationStage.Failure,
is SendConfirmationStage.FailureGrpc, -> {
SmallTopAppBar(
subTitle = subTitle,
titleText = stringResource(id = R.string.send_stage_confirmation_title),
is SendConfirmationStage.FailureGrpc,
-> {
ZashiSmallTopAppBar(
title = stringResource(id = R.string.send_stage_confirmation_title),
subtitle = subTitle,
)
}
SendConfirmationStage.MultipleTrxFailure -> {
SmallTopAppBar(
subTitle = subTitle,
titleText = stringResource(id = R.string.send_confirmation_multiple_error_title),
)
}
SendConfirmationStage.MultipleTrxFailureReported -> {
SmallTopAppBar(
subTitle = subTitle,
@ -366,6 +380,7 @@ private fun SendConfirmationMainContent(
)
}
}
is SendConfirmationStage.MultipleTrxFailure, SendConfirmationStage.MultipleTrxFailureReported -> {
MultipleSubmissionFailure(
onContactSupport = {
@ -397,7 +412,11 @@ private fun SendConfirmationContent(
) {
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(
parts = zecSend.amount.toZecStringFull().asZecAmountTriple(),
@ -409,27 +428,41 @@ private fun SendConfirmationContent(
zatoshi = zecSend.amount,
state = exchangeRate,
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))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
Row {
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.send_confirmation_amount_item),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textTertiary
)
StyledBalance(
// 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
// 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
isHideBalances = false,
textStyle =
@ -438,28 +471,89 @@ private fun SendConfirmationContent(
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
Spacer(modifier = Modifier.height(20.dp))
if (zecSend.memo.value.isNotEmpty()) {
Small(stringResource(R.string.send_confirmation_memo))
Row {
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))
BubbleMessage(
modifier = Modifier.fillMaxWidth(),
arrowAlignment = BubbleArrowAlignment.BottomLeft,
backgroundColor = Color.Transparent
) {
Tiny(
text = zecSend.memo.value,
modifier =
Modifier
.fillMaxWidth()
.padding(all = ZcashTheme.dimens.spacingMid)
StyledBalance(
// 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
// property declared in different module. See more details on the Kotlin forum.
balanceParts =
zecSend.proposal?.totalFeeRequired()?.toZecStringFull()?.asZecAmountTriple()
?: ZecAmountTriple("main", "prefix"),
// We don't hide any balance in confirmation screen
isHideBalances = false,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
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))
}
@ -476,12 +570,10 @@ private fun SendConfirmationContent(
onConfirmation = onConfirmation
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
Spacer(modifier = Modifier.height(52.dp))
}
}
const val BUTTON_WIDTH_RATIO = 0.5f
@Composable
fun SendConfirmationActionButtons(
onConfirmation: () -> Unit,
@ -489,33 +581,37 @@ fun SendConfirmationActionButtons(
isSending: Boolean,
modifier: Modifier = Modifier
) {
Row(
Column(
modifier = modifier
) {
PrimaryButton(
text = stringResource(id = R.string.send_confirmation_send_button),
ZashiButton(
state =
ButtonState(
text = stringRes(R.string.send_confirmation_send_button),
onClick = onConfirmation,
enabled = !isSending,
showProgressBar = isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall,
buttonColors = ZcashTheme.colors.tertiaryButtonColors,
isEnabled = !isSending,
isLoading = isSending,
),
modifier =
Modifier
.fillMaxWidth()
.testTag(SendConfirmationTag.SEND_CONFIRMATION_SEND_BUTTON)
.weight(BUTTON_WIDTH_RATIO)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingLarge))
Spacer(modifier = Modifier.height(12.dp))
SecondaryButton(
text = stringResource(R.string.send_confirmation_back_button),
ZashiButton(
state =
ButtonState(
text = stringRes(R.string.send_confirmation_back_button),
onClick = onBack,
enabled = !isSending,
minHeight = ZcashTheme.dimens.buttonHeightSmall,
isEnabled = !isSending,
),
modifier =
Modifier
.testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON)
.weight(BUTTON_WIDTH_RATIO)
.fillMaxWidth()
.testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON),
colors = ZashiButtonDefaults.tertiaryColors()
)
}
}

View File

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

View File

@ -76,6 +76,12 @@ fun Settings(
end = 4.dp
),
) {
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_address_book),
icon = R.drawable.ic_settings_address_book,
onClick = state.onAddressBookClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_advanced_settings),
icon = R.drawable.ic_advanced_settings orDark R.drawable.ic_advanced_settings_dark,
@ -230,6 +236,7 @@ private fun PreviewSettings() {
onAdvancedSettingsClick = {},
onAboutUsClick = {},
onSendUsFeedbackClick = {},
onAddressBookClick = {}
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
@ -251,6 +258,7 @@ private fun PreviewSettingsLoading() {
onAdvancedSettingsClick = {},
onAboutUsClick = {},
onSendUsFeedbackClick = {},
onAddressBookClick = {}
),
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.model.entry.BooleanPreferenceDefault
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.SUPPORT
import co.electriccoin.zcash.ui.R
@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@Suppress("TooManyFunctions")
class SettingsViewModel(
observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider,
@ -100,6 +102,7 @@ class SettingsViewModel(
onAdvancedSettingsClick = ::onAdvancedSettingsClick,
onAboutUsClick = ::onAboutUsClick,
onSendUsFeedbackClick = ::onSendUsFeedbackClick,
onAddressBookClick = ::onAddressBookClick
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), null)
@ -152,6 +155,12 @@ class SettingsViewModel(
navigationCommand.emit(SUPPORT)
}
private fun onAddressBookClick() {
viewModelScope.launch {
navigationCommand.emit(ADDRESS_BOOK)
}
}
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
flow<Boolean?> {
emitAll(default.observe(standardPreferenceProvider()))

View File

@ -1,16 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="11dp"
android:height="10dp"
android:viewportWidth="11"
android:viewportHeight="10">
android:width="17dp"
android:height="16dp"
android:viewportWidth="17"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h10.25v9.75h-10.25z"/>
android:pathData="M0.249,0h16v16h-16z"/>
<path
android:pathData="M0,9.75V1.72H8.46V9.75H0ZM7.58,8.9V2.57H0.88V8.91H7.58V8.9Z"
android:fillColor="#000000"/>
<path
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="#000000"/>
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:strokeLineJoin="round"
android:strokeWidth="1.33"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</group>
</vector>

View File

@ -16,7 +16,6 @@ n <plurals name="account_history_item_message">
<item quantity="one">Message</item>
<item quantity="other">Messages</item>
</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_transaction_id">Transaction ID</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"
android:width="11dp"
android:height="9dp"
android:viewportWidth="11"
android:viewportHeight="9">
<group>
<clip-path
android:pathData="M0.5,0.601h10v8h-10z"/>
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<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:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
android:pathData="M20,17H4M4,17L8,13M4,17L8,21M4,7H20M20,7L16,3M20,7L16,11"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</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"
android:width="7dp"
android:height="13dp"
android:viewportWidth="7"
android:viewportHeight="13">
android:width="20dp"
android:height="21dp"
android:viewportWidth="20"
android:viewportHeight="21">
<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:fillColor="#ffffff"/>
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="#87816F"/>
</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"
android:width="26dp"
android:height="26dp"
android:viewportWidth="26"
android:viewportHeight="26">
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<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:fillColor="#000000"/>
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="#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>

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">
<string name="send_stage_send_title">Send</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_invalid">Invalid address</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_usd_amount_hint">USD Amount</string>
<string name="send_amount_label">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</string>
<string name="send_amount_insufficient_balance">Insufficient funds</string>
<string name="send_amount_invalid">Invalid amount</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">
<xliff:g id="typed_bytes" example="12">%1$s</xliff:g>/
<xliff:g id="max_bytes" example="500">%2$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>
</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>
@ -23,5 +22,6 @@
<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_transparent_memo">Transparent transactions can\'t have memos</string>
</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">
<string name="send_stage_confirmation_title">Confirmation</string>
<string name="send_confirmation_amount">Amount:</string>
<string name="send_confirmation_address">To:</string>
<string name="send_confirmation_amount">Total Amount</string>
<string name="send_confirmation_address">Sending to</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_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_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_feedback">Send Us Feedback</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_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.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
import co.electriccoin.zcash.test.UiTestPrerequisites
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.viewmodel.RestoreViewModel
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.runBlocking
import kotlinx.coroutines.withContext
@ -475,11 +475,8 @@ private fun sendZecScreenshots(
// Screenshot: Empty form
ScreenshotTest.takeScreenshot(tag, "Send 1")
composeTestRule.onNodeWithText(
resContext.getString(
R.string.send_amount_hint,
ZcashCurrency.fromResources(resContext).name
)
composeTestRule.onNode(
hasTestTag(SendTag.SEND_AMOUNT_FIELD)
).also {
val separators = MonetarySeparators.current()