From 1fedce1cff1a9bf1c6deef229e91992b2652344c Mon Sep 17 00:00:00 2001 From: Milan Date: Thu, 3 Oct 2024 17:40:19 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 3 + docs/whatsNew/WHATS_NEW_EN.md | 3 + tools/detekt.yml | 13 + .../zcash/ui/design/component/Text.kt | 2 + .../zcash/ui/design/component/TextField.kt | 4 +- .../ui/design/component/TextFieldColorsExt.kt | 37 ++ .../ui/design/component/ZashiBottomBar.kt | 1 - .../design/component/ZashiSettingsListItem.kt | 194 ++++++--- .../ui/design/component/ZashiTextField.kt | 406 ++++++++++++------ .../ui/design/newcomponent/PreviewScreens.kt | 3 - ui-lib/build.gradle.kts | 4 + .../screen/send/ComposeContentTestRuleExt.kt | 8 +- .../screen/settings/SettingsViewTestSetup.kt | 9 + .../electriccoin/zcash/di/RepositoryModule.kt | 3 + .../co/electriccoin/zcash/di/UseCaseModule.kt | 14 + .../electriccoin/zcash/di/ViewModelModule.kt | 6 + .../co/electriccoin/zcash/ui/Navigation.kt | 29 ++ .../ui/common/model/AddressBookContact.kt | 9 + .../zcash/ui/common/model/ValidContactName.kt | 4 + .../repository/AddressBookRepository.kt | 65 +++ .../ui/common/repository/WalletRepository.kt | 4 + .../ui/common/usecase/DeleteContactUseCase.kt | 12 + .../ui/common/usecase/GetContactUseCase.kt | 9 + .../common/usecase/GetSynchronizerUseCase.kt | 4 +- .../ObserveAddressBookContactsUseCase.kt | 9 + .../ui/common/usecase/SaveContactUseCase.kt | 14 + .../ui/common/usecase/UpdateContactUseCase.kt | 16 + .../usecase/ValidateContactAddressUseCase.kt | 35 ++ .../usecase/ValidateContactNameUseCase.kt | 32 ++ .../ui/screen/account/view/HistoryView.kt | 258 +++++------ .../ui/screen/addressbook/AddressBookTag.kt | 5 + .../screen/addressbook/AndroidAddressBook.kt | 45 ++ .../addressbook/model/AddressBookState.kt | 20 + .../addressbook/view/AddressBookView.kt | 343 +++++++++++++++ .../viewmodel/AddressBookViewModel.kt | 94 ++++ .../advancedsettings/AdvancedSettingsState.kt | 4 +- .../view/AdvancedSettingsView.kt | 4 +- .../viewmodel/AdvancedSettingsViewModel.kt | 4 +- .../screen/chooseserver/ChooseServerView.kt | 3 +- .../ui/screen/contact/AndroidAddContact.kt | 47 ++ .../ui/screen/contact/AndroidUpdateContact.kt | 48 +++ .../zcash/ui/screen/contact/ContactTag.kt | 5 + .../ui/screen/contact/model/ContactState.kt | 15 + .../ui/screen/contact/view/ContactView.kt | 200 +++++++++ .../contact/viewmodel/AddContactViewModel.kt | 129 ++++++ .../viewmodel/UpdateContactViewModel.kt | 177 ++++++++ .../zcash/ui/screen/send/SendTag.kt | 3 +- .../zcash/ui/screen/send/view/SendView.kt | 331 +++++++------- .../view/SendConfirmationView.kt | 228 +++++++--- .../ui/screen/settings/model/SettingsState.kt | 1 + .../ui/screen/settings/view/SettingsView.kt | 8 + .../settings/viewmodel/SettingsViewModel.kt | 9 + .../res/ui/account/drawable/ic_trx_copy.xml | 21 +- .../main/res/ui/account/values/strings.xml | 1 - .../res/ui/add_contact/values/strings.xml | 5 + .../drawable/ic_address_book_empty.xml | 32 ++ .../drawable/ic_address_book_plus.xml | 13 + .../drawable/ic_address_book_shielded.xml | 18 + .../res/ui/address_book/values/strings.xml | 6 + .../main/res/ui/contact/values/strings.xml | 13 + .../res/ui/send/drawable/ic_send_convert.xml | 23 +- .../main/res/ui/send/drawable/ic_send_usd.xml | 13 + .../res/ui/send/drawable/ic_send_zashi.xml | 12 +- .../src/main/res/ui/send/drawable/ic_usd.xml | 9 - .../res/ui/send/drawable/qr_code_icon.xml | 24 +- .../res/ui/send/drawable/send_paper_plane.xml | 13 - .../src/main/res/ui/send/values/strings.xml | 14 +- .../drawable/ic_confirmation_message_info.xml | 17 + .../ui/send_confirmation/values/strings.xml | 9 +- .../ic_settings_address_book.xml | 16 + .../drawable/ic_settings_address_book.xml | 16 + .../main/res/ui/settings/values/strings.xml | 1 + .../res/ui/update_contact/values/strings.xml | 6 + .../zcash/ui/screenshot/ScreenshotTest.kt | 9 +- 74 files changed, 2580 insertions(+), 644 deletions(-) create mode 100644 ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextFieldColorsExt.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/AddressBookContact.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ValidContactName.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteContactUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetContactUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveAddressBookContactsUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SaveContactUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/UpdateContactUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactAddressUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactNameUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AddressBookTag.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AndroidAddressBook.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/model/AddressBookState.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/view/AddressBookView.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidAddContact.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidUpdateContact.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/ContactTag.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/model/ContactState.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/view/ContactView.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt create mode 100644 ui-lib/src/main/res/ui/add_contact/values/strings.xml create mode 100644 ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_empty.xml create mode 100644 ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_plus.xml create mode 100644 ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_shielded.xml create mode 100644 ui-lib/src/main/res/ui/address_book/values/strings.xml create mode 100644 ui-lib/src/main/res/ui/contact/values/strings.xml create mode 100644 ui-lib/src/main/res/ui/send/drawable/ic_send_usd.xml delete mode 100644 ui-lib/src/main/res/ui/send/drawable/ic_usd.xml delete mode 100644 ui-lib/src/main/res/ui/send/drawable/send_paper_plane.xml create mode 100644 ui-lib/src/main/res/ui/send_confirmation/drawable/ic_confirmation_message_info.xml create mode 100644 ui-lib/src/main/res/ui/settings/drawable-night/ic_settings_address_book.xml create mode 100644 ui-lib/src/main/res/ui/settings/drawable/ic_settings_address_book.xml create mode 100644 ui-lib/src/main/res/ui/update_contact/values/strings.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a51b0e69..e8a79b0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index c5aeb5a23..01510c9d3 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -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 diff --git a/tools/detekt.yml b/tools/detekt.yml index 5e1aa20ad..aa182ca0d 100644 --- a/tools/detekt.yml +++ b/tools/detekt.yml @@ -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: diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt index aa2496fa1..8d66880ec 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/Text.kt @@ -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 ) } } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt index e52f64ac4..904a56c76 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextField.kt @@ -137,4 +137,6 @@ data class TextFieldState( val error: StringResource? = null, val isEnabled: Boolean = true, val onValueChange: (String) -> Unit, -) +) { + val isError = error != null +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextFieldColorsExt.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextFieldColorsExt.kt new file mode 100644 index 000000000..d831b34ea --- /dev/null +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/TextFieldColorsExt.kt @@ -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 { + 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 { + return rememberUpdatedState(if (isError) errorCursorColor else cursorColor) +} diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt index c6705b04c..c80e5ce28 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiBottomBar.kt @@ -38,7 +38,6 @@ fun ZashiBottomBar( } } -@Suppress("UnusedPrivateMember") @PreviewScreens @Composable private fun BottomBarPreview() = diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSettingsListItem.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSettingsListItem.kt index 6f4c25483..f33083332 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSettingsListItem.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiSettingsListItem.kt @@ -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,100 +30,192 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.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 +import co.electriccoin.zcash.ui.design.util.stringRes @Composable fun ZashiSettingsListItem( - state: ButtonState, + text: String, @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(), - ) - } + subtitle: String? = null, + isEnabled: Boolean = true, + onClick: () -> Unit ) { ZashiSettingsListItem( - text = state.text.getValue(), + state = + ZashiSettingsListItemState( + text = stringRes(text), + subtitle = subtitle?.let { stringRes(it) }, + isEnabled = isEnabled, + onClick = onClick + ), icon = icon, - trailing = trailing, - onClick = state.onClick ) } @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, - ) - }, - onClick: () -> Unit + state: ZashiSettingsListItemState, + @DrawableRes icon: Int ) { ZashiSettingsListItem( - leading = { - Image( - modifier = Modifier.size(40.dp), - painter = painterResource(icon), - contentDescription = text + leading = { modifier -> + ZashiSettingsListLeadingItem( + modifier = modifier, + icon = icon, + contentDescription = state.text.getValue() ) }, - content = { - Text( - text = text, - style = ZashiTypography.textMd, - fontWeight = FontWeight.SemiBold, - color = ZashiColors.Text.textPrimary + content = { modifier -> + ZashiSettingsListContentItem( + modifier = modifier, + text = state.text.getValue(), + subtitle = state.subtitle?.getValue() ) }, - trailing = trailing, - onClick = onClick + 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 = contentDescription, + ) + } +} + +@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 + ) + 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( - indication = rememberRipple(), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - role = Role.Button, - ) - .padding(vertical = 12.dp), + .clip(RoundedCornerShape(12.dp)) then + if (onClick != null) { + Modifier.clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + role = Role.Button, + ) + } 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(Modifier.weight(1f)) Spacer(modifier = Modifier.width(16.dp)) - content() - Spacer(modifier = Modifier.weight(1f)) - trailing() + 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 EnabledPreview() = + ZcashTheme { + BlankSurface { + ZashiSettingsListItem( + text = "Test", + subtitle = "Subtitle", + icon = R.drawable.ic_radio_button_checked, + onClick = {} + ) + } + } + @Suppress("UnusedPrivateMember") @PreviewScreens @Composable -private fun ZashiSettingsListItemPreview() = +private fun DisabledPreview() = ZcashTheme { BlankSurface { ZashiSettingsListItem( text = "Test", + subtitle = "Subtitle", icon = R.drawable.ic_radio_button_checked, + isEnabled = false, onClick = {} ) } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt index b5e26c51f..1d04837f5 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiTextField.kt @@ -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,84 +170,88 @@ 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) { - BasicTextField( - value = value, - modifier = - modifier - .defaultMinSize(minWidth = TextFieldDefaults.MinWidth), - onValueChange = onValueChange, - enabled = enabled, - readOnly = readOnly, - textStyle = mergedTextStyle, - cursorBrush = SolidColor(colors.cursorColor(isError).value), - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - interactionSource = interactionSource, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - decorationBox = @Composable { innerTextField -> - // places leading icon, text field with label and placeholder, trailing icon - TextFieldDefaults.DecorationBox( - value = value, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = enabled, - isError = isError, - interactionSource = interactionSource, - colors = colors, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) + CompositionLocalProvider(LocalTextSelectionColors provides androidColors.selectionColors) { + Column( + modifier = modifier, + ) { + BasicTextField( + value = state.value.getValue(), + modifier = + 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(androidColors.cursorColor(state.isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = state.value.getValue(), + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = state.isEnabled, + isError = state.isError, + interactionSource = interactionSource, + colors = androidColors, + contentPadding = + PaddingValues( + start = if (leadingIcon != null) 8.dp else 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 { - 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 { - return rememberUpdatedState(if (isError) errorCursorColor else cursorColor) + } } @Immutable @@ -206,78 +260,133 @@ 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 { + val targetValue = + when { + !state.isEnabled -> disabledBorderColor + state.isError -> errorBorderColor + else -> borderColor + } + return rememberUpdatedState(targetValue) + } -@Composable -private fun ZashiTextFieldColors.toTextFieldColors() = - TextFieldDefaults.colors( - focusedTextColor = textColor, - unfocusedTextColor = textColor, - disabledTextColor = textColor, - errorTextColor = Color.Unspecified, - focusedContainerColor = containerColor, - unfocusedContainerColor = containerColor, - disabledContainerColor = containerColor, - errorContainerColor = Color.Unspecified, - cursorColor = Color.Unspecified, - errorCursorColor = Color.Unspecified, - selectionColors = null, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Unspecified, - focusedLeadingIconColor = Color.Unspecified, - unfocusedLeadingIconColor = Color.Unspecified, - disabledLeadingIconColor = Color.Unspecified, - errorLeadingIconColor = Color.Unspecified, - focusedTrailingIconColor = Color.Unspecified, - unfocusedTrailingIconColor = Color.Unspecified, - disabledTrailingIconColor = Color.Unspecified, - errorTrailingIconColor = Color.Unspecified, - focusedLabelColor = Color.Unspecified, - unfocusedLabelColor = Color.Unspecified, - disabledLabelColor = Color.Unspecified, - errorLabelColor = Color.Unspecified, - focusedPlaceholderColor = hintColor, - unfocusedPlaceholderColor = hintColor, - disabledPlaceholderColor = hintColor, - errorPlaceholderColor = Color.Unspecified, - focusedSupportingTextColor = hintColor, - unfocusedSupportingTextColor = hintColor, - disabledSupportingTextColor = hintColor, - errorSupportingTextColor = Color.Unspecified, - focusedPrefixColor = Color.Unspecified, - unfocusedPrefixColor = Color.Unspecified, - disabledPrefixColor = Color.Unspecified, - errorPrefixColor = Color.Unspecified, - focusedSuffixColor = Color.Unspecified, - unfocusedSuffixColor = Color.Unspecified, - disabledSuffixColor = Color.Unspecified, - errorSuffixColor = Color.Unspecified, - ) + @Composable + internal fun hintColor(state: TextFieldState): State { + 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 = disabledTextColor, + errorTextColor = errorTextColor, + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + disabledContainerColor = disabledContainerColor, + errorContainerColor = errorContainerColor, + cursorColor = Color.Unspecified, + errorCursorColor = Color.Unspecified, + selectionColors = null, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedLeadingIconColor = Color.Unspecified, + unfocusedLeadingIconColor = Color.Unspecified, + disabledLeadingIconColor = Color.Unspecified, + errorLeadingIconColor = Color.Unspecified, + focusedTrailingIconColor = Color.Unspecified, + unfocusedTrailingIconColor = Color.Unspecified, + disabledTrailingIconColor = Color.Unspecified, + errorTrailingIconColor = Color.Unspecified, + focusedLabelColor = Color.Unspecified, + unfocusedLabelColor = Color.Unspecified, + disabledLabelColor = Color.Unspecified, + errorLabelColor = Color.Unspecified, + focusedPlaceholderColor = placeholderColor, + unfocusedPlaceholderColor = placeholderColor, + disabledPlaceholderColor = disabledPlaceholderColor, + errorPlaceholderColor = errorPlaceholderColor, + focusedSupportingTextColor = hintColor, + unfocusedSupportingTextColor = hintColor, + disabledSupportingTextColor = disabledHintColor, + errorSupportingTextColor = errorHintColor, + focusedPrefixColor = Color.Unspecified, + unfocusedPrefixColor = Color.Unspecified, + disabledPrefixColor = Color.Unspecified, + errorPrefixColor = Color.Unspecified, + focusedSuffixColor = Color.Unspecified, + unfocusedSuffixColor = Color.Unspecified, + 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"), + ) {} + ) + } diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/newcomponent/PreviewScreens.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/newcomponent/PreviewScreens.kt index 3fb5ff6ad..6d9945447 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/newcomponent/PreviewScreens.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/newcomponent/PreviewScreens.kt @@ -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) diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 6424d4ff9..d785e1c83 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -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", diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt index 488632341..a87234c84 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ComposeContentTestRuleExt.kt @@ -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) diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/SettingsViewTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/SettingsViewTestSetup.kt index d815663b0..4987ddb84 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/SettingsViewTestSetup.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/settings/SettingsViewTestSetup.kt @@ -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, ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/RepositoryModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/RepositoryModule.kt index 697e7b5e5..cb35d597a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/RepositoryModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/RepositoryModule.kt @@ -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 } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index e314b84f9..1c0a54417 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -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) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt index fc99b7c0b..b4a4e4b12 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt @@ -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) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt index 691196169..3a9c895cb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt @@ -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" } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/AddressBookContact.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/AddressBookContact.kt new file mode 100644 index 000000000..476af808a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/AddressBookContact.kt @@ -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(), +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ValidContactName.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ValidContactName.kt new file mode 100644 index 000000000..9997760a6 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/model/ValidContactName.kt @@ -0,0 +1,4 @@ +package co.electriccoin.zcash.ui.common.model + +@JvmInline +value class ValidContactName(val value: String) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt new file mode 100644 index 000000000..29d7a350f --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt @@ -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> + + 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()) + + 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 } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt index 60f833d21..7a37f1dca 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt @@ -61,6 +61,8 @@ interface WalletRepository { suspend fun getSelectedServer(): LightWalletEndpoint suspend fun getAllServers(): List + + suspend fun getSynchronizer(): Synchronizer } class WalletRepositoryImpl( @@ -238,4 +240,6 @@ class WalletRepositoryImpl( defaultServers + selectedServer } } + + override suspend fun getSynchronizer(): Synchronizer = synchronizer.filterNotNull().first() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteContactUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteContactUseCase.kt new file mode 100644 index 000000000..bd44d18a5 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteContactUseCase.kt @@ -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) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetContactUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetContactUseCase.kt new file mode 100644 index 000000000..2019c7212 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetContactUseCase.kt @@ -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) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSynchronizerUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSynchronizerUseCase.kt index c2fcc9ac8..cae06060c 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSynchronizerUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetSynchronizerUseCase.kt @@ -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() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveAddressBookContactsUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveAddressBookContactsUseCase.kt new file mode 100644 index 000000000..3da165ba6 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ObserveAddressBookContactsUseCase.kt @@ -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 +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SaveContactUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SaveContactUseCase.kt new file mode 100644 index 000000000..5142f8f8a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/SaveContactUseCase.kt @@ -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) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/UpdateContactUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/UpdateContactUseCase.kt new file mode 100644 index 000000000..0f2b5ae7a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/UpdateContactUseCase.kt @@ -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) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactAddressUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactAddressUseCase.kt new file mode 100644 index 000000000..449dbb391 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactAddressUseCase.kt @@ -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 + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactNameUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactNameUseCase.kt new file mode 100644 index 000000000..170f06871 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ValidateContactNameUseCase.kt @@ -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 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/view/HistoryView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/view/HistoryView.kt index bddd8bc93..d02d7a16e 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/view/HistoryView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/account/view/HistoryView.kt @@ -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,50 +688,16 @@ private fun HistoryItemExpandedPart( ) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) } - } else if (transaction.recipientAddressType == null || - transaction.recipientAddressType == AddressType.Shielded - ) { - 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))) + HistoryItemTransactionIdPart(transaction = transaction, onAction = onAction) - 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) - ) + if (transaction.overview.getExtendedState() !in + listOf(TransactionExtendedState.RECEIVING, TransactionExtendedState.RECEIVED) + ) { + Spacer(modifier = Modifier.height(16.dp)) + HistoryItemTransactionFeePart(fee = transaction.overview.feePaid) + } } } @@ -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() } + ) ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AddressBookTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AddressBookTag.kt new file mode 100644 index 000000000..771e79f0d --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AddressBookTag.kt @@ -0,0 +1,5 @@ +package co.electriccoin.zcash.ui.screen.addressbook + +object AddressBookTag { + const val TOP_APP_BAR = "top_app_bar" +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AndroidAddressBook.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AndroidAddressBook.kt new file mode 100644 index 000000000..e1e32c316 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/AndroidAddressBook.kt @@ -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() + val viewModel = koinViewModel() + 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, + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/model/AddressBookState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/model/AddressBookState.kt new file mode 100644 index 000000000..b5eb8e604 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/model/AddressBookState.kt @@ -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, + 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, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/view/AddressBookView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/view/AddressBookView.kt new file mode 100644 index 000000000..bf4e2fa4c --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/view/AddressBookView.kt @@ -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, + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt new file mode 100644 index 000000000..77c5d0910 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/addressbook/viewmodel/AddressBookViewModel.kt @@ -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() + + val backNavigationCommand = MutableSharedFlow() + + private fun createState( + contacts: List, + 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) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt index 9f2880928..a3d51bbe5 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/AdvancedSettingsState.kt @@ -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?, ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt index d85fdc668..a278d5ee2 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/view/AdvancedSettingsView.kt @@ -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 = {} ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt index be651667d..a81419cb1 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/advancedsettings/viewmodel/AdvancedSettingsViewModel.kt @@ -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()), diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt index af410e4ac..209d298ef 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/chooseserver/ChooseServerView.kt @@ -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, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidAddContact.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidAddContact.kt new file mode 100644 index 000000000..6629612ac --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidAddContact.kt @@ -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() + val viewModel = koinViewModel() + 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, + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidUpdateContact.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidUpdateContact.kt new file mode 100644 index 000000000..c8f8d1736 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/AndroidUpdateContact.kt @@ -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() + val viewModel = koinViewModel { 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, + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/ContactTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/ContactTag.kt new file mode 100644 index 000000000..74bb40196 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/ContactTag.kt @@ -0,0 +1,5 @@ +package co.electriccoin.zcash.ui.screen.contact + +object ContactTag { + const val TOP_APP_BAR = "top_app_bar" +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/model/ContactState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/model/ContactState.kt new file mode 100644 index 000000000..164e53359 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/model/ContactState.kt @@ -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, +) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/view/ContactView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/view/ContactView.kt new file mode 100644 index 000000000..4cd4fef87 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/view/ContactView.kt @@ -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, + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt new file mode 100644 index 000000000..1a5a4d1f7 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/AddContactViewModel.kt @@ -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() + + val backNavigationCommand = MutableSharedFlow() + + 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 } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt new file mode 100644 index 000000000..bb6e671e5 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/contact/viewmodel/UpdateContactViewModel.kt @@ -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() + + val backNavigationCommand = MutableSharedFlow() + + 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 } + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendTag.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendTag.kt index d855eb4cb..2164d8d23 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendTag.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendTag.kt @@ -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" } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt index 3b092b07e..86d77ea84 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt @@ -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( - onClick = { + ZashiButton( + onClick = { + scope.launch { // SDK side validations val zecSendValidation = ZecSendExt.new( @@ -471,37 +470,47 @@ 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, - modifier = - Modifier - .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, - ) - } + } + }, + text = stringResource(id = R.string.send_create), + enabled = sendButtonEnabled, + modifier = + Modifier + .testTag(SendTag.SEND_FORM_BUTTON) + .fillMaxWidth() + ) } -@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( - onClick = onQrScannerOpen, - content = { - Icon( - painter = painterResource(id = R.drawable.qr_code_icon), - contentDescription = stringResource(R.string.send_scan_content_description), - tint = ZcashTheme.colors.secondaryColor, - ) - } + Image( + modifier = + Modifier.clickable( + onClick = onQrScannerOpen, + 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), ) } } 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) - } + 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,89 +797,91 @@ 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 = stringResource(id = R.string.send_memo_label), - color = - if (isMemoFieldAvailable) { - ZcashTheme.colors.textPrimary - } else { - ZcashTheme.colors.textDisabled - } - ) - } + Text( + text = stringResource(id = R.string.send_memo_label), + color = ZashiColors.Inputs.Default.label, + style = ZashiTypography.textMd + ) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) - BubbleMessage( - arrowAlignment = BubbleArrowAlignment.BottomLeft, - backgroundColor = + ZashiTextField( + minLines = if (isMemoFieldAvailable) 2 else 1, + isEnabled = isMemoFieldAvailable, + value = if (isMemoFieldAvailable) { - Color.Transparent + memoState.text } else { - ZcashTheme.colors.textDisabled - } - ) { - FormTextField( - enabled = isMemoFieldAvailable, - value = - if (isMemoFieldAvailable) { - memoState.text - } else { - "" - }, - onValueChange = { - setMemoState(MemoState.new(it)) + "" }, - bringIntoViewRequester = bringIntoViewRequester, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Default, - capitalization = KeyboardCapitalization.Sentences - ), - placeholder = { + error = if (memoState is MemoState.Correct) null else "", + onValueChange = { + setMemoState(MemoState.new(it)) + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + 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 - ) - } + modifier = Modifier.fillMaxWidth(), + ) 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() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/sendconfirmation/view/SendConfirmationView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/sendconfirmation/view/SendConfirmationView.kt index d905947d8..12bc73b94 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/sendconfirmation/view/SendConfirmationView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/sendconfirmation/view/SendConfirmationView.kt @@ -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,56 +428,131 @@ 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)) + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.send_confirmation_amount_item), + style = ZashiTypography.textSm, + color = ZashiColors.Text.textTertiary + ) - Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny)) + StyledBalance( + // The not-null assertion operator is necessary here even if we check its nullability before + // 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.amount.toZecStringFull().asZecAmountTriple(), + // We don't hide any balance in confirmation screen + isHideBalances = false, + textStyle = + StyledBalanceDefaults.textStyles( + mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first, + leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second + ), + ) + } - 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(), - // 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)) - Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge)) + Row { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.send_confirmation_fee), + style = ZashiTypography.textSm, + color = ZashiColors.Text.textTertiary + ) - if (zecSend.memo.value.isNotEmpty()) { - Small(stringResource(R.string.send_confirmation_memo)) + 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(ZcashTheme.dimens.spacingTiny)) + Spacer(modifier = Modifier.height(20.dp)) - BubbleMessage( - modifier = Modifier.fillMaxWidth(), - arrowAlignment = BubbleArrowAlignment.BottomLeft, - backgroundColor = Color.Transparent - ) { - Tiny( - text = zecSend.memo.value, - modifier = - Modifier - .fillMaxWidth() - .padding(all = ZcashTheme.dimens.spacingMid) - ) - } + 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), - onClick = onConfirmation, - enabled = !isSending, - showProgressBar = isSending, - minHeight = ZcashTheme.dimens.buttonHeightSmall, - buttonColors = ZcashTheme.colors.tertiaryButtonColors, + ZashiButton( + state = + ButtonState( + text = stringRes(R.string.send_confirmation_send_button), + onClick = onConfirmation, + 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), - onClick = onBack, - enabled = !isSending, - minHeight = ZcashTheme.dimens.buttonHeightSmall, + ZashiButton( + state = + ButtonState( + text = stringRes(R.string.send_confirmation_back_button), + onClick = onBack, + isEnabled = !isSending, + ), modifier = Modifier - .testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON) - .weight(BUTTON_WIDTH_RATIO) + .fillMaxWidth() + .testTag(SendConfirmationTag.SEND_CONFIRMATION_BACK_BUTTON), + colors = ZashiButtonDefaults.tertiaryColors() ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/model/SettingsState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/model/SettingsState.kt index 49a3915de..744dd687d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/model/SettingsState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/model/SettingsState.kt @@ -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, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt index 056980290..8d7b98248 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/view/SettingsView.kt @@ -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, ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt index 501316868..6c345d359 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/settings/viewmodel/SettingsViewModel.kt @@ -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 = flow { emitAll(default.observe(standardPreferenceProvider())) diff --git a/ui-lib/src/main/res/ui/account/drawable/ic_trx_copy.xml b/ui-lib/src/main/res/ui/account/drawable/ic_trx_copy.xml index 9c3ffef8f..ef7a70c11 100644 --- a/ui-lib/src/main/res/ui/account/drawable/ic_trx_copy.xml +++ b/ui-lib/src/main/res/ui/account/drawable/ic_trx_copy.xml @@ -1,16 +1,17 @@ + android:width="17dp" + android:height="16dp" + android:viewportWidth="17" + android:viewportHeight="16"> + android:pathData="M0.249,0h16v16h-16z"/> - + 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"/> diff --git a/ui-lib/src/main/res/ui/account/values/strings.xml b/ui-lib/src/main/res/ui/account/values/strings.xml index 025109288..dcd01fb0e 100644 --- a/ui-lib/src/main/res/ui/account/values/strings.xml +++ b/ui-lib/src/main/res/ui/account/values/strings.xml @@ -16,7 +16,6 @@ n Message Messages - No message included in transaction Collapse transaction Transaction ID Transaction Fee diff --git a/ui-lib/src/main/res/ui/add_contact/values/strings.xml b/ui-lib/src/main/res/ui/add_contact/values/strings.xml new file mode 100644 index 000000000..a0e16fe2b --- /dev/null +++ b/ui-lib/src/main/res/ui/add_contact/values/strings.xml @@ -0,0 +1,5 @@ + + + Add New Contact + Save + diff --git a/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_empty.xml b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_empty.xml new file mode 100644 index 000000000..437f50dd1 --- /dev/null +++ b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_empty.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_plus.xml b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_plus.xml new file mode 100644 index 000000000..8b38f6cfe --- /dev/null +++ b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_plus.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_shielded.xml b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_shielded.xml new file mode 100644 index 000000000..9fb7b174f --- /dev/null +++ b/ui-lib/src/main/res/ui/address_book/drawable/ic_address_book_shielded.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/ui-lib/src/main/res/ui/address_book/values/strings.xml b/ui-lib/src/main/res/ui/address_book/values/strings.xml new file mode 100644 index 000000000..53ad0babf --- /dev/null +++ b/ui-lib/src/main/res/ui/address_book/values/strings.xml @@ -0,0 +1,6 @@ + + Address Book + Add New Contact + Your address book is empty + Version %s + diff --git a/ui-lib/src/main/res/ui/contact/values/strings.xml b/ui-lib/src/main/res/ui/contact/values/strings.xml new file mode 100644 index 000000000..3935b9491 --- /dev/null +++ b/ui-lib/src/main/res/ui/contact/values/strings.xml @@ -0,0 +1,13 @@ + + Add New Contact + Saved Contact + + Contact Name + Enter contact name… + Wallet Address + Enter Wallet Address… + + This contact name is already in use. Please choose a different name. + This contact name exceeds the 32-character limit. Please shorten the name. + This wallet address is already in your Address Book. + diff --git a/ui-lib/src/main/res/ui/send/drawable/ic_send_convert.xml b/ui-lib/src/main/res/ui/send/drawable/ic_send_convert.xml index 118cfe23f..3adf52548 100644 --- a/ui-lib/src/main/res/ui/send/drawable/ic_send_convert.xml +++ b/ui-lib/src/main/res/ui/send/drawable/ic_send_convert.xml @@ -1,14 +1,13 @@ - - - - + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + diff --git a/ui-lib/src/main/res/ui/send/drawable/ic_send_usd.xml b/ui-lib/src/main/res/ui/send/drawable/ic_send_usd.xml new file mode 100644 index 000000000..d48f625ee --- /dev/null +++ b/ui-lib/src/main/res/ui/send/drawable/ic_send_usd.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/send/drawable/ic_send_zashi.xml b/ui-lib/src/main/res/ui/send/drawable/ic_send_zashi.xml index b3f0f3571..d15f7e7f4 100644 --- a/ui-lib/src/main/res/ui/send/drawable/ic_send_zashi.xml +++ b/ui-lib/src/main/res/ui/send/drawable/ic_send_zashi.xml @@ -1,9 +1,9 @@ + android:width="20dp" + android:height="21dp" + android:viewportWidth="20" + android:viewportHeight="21"> + 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"/> diff --git a/ui-lib/src/main/res/ui/send/drawable/ic_usd.xml b/ui-lib/src/main/res/ui/send/drawable/ic_usd.xml deleted file mode 100644 index 5b9e649b5..000000000 --- a/ui-lib/src/main/res/ui/send/drawable/ic_usd.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ui-lib/src/main/res/ui/send/drawable/qr_code_icon.xml b/ui-lib/src/main/res/ui/send/drawable/qr_code_icon.xml index 43255b124..71356cdc5 100644 --- a/ui-lib/src/main/res/ui/send/drawable/qr_code_icon.xml +++ b/ui-lib/src/main/res/ui/send/drawable/qr_code_icon.xml @@ -1,9 +1,21 @@ + android:width="36dp" + android:height="36dp" + android:viewportWidth="36" + android:viewportHeight="36"> + 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"/> + + diff --git a/ui-lib/src/main/res/ui/send/drawable/send_paper_plane.xml b/ui-lib/src/main/res/ui/send/drawable/send_paper_plane.xml deleted file mode 100644 index 3486e3168..000000000 --- a/ui-lib/src/main/res/ui/send/drawable/send_paper_plane.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/ui-lib/src/main/res/ui/send/values/strings.xml b/ui-lib/src/main/res/ui/send/values/strings.xml index c79d3c457..64603e7c9 100644 --- a/ui-lib/src/main/res/ui/send/values/strings.xml +++ b/ui-lib/src/main/res/ui/send/values/strings.xml @@ -1,19 +1,18 @@ Send Scan - To: + Send to Zcash Address Invalid address - Amount: - %1$s Amount - USD Amount + Amount + %1$s + USD Insufficient funds Invalid amount Message - Write private message here… + Write encrypted message here… - %1$s/ - %2$s + %1$s/%2$s Review (Typical Fee < %1$s) @@ -23,5 +22,6 @@ OK %1$s%2$s + Transparent transactions can\'t have memos diff --git a/ui-lib/src/main/res/ui/send_confirmation/drawable/ic_confirmation_message_info.xml b/ui-lib/src/main/res/ui/send_confirmation/drawable/ic_confirmation_message_info.xml new file mode 100644 index 000000000..9af951711 --- /dev/null +++ b/ui-lib/src/main/res/ui/send_confirmation/drawable/ic_confirmation_message_info.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/ui-lib/src/main/res/ui/send_confirmation/values/strings.xml b/ui-lib/src/main/res/ui/send_confirmation/values/strings.xml index 964b5f97f..ef716e458 100644 --- a/ui-lib/src/main/res/ui/send_confirmation/values/strings.xml +++ b/ui-lib/src/main/res/ui/send_confirmation/values/strings.xml @@ -1,12 +1,13 @@ Confirmation - Amount: - To: + Total Amount + Sending to Message - Fee: + Fee + Amount Send - Go Back + Cancel Transaction Failed An error occurred and the attempt to send funds failed. Try it again, please. diff --git a/ui-lib/src/main/res/ui/settings/drawable-night/ic_settings_address_book.xml b/ui-lib/src/main/res/ui/settings/drawable-night/ic_settings_address_book.xml new file mode 100644 index 000000000..255f99e80 --- /dev/null +++ b/ui-lib/src/main/res/ui/settings/drawable-night/ic_settings_address_book.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/settings/drawable/ic_settings_address_book.xml b/ui-lib/src/main/res/ui/settings/drawable/ic_settings_address_book.xml new file mode 100644 index 000000000..a140e488c --- /dev/null +++ b/ui-lib/src/main/res/ui/settings/drawable/ic_settings_address_book.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/settings/values/strings.xml b/ui-lib/src/main/res/ui/settings/values/strings.xml index 3d8f59893..9923da930 100644 --- a/ui-lib/src/main/res/ui/settings/values/strings.xml +++ b/ui-lib/src/main/res/ui/settings/values/strings.xml @@ -4,6 +4,7 @@ About Us Send Us Feedback Version %s + Address Book Additional settings Rescan blockchain diff --git a/ui-lib/src/main/res/ui/update_contact/values/strings.xml b/ui-lib/src/main/res/ui/update_contact/values/strings.xml new file mode 100644 index 000000000..a17ef0c07 --- /dev/null +++ b/ui-lib/src/main/res/ui/update_contact/values/strings.xml @@ -0,0 +1,6 @@ + + + Saved Contact + Save + Delete + diff --git a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt index 18745e46b..f18db36cd 100644 --- a/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt +++ b/ui-screenshot-test/src/main/java/co/electroniccoin/zcash/ui/screenshot/ScreenshotTest.kt @@ -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()