Transaction metadata with note & bookmark functionality (#1753)

* Transaction metadata with note & bookmark functionality

* Test hotfix

* Transaction loading and empty states

* Transaction search logic

* Application hotfixes and performance updates

* Performance update

* Transaction note hotfix

* Add fullText filtering feature

* Fix lint warnings

* Transaction restore timestamp taken into consideration when calculating whether transaction is read or unread

* Transaction search filter

* Code cleanup

* Code cleanup

* Bugfixing

* Adopt `TransactionId` wrapper

* Metadata update

* Code cleanup

* Eye icon added to transaction detail

* Metadata serialization update

* Memory optimization

* Sort update

* Design updates

---------

Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Milan 2025-02-18 14:49:40 +01:00 committed by GitHub
parent a4090044ad
commit ebd02de639
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 3728 additions and 759 deletions

View File

@ -3,9 +3,11 @@ package co.electriccoin.zcash.app
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import co.electriccoin.zcash.crash.android.GlobalCrashReporter
import co.electriccoin.zcash.di.addressBookModule
import co.electriccoin.zcash.di.coreModule
import co.electriccoin.zcash.di.dataSourceModule
import co.electriccoin.zcash.di.mapperModule
import co.electriccoin.zcash.di.metadataModule
import co.electriccoin.zcash.di.providerModule
import co.electriccoin.zcash.di.repositoryModule
import co.electriccoin.zcash.di.useCaseModule
@ -42,6 +44,8 @@ class ZcashApplication : CoroutineApplication() {
providerModule,
dataSourceModule,
repositoryModule,
addressBookModule,
metadataModule,
useCaseModule,
mapperModule,
viewModelModule

View File

@ -214,11 +214,12 @@ ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZXING_VERSION=3.5.3
ZIP_321_VERSION = 0.0.6
ZCASH_BIP39_VERSION=1.0.8
SHIMMER_VERSION=1.2.0
FLEXA_VERSION=1.0.9
# WARNING: Ensure a non-snapshot version is used before releasing to production
ZCASH_SDK_VERSION=2.2.7
ZCASH_SDK_VERSION=2.2.8-SNAPSHOT
# Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application.

View File

@ -11,6 +11,13 @@ interface PreferenceProvider {
value: String?
)
suspend fun putLong(
key: PreferenceKey,
value: Long?
)
suspend fun getLong(key: PreferenceKey): Long?
suspend fun getString(key: PreferenceKey): String?
fun observe(key: PreferenceKey): Flow<String?>

View File

@ -31,4 +31,13 @@ class MockPreferenceProvider(
) {
map[key.key] = value
}
override suspend fun putLong(
key: PreferenceKey,
value: Long?
) {
map[key.key] = value?.toString()
}
override suspend fun getLong(key: PreferenceKey): Long? = map[key.key]?.toLongOrNull()
}

View File

@ -59,6 +59,33 @@ class AndroidPreferenceProvider private constructor(
}
}
@SuppressLint("ApplySharedPref")
override suspend fun putLong(
key: PreferenceKey,
value: Long?
) = withContext(dispatcher) {
mutex.withLock {
val editor = sharedPreferences.edit()
if (value != null) {
editor.putLong(key.key, value)
} else {
editor.remove(key.key)
}
editor.commit()
Unit
}
}
override suspend fun getLong(key: PreferenceKey): Long? =
withContext(dispatcher) {
if (sharedPreferences.contains(key.key)) {
sharedPreferences.getLong(key.key, 0)
} else {
null
}
}
override suspend fun getString(key: PreferenceKey) =
withContext(dispatcher) {
sharedPreferences.getString(key.key, null)

View File

@ -194,6 +194,7 @@ dependencyResolutionManagement {
val koinVersion = extra["KOIN_VERSION"].toString()
val flexaVersion = extra["FLEXA_VERSION"].toString()
val keystoneVersion = extra["KEYSTONE_VERSION"].toString()
val shimmerVersion = extra["SHIMMER_VERSION"].toString()
// Standalone versions
version("flank", flankVersion)
@ -263,6 +264,7 @@ dependencyResolutionManagement {
library("flexa-core", "com.flexa:core:$flexaVersion")
library("flexa-spend", "com.flexa:spend:$flexaVersion")
library("keystone", "com.github.KeystoneHQ:keystone-sdk-android:$keystoneVersion")
library("compose-shimmer", "com.valentinilk.shimmer:compose-shimmer:$shimmerVersion")
// Test libraries
library("androidx-compose-test-junit", "androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")

View File

@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.orDark
@Preview("Scaffold with blank background")
@ -30,7 +31,7 @@ fun BlankBgScaffold(
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
containerColor = ZcashTheme.colors.backgroundColor,
containerColor = ZashiColors.Surfaces.bgPrimary,
topBar = topBar,
snackbarHost = snackbarHost,
bottomBar = bottomBar,

View File

@ -14,6 +14,7 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
@ -132,6 +133,7 @@ fun FormTextField(
}
}
@Immutable
data class TextFieldState(
val value: StringResource,
val error: StringResource? = null,

View File

@ -10,6 +10,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -61,6 +62,7 @@ fun ZashiIconButton(
}
}
@Immutable
data class IconButtonState(
@DrawableRes val icon: Int,
val contentDescription: StringResource? = null,

View File

@ -162,7 +162,7 @@ object StringResourceDefaults {
}
fun convertAddress(res: StringResource.ByAddress): String {
return if (res.abbreviated) {
return if (res.abbreviated && res.address.isNotBlank()) {
"${res.address.take(ADDRESS_MAX_LENGTH_ABBREVIATED)}..."
} else {
res.address

View File

@ -69,6 +69,7 @@ android {
"src/main/res/ui/transaction_detail",
"src/main/res/ui/transaction_filters",
"src/main/res/ui/transaction_history",
"src/main/res/ui/transaction_note",
"src/main/res/ui/feedback",
"src/main/res/ui/update",
"src/main/res/ui/update_contact",
@ -149,6 +150,7 @@ dependencies {
implementation(libs.zcash.bip39)
implementation(libs.tink)
implementation(libs.zxing)
api(libs.compose.shimmer)
api(libs.flexa.core)
api(libs.flexa.spend)

View File

@ -14,6 +14,7 @@ import cash.z.ecc.android.sdk.model.ObserveFiatCurrencyResult
import cash.z.ecc.android.sdk.model.Pczt
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionId
import cash.z.ecc.android.sdk.model.TransactionOutput
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
@ -46,6 +47,9 @@ internal class MockSynchronizer : CloseableSynchronizer {
override val accountsFlow: Flow<List<Account>?>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val allTransactions: Flow<List<TransactionOverview>>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override suspend fun importAccountByUfvk(setup: AccountImportSetup): Account {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
@ -91,9 +95,6 @@ internal class MockSynchronizer : CloseableSynchronizer {
override val status: Flow<Synchronizer.Status>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override val transactions: Flow<List<TransactionOverview>>
get() = error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
override fun close() {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
@ -239,6 +240,10 @@ internal class MockSynchronizer : CloseableSynchronizer {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
override fun getTransactionsByMemoSubstring(query: String): Flow<List<TransactionId>> {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
companion object {
fun new() = MockSynchronizer()
}

View File

@ -0,0 +1,31 @@
package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSourceImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookProviderImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProviderImpl
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookEncryptor
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookEncryptorImpl
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookSerializer
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val addressBookModule =
module {
singleOf(::AddressBookSerializer)
singleOf(::AddressBookEncryptorImpl) bind AddressBookEncryptor::class
factoryOf(::AddressBookKeyStorageProviderImpl) bind AddressBookKeyStorageProvider::class
factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class
factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class
singleOf(::LocalAddressBookDataSourceImpl) bind LocalAddressBookDataSource::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::class
}

View File

@ -2,10 +2,10 @@ package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.AccountDataSourceImpl
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource
import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSourceImpl
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSourceImpl
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSourceImpl
import co.electriccoin.zcash.ui.common.datasource.ZashiSpendingKeyDataSource
import co.electriccoin.zcash.ui.common.datasource.ZashiSpendingKeyDataSourceImpl
import org.koin.core.module.dsl.singleOf
@ -14,8 +14,8 @@ import org.koin.dsl.module
val dataSourceModule =
module {
singleOf(::LocalAddressBookDataSourceImpl) bind LocalAddressBookDataSource::class
singleOf(::AccountDataSourceImpl) bind AccountDataSource::class
singleOf(::ZashiSpendingKeyDataSourceImpl) bind ZashiSpendingKeyDataSource::class
singleOf(::ProposalDataSourceImpl) bind ProposalDataSource::class
singleOf(::RestoreTimestampDataSourceImpl) bind RestoreTimestampDataSource::class
}

View File

@ -0,0 +1,30 @@
package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.datasource.MetadataDataSource
import co.electriccoin.zcash.ui.common.datasource.MetadataDataSourceImpl
import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.MetadataProvider
import co.electriccoin.zcash.ui.common.provider.MetadataProviderImpl
import co.electriccoin.zcash.ui.common.provider.MetadataStorageProvider
import co.electriccoin.zcash.ui.common.provider.MetadataStorageProviderImpl
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepositoryImpl
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataEncryptor
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataEncryptorImpl
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataSerializer
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val metadataModule =
module {
singleOf(::MetadataSerializer)
singleOf(::MetadataEncryptorImpl) bind MetadataEncryptor::class
factoryOf(::MetadataKeyStorageProviderImpl) bind MetadataKeyStorageProvider::class
factoryOf(::MetadataStorageProviderImpl) bind MetadataStorageProvider::class
factoryOf(::MetadataProviderImpl) bind MetadataProvider::class
singleOf(::MetadataDataSourceImpl) bind MetadataDataSource::class
singleOf(::MetadataRepositoryImpl) bind MetadataRepository::class
}

View File

@ -1,11 +1,5 @@
package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookProviderImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProviderImpl
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
@ -14,6 +8,8 @@ import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProviderImpl
import co.electriccoin.zcash.ui.common.provider.RestoreTimestampStorageProvider
import co.electriccoin.zcash.ui.common.provider.RestoreTimestampStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.SelectedAccountUUIDProvider
import co.electriccoin.zcash.ui.common.provider.SelectedAccountUUIDProviderImpl
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
@ -29,11 +25,9 @@ val providerModule =
factoryOf(::GetVersionInfoProvider)
factoryOf(::GetZcashCurrencyProvider)
factoryOf(::GetMonetarySeparatorProvider)
factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class
factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class
factoryOf(::AddressBookKeyStorageProviderImpl) bind AddressBookKeyStorageProvider::class
factoryOf(::SelectedAccountUUIDProviderImpl) bind SelectedAccountUUIDProvider::class
singleOf(::PersistableWalletProviderImpl) bind PersistableWalletProvider::class
singleOf(::SynchronizerProviderImpl) bind SynchronizerProvider::class
singleOf(::ApplicationStateProviderImpl) bind ApplicationStateProvider::class
factoryOf(::RestoreTimestampStorageProviderImpl) bind RestoreTimestampStorageProvider::class
}

View File

@ -1,7 +1,5 @@
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.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.BalanceRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
@ -32,7 +30,6 @@ val repositoryModule =
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::class
singleOf(::FlexaRepositoryImpl) bind FlexaRepository::class
singleOf(::BiometricRepositoryImpl) bind BiometricRepository::class
singleOf(::KeystoneProposalRepositoryImpl) bind KeystoneProposalRepository::class

View File

@ -8,33 +8,37 @@ import co.electriccoin.zcash.ui.common.usecase.CopyToClipboardUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneProposalPCZTEncoderUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneShieldProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateOrUpdateTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateZip321ProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteContactUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.DeriveKeystoneAccountUnifiedAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.FlipTransactionBookmarkUseCase
import co.electriccoin.zcash.ui.common.usecase.GetBackupPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetExchangeRateUseCase
import co.electriccoin.zcash.ui.common.usecase.GetMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSupportUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionByIdUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionDetailByIdUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionFulltextFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToSendUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveClearSendUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
@ -58,7 +62,8 @@ import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.PrefillSendUseCase
import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetInMemoryDataUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetSharedPrefsDataUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase
import co.electriccoin.zcash.ui.common.usecase.SelectWalletAccountUseCase
@ -76,9 +81,11 @@ import co.electriccoin.zcash.ui.common.usecase.ViewTransactionDetailAfterSuccess
import co.electriccoin.zcash.ui.common.usecase.ViewTransactionsAfterSuccessfulProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase
import co.electriccoin.zcash.ui.util.closeableCallback
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.dsl.onClose
val useCaseModule =
module {
@ -94,8 +101,6 @@ val useCaseModule =
factoryOf(::ObserveConfigurationUseCase)
factoryOf(::RescanBlockchainUseCase)
factoryOf(::GetTransparentAddressUseCase)
factoryOf(::ObserveAddressBookContactsUseCase)
factoryOf(::ResetAddressBookUseCase)
factoryOf(::ValidateContactAddressUseCase)
factoryOf(::ValidateContactNameUseCase)
factoryOf(::SaveContactUseCase)
@ -117,7 +122,7 @@ val useCaseModule =
factoryOf(::SendEmailUseCase)
factoryOf(::SendSupportEmailUseCase)
factoryOf(::IsFlexaAvailableUseCase)
factoryOf(::SensitiveSettingsVisibleUseCase)
singleOf(::SensitiveSettingsVisibleUseCase)
factoryOf(::ObserveWalletAccountsUseCase)
factoryOf(::SelectWalletAccountUseCase)
factoryOf(::ObserveSelectedWalletAccountUseCase)
@ -131,7 +136,7 @@ val useCaseModule =
singleOf(::ObserveClearSendUseCase)
singleOf(::PrefillSendUseCase)
factoryOf(::GetCurrentTransactionsUseCase)
factoryOf(::GetCurrentFilteredTransactionsUseCase)
factoryOf(::GetCurrentFilteredTransactionsUseCase) onClose ::closeableCallback
factoryOf(::CreateProposalUseCase)
factoryOf(::CreateZip321ProposalUseCase)
factoryOf(::CreateKeystoneShieldProposalUseCase)
@ -148,14 +153,21 @@ val useCaseModule =
factoryOf(::ObserveTransactionSubmitStateUseCase)
factoryOf(::GetProposalUseCase)
factoryOf(::ConfirmProposalUseCase)
factoryOf(::NavigateToAddressBookUseCase)
factoryOf(::NavigateToSendUseCase)
factoryOf(::GetWalletRestoringStateUseCase)
factoryOf(::ApplyTransactionFiltersUseCase)
factoryOf(::ResetTransactionFiltersUseCase)
factoryOf(::ApplyTransactionFulltextFiltersUseCase)
factoryOf(::GetTransactionFiltersUseCase)
factoryOf(::GetTransactionFulltextFiltersUseCase)
factoryOf(::GetTransactionByIdUseCase)
factoryOf(::GetTransactionDetailByIdUseCase)
factoryOf(::SendTransactionAgainUseCase)
factoryOf(::ObserveAddressBookContactsUseCase)
factoryOf(::ResetInMemoryDataUseCase)
factoryOf(::ResetSharedPrefsDataUseCase)
factoryOf(::NavigateToAddressBookUseCase)
factoryOf(::GetTransactionMetadataUseCase)
factoryOf(::FlipTransactionBookmarkUseCase)
factoryOf(::DeleteTransactionNoteUseCase)
factoryOf(::CreateOrUpdateTransactionNoteUseCase)
factoryOf(::MarkTxMemoAsReadUseCase)
factoryOf(::GetMetadataUseCase)
}

View File

@ -40,6 +40,8 @@ import co.electriccoin.zcash.ui.screen.transactiondetail.TransactionDetailViewMo
import co.electriccoin.zcash.ui.screen.transactionfilters.viewmodel.TransactionFiltersViewModel
import co.electriccoin.zcash.ui.screen.transactionhistory.TransactionHistoryViewModel
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetViewModel
import co.electriccoin.zcash.ui.screen.transactionnote.TransactionNote
import co.electriccoin.zcash.ui.screen.transactionnote.viewmodel.TransactionNoteViewModel
import co.electriccoin.zcash.ui.screen.transactionprogress.TransactionProgressViewModel
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel
@ -73,8 +75,6 @@ val viewModelModule =
)
}
viewModelOf(::ChooseServerViewModel)
viewModelOf(::AddressBookViewModel)
viewModelOf(::SelectRecipientViewModel)
viewModel { (address: String?) ->
AddContactViewModel(
address = address,
@ -128,10 +128,23 @@ val viewModelModule =
viewModel { (transactionDetail: TransactionDetail) ->
TransactionDetailViewModel(
transactionDetail = transactionDetail,
getTransactionById = get(),
getTransactionDetailById = get(),
copyToClipboard = get(),
navigationRouter = get(),
sendTransactionAgain = get()
sendTransactionAgain = get(),
flipTransactionBookmark = get(),
markTxMemoAsRead = get()
)
}
viewModelOf(::AddressBookViewModel)
viewModelOf(::SelectRecipientViewModel)
viewModel { (transactionNote: TransactionNote) ->
TransactionNoteViewModel(
transactionNote = transactionNote,
navigationRouter = get(),
getTransactionNote = get(),
createOrUpdateTransactionNote = get(),
deleteTransactionNote = get(),
)
}
}

View File

@ -101,6 +101,8 @@ import co.electriccoin.zcash.ui.screen.transactionfilters.AndroidTransactionFilt
import co.electriccoin.zcash.ui.screen.transactionfilters.TransactionFilters
import co.electriccoin.zcash.ui.screen.transactionhistory.AndroidTransactionHistory
import co.electriccoin.zcash.ui.screen.transactionhistory.TransactionHistory
import co.electriccoin.zcash.ui.screen.transactionnote.AndroidTransactionNote
import co.electriccoin.zcash.ui.screen.transactionnote.TransactionNote
import co.electriccoin.zcash.ui.screen.transactionprogress.AndroidTransactionProgress
import co.electriccoin.zcash.ui.screen.transactionprogress.TransactionProgress
import co.electriccoin.zcash.ui.screen.update.WrapCheckForUpdate
@ -425,6 +427,15 @@ internal fun MainActivity.Navigation() {
composable<TransactionDetail> {
AndroidTransactionDetail(it.toRoute())
}
dialog<TransactionNote>(
dialogProperties =
DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
)
) {
AndroidTransactionNote(it.toRoute())
}
}
}

View File

@ -9,7 +9,6 @@ import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.Zip32AccountIndex
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.SaplingInfo
@ -28,7 +27,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@ -41,7 +39,6 @@ import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
interface AccountDataSource {
@ -197,7 +194,7 @@ class AccountDataSourceImpl(
}.flowOn(Dispatchers.IO)
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT, Duration.ZERO),
started = SharingStarted.Eagerly,
initialValue = null
)

View File

@ -6,7 +6,7 @@ import co.electriccoin.zcash.ui.common.model.AddressBook
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.provider.AddressBookProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider
import co.electriccoin.zcash.ui.common.serialization.addressbook.ADDRESS_BOOK_SERIALIZATION_V1
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SERIALIZATION_V1
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@ -0,0 +1,309 @@
package co.electriccoin.zcash.ui.common.datasource
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.AccountMetadata
import co.electriccoin.zcash.ui.common.model.AnnotationMetadata
import co.electriccoin.zcash.ui.common.model.BookmarkMetadata
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.provider.MetadataProvider
import co.electriccoin.zcash.ui.common.provider.MetadataStorageProvider
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant
interface MetadataDataSource {
suspend fun getMetadata(key: MetadataKey): Metadata
suspend fun flipTxAsBookmarked(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun createOrUpdateTxNote(
txId: String,
note: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun deleteTxNote(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun markTxMemoAsRead(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun save(
metadata: Metadata,
key: MetadataKey
)
suspend fun resetMetadata()
}
@Suppress("TooManyFunctions")
class MetadataDataSourceImpl(
private val metadataStorageProvider: MetadataStorageProvider,
private val metadataProvider: MetadataProvider,
) : MetadataDataSource {
private var metadata: Metadata? = null
private val mutex = Mutex()
override suspend fun getMetadata(key: MetadataKey): Metadata =
mutex.withLock {
getMetadataInternal(key)
}
override suspend fun flipTxAsBookmarked(
txId: String,
account: AccountUuid,
key: MetadataKey,
): Metadata =
mutex.withLock {
updateMetadataBookmark(txId = txId, account = account, key = key) {
it.copy(
isBookmarked = !it.isBookmarked,
lastUpdated = Instant.now(),
)
}
}
override suspend fun createOrUpdateTxNote(
txId: String,
note: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadataAnnotation(
txId = txId,
account = account,
key = key
) {
it.copy(
content = note,
lastUpdated = Instant.now(),
)
}
}
override suspend fun deleteTxNote(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadataAnnotation(
txId = txId,
account = account,
key = key
) {
it.copy(
content = null,
lastUpdated = Instant.now(),
)
}
}
override suspend fun markTxMemoAsRead(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
read = (metadata.read.toSet() + txId).toList()
)
}
)
}
override suspend fun save(
metadata: Metadata,
key: MetadataKey
) = mutex.withLock {
writeToLocalStorage(metadata, key)
this.metadata = metadata
}
override suspend fun resetMetadata() =
mutex.withLock {
metadata = null
}
private suspend fun getMetadataInternal(key: MetadataKey): Metadata {
fun readLocalFileToMetadata(key: MetadataKey): Metadata? {
val encryptedFile =
runCatching { metadataStorageProvider.getStorageFile(key) }.getOrNull()
?: return null
return runCatching {
metadataProvider.readMetadataFromFile(encryptedFile, key)
}.onFailure { e -> Twig.warn(e) { "Failed to decrypt metadata" } }.getOrNull()
}
return withContext(Dispatchers.IO) {
val inMemory = metadata
if (inMemory == null) {
var new: Metadata? = readLocalFileToMetadata(key)
if (new == null) {
new =
Metadata(
version = 1,
lastUpdated = Instant.now(),
accountMetadata = emptyMap(),
).also {
this@MetadataDataSourceImpl.metadata = it
}
writeToLocalStorage(new, key)
}
new
} else {
inMemory
}
}
}
private suspend fun writeToLocalStorage(
metadata: Metadata,
key: MetadataKey
) = withContext(Dispatchers.IO) {
runCatching {
val file = metadataStorageProvider.getOrCreateStorageFile(key)
metadataProvider.writeMetadataToFile(file, metadata, key)
}.onFailure { e -> Twig.warn(e) { "Failed to write address book" } }
}
private suspend fun updateMetadataAnnotation(
txId: String,
account: AccountUuid,
key: MetadataKey,
transform: (AnnotationMetadata) -> AnnotationMetadata
): Metadata {
return updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
annotations =
metadata.annotations
.replaceOrAdd(
predicate = { it.txId == txId },
transform = {
val bookmarkMetadata = it ?: defaultAnnotationMetadata(txId)
transform(bookmarkMetadata)
}
)
)
}
)
}
private suspend fun updateMetadataBookmark(
txId: String,
account: AccountUuid,
key: MetadataKey,
transform: (BookmarkMetadata) -> BookmarkMetadata
): Metadata {
return updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
bookmarked =
metadata.bookmarked
.replaceOrAdd(
predicate = { it.txId == txId },
transform = {
val bookmarkMetadata = it ?: defaultBookmarkMetadata(txId)
transform(bookmarkMetadata)
}
)
)
}
)
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun updateMetadata(
account: AccountUuid,
key: MetadataKey,
transform: (AccountMetadata) -> AccountMetadata
): Metadata {
return withContext(Dispatchers.IO) {
val metadata = getMetadataInternal(key)
val accountMetadata = metadata.accountMetadata[account.value.toHexString()] ?: defaultAccountMetadata()
val updatedMetadata =
metadata.copy(
lastUpdated = Instant.now(),
accountMetadata =
metadata.accountMetadata
.toMutableMap()
.apply {
put(account.value.toHexString(), transform(accountMetadata))
}
.toMap()
)
this@MetadataDataSourceImpl.metadata = updatedMetadata
writeToLocalStorage(updatedMetadata, key)
updatedMetadata
}
}
}
private fun defaultAccountMetadata() =
AccountMetadata(
bookmarked = emptyList(),
read = emptyList(),
annotations = emptyList(),
)
private fun defaultBookmarkMetadata(txId: String) =
BookmarkMetadata(
txId = txId,
lastUpdated = Instant.now(),
isBookmarked = false
)
private fun defaultAnnotationMetadata(txId: String) =
AnnotationMetadata(
txId = txId,
lastUpdated = Instant.now(),
content = null
)
private fun <T : Any> List<T>.replaceOrAdd(
predicate: (T) -> Boolean,
transform: (T?) -> T
): List<T> {
val index = this.indexOfFirst(predicate)
return if (index != -1) {
this.toMutableList()
.apply {
set(index, transform(this[index]))
}
.toList()
} else {
this + transform(null)
}
}

View File

@ -0,0 +1,36 @@
package co.electriccoin.zcash.ui.common.datasource
import co.electriccoin.zcash.ui.common.provider.RestoreTimestampStorageProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
interface RestoreTimestampDataSource {
suspend fun getOrCreate(): Instant
suspend fun clear()
}
class RestoreTimestampDataSourceImpl(
private val restoreTimestampStorageProvider: RestoreTimestampStorageProvider
) : RestoreTimestampDataSource {
private val mutex = Mutex()
override suspend fun getOrCreate(): Instant =
mutex.withLock {
val existing = restoreTimestampStorageProvider.get()
return if (existing == null) {
val now = Instant.now()
restoreTimestampStorageProvider.store(now)
now
} else {
existing
}
}
override suspend fun clear() =
mutex.withLock {
restoreTimestampStorageProvider.clear()
}
}

View File

@ -12,6 +12,7 @@ import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SENT
import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SHIELDED
import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SHIELDING
import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SHIELDING_FAILED
import co.electriccoin.zcash.ui.common.usecase.ListTransactionData
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.StringResourceColor
import co.electriccoin.zcash.ui.design.util.StyledStringResource
@ -21,40 +22,67 @@ import co.electriccoin.zcash.ui.screen.transactionhistory.TransactionState
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
class TransactionHistoryMapper {
fun createTransactionState(
transaction: TransactionData,
transaction: ListTransactionData,
restoreTimestamp: Instant,
onTransactionClick: (TransactionData) -> Unit
) = TransactionState(
key = transaction.overview.txIdString(),
icon = getIcon(transaction),
title = getTitle(transaction),
subtitle = getSubtitle(transaction),
isShielded = isShielded(transaction),
value = getValue(transaction),
onClick = { onTransactionClick(transaction) },
hasMemo = transaction.overview.memoCount > 0
)
): TransactionState {
val transactionDate =
transaction.data.overview.blockTimeEpochSeconds
?.let { blockTimeEpochSeconds ->
Instant.ofEpochSecond(blockTimeEpochSeconds).atZone(ZoneId.systemDefault())
}
private fun getIcon(transaction: TransactionData) =
when (transaction.state) {
SENT,
SENDING,
SEND_FAILED -> R.drawable.ic_transaction_sent
return TransactionState(
key = transaction.data.overview.txId.txIdString(),
icon = getIcon(transaction),
title = getTitle(transaction),
subtitle = getSubtitle(transactionDate),
isShielded = isShielded(transaction),
value = getValue(transaction),
onClick = { onTransactionClick(transaction.data) },
isUnread = isUnread(transaction, transactionDate, restoreTimestamp)
)
}
RECEIVED,
RECEIVING,
RECEIVE_FAILED -> R.drawable.ic_transaction_received
private fun isUnread(
transaction: ListTransactionData,
transactionDateTime: ZonedDateTime?,
restoreTimestamp: Instant,
): Boolean {
val hasMemo = transaction.data.overview.memoCount > 0
val transactionDate = transactionDateTime?.toLocalDate() ?: LocalDate.now()
val restoreDate = restoreTimestamp.atZone(ZoneId.systemDefault()).toLocalDate()
SHIELDED,
SHIELDING,
SHIELDING_FAILED -> R.drawable.ic_transaction_shielded
return if (hasMemo && transactionDate < restoreDate) {
false
} else {
val transactionMetadata = transaction.metadata
hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not())
}
}
private fun getIcon(transaction: ListTransactionData) =
when (transaction.data.state) {
SENT -> R.drawable.ic_transaction_sent
SENDING -> R.drawable.ic_transaction_send_pending
SEND_FAILED -> R.drawable.ic_transaction_send_failed
RECEIVED -> R.drawable.ic_transaction_received
RECEIVING -> R.drawable.ic_transaction_receive_pending
RECEIVE_FAILED -> R.drawable.ic_transaction_receive_pending
SHIELDED -> R.drawable.ic_transaction_shielded
SHIELDING -> R.drawable.ic_transaction_shield_pending
SHIELDING_FAILED -> R.drawable.ic_transaction_shield_failed
}
private fun getTitle(transaction: TransactionData) =
when (transaction.state) {
private fun getTitle(transaction: ListTransactionData) =
when (transaction.data.state) {
SENT -> stringRes(R.string.transaction_history_sent)
SENDING -> stringRes(R.string.transaction_history_sending)
SEND_FAILED -> stringRes(R.string.transaction_history_sending_failed)
@ -66,46 +94,36 @@ class TransactionHistoryMapper {
SHIELDING_FAILED -> stringRes(R.string.transaction_history_shielding_failed)
}
private fun getSubtitle(transaction: TransactionData): StringResource? {
val transactionDate =
transaction.overview.blockTimeEpochSeconds
?.let { blockTimeEpochSeconds ->
Instant.ofEpochSecond(blockTimeEpochSeconds)
}
?.atZone(ZoneId.systemDefault()) ?: return null
private fun getSubtitle(transactionDate: ZonedDateTime?): StringResource? {
if (transactionDate == null) return null
val daysBetween = ChronoUnit.DAYS.between(transactionDate.toLocalDate(), LocalDate.now())
return when {
LocalDate.now() == transactionDate.toLocalDate() -> {
LocalDate.now() == transactionDate.toLocalDate() ->
stringRes(R.string.transaction_history_today)
}
LocalDate.now().minusDays(1) == transactionDate.toLocalDate() -> {
LocalDate.now().minusDays(1) == transactionDate.toLocalDate() ->
stringRes(R.string.transaction_history_yesterday)
}
daysBetween < MONTH_THRESHOLD -> {
daysBetween < MONTH_THRESHOLD ->
stringRes(R.string.transaction_history_days_ago, daysBetween.toString())
}
else -> {
stringResByDateTime(zonedDateTime = transactionDate, useFullFormat = false)
}
else -> stringResByDateTime(zonedDateTime = transactionDate, useFullFormat = false)
}
}
private fun isShielded(transaction: TransactionData) =
transaction.transactionOutputs
private fun isShielded(transaction: ListTransactionData) =
transaction.data.transactionOutputs
.none { output -> output.pool == TransactionPool.TRANSPARENT } &&
!transaction.overview.isShielding
!transaction.data.overview.isShielding
private fun getValue(transaction: TransactionData) =
when (transaction.state) {
private fun getValue(transaction: ListTransactionData) =
when (transaction.data.state) {
SENT,
SENDING ->
StyledStringResource(
stringRes(
R.string.transaction_history_minus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
)
)
@ -113,7 +131,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_minus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
),
StringResourceColor.NEGATIVE
)
@ -122,7 +140,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
),
StringResourceColor.POSITIVE
)
@ -131,7 +149,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
),
)
@ -139,7 +157,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
),
StringResourceColor.NEGATIVE
)
@ -148,7 +166,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
)
)
@ -156,7 +174,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
)
)
@ -164,7 +182,7 @@ class TransactionHistoryMapper {
StyledStringResource(
stringRes(
R.string.transaction_history_plus,
stringRes(transaction.overview.netValue)
stringRes(transaction.data.overview.netValue)
),
StringResourceColor.NEGATIVE
)

View File

@ -0,0 +1,49 @@
package co.electriccoin.zcash.ui.common.model
import co.electriccoin.zcash.ui.common.serialization.InstantSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
data class Metadata(
@SerialName("version")
val version: Int,
@SerialName("lastUpdated")
@Serializable(InstantSerializer::class)
val lastUpdated: Instant,
@SerialName("accountMetadata")
val accountMetadata: Map<String, AccountMetadata>
)
@Serializable
data class AccountMetadata(
@SerialName("bookmarked")
val bookmarked: List<BookmarkMetadata>,
@SerialName("read")
val read: List<String>,
@SerialName("annotations")
val annotations: List<AnnotationMetadata>
)
@Serializable
data class BookmarkMetadata(
@SerialName("txId")
val txId: String,
@SerialName("lastUpdated")
@Serializable(InstantSerializer::class)
val lastUpdated: Instant,
@SerialName("isBookmarked")
val isBookmarked: Boolean
)
@Serializable
data class AnnotationMetadata(
@SerialName("txId")
val txId: String,
@SerialName("content")
val content: String?,
@SerialName("lastUpdated")
@Serializable(InstantSerializer::class)
val lastUpdated: Instant,
)

View File

@ -5,7 +5,6 @@ import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookEncr
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey
import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookSerializer
import java.io.File
import kotlin.LazyThreadSafetyMode.NONE
interface AddressBookProvider {
fun writeAddressBookToFile(
@ -22,21 +21,20 @@ interface AddressBookProvider {
fun readLegacyUnencryptedAddressBookFromFile(file: File): AddressBook
}
class AddressBookProviderImpl : AddressBookProvider {
private val addressBookSerializer by lazy(NONE) { AddressBookSerializer() }
private val addressBookEncryptor by lazy(NONE) { AddressBookEncryptor() }
class AddressBookProviderImpl(
private val addressBookEncryptor: AddressBookEncryptor,
private val addressBookSerializer: AddressBookSerializer,
) : AddressBookProvider {
override fun writeAddressBookToFile(
file: File,
addressBook: AddressBook,
addressBookKey: AddressBookKey
) {
file.outputStream().buffered().use { stream ->
addressBookEncryptor.encryptAddressBook(
addressBookKey,
addressBookSerializer,
stream,
addressBook
addressBookEncryptor.encrypt(
key = addressBookKey,
outputStream = stream,
data = addressBook
)
stream.flush()
}
@ -47,10 +45,9 @@ class AddressBookProviderImpl : AddressBookProvider {
addressBookKey: AddressBookKey
): AddressBook {
return file.inputStream().use { stream ->
addressBookEncryptor.decryptAddressBook(
addressBookKey,
addressBookSerializer,
stream
addressBookEncryptor.decrypt(
key = addressBookKey,
inputStream = stream
)
}
}

View File

@ -0,0 +1,62 @@
package co.electriccoin.zcash.ui.common.provider
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import com.google.crypto.tink.InsecureSecretKeyAccess
import com.google.crypto.tink.SecretKeyAccess
import com.google.crypto.tink.util.SecretBytes
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
interface MetadataKeyStorageProvider {
suspend fun get(): MetadataKey?
suspend fun store(key: MetadataKey)
}
class MetadataKeyStorageProviderImpl(
private val encryptedPreferenceProvider: EncryptedPreferenceProvider
) : MetadataKeyStorageProvider {
private val default = MetadataKeyPreferenceDefault()
override suspend fun get(): MetadataKey? {
return default.getValue(encryptedPreferenceProvider())
}
override suspend fun store(key: MetadataKey) {
default.putValue(encryptedPreferenceProvider(), key)
}
}
private class MetadataKeyPreferenceDefault : PreferenceDefault<MetadataKey?> {
private val secretKeyAccess: SecretKeyAccess?
get() = InsecureSecretKeyAccess.get()
override val key: PreferenceKey = PreferenceKey("metadata_key")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.decode()
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: MetadataKey?
) = preferenceProvider.putString(key, newValue?.encode())
@OptIn(ExperimentalEncodingApi::class)
private fun MetadataKey?.encode() =
if (this != null) {
Base64.encode(this.key.toByteArray(secretKeyAccess))
} else {
null
}
@OptIn(ExperimentalEncodingApi::class)
private fun String?.decode() =
if (this != null) {
MetadataKey(SecretBytes.copyFrom(Base64.decode(this), secretKeyAccess))
} else {
null
}
}

View File

@ -0,0 +1,50 @@
package co.electriccoin.zcash.ui.common.provider
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataEncryptor
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import java.io.File
interface MetadataProvider {
fun writeMetadataToFile(
file: File,
metadata: Metadata,
metadataKey: MetadataKey
)
fun readMetadataFromFile(
file: File,
addressBookKey: MetadataKey
): Metadata
}
class MetadataProviderImpl(
private val metadataEncryptor: MetadataEncryptor
) : MetadataProvider {
override fun writeMetadataToFile(
file: File,
metadata: Metadata,
metadataKey: MetadataKey
) {
file.outputStream().buffered().use { stream ->
metadataEncryptor.encrypt(
key = metadataKey,
outputStream = stream,
data = metadata
)
stream.flush()
}
}
override fun readMetadataFromFile(
file: File,
addressBookKey: MetadataKey
): Metadata {
return file.inputStream().use { stream ->
metadataEncryptor.decrypt(
key = addressBookKey,
inputStream = stream
)
}
}
}

View File

@ -0,0 +1,37 @@
package co.electriccoin.zcash.ui.common.provider
import android.content.Context
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import java.io.File
interface MetadataStorageProvider {
fun getStorageFile(key: MetadataKey): File?
fun getOrCreateStorageFile(key: MetadataKey): File
}
class MetadataStorageProviderImpl(
private val context: Context
) : MetadataStorageProvider {
override fun getStorageFile(key: MetadataKey): File? {
return File(getOrCreateMetadataDir(), key.fileIdentifier())
.takeIf { it.exists() && it.isFile }
}
override fun getOrCreateStorageFile(key: MetadataKey): File {
val file = File(getOrCreateMetadataDir(), key.fileIdentifier())
if (!file.exists()) {
file.createNewFile()
}
return file
}
private fun getOrCreateMetadataDir(): File {
val filesDir = context.filesDir
val addressBookDir = File(filesDir, "metadata")
if (!addressBookDir.exists()) {
addressBookDir.mkdir()
}
return addressBookDir
}
}

View File

@ -0,0 +1,47 @@
package co.electriccoin.zcash.ui.common.provider
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import java.time.Instant
interface RestoreTimestampStorageProvider {
suspend fun get(): Instant?
suspend fun store(key: Instant)
suspend fun clear()
}
class RestoreTimestampStorageProviderImpl(
private val encryptedPreferenceProvider: EncryptedPreferenceProvider
) : RestoreTimestampStorageProvider {
private val default = RestoreTimestampPreferenceDefault()
override suspend fun get(): Instant? {
return default.getValue(encryptedPreferenceProvider())
}
override suspend fun store(key: Instant) {
default.putValue(encryptedPreferenceProvider(), key)
}
override suspend fun clear() {
default.putValue(encryptedPreferenceProvider(), null)
}
}
private class RestoreTimestampPreferenceDefault : PreferenceDefault<Instant?> {
override val key: PreferenceKey = PreferenceKey("restore_timestamp")
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getLong(key)?.let {
Instant.ofEpochMilli(it)
}
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: Instant?
) = preferenceProvider.putLong(key, newValue?.toEpochMilli())
}

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@ -113,6 +114,7 @@ class AddressBookRepositoryImpl(
.map {
it.contacts.find { contact -> contact.address == address }
}
.distinctUntilChanged()
}
private suspend fun ensureSynchronization() {

View File

@ -0,0 +1,167 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.MetadataDataSource
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface MetadataRepository {
val metadata: Flow<Metadata?>
suspend fun flipTxBookmark(txId: String)
suspend fun createOrUpdateTxNote(
txId: String,
note: String
)
suspend fun deleteTxNote(txId: String)
suspend fun markTxMemoAsRead(txId: String)
suspend fun resetMetadata()
fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?>
}
class MetadataRepositoryImpl(
private val metadataDataSource: MetadataDataSource,
private val metadataKeyStorageProvider: MetadataKeyStorageProvider,
private val accountDataSource: AccountDataSource,
private val persistableWalletProvider: PersistableWalletProvider,
) : MetadataRepository {
private val semaphore = Mutex()
private val cache = MutableStateFlow<Metadata?>(null)
override val metadata: Flow<Metadata?> =
cache
.onSubscription {
withNonCancellableSemaphore {
ensureSynchronization()
}
}
override suspend fun flipTxBookmark(txId: String) =
mutateMetadata {
metadataDataSource.flipTxAsBookmarked(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun createOrUpdateTxNote(
txId: String,
note: String
) = mutateMetadata {
metadataDataSource.createOrUpdateTxNote(
txId = txId,
note = note,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun deleteTxNote(txId: String) =
mutateMetadata {
metadataDataSource.deleteTxNote(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun markTxMemoAsRead(txId: String) =
mutateMetadata {
metadataDataSource.markTxMemoAsRead(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun resetMetadata() {
withNonCancellableSemaphore {
metadataDataSource.resetMetadata()
cache.update { null }
}
}
@OptIn(ExperimentalStdlibApi::class)
override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> =
combine<Metadata?, AccountUuid, TransactionMetadata?>(
metadata,
accountDataSource.selectedAccount.filterNotNull().map { it.sdkAccount.accountUuid }.distinctUntilChanged()
) { metadata, account ->
val accountMetadata = metadata?.accountMetadata?.get(account.value.toHexString())
TransactionMetadata(
isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true,
isRead = accountMetadata?.read?.any { it == txId } == true,
note = accountMetadata?.annotations?.find { it.txId == txId }?.content,
)
}.distinctUntilChanged().onStart { emit(null) }
private suspend fun ensureSynchronization() {
if (cache.value == null) {
val metadata = metadataDataSource.getMetadata(key = getMetadataKey())
metadataDataSource.save(metadata = metadata, key = getMetadataKey())
cache.update { metadata }
}
}
private suspend fun mutateMetadata(block: suspend () -> Metadata) =
withNonCancellableSemaphore {
ensureSynchronization()
val new = block()
cache.update { new }
}
private suspend fun withNonCancellableSemaphore(block: suspend () -> Unit) =
withContext(NonCancellable + Dispatchers.Default) {
semaphore.withLock { block() }
}
private suspend fun getMetadataKey(): MetadataKey {
val key = metadataKeyStorageProvider.get()
return if (key != null) {
key
} else {
val account = accountDataSource.getZashiAccount()
val persistableWallet = persistableWalletProvider.getPersistableWallet()
val newKey =
MetadataKey.derive(
seedPhrase = persistableWallet.seedPhrase,
network = persistableWallet.network,
account = account
)
metadataKeyStorageProvider.store(newKey)
newKey
}
}
}
data class TransactionMetadata(
val isBookmarked: Boolean,
val isRead: Boolean,
val note: String?
)

View File

@ -1,18 +1,10 @@
package co.electriccoin.zcash.ui.common.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
interface TransactionFilterRepository {
val onFilterChanged: Flow<Unit>
val fulltextFilter: StateFlow<String?>
val filters: StateFlow<List<TransactionFilter>>
@ -27,48 +19,24 @@ interface TransactionFilterRepository {
}
class TransactionFilterRepositoryImpl : TransactionFilterRepository {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
override val onFilterChanged = MutableSharedFlow<Unit>()
override val fulltextFilter = MutableStateFlow<String?>(null)
override val filters = MutableStateFlow<List<TransactionFilter>>(emptyList())
override fun apply(filters: List<TransactionFilter>) {
val old = this.filters.getAndUpdate { filters }
if (old != filters) {
scope.launch {
onFilterChanged.emit(Unit)
}
}
this.filters.getAndUpdate { filters }
}
override fun applyFulltext(fulltext: String) {
val old = fulltextFilter.getAndUpdate { fulltext }
if (old != fulltext) {
scope.launch {
onFilterChanged.emit(Unit)
}
}
fulltextFilter.getAndUpdate { fulltext }
}
override fun clear() {
val old = filters.getAndUpdate { emptyList() }
if (old != emptyList<TransactionFilter>()) {
scope.launch {
onFilterChanged.emit(Unit)
}
}
filters.getAndUpdate { emptyList() }
}
override fun clearFulltext() {
val old = fulltextFilter.getAndUpdate { null }
if (old != null) {
scope.launch {
onFilterChanged.emit(Unit)
}
}
fulltextFilter.getAndUpdate { null }
}
}

View File

@ -1,6 +1,6 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.TransactionId
import cash.z.ecc.android.sdk.model.TransactionOutput
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
@ -19,18 +19,27 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
interface TransactionRepository {
val currentTransactions: Flow<List<TransactionData>?>
@ -38,71 +47,96 @@ interface TransactionRepository {
suspend fun getMemos(transactionData: TransactionData): List<String>
suspend fun getRecipients(transactionData: TransactionData): String?
fun observeTransaction(txId: String): Flow<TransactionData?>
fun observeTransactionsByMemo(memo: String): Flow<List<TransactionId>?>
}
class TransactionRepositoryImpl(
accountDataSource: AccountDataSource,
private val synchronizerProvider: SynchronizerProvider,
) : TransactionRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@OptIn(ExperimentalCoroutinesApi::class)
override val currentTransactions: Flow<List<TransactionData>?> =
combine(
synchronizerProvider.synchronizer,
synchronizerProvider.synchronizer.flatMapLatest { it?.networkHeight ?: flowOf(null) },
accountDataSource.selectedAccount.map { it?.sdkAccount }
) { synchronizer, networkHeight, account ->
Triple(synchronizer, networkHeight, account)
}.flatMapLatest { (synchronizer, networkHeight, account) ->
) { synchronizer, account ->
synchronizer to account
}.distinctUntilChanged().flatMapLatest { (synchronizer, account) ->
if (synchronizer == null || account == null) {
flowOf(null)
} else {
flowOf(null).flatMapLatest {
synchronizer.getTransactions(account.accountUuid)
.map {
it.map { transaction ->
TransactionData(
overview = transaction,
transactionOutputs = synchronizer.getTransactionOutputs(transaction),
state = transaction.getExtendedState()
)
}.sortedByDescending { transaction ->
transaction.overview.getSortHeight(networkHeight)
channelFlow<List<TransactionData>?> {
send(null)
launch {
synchronizer.getTransactions(account.accountUuid)
.mapLatest { transactions ->
transactions.map { transaction ->
TransactionData(
overview = transaction,
transactionOutputs = synchronizer.getTransactionOutputs(transaction),
state = transaction.getExtendedState()
)
}.sortedByDescending { transaction ->
transaction.overview.blockTimeEpochSeconds ?: ZonedDateTime.now().toEpochSecond()
}
}
}
.collect {
send(it)
}
}
awaitClose {
// do nothing
}
}
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(Duration.ZERO, Duration.ZERO),
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
override suspend fun getMemos(transactionData: TransactionData): List<String> {
return synchronizerProvider.getSynchronizer().getMemos(transactionData.overview)
.mapNotNull { memo -> memo.takeIf { it.isNotEmpty() } }
.toList()
}
override suspend fun getRecipients(transactionData: TransactionData): String? {
if (transactionData.overview.isSentTransaction) {
val result = synchronizerProvider.getSynchronizer().getRecipients(transactionData.overview).firstOrNull()
return (result as? TransactionRecipient.RecipientAddress)?.addressValue
} else {
return null
override suspend fun getMemos(transactionData: TransactionData): List<String> =
withContext(Dispatchers.IO) {
synchronizerProvider.getSynchronizer().getMemos(transactionData.overview)
.mapNotNull { memo -> memo.takeIf { it.isNotEmpty() } }
.toList()
}
}
private fun TransactionOverview.getSortHeight(networkHeight: BlockHeight?): BlockHeight? {
// Non-null assertion operator is necessary here as the smart cast to is impossible because `minedHeight` and
// `expiryHeight` are declared in a different module
return when {
minedHeight != null -> minedHeight!!
(expiryHeight?.value ?: 0) > 0 -> expiryHeight!!
else -> networkHeight
override suspend fun getRecipients(transactionData: TransactionData): String? =
withContext(Dispatchers.IO) {
if (transactionData.overview.isSentTransaction) {
val result =
synchronizerProvider
.getSynchronizer()
.getRecipients(transactionData.overview)
.firstOrNull()
(result as? TransactionRecipient.RecipientAddress)?.addressValue
} else {
null
}
}
}
override fun observeTransaction(txId: String): Flow<TransactionData?> =
currentTransactions
.map { transactions ->
transactions?.find { it.overview.txId.txIdString() == txId }
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun observeTransactionsByMemo(memo: String): Flow<List<TransactionId>?> =
synchronizerProvider
.synchronizer
.flatMapLatest { synchronizer ->
synchronizer?.getTransactionsByMemoSubstring(memo)?.onEmpty { emit(listOf()) } ?: flowOf(null)
}
.distinctUntilChanged()
}
data class TransactionData(

View File

@ -17,6 +17,7 @@ import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource
import co.electriccoin.zcash.ui.common.model.FastestServersState
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
@ -116,6 +117,7 @@ class WalletRepositoryImpl(
private val standardPreferenceProvider: StandardPreferenceProvider,
private val persistableWalletPreference: PersistableWalletPreferenceDefault,
private val encryptedPreferenceProvider: EncryptedPreferenceProvider,
private val restoreTimestampDataSource: RestoreTimestampDataSource,
) : WalletRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -358,6 +360,7 @@ class WalletRepositoryImpl(
standardPreferenceProvider(),
WalletRestoringState.RESTORING.toNumber()
)
restoreTimestampDataSource.getOrCreate()
persistOnboardingStateInternal(OnboardingState.READY)
}
}

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.common.serialization.addressbook
package co.electriccoin.zcash.ui.common.serialization
import java.io.InputStream
import java.nio.ByteBuffer
internal abstract class BaseAddressBookSerializer {
abstract class BaseSerializer {
protected fun Int.createByteArray(): ByteArray {
return this.toLong().createByteArray()
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common.serialization.addressbook
package co.electriccoin.zcash.ui.common.serialization
import java.nio.ByteOrder
@ -8,3 +8,9 @@ internal const val ADDRESS_BOOK_ENCRYPTION_KEY_SIZE = 32
internal const val ADDRESS_BOOK_FILE_IDENTIFIER_SIZE = 32
internal const val ADDRESS_BOOK_SALT_SIZE = 32
internal val ADDRESS_BOOK_BYTE_ORDER = ByteOrder.BIG_ENDIAN
internal const val METADATA_SERIALIZATION_V1 = 1
internal const val METADATA_ENCRYPTION_V1 = 1
internal const val METADATA_SALT_SIZE = 32
internal const val METADATA_ENCRYPTION_KEY_SIZE = 32
internal const val METADATA_FILE_IDENTIFIER_SIZE = 32

View File

@ -0,0 +1,82 @@
package co.electriccoin.zcash.ui.common.serialization
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface Encryptor<KEY : Key, T> {
fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
)
fun decrypt(
key: KEY,
inputStream: InputStream
): T
}
abstract class BaseEncryptor<KEY : Key, T> : BaseSerializer(), Encryptor<KEY, T> {
abstract val version: Int
abstract val saltSize: Int
protected abstract fun serialize(
outputStream: ByteArrayOutputStream,
data: T
)
protected abstract fun deserialize(inputStream: ByteArrayInputStream): T
override fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
}
override fun decrypt(
key: KEY,
inputStream: InputStream
): T {
val version = inputStream.readInt()
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
}
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")

View File

@ -0,0 +1,25 @@
package co.electriccoin.zcash.ui.common.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
object InstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG)
override fun serialize(
encoder: Encoder,
value: Instant
) {
encoder.encodeLong(value.toEpochMilli())
}
override fun deserialize(decoder: Decoder): Instant {
return Instant.ofEpochMilli(decoder.decodeLong())
}
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.common.serialization
import com.google.crypto.tink.aead.ChaCha20Poly1305Key
interface Key {
fun fileIdentifier(): String
fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key
}

View File

@ -1,61 +1,29 @@
package co.electriccoin.zcash.ui.common.serialization.addressbook
import co.electriccoin.zcash.ui.common.model.AddressBook
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor
import co.electriccoin.zcash.ui.common.serialization.Encryptor
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
internal class AddressBookEncryptor : BaseAddressBookSerializer() {
fun encryptAddressBook(
addressBookKey: AddressBookKey,
serializer: AddressBookSerializer,
outputStream: OutputStream,
addressBook: AddressBook
interface AddressBookEncryptor : Encryptor<AddressBookKey, AddressBook>
class AddressBookEncryptorImpl(
private val addressBookSerializer: AddressBookSerializer,
) : AddressBookEncryptor, BaseEncryptor<AddressBookKey, AddressBook>() {
override val version: Int = ADDRESS_BOOK_ENCRYPTION_V1
override val saltSize: Int = ADDRESS_BOOK_SALT_SIZE
override fun serialize(
outputStream: ByteArrayOutputStream,
data: AddressBook
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(ADDRESS_BOOK_SALT_SIZE)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serializer.serializeAddressBook(stream, addressBook)
stream.toByteArray()
}.let {
val key = addressBookKey.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(key)
cipher.encrypt(it, null)
}
outputStream.write(ADDRESS_BOOK_ENCRYPTION_V1.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
addressBookSerializer.serializeAddressBook(outputStream, data)
}
fun decryptAddressBook(
addressBookKey: AddressBookKey,
serializer: AddressBookSerializer,
inputStream: InputStream
): AddressBook {
val version = inputStream.readInt()
if (version != ADDRESS_BOOK_ENCRYPTION_V1) {
throw UnknownAddressBookEncryptionVersionException()
}
val salt = ByteArray(ADDRESS_BOOK_SALT_SIZE)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val key = addressBookKey.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(key)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
serializer.deserializeAddressBook(stream)
}
override fun deserialize(inputStream: ByteArrayInputStream): AddressBook {
return addressBookSerializer.deserializeAddressBook(inputStream)
}
}
class UnknownAddressBookEncryptionVersionException : RuntimeException("Unknown address book encryption version")

View File

@ -4,6 +4,10 @@ import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_ENCRYPTION_KEY_SIZE
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_FILE_IDENTIFIER_SIZE
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.Key
import com.google.crypto.tink.InsecureSecretKeyAccess
import com.google.crypto.tink.aead.ChaCha20Poly1305Key
import com.google.crypto.tink.subtle.Hkdf
@ -12,12 +16,12 @@ import com.google.crypto.tink.util.SecretBytes
/**
* The long-term key that can decrypt an account's encrypted address book.
*/
class AddressBookKey(val key: SecretBytes) {
class AddressBookKey(val key: SecretBytes) : Key {
/**
* Derives the filename that this key is able to decrypt.
*/
@OptIn(ExperimentalStdlibApi::class)
fun fileIdentifier(): String {
override fun fileIdentifier(): String {
val access = InsecureSecretKeyAccess.get()
val fileIdentifier =
Hkdf.computeHkdf(
@ -36,7 +40,7 @@ class AddressBookKey(val key: SecretBytes) {
* At encryption time, the one-time property MUST be ensured by generating a
* random 32-byte salt.
*/
fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
override fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
assert(salt.size == ADDRESS_BOOK_SALT_SIZE)
val access = InsecureSecretKeyAccess.get()
val subKey =

View File

@ -2,11 +2,12 @@ package co.electriccoin.zcash.ui.common.serialization.addressbook
import co.electriccoin.zcash.ui.common.model.AddressBook
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.serialization.BaseSerializer
import kotlinx.datetime.Instant
import java.io.InputStream
import java.io.OutputStream
internal class AddressBookSerializer : BaseAddressBookSerializer() {
class AddressBookSerializer : BaseSerializer() {
fun serializeAddressBook(
outputStream: OutputStream,
addressBook: AddressBook

View File

@ -0,0 +1,83 @@
package co.electriccoin.zcash.ui.common.serialization.metada
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor
import co.electriccoin.zcash.ui.common.serialization.Encryptor
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface MetadataEncryptor : Encryptor<MetadataKey, Metadata>
class MetadataEncryptorImpl(
private val metadataSerializer: MetadataSerializer,
) : MetadataEncryptor, BaseEncryptor<MetadataKey, Metadata>() {
override val version: Int = METADATA_ENCRYPTION_V1
override val saltSize: Int = METADATA_SALT_SIZE
override fun serialize(
outputStream: ByteArrayOutputStream,
data: Metadata
) {
metadataSerializer.serialize(outputStream, data)
}
override fun deserialize(inputStream: ByteArrayInputStream): Metadata {
return metadataSerializer.deserialize(inputStream)
}
override fun encrypt(
key: MetadataKey,
outputStream: OutputStream,
data: Metadata
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(data.version.createByteArray())
outputStream.write(cipherText)
}
override fun decrypt(
key: MetadataKey,
inputStream: InputStream
): Metadata {
val version = inputStream.readInt() // read encryption version
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
inputStream.readInt() // read metadata version
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
}

View File

@ -0,0 +1,81 @@
package co.electriccoin.zcash.ui.common.serialization.metada
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.Key
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_KEY_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_FILE_IDENTIFIER_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
import com.google.crypto.tink.InsecureSecretKeyAccess
import com.google.crypto.tink.aead.ChaCha20Poly1305Key
import com.google.crypto.tink.subtle.Hkdf
import com.google.crypto.tink.util.SecretBytes
/**
* The long-term key that can decrypt an account's encrypted address book.
*/
class MetadataKey(val key: SecretBytes) : Key {
/**
* Derives the filename that this key is able to decrypt.
*/
@OptIn(ExperimentalStdlibApi::class)
override fun fileIdentifier(): String {
val access = InsecureSecretKeyAccess.get()
val fileIdentifier =
Hkdf.computeHkdf(
"HMACSHA256",
key.toByteArray(access),
null,
"file_identifier".toByteArray(),
METADATA_FILE_IDENTIFIER_SIZE
)
return "zashi-metadata-" + fileIdentifier.toHexString()
}
/**
* Derives a one-time address book encryption key.
*
* At encryption time, the one-time property MUST be ensured by generating a
* random 32-byte salt.
*/
override fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
assert(salt.size == METADATA_SALT_SIZE)
val access = InsecureSecretKeyAccess.get()
val subKey =
Hkdf.computeHkdf(
"HMACSHA256",
key.toByteArray(access),
null,
salt + "encryption_key".toByteArray(),
METADATA_ENCRYPTION_KEY_SIZE
)
return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access))
}
companion object {
/**
* Derives the long-term key that can decrypt the given account's encrypted
* address book.
*
* This requires access to the seed phrase. If the app has separate access
* control requirements for the seed phrase and the address book, this key
* should be cached in the app's keystore.
*/
suspend fun derive(
seedPhrase: SeedPhrase,
network: ZcashNetwork,
account: WalletAccount
): MetadataKey {
val key =
DerivationTool.getInstance().deriveArbitraryAccountKey(
contextString = "ZashiMetadataEncryptionV1".toByteArray(),
seed = seedPhrase.toByteArray(),
network = network,
accountIndex = account.hdAccountIndex,
)
return MetadataKey(SecretBytes.copyFrom(key, InsecureSecretKeyAccess.get()))
}
}
}

View File

@ -0,0 +1,24 @@
package co.electriccoin.zcash.ui.common.serialization.metada
import co.electriccoin.zcash.ui.common.model.Metadata
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import java.io.InputStream
import java.io.OutputStream
class MetadataSerializer {
@OptIn(ExperimentalSerializationApi::class)
fun serialize(
outputStream: OutputStream,
metadata: Metadata
) {
Json.encodeToStream(metadata, outputStream)
}
@OptIn(ExperimentalSerializationApi::class)
fun deserialize(inputStream: InputStream): Metadata {
return Json.decodeFromStream(inputStream)
}
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class CreateOrUpdateTransactionNoteUseCase(
private val metadataRepository: MetadataRepository,
private val navigationRouter: NavigationRouter
) {
suspend operator fun invoke(
txId: String,
note: String,
closeBottomSheet: suspend () -> Unit
) {
metadataRepository.createOrUpdateTxNote(txId, note.trim())
closeBottomSheet()
navigationRouter.back()
}
}

View File

@ -0,0 +1,18 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class DeleteTransactionNoteUseCase(
private val metadataRepository: MetadataRepository,
private val navigationRouter: NavigationRouter
) {
suspend operator fun invoke(
txId: String,
closeBottomSheet: suspend () -> Unit
) {
metadataRepository.deleteTxNote(txId)
closeBottomSheet()
navigationRouter.back()
}
}

View File

@ -0,0 +1,11 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class FlipTransactionBookmarkUseCase(
private val metadataRepository: MetadataRepository,
) {
suspend operator fun invoke(txId: String) {
metadataRepository.flipTxBookmark(txId)
}
}

View File

@ -1,50 +1,295 @@
package co.electriccoin.zcash.ui.common.usecase
import android.content.Context
import cash.z.ecc.android.sdk.model.TransactionId
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import co.electriccoin.zcash.ui.common.repository.TransactionData
import co.electriccoin.zcash.ui.common.repository.TransactionFilter
import co.electriccoin.zcash.ui.common.repository.TransactionFilterRepository
import co.electriccoin.zcash.ui.common.repository.TransactionMetadata
import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import co.electriccoin.zcash.ui.design.util.getString
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.util.CloseableScopeHolder
import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl
import co.electriccoin.zcash.ui.util.combineToFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.shareIn
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Duration.Companion.seconds
@Suppress("TooManyFunctions")
class GetCurrentFilteredTransactionsUseCase(
transactionFilterRepository: TransactionFilterRepository,
private val metadataRepository: MetadataRepository,
private val transactionRepository: TransactionRepository,
private val transactionFilterRepository: TransactionFilterRepository
) {
suspend operator fun invoke() = observe().filterNotNull().first()
private val restoreTimestampDataSource: RestoreTimestampDataSource,
private val addressBookRepository: AddressBookRepository,
private val context: Context
) : CloseableScopeHolder by CloseableScopeHolderImpl(coroutineContext = Dispatchers.IO) {
@OptIn(ExperimentalCoroutinesApi::class)
private val detailedCurrentTransactions =
transactionRepository.currentTransactions
.flatMapLatest { transactions ->
val enhancedTransactions =
transactions
?.map { transaction ->
val recipient = transactionRepository.getRecipients(transaction)
if (recipient == null) {
metadataRepository.observeTransactionMetadataByTxId(
transaction.overview.txId.txIdString()
).map {
FilterTransactionData(
transaction = transaction,
contact = null,
recipientAddress = null,
transactionMetadata = it
)
}
} else {
combine(
addressBookRepository.observeContactByAddress(recipient),
metadataRepository.observeTransactionMetadataByTxId(
txId = transaction.overview.txId.txIdString(),
)
) { contact, transactionMetadata ->
FilterTransactionData(
transaction = transaction,
contact = contact,
recipientAddress = recipient,
transactionMetadata = transactionMetadata
)
}
}
}
enhancedTransactions?.combineToFlow() ?: flowOf(null)
}
.shareIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, 5.seconds),
replay = 1
)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
private val transactionsFilteredByFulltext: Flow<List<FilterTransactionData>?> =
transactionFilterRepository
.fulltextFilter
.debounce(.69.seconds)
.distinctUntilChanged()
.flatMapLatest { fulltextFilter ->
flow {
emit(null)
emitAll(
if (fulltextFilter == null || fulltextFilter.length < MIN_TEXT_FILTER_LENGTH) {
detailedCurrentTransactions
} else {
combine(
detailedCurrentTransactions,
transactionRepository.observeTransactionsByMemo(fulltextFilter)
) { transactions, memoTxIds ->
transactions to memoTxIds
}.mapLatest { (transactions, memoTxIds) ->
transactions
?.filter { transaction ->
hasMemoInFilteredIds(memoTxIds, transaction) ||
hasContactInAddressBookWithFulltext(transaction, fulltextFilter) ||
hasAddressWithFulltext(transaction, fulltextFilter) ||
hasNotesWithFulltext(transaction, fulltextFilter) ||
hasAmountWithFulltext(transaction, fulltextFilter)
}
}
}
)
}
}
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
fun observe() =
combine(
transactionRepository.currentTransactions,
transactionFilterRepository.filters,
transactionFilterRepository.fulltextFilter
) { transactions, filters, fulltextFilter ->
Triple(transactions, filters, fulltextFilter)
}.mapLatest { (transactions, filters, _) ->
transactions
?.filter {
if (filters.isEmpty()) {
return@filter true
}
if (filters.contains(TransactionFilter.SENT) &&
it.overview.isSentTransaction &&
!it.overview.isShielding
) {
return@filter true
}
if (filters.contains(TransactionFilter.RECEIVED) &&
!it.overview.isSentTransaction &&
!it.overview.isShielding
) {
return@filter true
}
false
val result =
transactionFilterRepository.filters
.flatMapLatest { filters ->
flow {
emit(null)
emitAll(
transactionsFilteredByFulltext
.mapLatest { transactions ->
transactions
?.filter { transaction ->
filterBySentReceived(filters, transaction)
}
?.filter { transaction ->
filterByGeneralFilters(
filters = filters,
transaction = transaction,
restoreTimestamp = restoreTimestampDataSource.getOrCreate()
)
}
?.map { transaction ->
ListTransactionData(
data = transaction.transaction,
metadata = transaction.transactionMetadata
)
}
}
)
}
}
.distinctUntilChanged()
fun observe() = result
private fun filterByGeneralFilters(
filters: List<TransactionFilter>,
transaction: FilterTransactionData,
restoreTimestamp: Instant,
): Boolean {
val memoPass =
if (filters.contains(TransactionFilter.MEMOS)) {
transaction.transaction.overview.memoCount > 0
} else {
true
}
val unreadPass =
if (filters.contains(TransactionFilter.UNREAD)) {
isUnread(transaction, restoreTimestamp)
} else {
true
}
val bookmarkPass =
if (filters.contains(TransactionFilter.BOOKMARKED)) {
isBookmark(transaction)
} else {
true
}
val notesPass =
if (filters.contains(TransactionFilter.NOTES)) {
hasNotes(transaction)
} else {
true
}
return memoPass && unreadPass && bookmarkPass && notesPass
}
@Suppress
private fun filterBySentReceived(
filters: List<TransactionFilter>,
transaction: FilterTransactionData
): Boolean {
return if (filters.contains(TransactionFilter.SENT) || filters.contains(TransactionFilter.RECEIVED)) {
when {
filters.contains(TransactionFilter.SENT) &&
transaction.transaction.overview.isSentTransaction &&
!transaction.transaction.overview.isShielding -> true
filters.contains(TransactionFilter.RECEIVED) &&
!transaction.transaction.overview.isSentTransaction &&
!transaction.transaction.overview.isShielding -> true
else -> false
}
} else {
true
}
}
private fun isUnread(
transaction: FilterTransactionData,
restoreTimestamp: Instant,
): Boolean {
val transactionDate =
transaction.transaction.overview.blockTimeEpochSeconds
?.let { blockTimeEpochSeconds ->
Instant.ofEpochSecond(blockTimeEpochSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
} ?: LocalDate.now()
val hasMemo = transaction.transaction.overview.memoCount > 0
val restoreDate = restoreTimestamp.atZone(ZoneId.systemDefault()).toLocalDate()
return if (hasMemo && transactionDate < restoreDate) {
false
} else {
val transactionMetadata = transaction.transactionMetadata
hasMemo && (transactionMetadata == null || transactionMetadata.isRead.not())
}
}
private fun isBookmark(transaction: FilterTransactionData): Boolean {
return transaction.transactionMetadata?.isBookmarked ?: false
}
private fun hasNotes(transaction: FilterTransactionData): Boolean {
return transaction.transactionMetadata?.note != null
}
private fun hasNotesWithFulltext(
transaction: FilterTransactionData,
fulltextFilter: String
): Boolean {
return transaction.transactionMetadata?.note
?.contains(
fulltextFilter,
ignoreCase = true
)
?: false
}
private fun hasAmountWithFulltext(
transaction: FilterTransactionData,
fulltextFilter: String
): Boolean {
val text = stringRes(transaction.transaction.overview.netValue).getString(context)
return text.contains(fulltextFilter, ignoreCase = true)
}
private fun hasAddressWithFulltext(
transaction: FilterTransactionData,
fulltextFilter: String
): Boolean {
return transaction.recipientAddress?.contains(fulltextFilter, ignoreCase = true) ?: false
}
private fun hasContactInAddressBookWithFulltext(
transaction: FilterTransactionData,
fulltextFilter: String
): Boolean {
return transaction.contact?.name?.contains(fulltextFilter, ignoreCase = true) ?: false
}
private fun hasMemoInFilteredIds(
memoTxIds: List<TransactionId>?,
transaction: FilterTransactionData
) = memoTxIds?.contains(transaction.transaction.overview.txId) ?: false
}
private data class FilterTransactionData(
val transaction: TransactionData,
val contact: AddressBookContact?,
val recipientAddress: String?,
val transactionMetadata: TransactionMetadata?
)
private const val MIN_TEXT_FILTER_LENGTH = 3

View File

@ -1,13 +1,44 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import co.electriccoin.zcash.ui.common.repository.TransactionData
import co.electriccoin.zcash.ui.common.repository.TransactionMetadata
import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import co.electriccoin.zcash.ui.util.combineToFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
class GetCurrentTransactionsUseCase(
private val transactionRepository: TransactionRepository
private val transactionRepository: TransactionRepository,
private val metadataRepository: MetadataRepository,
) {
suspend operator fun invoke() = transactionRepository.currentTransactions.filterNotNull().first()
fun observe() = transactionRepository.currentTransactions
@OptIn(ExperimentalCoroutinesApi::class)
fun observe() =
transactionRepository.currentTransactions
.flatMapLatest { transactions ->
if (transactions == null) {
flowOf(null)
} else if (transactions.isEmpty()) {
flowOf(emptyList())
} else {
transactions
.map {
metadataRepository.observeTransactionMetadataByTxId(it.overview.txId.txIdString())
.mapLatest { metadata ->
ListTransactionData(
data = it,
metadata = metadata
)
}
}
.combineToFlow()
}
}
}
data class ListTransactionData(
val data: TransactionData,
val metadata: TransactionMetadata?
)

View File

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

View File

@ -5,79 +5,68 @@ import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.model.AddressBookContact
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
import co.electriccoin.zcash.ui.common.repository.TransactionData
import co.electriccoin.zcash.ui.common.repository.TransactionMetadata
import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
class GetTransactionByIdUseCase(
class GetTransactionDetailByIdUseCase(
private val transactionRepository: TransactionRepository,
private val addressBookRepository: AddressBookRepository,
private val metadataRepository: MetadataRepository,
private val synchronizerProvider: SynchronizerProvider,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun observe(txId: String): Flow<DetailedTransactionData> =
transactionRepository.currentTransactions
.filterNotNull()
.flatMapLatest { transactions ->
fun observe(txId: String) =
transactionRepository
.observeTransaction(txId).filterNotNull().flatMapLatest { transaction ->
channelFlow {
val memosData = MutableStateFlow<List<String>?>(null)
val contactData = MutableStateFlow<AddressBookContact?>(null)
val transaction = transactions.find { tx -> tx.overview.txIdString() == txId }
if (transaction != null) {
val recipientAddress = getWalletAddress(transactionRepository.getRecipients(transaction))
val contact =
recipientAddress?.let {
addressBookRepository.getContactByAddress(it.address)
}
contactData.update { contact }
launch {
combine(memosData, contactData) { memos, contact ->
memos to contact
}.collect { (memos, contact) ->
send(
launch {
combine(
flow {
emit(null)
emit(getWalletAddress(transactionRepository.getRecipients(transaction)))
},
flow {
emit(null)
emit(transaction.let { transactionRepository.getMemos(it) })
},
metadataRepository.observeTransactionMetadataByTxId(txId)
) { address, memos, metadata ->
Triple(address, memos, metadata)
}.flatMapLatest { (address, memos, metadata) ->
addressBookRepository
.observeContactByAddress(address?.address.orEmpty())
.mapLatest { contact ->
DetailedTransactionData(
transaction = transaction,
memos = memos,
contact = contact,
recipientAddress = recipientAddress
recipientAddress = address,
metadata = metadata
)
)
}
}
val memos = transaction.let { transactionRepository.getMemos(it) }
memosData.update { memos }
if (recipientAddress != null) {
launch {
addressBookRepository
.observeContactByAddress(recipientAddress.address)
.collect { new ->
contactData.update { new }
}
}
}
}.collect {
send(it)
}
}
awaitClose {
// do nothing
}
}
}
.distinctUntilChanged()
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private suspend fun getWalletAddress(address: String?): WalletAddress? {
if (address == null) return null
@ -96,5 +85,6 @@ data class DetailedTransactionData(
val transaction: TransactionData,
val memos: List<String>?,
val contact: AddressBookContact?,
val recipientAddress: WalletAddress?
val recipientAddress: WalletAddress?,
val metadata: TransactionMetadata?
)

View File

@ -1,12 +0,0 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.TransactionFilterRepository
import kotlinx.coroutines.flow.first
class GetTransactionFulltextFiltersUseCase(
private val transactionFilterRepository: TransactionFilterRepository
) {
suspend operator fun invoke() = observe().first()
fun observe() = transactionFilterRepository.fulltextFilter
}

View File

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

View File

@ -0,0 +1,11 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class MarkTxMemoAsReadUseCase(
private val metadataRepository: MetadataRepository
) {
suspend operator fun invoke(txId: String) {
metadataRepository.markTxMemoAsRead(txId)
}
}

View File

@ -1,15 +0,0 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
class NavigateToSendUseCase(
private val navigationRouter: NavigationRouter,
private val homeTabNavigationRouter: HomeTabNavigationRouter
) {
operator fun invoke() {
homeTabNavigationRouter.select(HomeScreenIndex.SEND)
navigationRouter.backToRoot()
}
}

View File

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

View File

@ -0,0 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class ResetInMemoryDataUseCase(
private val addressBookRepository: AddressBookRepository,
private val metadataRepository: MetadataRepository
) {
suspend operator fun invoke() {
addressBookRepository.resetAddressBook()
metadataRepository.resetMetadata()
}
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.Twig
class ResetSharedPrefsDataUseCase(
private val encryptedPreferenceProvider: EncryptedPreferenceProvider,
private val standardPreferenceProvider: StandardPreferenceProvider,
) {
suspend operator fun invoke(): Boolean {
val standardPrefsCleared = standardPreferenceProvider().clearPreferences()
val encryptedPrefsCleared = encryptedPreferenceProvider().clearPreferences()
Twig.info { "Both preferences cleared: ${standardPrefsCleared && encryptedPrefsCleared}" }
return standardPrefsCleared && encryptedPrefsCleared
}
}

View File

@ -11,7 +11,6 @@ import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.SeedPhrase
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.OnboardingState
@ -23,7 +22,8 @@ import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetInMemoryDataUseCase
import co.electriccoin.zcash.ui.common.usecase.ResetSharedPrefsDataUseCase
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import com.flexa.core.Flexa
import com.flexa.identity.buildIdentity
@ -46,10 +46,10 @@ class WalletViewModel(
private val walletCoordinator: WalletCoordinator,
private val walletRepository: WalletRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val encryptedPreferenceProvider: EncryptedPreferenceProvider,
private val standardPreferenceProvider: StandardPreferenceProvider,
private val getAvailableServers: GetDefaultServersProvider,
private val resetAddressBook: ResetAddressBookUseCase,
private val resetInMemoryData: ResetInMemoryDataUseCase,
private val resetSharedPrefsData: ResetSharedPrefsDataUseCase,
private val isFlexaAvailable: IsFlexaAvailableUseCase,
private val getSynchronizer: GetSynchronizerUseCase,
) : AndroidViewModel(application) {
@ -137,17 +137,9 @@ class WalletViewModel(
private fun clearAppStateFlow(): Flow<Boolean> =
callbackFlow {
viewModelScope.launch {
val standardPrefsCleared =
standardPreferenceProvider()
.clearPreferences()
val encryptedPrefsCleared =
encryptedPreferenceProvider()
.clearPreferences()
resetAddressBook()
Twig.info { "Both preferences cleared: ${standardPrefsCleared && encryptedPrefsCleared}" }
trySend(standardPrefsCleared && encryptedPrefsCleared)
val prefReset = resetSharedPrefsData()
resetInMemoryData()
trySend(prefReset)
}
awaitClose {

View File

@ -189,7 +189,7 @@ private fun AccountMainContent(
animateDpAsState(
targetValue =
if (balanceState.exchangeRate is ExchangeRateState.OptIn) {
120.dp
112.dp
} else {
0.dp
},

View File

@ -235,10 +235,19 @@ internal fun WrapSend(
type = type
)
)
val fee = it.transaction.overview.feePaid
val value =
if (fee == null) {
it.transaction.overview.netValue
} else {
it.transaction.overview.netValue - fee
}
setAmountState(
AmountState.newFromZec(
context = context,
value = it.transaction.overview.netValue.convertZatoshiToZecString(),
value = value.convertZatoshiToZecString(),
monetarySeparators = monetarySeparators,
isTransparentOrTextRecipient = type == AddressType.Transparent,
fiatValue = amountState.fiatValue,

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@ -12,12 +13,20 @@ import org.koin.core.parameter.parametersOf
fun AndroidTransactionDetail(transactionDetail: TransactionDetail) {
val viewModel: TransactionDetailViewModel = koinViewModel { parametersOf(transactionDetail) }
val walletViewModel: WalletViewModel = koinViewModel()
val mainTopAppBarViewModel = koinViewModel<ZashiMainTopAppBarViewModel>()
val mainAppBarState by mainTopAppBarViewModel.state.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val appBarState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
BackHandler {
BackHandler(state != null) {
state?.onBack?.invoke()
}
state?.let { TransactionDetailView(state = it, appBarState = appBarState) }
state?.let {
TransactionDetailView(
state = it,
appBarState = appBarState,
mainAppBarState = mainAppBarState
)
}
}

View File

@ -33,6 +33,7 @@ object SendShieldStateFixture {
TransactionDetailMemoState(content = stringRes("Short message"), onClick = {}),
)
),
note = stringRes("None")
)
}
@ -48,6 +49,7 @@ object SendTransparentStateFixture {
onTransactionAddressClick = {},
fee = stringRes(Zatoshi(1011)),
completedTimestamp = stringResByDateTime(ZonedDateTime.now(), true),
note = stringRes("None")
)
}
@ -66,6 +68,7 @@ object ReceiveShieldedStateFixture {
onTransactionIdClick = {},
completedTimestamp = stringResByDateTime(ZonedDateTime.now(), true),
memo = memo,
note = stringRes("None")
)
}
@ -76,6 +79,7 @@ object ReceiveTransparentStateFixture {
transactionId = stringRes("Transaction ID"),
onTransactionIdClick = {},
completedTimestamp = stringResByDateTime(ZonedDateTime.now(), true),
note = stringRes("None")
)
}
@ -86,6 +90,7 @@ object ShieldingStateFixture {
transactionId = stringRes("Transaction ID"),
onTransactionIdClick = {},
completedTimestamp = stringResByDateTime(ZonedDateTime.now(), true),
fee = stringRes(Zatoshi(1011))
fee = stringRes(Zatoshi(1011)),
note = stringRes("None")
)
}

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -22,10 +23,12 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.balances.LocalBalancesAvailable
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.orHiddenString
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
@ -73,19 +76,25 @@ fun TransactionDetailHeader(
color = ZashiColors.Text.textTertiary
)
Spacer(Modifier.height(2.dp))
Row {
Text(
text = state.amount.getValue(),
style = ZashiTypography.header3,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Text(
text = stringResource(cash.z.ecc.sdk.ext.R.string.zcash_token_zec),
style = ZashiTypography.header3,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textQuaternary
)
SelectionContainer {
Row {
Text(
text =
state.amount orHiddenString
stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder),
style = ZashiTypography.header3,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
if (LocalBalancesAvailable.current) {
Text(
text = stringResource(cash.z.ecc.sdk.ext.R.string.zcash_token_zec),
style = ZashiTypography.header3,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textQuaternary
)
}
}
}
}
}

View File

@ -1,11 +1,12 @@
package co.electriccoin.zcash.ui.screen.transactiondetail
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.TransactionDetailInfoState
data class TransactionDetailState(
val onBack: () -> Unit,
// val bookmarkButton: IconButtonState,
val bookmarkButton: IconButtonState,
val header: TransactionDetailHeaderState,
val info: TransactionDetailInfoState,
val primaryButton: ButtonState?,

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -20,9 +21,12 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.GradientBgScaffold
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiBottomBar
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
@ -31,6 +35,7 @@ import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.transactiondetail.info.ReceiveShielded
import co.electriccoin.zcash.ui.screen.transactiondetail.info.ReceiveShieldedState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.ReceiveTransparent
@ -46,7 +51,8 @@ import co.electriccoin.zcash.ui.screen.transactiondetail.info.TransactionDetailI
@Composable
fun TransactionDetailView(
state: TransactionDetailState,
appBarState: TopAppBarSubTitleState
appBarState: TopAppBarSubTitleState,
mainAppBarState: ZashiMainTopAppBarState?,
) {
GradientBgScaffold(
startColor = ZashiColors.Surfaces.bgPrimary orDark ZashiColors.Surfaces.bgAdjust,
@ -55,7 +61,8 @@ fun TransactionDetailView(
TransactionDetailTopAppBar(
onBack = state.onBack,
appBarState = appBarState,
// state = state,
mainAppBarState = mainAppBarState,
state = state,
)
}
) { paddingValues ->
@ -202,7 +209,8 @@ fun getHeaderIconState(info: TransactionDetailInfoState): TransactionDetailIconH
private fun TransactionDetailTopAppBar(
onBack: () -> Unit,
appBarState: TopAppBarSubTitleState,
// state: TransactionDetailState
state: TransactionDetailState,
mainAppBarState: ZashiMainTopAppBarState?,
) {
ZashiSmallTopAppBar(
subtitle =
@ -215,7 +223,12 @@ private fun TransactionDetailTopAppBar(
ZashiTopAppBarBackNavigation(onBack = onBack)
},
regularActions = {
// ZashiIconButton(state.bookmarkButton, modifier = Modifier.size(40.dp))
mainAppBarState?.balanceVisibilityButton?.let {
ZashiIconButton(it, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(4.dp))
}
ZashiIconButton(state.bookmarkButton, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(20.dp))
},
colors =
ZcashTheme.colors.topAppBarColors orDark
@ -242,7 +255,9 @@ private fun SendShieldPreview() =
info = SendShieldStateFixture.new(),
primaryButton = ButtonState(stringRes("Primary")),
secondaryButton = ButtonState(stringRes("Secondary")),
)
bookmarkButton = IconButtonState(R.drawable.ic_transaction_detail_no_bookmark) {}
),
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
)
}
@ -263,7 +278,9 @@ private fun SendTransparentPreview() =
info = SendTransparentStateFixture.new(),
primaryButton = ButtonState(stringRes("Primary")),
secondaryButton = ButtonState(stringRes("Secondary")),
)
bookmarkButton = IconButtonState(R.drawable.ic_transaction_detail_no_bookmark) {}
),
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
)
}
@ -284,7 +301,9 @@ private fun ReceiveShieldPreview() =
info = ReceiveShieldedStateFixture.new(),
primaryButton = ButtonState(stringRes("Primary")),
secondaryButton = ButtonState(stringRes("Secondary")),
)
bookmarkButton = IconButtonState(R.drawable.ic_transaction_detail_no_bookmark) {}
),
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
)
}
@ -305,7 +324,9 @@ private fun ReceiveTransparentPreview() =
info = ReceiveTransparentStateFixture.new(),
primaryButton = ButtonState(stringRes("Primary")),
secondaryButton = ButtonState(stringRes("Secondary")),
)
bookmarkButton = IconButtonState(R.drawable.ic_transaction_detail_no_bookmark) {}
),
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
)
}
@ -326,6 +347,8 @@ private fun ShieldingPreview() =
info = ShieldingStateFixture.new(),
primaryButton = ButtonState(stringRes("Primary")),
secondaryButton = ButtonState(stringRes("Secondary")),
)
bookmarkButton = IconButtonState(R.drawable.ic_transaction_detail_no_bookmark) {}
),
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
)
}

View File

@ -18,9 +18,12 @@ import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SHIEL
import co.electriccoin.zcash.ui.common.repository.TransactionExtendedState.SHIELDING_FAILED
import co.electriccoin.zcash.ui.common.usecase.CopyToClipboardUseCase
import co.electriccoin.zcash.ui.common.usecase.DetailedTransactionData
import co.electriccoin.zcash.ui.common.usecase.GetTransactionByIdUseCase
import co.electriccoin.zcash.ui.common.usecase.FlipTransactionBookmarkUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionDetailByIdUseCase
import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase
import co.electriccoin.zcash.ui.common.usecase.SendTransactionAgainUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.design.util.stringResByAddress
@ -32,42 +35,94 @@ import co.electriccoin.zcash.ui.screen.transactiondetail.info.ReceiveTransparent
import co.electriccoin.zcash.ui.screen.transactiondetail.info.SendShieldedState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.SendTransparentState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.ShieldingState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.TransactionDetailInfoState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.TransactionDetailMemoState
import co.electriccoin.zcash.ui.screen.transactiondetail.info.TransactionDetailMemosState
import co.electriccoin.zcash.ui.screen.transactionnote.TransactionNote
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.ZoneId
@Suppress("TooManyFunctions")
class TransactionDetailViewModel(
transactionDetail: TransactionDetail,
getTransactionById: GetTransactionByIdUseCase,
getTransactionDetailById: GetTransactionDetailByIdUseCase,
private val markTxMemoAsRead: MarkTxMemoAsReadUseCase,
private val transactionDetail: TransactionDetail,
private val copyToClipboard: CopyToClipboardUseCase,
private val navigationRouter: NavigationRouter,
private val sendTransactionAgain: SendTransactionAgainUseCase
private val sendTransactionAgain: SendTransactionAgainUseCase,
private val flipTransactionBookmark: FlipTransactionBookmarkUseCase,
) : ViewModel() {
val state =
getTransactionById.observe(transactionDetail.transactionId)
.map { transaction ->
TransactionDetailState(
onBack = ::onBack,
header = createTransactionHeaderState(transaction),
info = createTransactionInfoState(transaction),
primaryButton = createPrimaryButtonState(transaction),
secondaryButton = null
)
}
private val transaction =
getTransactionDetailById
.observe(transactionDetail.transactionId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
private fun createTransactionInfoState(transaction: DetailedTransactionData) =
when (transaction.transaction.state) {
@OptIn(ExperimentalCoroutinesApi::class)
val state =
transaction.filterNotNull().mapLatest { transaction ->
TransactionDetailState(
onBack = ::onBack,
header = createTransactionHeaderState(transaction),
info = createTransactionInfoState(transaction),
primaryButton = createPrimaryButtonState(transaction),
secondaryButton =
ButtonState(
text =
if (transaction.metadata?.note != null) {
stringRes(R.string.transaction_detail_edit_note)
} else {
stringRes(R.string.transaction_detail_add_a_note)
},
onClick = ::onAddOrEditNoteClick
),
bookmarkButton =
IconButtonState(
icon =
if (transaction.metadata?.isBookmarked == true) {
R.drawable.ic_transaction_detail_bookmark
} else {
R.drawable.ic_transaction_detail_no_bookmark
},
onClick = ::onBookmarkClick
)
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
init {
viewModelScope.launch {
withContext(Dispatchers.Default) {
val transaction = transaction.filterNotNull().first()
if (transaction.transaction.overview.memoCount > 0) {
markTxMemoAsRead(transactionDetail.transactionId)
}
}
}
}
private fun onAddOrEditNoteClick() {
navigationRouter.forward(TransactionNote(transactionDetail.transactionId))
}
private fun createTransactionInfoState(transaction: DetailedTransactionData): TransactionDetailInfoState {
return when (transaction.transaction.state) {
SENT,
SENDING,
SEND_FAILED -> {
@ -78,13 +133,16 @@ class TransactionDetailViewModel(
addressAbbreviated = createAbbreviatedAddressStringRes(transaction),
transactionId =
stringResByTransactionId(
value = transaction.transaction.overview.txIdString(),
value = transaction.transaction.overview.txId.txIdString(),
abbreviated = true
),
onTransactionIdClick = { onCopyToClipboard(transaction.transaction.overview.txIdString()) },
onTransactionIdClick = {
onCopyToClipboard(transaction.transaction.overview.txId.txIdString())
},
onTransactionAddressClick = { onCopyToClipboard(transaction.recipientAddress.address) },
fee = createFeeStringRes(transaction),
completedTimestamp = createTimestampStringRes(transaction),
note = transaction.metadata?.note?.let { stringRes(it) }
)
} else {
SendShieldedState(
@ -93,11 +151,11 @@ class TransactionDetailViewModel(
addressAbbreviated = createAbbreviatedAddressStringRes(transaction),
transactionId =
stringResByTransactionId(
value = transaction.transaction.overview.txIdString(),
value = transaction.transaction.overview.txId.txIdString(),
abbreviated = true
),
onTransactionIdClick = {
onCopyToClipboard(transaction.transaction.overview.txIdString())
onCopyToClipboard(transaction.transaction.overview.txId.txIdString())
},
onTransactionAddressClick = {
onCopyToClipboard(transaction.recipientAddress?.address.orEmpty())
@ -113,7 +171,8 @@ class TransactionDetailViewModel(
onClick = { onCopyToClipboard(memo) }
)
}
)
),
note = transaction.metadata?.note?.let { stringRes(it) }
)
}
}
@ -125,20 +184,25 @@ class TransactionDetailViewModel(
ReceiveTransparentState(
transactionId =
stringResByTransactionId(
value = transaction.transaction.overview.txIdString(),
value = transaction.transaction.overview.txId.txIdString(),
abbreviated = true
),
onTransactionIdClick = { onCopyToClipboard(transaction.transaction.overview.txIdString()) },
onTransactionIdClick = {
onCopyToClipboard(transaction.transaction.overview.txId.txIdString())
},
completedTimestamp = createTimestampStringRes(transaction),
note = transaction.metadata?.note?.let { stringRes(it) }
)
} else {
ReceiveShieldedState(
transactionId =
stringResByTransactionId(
value = transaction.transaction.overview.txIdString(),
value = transaction.transaction.overview.txId.txIdString(),
abbreviated = true
),
onTransactionIdClick = { onCopyToClipboard(transaction.transaction.overview.txIdString()) },
onTransactionIdClick = {
onCopyToClipboard(transaction.transaction.overview.txId.txIdString())
},
completedTimestamp = createTimestampStringRes(transaction),
memo =
TransactionDetailMemosState(
@ -149,7 +213,8 @@ class TransactionDetailViewModel(
onClick = { onCopyToClipboard(memo) }
)
}
)
),
note = transaction.metadata?.note?.let { stringRes(it) }
)
}
}
@ -160,15 +225,19 @@ class TransactionDetailViewModel(
ShieldingState(
transactionId =
stringResByTransactionId(
value = transaction.transaction.overview.txIdString(),
value = transaction.transaction.overview.txId.txIdString(),
abbreviated = true
),
onTransactionIdClick = { onCopyToClipboard(transaction.transaction.overview.txIdString()) },
onTransactionIdClick = {
onCopyToClipboard(transaction.transaction.overview.txId.txIdString())
},
completedTimestamp = createTimestampStringRes(transaction),
fee = createFeeStringRes(transaction),
note = transaction.metadata?.note?.let { stringRes(it) }
)
}
}
}
private fun createFeeStringRes(transaction: DetailedTransactionData): StringResource {
val feePaid =
@ -271,6 +340,11 @@ class TransactionDetailViewModel(
private fun onBack() {
navigationRouter.back()
}
private fun onBookmarkClick() =
viewModelScope.launch {
flipTransactionBookmark(transactionDetail.transactionId)
}
}
private const val MIN_FEE_THRESHOLD = 100000

View File

@ -14,6 +14,8 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.transactiondetail.ReceiveShieldedStateFixture
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumn
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumnState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeader
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeaderState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoRow
@ -66,9 +68,27 @@ fun ReceiveShielded(
TransactionDetailInfoRowState(
title = stringRes(R.string.transaction_detail_info_transaction_completed),
message = state.completedTimestamp,
shape = TransactionDetailInfoShape.LAST,
shape =
if (state.note != null) {
TransactionDetailInfoShape.MIDDLE
} else {
TransactionDetailInfoShape.LAST
},
)
)
if (state.note != null) {
ZashiHorizontalDivider()
TransactionDetailInfoColumn(
modifier = Modifier.fillMaxWidth(),
state =
TransactionDetailInfoColumnState(
title = stringRes(R.string.transaction_detail_info_note),
message = state.note,
shape = TransactionDetailInfoShape.LAST,
onClick = null
)
)
}
}
}

View File

@ -14,6 +14,8 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.transactiondetail.ReceiveTransparentStateFixture
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumn
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumnState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeader
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeaderState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoRow
@ -54,9 +56,27 @@ fun ReceiveTransparent(
TransactionDetailInfoRowState(
title = stringRes(R.string.transaction_detail_info_transaction_completed),
message = state.completedTimestamp,
shape = TransactionDetailInfoShape.LAST,
shape =
if (state.note != null) {
TransactionDetailInfoShape.MIDDLE
} else {
TransactionDetailInfoShape.LAST
},
)
)
if (state.note != null) {
ZashiHorizontalDivider()
TransactionDetailInfoColumn(
modifier = Modifier.fillMaxWidth(),
state =
TransactionDetailInfoColumnState(
title = stringRes(R.string.transaction_detail_info_note),
message = state.note,
shape = TransactionDetailInfoShape.LAST,
onClick = null
)
)
}
}
}

View File

@ -61,11 +61,15 @@ fun SendShielded(
else -> null
},
trailingIcon = if (isExpanded) R.drawable.ic_chevron_up_small else R.drawable.ic_chevron_down_small,
shape = if (isExpanded) TransactionDetailInfoShape.FIRST else TransactionDetailInfoShape.SINGLE,
shape =
when {
state.note != null -> TransactionDetailInfoShape.FIRST
isExpanded -> TransactionDetailInfoShape.FIRST
else -> TransactionDetailInfoShape.SINGLE
},
onClick = { isExpanded = !isExpanded }
)
),
)
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(),
@ -116,11 +120,29 @@ fun SendShielded(
TransactionDetailInfoRowState(
title = stringRes(R.string.transaction_detail_info_transaction_completed),
message = state.completedTimestamp,
shape = TransactionDetailInfoShape.LAST,
shape =
if (state.note == null) {
TransactionDetailInfoShape.LAST
} else {
TransactionDetailInfoShape.MIDDLE
},
)
)
}
}
if (state.note != null) {
ZashiHorizontalDivider()
TransactionDetailInfoColumn(
modifier = Modifier.fillMaxWidth(),
state =
TransactionDetailInfoColumnState(
title = stringRes(R.string.transaction_detail_info_note),
message = state.note,
shape = TransactionDetailInfoShape.LAST,
onClick = null
)
)
}
Spacer(Modifier.height(20.dp))
TransactionDetailInfoHeader(
state =

View File

@ -117,9 +117,27 @@ fun SendTransparent(
TransactionDetailInfoRowState(
title = stringRes(R.string.transaction_detail_info_transaction_completed),
message = state.completedTimestamp,
shape = TransactionDetailInfoShape.LAST,
shape =
if (state.note != null) {
TransactionDetailInfoShape.MIDDLE
} else {
TransactionDetailInfoShape.LAST
},
)
)
if (state.note != null) {
ZashiHorizontalDivider()
TransactionDetailInfoColumn(
modifier = Modifier.fillMaxWidth(),
state =
TransactionDetailInfoColumnState(
title = stringRes(R.string.transaction_detail_info_note),
message = state.note,
shape = TransactionDetailInfoShape.LAST,
onClick = null
)
)
}
}
}

View File

@ -14,6 +14,8 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.transactiondetail.ShieldingStateFixture
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumn
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoColumnState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeader
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoHeaderState
import co.electriccoin.zcash.ui.screen.transactiondetail.infoitems.TransactionDetailInfoRow
@ -64,9 +66,27 @@ fun Shielding(
TransactionDetailInfoRowState(
title = stringRes(R.string.transaction_detail_info_transaction_fee),
message = state.fee,
shape = TransactionDetailInfoShape.LAST,
shape =
if (state.note != null) {
TransactionDetailInfoShape.MIDDLE
} else {
TransactionDetailInfoShape.LAST
},
)
)
if (state.note != null) {
ZashiHorizontalDivider()
TransactionDetailInfoColumn(
modifier = Modifier.fillMaxWidth(),
state =
TransactionDetailInfoColumnState(
title = stringRes(R.string.transaction_detail_info_note),
message = state.note,
shape = TransactionDetailInfoShape.LAST,
onClick = null
)
)
}
}
}

View File

@ -15,7 +15,8 @@ data class SendShieldedState(
val onTransactionAddressClick: () -> Unit,
val fee: StringResource,
val completedTimestamp: StringResource,
val memo: TransactionDetailMemosState
val memo: TransactionDetailMemosState,
val note: StringResource?
) : TransactionDetailInfoState
@Immutable
@ -28,6 +29,7 @@ data class SendTransparentState(
val onTransactionAddressClick: () -> Unit,
val fee: StringResource,
val completedTimestamp: StringResource,
val note: StringResource?
) : TransactionDetailInfoState
@Immutable
@ -35,14 +37,16 @@ data class ReceiveShieldedState(
val memo: TransactionDetailMemosState,
val transactionId: StringResource,
val onTransactionIdClick: () -> Unit,
val completedTimestamp: StringResource
val completedTimestamp: StringResource,
val note: StringResource?
) : TransactionDetailInfoState
@Immutable
data class ReceiveTransparentState(
val transactionId: StringResource,
val onTransactionIdClick: () -> Unit,
val completedTimestamp: StringResource
val completedTimestamp: StringResource,
val note: StringResource?
) : TransactionDetailInfoState
@Immutable
@ -51,6 +55,7 @@ data class ShieldingState(
val onTransactionIdClick: () -> Unit,
val completedTimestamp: StringResource,
val fee: StringResource,
val note: StringResource?
) : TransactionDetailInfoState
@Immutable

View File

@ -47,7 +47,7 @@ fun AndroidTransactionFiltersList() {
}
}
BackHandler {
BackHandler(state != null) {
state?.onBack?.invoke()
}
}

View File

@ -1,9 +1,7 @@
package co.electriccoin.zcash.ui.screen.transactionhistory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.lazy.rememberLazyListState
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
@ -19,13 +17,7 @@ fun AndroidTransactionHistory() {
val mainAppBarState by mainTopAppBarViewModel.state.collectAsStateWithLifecycle()
val topAppbarState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
viewModel.onScrollToTopRequested.collect {
listState.scrollToItem(0)
}
}
val searchState by viewModel.search.collectAsStateWithLifecycle()
BackHandler {
state.onBack()
@ -35,6 +27,6 @@ fun AndroidTransactionHistory() {
state = state,
mainAppBarState = mainAppBarState,
appBarState = topAppbarState,
listState = listState
search = searchState
)
}

View File

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
@ -74,7 +75,7 @@ fun Transaction(
painter = painterResource(state.icon),
contentDescription = null
)
if (state.hasMemo) {
if (state.isUnread) {
Box(
modifier =
Modifier
@ -117,13 +118,15 @@ fun Transaction(
}
state.value?.let {
Spacer(Modifier.width(16.dp))
Text(
text =
it.resource orHiddenString
stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder),
color = it.getColor(),
style = ZashiTypography.textSm
)
SelectionContainer {
Text(
text =
it.resource orHiddenString
stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder),
color = it.getColor(),
style = ZashiTypography.textSm
)
}
}
}
}
@ -136,7 +139,7 @@ data class TransactionState(
val subtitle: StringResource?,
val isShielded: Boolean,
val value: StyledStringResource?,
val hasMemo: Boolean,
val isUnread: Boolean,
val onClick: () -> Unit,
) : Itemizable {
override val contentType: Any = "Transaction"

View File

@ -2,17 +2,34 @@ package co.electriccoin.zcash.ui.screen.transactionhistory
import androidx.compose.runtime.Immutable
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.Itemizable
import co.electriccoin.zcash.ui.design.util.StringResource
import java.util.UUID
data class TransactionHistoryState(
val onBack: () -> Unit,
val search: TextFieldState,
val filterButton: IconButtonState,
val items: List<TransactionHistoryItem>,
)
@Immutable
sealed interface TransactionHistoryState {
val onBack: () -> Unit
val filterButton: IconButtonState
@Immutable
data class Loading(
override val onBack: () -> Unit,
override val filterButton: IconButtonState
) : TransactionHistoryState
@Immutable
data class Empty(
override val onBack: () -> Unit,
override val filterButton: IconButtonState
) : TransactionHistoryState
@Immutable
data class Data(
override val onBack: () -> Unit,
override val filterButton: IconButtonState,
val items: List<TransactionHistoryItem>,
) : TransactionHistoryState
}
@Immutable
sealed interface TransactionHistoryItem : Itemizable {

View File

@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.transactionhistory
import androidx.compose.foundation.ExperimentalFoundationApi
@ -19,14 +21,14 @@ 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.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -55,13 +57,12 @@ import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TransactionHistoryView(
state: TransactionHistoryState,
search: TextFieldState,
appBarState: TopAppBarSubTitleState,
mainAppBarState: ZashiMainTopAppBarState?,
listState: LazyListState = rememberLazyListState()
) {
BlankBgScaffold(
topBar = {
@ -91,7 +92,7 @@ fun TransactionHistoryView(
) {
ZashiTextField(
modifier = Modifier.weight(1f),
state = state.search,
state = search,
singleLine = true,
maxLines = 1,
prefix = {
@ -116,50 +117,186 @@ fun TransactionHistoryView(
)
}
LazyColumn(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = paddingValues.asScaffoldScrollPaddingValues(top = 26.dp),
state = listState
) {
state.items.forEachIndexed { index, item ->
when (item) {
is TransactionHistoryItem.Header ->
stickyHeader(
contentType = item.contentType,
key = item.key
) {
HeaderItem(
item,
modifier =
Modifier
.fillParentMaxWidth()
.background(ZashiColors.Surfaces.bgPrimary)
.animateItem()
)
}
when (state) {
is TransactionHistoryState.Data ->
Data(
paddingValues = paddingValues,
state = state,
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
)
is TransactionHistoryItem.Transaction ->
item(
contentType = item.contentType,
key = item.key,
) {
TransactionItem(
item = item,
index = index,
state = state,
modifier = Modifier.animateItem()
)
}
}
}
is TransactionHistoryState.Empty -> Empty(modifier = Modifier.fillMaxSize())
is TransactionHistoryState.Loading ->
Loading(
modifier =
Modifier
.fillMaxSize()
.padding(top = 20.dp)
)
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun Data(
paddingValues: PaddingValues,
state: TransactionHistoryState.Data,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = paddingValues.asScaffoldScrollPaddingValues(top = 26.dp),
) {
state.items.forEachIndexed { index, item ->
when (item) {
is TransactionHistoryItem.Header ->
stickyHeader(
contentType = item.contentType,
key = item.key
) {
HeaderItem(
item,
modifier =
Modifier
.fillParentMaxWidth()
.background(ZashiColors.Surfaces.bgPrimary)
.animateItem()
)
}
is TransactionHistoryItem.Transaction ->
item(
contentType = item.contentType,
key = item.key,
) {
TransactionItem(
item = item,
index = index,
state = state,
modifier = Modifier.animateItem()
)
}
}
}
}
}
@Composable
private fun Loading(modifier: Modifier = Modifier) {
TransactionShimmerLoading(
modifier = modifier,
shimmerItemsCount = 10,
)
}
@Composable
private fun Empty(modifier: Modifier = Modifier) {
Box(
modifier = modifier
) {
TransactionShimmerLoading(
modifier = Modifier.padding(top = 22.dp),
shimmerItemsCount = 3,
disableShimmer = true,
showDivider = false,
contentPaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp)
)
Column(
modifier =
Modifier
.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
0f to Color.Transparent,
EMPTY_GRADIENT_THRESHOLD to ZashiColors.Surfaces.bgPrimary,
1f to ZashiColors.Surfaces.bgPrimary,
)
),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(118.dp))
Image(
painter = painterResource(R.drawable.ic_transaction_widget_empty),
contentDescription = null,
)
Spacer(Modifier.height(20.dp))
Text(
text = "No results",
color = ZashiColors.Text.textPrimary,
style = ZashiTypography.textLg,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = "We tried but couldnt find anything.",
color = ZashiColors.Text.textTertiary,
style = ZashiTypography.textSm,
)
}
}
}
@Composable
private fun HeaderItem(
item: TransactionHistoryItem.Header,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
) {
Spacer(Modifier.height(8.dp))
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = item.title.getValue(),
style = ZashiTypography.textMd,
color = ZashiColors.Text.textTertiary,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun TransactionItem(
item: TransactionHistoryItem.Transaction,
index: Int,
state: TransactionHistoryState.Data,
modifier: Modifier = Modifier
) {
val previousItem = if (index != 0) state.items[index - 1] else null
val nextItem = if (index != state.items.lastIndex) state.items[index + 1] else null
Column(
modifier = modifier,
) {
if (previousItem is TransactionHistoryItem.Header) {
Spacer(Modifier.height(6.dp))
}
Transaction(
modifier = Modifier.padding(horizontal = 4.dp),
state = item.state,
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 20.dp)
)
if (index != state.items.lastIndex && nextItem is TransactionHistoryItem.Transaction) {
ZashiHorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp),
)
} else if (index != state.items.lastIndex && nextItem !is TransactionHistoryItem.Transaction) {
Spacer(
modifier = Modifier.height(26.dp)
)
}
}
}
@Composable
private fun BadgeIconButton(
state: IconButtonState,
@ -204,61 +341,6 @@ private fun BadgeIconButton(
}
}
@Composable
private fun HeaderItem(
item: TransactionHistoryItem.Header,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
) {
Spacer(Modifier.height(8.dp))
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = item.title.getValue(),
style = ZashiTypography.textMd,
color = ZashiColors.Text.textTertiary,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun TransactionItem(
item: TransactionHistoryItem.Transaction,
index: Int,
state: TransactionHistoryState,
modifier: Modifier = Modifier
) {
val previousItem = if (index != 0) state.items[index - 1] else null
val nextItem = if (index != state.items.lastIndex) state.items[index + 1] else null
Column(
modifier = modifier,
) {
if (previousItem is TransactionHistoryItem.Header) {
Spacer(Modifier.height(6.dp))
}
Transaction(
modifier = Modifier.padding(horizontal = 4.dp),
state = item.state,
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 20.dp)
)
if (index != state.items.lastIndex && nextItem is TransactionHistoryItem.Transaction) {
ZashiHorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp),
)
} else if (index != state.items.lastIndex && nextItem !is TransactionHistoryItem.Transaction) {
Spacer(
modifier = Modifier.height(26.dp)
)
}
}
}
@Composable
private fun TransactionHistoryAppBar(
appBarState: TopAppBarSubTitleState,
@ -286,15 +368,16 @@ private fun TransactionHistoryAppBar(
)
}
private const val EMPTY_GRADIENT_THRESHOLD = .28f
@PreviewScreens
@Composable
private fun Preview() =
private fun DataPreview() =
ZcashTheme {
TransactionHistoryView(
state =
TransactionHistoryState(
TransactionHistoryState.Data(
onBack = {},
search = TextFieldState(stringRes(value = "")) {},
filterButton =
IconButtonState(
icon = R.drawable.ic_transaction_filters,
@ -324,6 +407,49 @@ private fun Preview() =
)
),
appBarState = TopAppBarSubTitleState.None,
mainAppBarState = ZashiMainTopAppBarStateFixture.new()
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
search = TextFieldState(stringRes(value = "")) {},
)
}
@PreviewScreens
@Composable
private fun EmptyPreview() =
ZcashTheme {
TransactionHistoryView(
state =
TransactionHistoryState.Empty(
onBack = {},
filterButton =
IconButtonState(
icon = R.drawable.ic_transaction_filters,
badge = stringRes("1"),
onClick = {}
)
),
appBarState = TopAppBarSubTitleState.None,
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
search = TextFieldState(stringRes(value = "")) {},
)
}
@PreviewScreens
@Composable
private fun LoadingPreview() =
ZcashTheme {
TransactionHistoryView(
state =
TransactionHistoryState.Loading(
onBack = {},
filterButton =
IconButtonState(
icon = R.drawable.ic_transaction_filters,
badge = stringRes("1"),
onClick = {}
)
),
appBarState = TopAppBarSubTitleState.None,
mainAppBarState = ZashiMainTopAppBarStateFixture.new(),
search = TextFieldState(stringRes(value = "")) {},
)
}

View File

@ -5,11 +5,14 @@ import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource
import co.electriccoin.zcash.ui.common.mapper.TransactionHistoryMapper
import co.electriccoin.zcash.ui.common.repository.TransactionData
import co.electriccoin.zcash.ui.common.repository.TransactionFilterRepository
import co.electriccoin.zcash.ui.common.usecase.ApplyTransactionFulltextFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetCurrentFilteredTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.ListTransactionData
import co.electriccoin.zcash.ui.common.usecase.ResetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
@ -22,117 +25,198 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import java.time.Instant
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.time.ZonedDateTime
class TransactionHistoryViewModel(
getCurrentTransactions: GetCurrentFilteredTransactionsUseCase,
getCurrentFilteredTransactions: GetCurrentFilteredTransactionsUseCase,
getTransactionFilters: GetTransactionFiltersUseCase,
transactionFilterRepository: TransactionFilterRepository,
private val applyTransactionFulltextFilters: ApplyTransactionFulltextFiltersUseCase,
private val transactionHistoryMapper: TransactionHistoryMapper,
private val navigationRouter: NavigationRouter,
private val resetTransactionFilters: ResetTransactionFiltersUseCase,
private val restoreTimestampDataSource: RestoreTimestampDataSource
) : ViewModel() {
val onScrollToTopRequested = transactionFilterRepository.onFilterChanged
val search =
transactionFilterRepository.fulltextFilter.map {
TextFieldState(stringRes(it.orEmpty()), onValueChange = ::onFulltextFilterChanged)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue =
TextFieldState(
stringRes(
transactionFilterRepository.fulltextFilter.value.orEmpty()
),
onValueChange = ::onFulltextFilterChanged
)
)
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("SpreadOperator")
val state =
combine(getCurrentTransactions.observe(), getTransactionFilters.observe()) { transactions, filters ->
combine(
getCurrentFilteredTransactions.observe(),
getTransactionFilters.observe(),
) { transactions, filters ->
transactions to filters
}.mapLatest { (transactions, filters) ->
val items =
transactions.orEmpty()
.groupBy {
val now = ZonedDateTime.now().toLocalDate()
val other =
it.overview.blockTimeEpochSeconds?.let { sec ->
Instant
.ofEpochSecond(sec)
.atZone(ZoneId.systemDefault())
.toLocalDate()
} ?: LocalDate.now()
when {
now == other ->
stringRes(R.string.transaction_history_today) to "today"
other == now.minusDays(1) ->
stringRes(R.string.transaction_history_yesterday) to "yesterday"
other >= now.minusDays(WEEK_THRESHOLD) ->
stringRes(R.string.transaction_history_previous_7_days) to "previous_7_days"
other >= now.minusDays(MONTH_THRESHOLD) ->
stringRes(R.string.transaction_history_previous_30_days) to "previous_30_days"
else -> {
val yearMonth = YearMonth.from(other)
stringRes(yearMonth) to yearMonth.toString()
}
}
}
.map { (entry, transactions) ->
val (headerStringRes, headerId) = entry
listOf(
TransactionHistoryItem.Header(
title = headerStringRes,
key = headerId,
),
*transactions.map { transaction ->
TransactionHistoryItem.Transaction(
state =
transactionHistoryMapper.createTransactionState(
transaction = transaction,
onTransactionClick = ::onTransactionClick
)
)
}.toTypedArray()
)
}
.flatten()
when {
transactions == null ->
createLoadingState(
filtersSize = filters.size,
)
createState(items = items, filtersSize = filters.size)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(items = emptyList(), filtersSize = 0)
)
transactions.isEmpty() ->
createEmptyState(
filtersSize = filters.size,
)
else ->
createDataState(
transactions = transactions,
filtersSize = filters.size,
restoreTimestamp = restoreTimestampDataSource.getOrCreate()
)
}
}.flowOn(Dispatchers.Default).stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue =
createLoadingState(
filtersSize = 0,
)
)
override fun onCleared() {
resetTransactionFilters()
super.onCleared()
}
private fun createState(
items: List<TransactionHistoryItem>,
filtersSize: Int
) = TransactionHistoryState(
onBack = ::onBack,
items = items,
filterButton =
IconButtonState(
icon =
if (filtersSize <= 0) {
R.drawable.ic_transaction_filters
} else {
R.drawable.ic_transactions_filters_selected
},
badge = stringRes(filtersSize.toString()).takeIf { filtersSize > 0 },
onClick = ::onTransactionFiltersClicked,
contentDescription = null
),
search = TextFieldState(stringRes("")) {}
)
private fun createDataState(
transactions: List<ListTransactionData>,
filtersSize: Int,
restoreTimestamp: Instant,
): TransactionHistoryState.Data {
val now = ZonedDateTime.now().toLocalDate()
val items =
transactions
.groupBy {
val other =
it.data.overview.blockTimeEpochSeconds?.let { sec ->
Instant
.ofEpochSecond(sec)
.atZone(ZoneId.systemDefault())
.toLocalDate()
} ?: now
when {
now == other ->
stringRes(R.string.transaction_history_today) to "today"
other == now.minusDays(1) ->
stringRes(R.string.transaction_history_yesterday) to "yesterday"
other >= now.minusDays(WEEK_THRESHOLD) ->
stringRes(R.string.transaction_history_previous_7_days) to "previous_7_days"
other >= now.minusDays(MONTH_THRESHOLD) ->
stringRes(R.string.transaction_history_previous_30_days) to "previous_30_days"
else -> {
val yearMonth = YearMonth.from(other)
stringRes(yearMonth) to yearMonth.toString()
}
}
}
.map { (entry, transactions) ->
val (headerStringRes, headerId) = entry
listOf(
TransactionHistoryItem.Header(
title = headerStringRes,
key = headerId,
),
) +
transactions.map { transaction ->
TransactionHistoryItem.Transaction(
state =
transactionHistoryMapper.createTransactionState(
transaction = transaction,
onTransactionClick = ::onTransactionClick,
restoreTimestamp = restoreTimestamp
)
)
}
}
.flatten()
return TransactionHistoryState.Data(
onBack = ::onBack,
items = items,
filterButton =
IconButtonState(
icon =
if (filtersSize <= 0) {
R.drawable.ic_transaction_filters
} else {
R.drawable.ic_transactions_filters_selected
},
badge = stringRes(filtersSize.toString()).takeIf { filtersSize > 0 },
onClick = ::onTransactionFiltersClicked,
contentDescription = null
),
)
}
private fun onFulltextFilterChanged(value: String) {
applyTransactionFulltextFilters(value)
}
private fun createLoadingState(filtersSize: Int) =
TransactionHistoryState.Loading(
onBack = ::onBack,
filterButton =
IconButtonState(
icon =
if (filtersSize <= 0) {
R.drawable.ic_transaction_filters
} else {
R.drawable.ic_transactions_filters_selected
},
badge = stringRes(filtersSize.toString()).takeIf { filtersSize > 0 },
onClick = ::onTransactionFiltersClicked,
contentDescription = null
),
)
private fun createEmptyState(filtersSize: Int) =
TransactionHistoryState.Empty(
onBack = ::onBack,
filterButton =
IconButtonState(
icon =
if (filtersSize <= 0) {
R.drawable.ic_transaction_filters
} else {
R.drawable.ic_transactions_filters_selected
},
badge = stringRes(filtersSize.toString()).takeIf { filtersSize > 0 },
onClick = ::onTransactionFiltersClicked,
contentDescription = null
),
)
private fun onBack() {
navigationRouter.back()
}
private fun onTransactionClick(transactionData: TransactionData) {
navigationRouter.forward(TransactionDetail(transactionData.overview.txIdString()))
navigationRouter.forward(TransactionDetail(transactionData.overview.txId.txIdString()))
}
private fun onTransactionFiltersClicked() = navigationRouter.forward(TransactionFilters)

View File

@ -0,0 +1,132 @@
package co.electriccoin.zcash.ui.screen.transactionhistory
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
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 com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer
@Suppress("MagicNumber")
@Composable
fun TransactionShimmerLoading(
shimmerItemsCount: Int,
modifier: Modifier = Modifier,
contentPaddingValues: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 12.dp),
disableShimmer: Boolean = false,
showDivider: Boolean = true
) {
Column(
modifier =
modifier then
if (disableShimmer) {
Modifier
} else {
Modifier.shimmer(
customShimmer =
rememberShimmer(
ShimmerBounds.View,
LocalShimmerTheme.current.copy(
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = 750,
easing = LinearEasing,
delayMillis = 450,
),
repeatMode = RepeatMode.Restart,
)
)
)
)
}
) {
repeat(shimmerItemsCount) {
if (it != 0 && showDivider) {
ZashiHorizontalDivider(modifier = Modifier.padding(4.dp))
}
FakeItem(
modifier = Modifier.padding(contentPaddingValues)
)
}
}
}
@Composable
private fun FakeItem(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier
.size(40.dp)
.background(ZashiColors.Surfaces.bgSecondary, CircleShape)
)
Spacer(Modifier.width(16.dp))
Column {
Box(
modifier =
Modifier
.width(86.dp)
.height(14.dp)
.background(ZashiColors.Surfaces.bgSecondary, CircleShape)
)
Spacer(Modifier.height(4.dp))
Box(
modifier =
Modifier
.width(64.dp)
.height(14.dp)
.background(ZashiColors.Surfaces.bgSecondary, CircleShape)
)
}
Spacer(Modifier.weight(1f))
Box(
modifier =
Modifier
.width(32.dp)
.height(14.dp)
.background(ZashiColors.Surfaces.bgSecondary, CircleShape)
)
}
}
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
BlankSurface {
TransactionShimmerLoading(
10,
modifier = Modifier.fillMaxSize()
)
}
}

View File

@ -22,6 +22,6 @@ object TransactionStateFixture {
),
onClick = {},
key = UUID.randomUUID().toString(),
hasMemo = true,
isUnread = true,
)
}

View File

@ -35,11 +35,13 @@ 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.transactionhistory.Transaction
import co.electriccoin.zcash.ui.screen.transactionhistory.TransactionShimmerLoading
fun LazyListScope.createTransactionHistoryWidgets(state: TransactionHistoryWidgetState) {
when (state) {
is TransactionHistoryWidgetState.Data -> transactionHistoryWidgets(state)
is TransactionHistoryWidgetState.Empty -> transactionHistoryEmptyWidget(state)
TransactionHistoryWidgetState.Loading -> transactionHistoryLoadingWidget()
}
}
@ -78,18 +80,13 @@ private fun LazyListScope.transactionHistoryWidgets(state: TransactionHistoryWid
private fun LazyListScope.transactionHistoryEmptyWidget(state: TransactionHistoryWidgetState.Empty) {
item {
Box {
Column {
Spacer(Modifier.height(32.dp))
Image(
painter = painterResource(id = R.drawable.transaction_widget_loading_background),
contentDescription = null
)
Spacer(Modifier.height(20.dp))
Image(
painter = painterResource(id = R.drawable.transaction_widget_loading_background),
contentDescription = null
)
}
TransactionShimmerLoading(
modifier = Modifier.padding(top = 32.dp),
shimmerItemsCount = 2,
contentPaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
disableShimmer = !state.enableShimmer,
showDivider = false
)
Column(
modifier =
Modifier
@ -136,6 +133,26 @@ private fun LazyListScope.transactionHistoryEmptyWidget(state: TransactionHistor
}
}
private fun LazyListScope.transactionHistoryLoadingWidget() {
item {
Column {
Spacer(Modifier.height(13.dp))
Text(
modifier = Modifier.fillParentMaxWidth().padding(horizontal = 24.dp),
text = stringResource(R.string.transaction_history_widget_title),
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold,
style = ZashiTypography.textLg
)
Spacer(Modifier.height(10.dp))
TransactionShimmerLoading(
modifier = Modifier.fillParentMaxWidth(),
shimmerItemsCount = 5
)
}
}
}
private const val EMPTY_GRADIENT_THRESHOLD = .41f
@PreviewScreens
@ -165,9 +182,23 @@ private fun EmptyPreview() =
ButtonState(
text = stringRes("Send a transaction"),
onClick = {}
)
),
enableShimmer = true
)
)
}
}
}
@PreviewScreens
@Composable
private fun LoadingPreview() =
ZcashTheme {
BlankSurface {
LazyColumn {
createTransactionHistoryWidgets(
state = TransactionHistoryWidgetState.Loading
)
}
}
}

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.screen.transactionhistory.widget
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -32,10 +33,11 @@ fun TransactionHistoryWidgetHeader(
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f) then if (state.button == null) Modifier.padding(top = 13.dp) else Modifier,
text = state.title.getValue(),
color = ZashiColors.Text.textPrimary,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
style = ZashiTypography.textLg
)
state.button?.let {
ZashiButton(
@ -72,3 +74,17 @@ private fun Preview() =
)
}
}
@PreviewScreens
@Composable
private fun PreviewWithoutButton() =
ZcashTheme {
BlankSurface {
TransactionHistoryWidgetHeader(
TransactionHistoryWidgetHeaderState(
title = stringRes("Transactions"),
button = null
)
)
}
}

View File

@ -16,6 +16,10 @@ sealed interface TransactionHistoryWidgetState {
@Immutable
data class Empty(
val subtitle: StringResource?,
val sendTransaction: ButtonState?
val sendTransaction: ButtonState?,
val enableShimmer: Boolean
) : TransactionHistoryWidgetState
@Immutable
data object Loading : TransactionHistoryWidgetState
}

View File

@ -4,15 +4,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.datasource.RestoreTimestampDataSource
import co.electriccoin.zcash.ui.common.mapper.TransactionHistoryMapper
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.repository.TransactionData
import co.electriccoin.zcash.ui.common.usecase.GetCurrentTransactionsUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToSendUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.transactiondetail.TransactionDetail
import co.electriccoin.zcash.ui.screen.transactionhistory.TransactionHistory
import kotlinx.coroutines.flow.SharingStarted
@ -25,64 +27,70 @@ class TransactionHistoryWidgetViewModel(
getWalletRestoringState: GetWalletRestoringStateUseCase,
private val transactionHistoryMapper: TransactionHistoryMapper,
private val navigationRouter: NavigationRouter,
private val navigateToSend: NavigateToSendUseCase,
private val restoreTimestampDataSource: RestoreTimestampDataSource,
) : ViewModel() {
val state =
combine(getCurrentTransactions.observe(), getWalletRestoringState.observe()) { transactions, restoringState ->
if (transactions.isNullOrEmpty()) {
TransactionHistoryWidgetState.Empty(
subtitle =
stringRes(R.string.transaction_history_widget_empty_subtitle)
.takeIf { restoringState != WalletRestoringState.RESTORING },
sendTransaction =
ButtonState(
text = stringRes(R.string.transaction_history_send_transaction),
onClick = ::onSendTransactionClick
).takeIf { restoringState != WalletRestoringState.RESTORING },
)
} else {
TransactionHistoryWidgetState.Data(
header =
TransactionHistoryWidgetHeaderState(
title = stringRes(R.string.transaction_history_widget_title),
button =
ButtonState(
text = stringRes(R.string.transaction_history_widget_header_button),
onClick = ::onSeeAllTransactionsClick
).takeIf {
transactions.size > MAX_TRANSACTION_COUNT
combine(
getCurrentTransactions.observe(),
getWalletRestoringState.observe(),
) { transactions, restoringState ->
when {
transactions == null -> TransactionHistoryWidgetState.Loading
transactions.isEmpty() ->
TransactionHistoryWidgetState.Empty(
subtitle =
stringRes(R.string.transaction_history_widget_empty_subtitle)
.takeIf { restoringState != WalletRestoringState.RESTORING },
sendTransaction =
ButtonState(
text = stringRes(R.string.transaction_history_send_transaction),
onClick = ::onRequestZecClick
).takeIf { restoringState != WalletRestoringState.RESTORING },
enableShimmer = restoringState == WalletRestoringState.RESTORING
)
else ->
TransactionHistoryWidgetState.Data(
header =
TransactionHistoryWidgetHeaderState(
title = stringRes(R.string.transaction_history_widget_title),
button =
ButtonState(
text = stringRes(R.string.transaction_history_widget_header_button),
onClick = ::onSeeAllTransactionsClick
).takeIf {
transactions.size > MAX_TRANSACTION_COUNT
}
),
transactions =
transactions
.take(MAX_TRANSACTION_COUNT)
.map { transaction ->
transactionHistoryMapper.createTransactionState(
transaction = transaction,
restoreTimestamp = restoreTimestampDataSource.getOrCreate(),
onTransactionClick = ::onTransactionClick,
)
}
),
transactions =
transactions
.take(MAX_TRANSACTION_COUNT)
.map { transaction ->
transactionHistoryMapper.createTransactionState(
transaction = transaction,
onTransactionClick = ::onTransactionClick
)
}
)
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue =
TransactionHistoryWidgetState.Empty(subtitle = null, sendTransaction = null)
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue =
TransactionHistoryWidgetState.Loading
)
private fun onTransactionClick(transactionData: TransactionData) {
navigationRouter.forward(TransactionDetail(transactionData.overview.txIdString()))
navigationRouter.forward(TransactionDetail(transactionData.overview.txId.txIdString()))
}
private fun onSeeAllTransactionsClick() {
navigationRouter.forward(TransactionHistory)
}
@Suppress("EmptyFunctionBlock")
private fun onSendTransactionClick() {
navigateToSend()
private fun onRequestZecClick() {
navigationRouter.forward("${NavigationTargets.REQUEST}/${ReceiveAddressType.Unified.ordinal}")
}
}

View File

@ -0,0 +1,65 @@
package co.electriccoin.zcash.ui.screen.transactionnote
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState
import co.electriccoin.zcash.ui.screen.transactionnote.view.TransactionNoteView
import co.electriccoin.zcash.ui.screen.transactionnote.viewmodel.TransactionNoteViewModel
import kotlinx.coroutines.cancel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidTransactionNote(transactionNote: TransactionNote) {
val viewModel = koinViewModel<TransactionNoteViewModel> { parametersOf(transactionNote) }
val state by viewModel.state.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val parent = LocalView.current.parent
SideEffect {
(parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
(parent as? DialogWindowProvider)?.window?.setDimAmount(0f)
}
TransactionNoteView(
state = state,
sheetState = sheetState,
onDismissRequest = state.onBack,
)
LaunchedEffect(Unit) {
sheetState.show()
}
LaunchedEffect(Unit) {
snapshotFlow { sheetState.currentValue }.collect {
if (it == Expanded) {
this.cancel()
}
}
}
LaunchedEffect(Unit) {
viewModel.hideBottomSheetRequest.collect {
sheetState.hide()
state.onBottomSheetHidden()
}
}
BackHandler {
state.onBack()
}
}

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.ui.screen.transactionnote
import kotlinx.serialization.Serializable
@Serializable
data class TransactionNote(val txId: String)

View File

@ -0,0 +1,17 @@
package co.electriccoin.zcash.ui.screen.transactionnote.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
import co.electriccoin.zcash.ui.design.util.StyledStringResource
data class TransactionNoteState(
val onBack: () -> Unit,
val onBottomSheetHidden: () -> Unit,
val title: StringResource,
val note: TextFieldState,
val noteCharacters: StyledStringResource,
val primaryButton: ButtonState?,
val secondaryButton: ButtonState?,
val negative: ButtonState?,
)

View File

@ -0,0 +1,160 @@
package co.electriccoin.zcash.ui.screen.transactionnote.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.component.ButtonState
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.ZashiModalBottomSheet
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState
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.StyledStringResource
import co.electriccoin.zcash.ui.design.util.getColor
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.transactionnote.model.TransactionNoteState
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun TransactionNoteView(
onDismissRequest: () -> Unit,
sheetState: SheetState,
state: TransactionNoteState,
) {
ZashiModalBottomSheet(
sheetState = sheetState,
content = {
BottomSheetContent(state)
},
onDismissRequest = onDismissRequest
)
}
@Composable
private fun BottomSheetContent(state: TransactionNoteState) {
Column {
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = state.title.getValue(),
style = ZashiTypography.textXl,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(28.dp))
ZashiTextField(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
state = state.note,
minLines = 4,
placeholder = {
Text(
text = "Write an optional note to describe this transaction...",
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
}
)
Spacer(Modifier.height(6.dp))
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = state.noteCharacters.getValue(),
style = ZashiTypography.textSm,
color = state.noteCharacters.getColor()
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
state.negative?.let {
ZashiButton(
state = it,
modifier = Modifier.weight(1f),
colors = ZashiButtonDefaults.destructive1Colors()
)
}
state.secondaryButton?.let {
ZashiButton(
state = it,
modifier = Modifier.weight(1f),
colors = ZashiButtonDefaults.tertiaryColors()
)
}
state.primaryButton?.let {
ZashiButton(
state = it,
modifier = Modifier.weight(1f),
colors = ZashiButtonDefaults.primaryColors()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
TransactionNoteView(
state =
TransactionNoteState(
onBack = {},
onBottomSheetHidden = {},
title = stringRes("Title"),
note = TextFieldState(stringRes("")) {},
noteCharacters =
StyledStringResource(
stringRes("x/y characters")
),
primaryButton = null,
secondaryButton = null,
negative = ButtonState(stringRes("Delete note")),
),
onDismissRequest = {},
sheetState =
rememberModalBottomSheetState(
skipHiddenState = true,
skipPartiallyExpanded = true,
initialValue = SheetValue.Expanded,
confirmValueChange = { true }
)
)
}

View File

@ -0,0 +1,143 @@
package co.electriccoin.zcash.ui.screen.transactionnote.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.usecase.CreateOrUpdateTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.component.TextFieldState
import co.electriccoin.zcash.ui.design.util.StringResourceColor
import co.electriccoin.zcash.ui.design.util.StyledStringResource
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.transactionnote.TransactionNote
import co.electriccoin.zcash.ui.screen.transactionnote.model.TransactionNoteState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
internal class TransactionNoteViewModel(
private val transactionNote: TransactionNote,
private val navigationRouter: NavigationRouter,
private val getTransactionNote: GetTransactionMetadataUseCase,
private val createOrUpdateTransactionNote: CreateOrUpdateTransactionNoteUseCase,
private val deleteTransactionNote: DeleteTransactionNoteUseCase
) : ViewModel() {
val hideBottomSheetRequest = MutableSharedFlow<Unit>()
private val bottomSheetHiddenResponse = MutableSharedFlow<Unit>()
private val noteText = MutableStateFlow("")
private val foundNote = MutableStateFlow<String?>(null)
val state: StateFlow<TransactionNoteState> =
combine(noteText, foundNote) { noteText, foundNote ->
createState(noteText = noteText, foundNote = foundNote)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(noteText = "", foundNote = null)
)
private fun createState(
noteText: String,
foundNote: String?
): TransactionNoteState {
val noteTextNormalized = noteText.trim()
val isNoteTextTooLong = noteText.length > MAX_NOTE_LENGTH
return TransactionNoteState(
onBack = ::onBack,
onBottomSheetHidden = ::onBottomSheetHidden,
title =
if (foundNote == null) {
stringRes(R.string.transaction_note_add_note_title)
} else {
stringRes(R.string.transaction_note_edit_note_title)
},
note =
TextFieldState(
value = stringRes(noteText),
error = stringRes("").takeIf { isNoteTextTooLong },
onValueChange = ::onNoteTextChanged
),
noteCharacters =
StyledStringResource(
resource = stringRes(R.string.transaction_note_max_length, noteText.length.toString()),
color = if (isNoteTextTooLong) StringResourceColor.NEGATIVE else StringResourceColor.DEFAULT
),
primaryButton =
ButtonState(
text = stringRes(R.string.transaction_note_add_note),
onClick = ::onAddOrUpdateNoteClick,
isEnabled = !isNoteTextTooLong && noteTextNormalized.isNotEmpty()
).takeIf { foundNote == null },
secondaryButton =
ButtonState(
text = stringRes(R.string.transaction_note_save_note),
onClick = ::onAddOrUpdateNoteClick,
isEnabled = !isNoteTextTooLong && noteTextNormalized.isNotEmpty()
).takeIf { foundNote != null },
negative =
ButtonState(
text = stringRes(R.string.transaction_note_delete_note),
onClick = ::onDeleteNoteClick,
).takeIf { foundNote != null },
)
}
init {
viewModelScope.launch {
val metadata = getTransactionNote(transactionNote.txId)
foundNote.update { metadata.note }
noteText.update { metadata.note.orEmpty() }
}
}
private fun onAddOrUpdateNoteClick() =
viewModelScope.launch {
createOrUpdateTransactionNote(txId = transactionNote.txId, note = noteText.value) {
hideBottomSheet()
}
}
private fun onDeleteNoteClick() =
viewModelScope.launch {
deleteTransactionNote(transactionNote.txId) {
hideBottomSheet()
}
}
private fun onNoteTextChanged(newValue: String) {
noteText.update { newValue }
}
private suspend fun hideBottomSheet() {
hideBottomSheetRequest.emit(Unit)
bottomSheetHiddenResponse.first()
}
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onBack() =
viewModelScope.launch {
hideBottomSheet()
navigationRouter.back()
}
}
private const val MAX_NOTE_LENGTH = 90

View File

@ -0,0 +1,34 @@
package co.electriccoin.zcash.ui.util
import co.electriccoin.zcash.spackle.Twig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.io.Closeable
import kotlin.coroutines.CoroutineContext
interface CloseableScopeHolder : Closeable {
val scope: CoroutineScope
}
class CloseableScopeHolderImpl(
override val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()),
) : CloseableScopeHolder {
constructor(coroutineContext: CoroutineContext) : this(CoroutineScope(coroutineContext + SupervisorJob()))
override fun close() {
try {
scope.cancel()
} catch (e: IllegalStateException) {
Twig.error(e) { "Failed to close scope" }
}
}
}
/**
* A function to call during koin element lifecycle close action.
*/
fun <T> closeableCallback(t: T?) {
(t as? Closeable)?.close()
}

View File

@ -0,0 +1,8 @@
package co.electriccoin.zcash.ui.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
inline fun <reified T> List<Flow<T>>.combineToFlow(): Flow<List<T>> {
return combine(this.map { flow -> flow }) { items -> items.toList() }
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.241,2H9.759C8.954,2 8.289,2 7.748,2.044C7.186,2.09 6.669,2.189 6.184,2.436C5.431,2.819 4.819,3.431 4.436,4.184C4.189,4.669 4.09,5.186 4.044,5.748C4,6.289 4,6.954 4,7.759V21C4,21.357 4.19,21.686 4.498,21.865C4.806,22.044 5.187,22.045 5.496,21.868L12,18.152L18.504,21.868C18.813,22.045 19.194,22.044 19.502,21.865C19.81,21.686 20,21.357 20,21V7.759C20,6.954 20,6.289 19.956,5.748C19.91,5.186 19.811,4.669 19.564,4.184C19.181,3.431 18.569,2.819 17.816,2.436C17.331,2.189 16.814,2.09 16.252,2.044C15.711,2 15.046,2 14.241,2ZM16.207,8.707C16.598,8.317 16.598,7.683 16.207,7.293C15.817,6.902 15.183,6.902 14.793,7.293L11,11.086L9.707,9.793C9.317,9.402 8.683,9.402 8.293,9.793C7.902,10.183 7.902,10.817 8.293,11.207L10.293,13.207C10.683,13.598 11.317,13.598 11.707,13.207L16.207,8.707Z"
android:fillColor="#E8E8E8"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5,7.8C5,6.12 5,5.28 5.327,4.638C5.615,4.074 6.074,3.615 6.638,3.327C7.28,3 8.12,3 9.8,3H14.2C15.88,3 16.72,3 17.362,3.327C17.927,3.615 18.385,4.074 18.673,4.638C19,5.28 19,6.12 19,7.8V21L12,17L5,21V7.8Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E8E8E8"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.241,2H9.759C8.954,2 8.289,2 7.748,2.044C7.186,2.09 6.669,2.189 6.184,2.436C5.431,2.819 4.819,3.431 4.436,4.184C4.189,4.669 4.09,5.186 4.044,5.748C4,6.289 4,6.954 4,7.759V21C4,21.357 4.19,21.686 4.498,21.865C4.806,22.044 5.187,22.045 5.496,21.868L12,18.152L18.504,21.868C18.813,22.045 19.194,22.044 19.502,21.865C19.81,21.686 20,21.357 20,21V7.759C20,6.954 20,6.289 19.956,5.748C19.91,5.186 19.811,4.669 19.564,4.184C19.181,3.431 18.569,2.819 17.816,2.436C17.331,2.189 16.814,2.09 16.252,2.044C15.711,2 15.046,2 14.241,2ZM16.207,8.707C16.598,8.317 16.598,7.683 16.207,7.293C15.817,6.902 15.183,6.902 14.793,7.293L11,11.086L9.707,9.793C9.317,9.402 8.683,9.402 8.293,9.793C7.902,10.183 7.902,10.817 8.293,11.207L10.293,13.207C10.683,13.598 11.317,13.598 11.707,13.207L16.207,8.707Z"
android:fillColor="#231F20"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5,7.8C5,6.12 5,5.28 5.327,4.638C5.615,4.074 6.074,3.615 6.638,3.327C7.28,3 8.12,3 9.8,3H14.2C15.88,3 16.72,3 17.362,3.327C17.927,3.615 18.385,4.074 18.673,4.638C19,5.28 19,6.12 19,7.8V21L12,17L5,21V7.8Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector>

View File

@ -24,4 +24,7 @@
<string name="transaction_detail_shielded">Shielded</string>
<string name="transaction_detail_shielding">Shielding</string>
<string name="transaction_detail_shielding_failed">Shielding failed</string>
<string name="transaction_detail_edit_note">Edit note</string>
<string name="transaction_detail_add_a_note">Add a note</string>
<string name="transaction_detail_info_note">Note</string>
</resources>

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