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:
parent
a4090044ad
commit
ebd02de639
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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?>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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?
|
||||
)
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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")
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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?
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -189,7 +189,7 @@ private fun AccountMainContent(
|
|||
animateDpAsState(
|
||||
targetValue =
|
||||
if (balanceState.exchangeRate is ExchangeRateState.OptIn) {
|
||||
120.dp
|
||||
112.dp
|
||||
} else {
|
||||
0.dp
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -47,7 +47,7 @@ fun AndroidTransactionFiltersList() {
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
BackHandler(state != null) {
|
||||
state?.onBack?.invoke()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 couldn’t 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 = "")) {},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -22,6 +22,6 @@ object TransactionStateFixture {
|
|||
),
|
||||
onClick = {},
|
||||
key = UUID.randomUUID().toString(),
|
||||
hasMemo = true,
|
||||
isUnread = true,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package co.electriccoin.zcash.ui.screen.transactionnote
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TransactionNote(val txId: String)
|
|
@ -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?,
|
||||
)
|
|
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
|
@ -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() }
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
Loading…
Reference in New Issue