Merge pull request #1 from zcash/feature/scan-blocks-integration
Librustzcash integration
|
@ -0,0 +1,22 @@
|
|||
Change Log
|
||||
==========
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- stop squishing the view when the keyboard opens
|
||||
- dismiss keyboard on background tap
|
||||
- display TAZ when on testnet
|
||||
- added currency abbreviation after subheader text
|
||||
- added error detection when address is too short
|
||||
- disabled send button when errors exist
|
||||
- updated dialog to reflect actual user input
|
||||
- corrected toggle arrows from gray to black
|
||||
- corrected corner radii from 8 to 6
|
||||
- added paste button to apply default sample address for testing
|
||||
- corrected address line color from black to 12% black
|
||||
- corrected memo line color
|
||||
- corrected memo 0 / 512 text color
|
||||
- corrected memo background color
|
||||
- corrected amount box background color
|
||||
- updated send button to display TAZ (after toggling to "sending...")
|
||||
- fixed a lot of edge cases around formatting numerical number entry and toggling
|
44
README.md
|
@ -1,2 +1,42 @@
|
|||
# zcash-android-wallet-poc [![Build Status](https://app.bitrise.io/app/3f9040b242d98534/status.svg?token=AxoSmdULfUeBgW_GpS6VWg&branch=feature/revert-gradle-kotlin-dsl)](https://app.bitrise.io/app/3f9040b242d98534) [<img src="https://dply.me/c99ve9/button/small" alt="Download to device">](https://dply.me/c99ve9#install)
|
||||
Proof of concept for a zcash wallet
|
||||
# Security Disclaimer
|
||||
|
||||
#### :warning: WARNING: This is an *early preview* under active development and *anything may change at anytime!*
|
||||
|
||||
----
|
||||
|
||||
In the spirit of transparency, we provide this as a window into what we are actively developing. This is an alpha build, not yet intended for 3rd party use. Please be advised of the following:
|
||||
|
||||
* 🛑 This code currently is not audited 🛑
|
||||
* ❌ This is a public, active branch with **no support**
|
||||
* ❌ The code **does not have** documentation that is reviewed and approved by our Documentation team
|
||||
* ❌ The code **does not have** adequate unit tests, acceptance tests and stress tests
|
||||
* ❌ The code **does not have** automated tests that use the officially supported CI system
|
||||
* ❌ The code **has not been subjected to thorough review** by engineers at the Electric Coin Company
|
||||
* ❌ This product **does not run** compatibly with the latest version of zcashd
|
||||
* ❌ The product **may be** majorly broken in several ways
|
||||
* ❌ The app **only runs** on testnet
|
||||
* ❌ The app **does not run** on mainnet and **cannot** run on regtest
|
||||
* ❌ We **are actively changing** the codebase and adding features where/when needed
|
||||
* ❌ We **do not** undertake appropriate security coverage (threat models, review, response, etc.)
|
||||
* :heavy_check_mark: There is a product manager for this app
|
||||
* :heavy_check_mark: Electric Coin Company maintains the app as we discover bugs and do network upgrades/minor releases
|
||||
* :heavy_check_mark: Users can expect to get a response within a few weeks after submitting an issue
|
||||
* ❌ The User Support team **had not yet been briefed** on the features provided to users and the functionality of the associated test-framework
|
||||
* ❌ The code is **unpolished**
|
||||
* ❌ The code is **not documented**
|
||||
* ❌ The code **is not yet published** (to Bintray/Maven Central)
|
||||
* ❌ Requires external lightwalletd server
|
||||
|
||||
|
||||
### 🛑 Use of this code may lead to a loss of funds 🛑
|
||||
|
||||
Use of this code in its current form or with modifications may lead to loss of funds, loss of "expected" privacy, or denial of service for a large portion of users, or a bug which could leverage any of those kinds of attacks (especially a "0 day" where we suspect few people know about the vulnerability).
|
||||
|
||||
### :eyes: At this time, this is for preview purposes only. :eyes:
|
||||
|
||||
# Zcash Android Reference Wallet - Proof of Concept
|
||||
[![Build Status](https://app.bitrise.io/app/3f9040b242d98534/status.svg?token=AxoSmdULfUeBgW_GpS6VWg&branch=feature/revert-gradle-kotlin-dsl)](https://app.bitrise.io/app/3f9040b242d98534) [<img src="https://dply.me/n4br57/button/small" alt="Download to device">](https://dply.me/n4br57#install)
|
||||
|
||||
Proof of concept for shielded-only Zcash wallet. Additional documentation will be added in a future milestone.
|
||||
|
||||
[![Alt text](https://img.youtube.com/vi/BgNO5Wn-9r0/0.jpg)](https://www.youtube.com/watch?v=BgNO5Wn-9r0)
|
||||
|
|
|
@ -6,6 +6,7 @@ apply plugin: 'kotlin-android-extensions'
|
|||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'deploygate'
|
||||
apply plugin: 'com.github.ben-manes.versions'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
|
||||
android {
|
||||
compileSdkVersion buildConfig.compileSdkVersion
|
||||
|
@ -13,21 +14,53 @@ android {
|
|||
applicationId "cash.z.android.wallet"
|
||||
minSdkVersion buildConfig.minSdkVersion
|
||||
targetSdkVersion buildConfig.targetSdkVersion
|
||||
versionCode 17 // todo: change this to 1_00_04 format, once we graduate beyond zero for the major version number because leading zeros indicate on octal number.
|
||||
versionName "0.2.5-alpha"
|
||||
versionCode 20 // todo: change this to 1_00_04 format, once we graduate beyond zero for the major version number because leading zeros indicate on octal number.
|
||||
versionName "0.6.1-alpha"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
dataBinding {
|
||||
enabled true
|
||||
}
|
||||
|
||||
flavorDimensions 'network'
|
||||
productFlavors {
|
||||
// product flavor names cannot start with the word "test" because they would clash with other targets
|
||||
zcashtestnet {
|
||||
dimension 'network'
|
||||
applicationId 'cash.z.android.wallet.testnet'
|
||||
matchingFallbacks = ['debug', 'zcashtestnet']
|
||||
}
|
||||
|
||||
zcashmainnet {
|
||||
dimension 'network'
|
||||
applicationId 'cash.z.android.wallet.mainnet'
|
||||
matchingFallbacks = ['release', 'zcashmainnet']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
mock {
|
||||
initWith debug
|
||||
matchingFallbacks = ['debug', 'release', 'zcashtestnet']
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
lintConfig file("zcash-lint-options.xml")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -41,11 +74,28 @@ dependencies {
|
|||
implementation deps.androidx.navigation.fragmentKtx
|
||||
implementation deps.androidx.navigation.ui
|
||||
implementation deps.androidx.navigation.uiKtx
|
||||
implementation deps.kotlin.stdlib
|
||||
implementation deps.material
|
||||
implementation deps.androidx.multidex
|
||||
|
||||
// Kotlin
|
||||
implementation deps.kotlin.stdlib
|
||||
implementation deps.kotlin.reflect
|
||||
implementation deps.kotlin.coroutines.core
|
||||
implementation deps.kotlin.coroutines.android
|
||||
|
||||
// Zcash
|
||||
implementation deps.zcash.walletSdk
|
||||
implementation project(path: ':qrecycler')
|
||||
|
||||
// TODO: get the AAR to provide these
|
||||
implementation "io.grpc:grpc-okhttp:1.19.0"
|
||||
implementation "io.grpc:grpc-protobuf-lite:1.19.0"
|
||||
implementation "io.grpc:grpc-stub:1.19.0"
|
||||
implementation 'javax.annotation:javax.annotation-api:1.3.2'
|
||||
implementation "androidx.room:room-runtime:2.1.0-alpha06"
|
||||
implementation "androidx.room:room-common:2.1.0-alpha06"
|
||||
implementation 'com.google.guava:guava:27.0.1-android'
|
||||
kapt "androidx.room:room-compiler:2.1.0-alpha06"
|
||||
|
||||
// Dagger
|
||||
implementation deps.dagger.android.support
|
||||
|
@ -54,9 +104,17 @@ dependencies {
|
|||
|
||||
// Other
|
||||
implementation deps.speeddial
|
||||
implementation deps.lottie
|
||||
debugImplementation deps.stetho
|
||||
mockImplementation deps.stetho
|
||||
|
||||
testImplementation deps.mockito.jupiter
|
||||
testImplementation deps.mockito.kotlin
|
||||
testImplementation deps.junit5.api
|
||||
testImplementation deps.junit5.engine
|
||||
testImplementation deps.junit5.migrationsupport
|
||||
|
||||
testImplementation deps.junit
|
||||
androidTestImplementation deps.androidx.test.runner
|
||||
androidTestImplementation deps.androidx.test.espresso
|
||||
compile project(path: ':qrecycler')
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
package cash.z.android.wallet.di.module
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import cash.z.android.wallet.BuildConfig
|
||||
import cash.z.android.wallet.ZcashWalletApplication
|
||||
import cash.z.android.wallet.sample.*
|
||||
import cash.z.android.wallet.sample.SampleProperties.COMPACT_BLOCK_PORT
|
||||
import cash.z.android.wallet.sample.SampleProperties.COMPACT_BLOCK_SERVER
|
||||
import cash.z.android.wallet.sample.SampleProperties.PREFS_SERVER_NAME
|
||||
import cash.z.android.wallet.sample.SampleProperties.PREFS_WALLET_DISPLAY_NAME
|
||||
import cash.z.wallet.sdk.data.*
|
||||
import cash.z.wallet.sdk.jni.JniConverter
|
||||
import cash.z.wallet.sdk.secure.Wallet
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Module that contributes all the objects necessary for the synchronizer, which is basically everything that has
|
||||
* application scope.
|
||||
*/
|
||||
@Module
|
||||
internal object SynchronizerModule {
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePrefs(): SharedPreferences {
|
||||
return PreferenceManager.getDefaultSharedPreferences(ZcashWalletApplication.instance)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWalletConfig(prefs: SharedPreferences): WalletConfig {
|
||||
val walletName = prefs.getString(PREFS_WALLET_DISPLAY_NAME, null)
|
||||
twig("FOUND WALLET DISPLAY NAME : $walletName")
|
||||
return when(walletName) {
|
||||
AliceWallet.displayName -> AliceWallet
|
||||
BobWallet.displayName, null -> BobWallet // Default wallet
|
||||
CarolWallet.displayName -> CarolWallet
|
||||
DaveWallet.displayName -> DaveWallet
|
||||
else -> WalletConfig.create(walletName)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(PREFS_SERVER_NAME)
|
||||
fun provideServer(prefs: SharedPreferences): String {
|
||||
val serverName = prefs.getString(PREFS_SERVER_NAME, null)
|
||||
// in theory, the actual stored value itself could be null so provide the default this way to be safe
|
||||
val server = Servers.values().firstOrNull { it.displayName == serverName }?.host ?: COMPACT_BLOCK_SERVER //TODO: validate that this is a hostname or IP. For now use default, instead
|
||||
twig("FOUND SERVER DISPLAY NAME : $serverName ($server)")
|
||||
return server
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTwig(): Twig = TroubleshootingTwig() // troubleshoot on debug, silent on release
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloader(@Named(PREFS_SERVER_NAME) server: String, twigger: Twig): CompactBlockStream {
|
||||
return CompactBlockStream(server, COMPACT_BLOCK_PORT, twigger)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProcessor(application: ZcashWalletApplication, converter: JniConverter, walletConfig: WalletConfig, twigger: Twig): CompactBlockProcessor {
|
||||
return CompactBlockProcessor(application, converter, walletConfig.cacheDbName, walletConfig.dataDbName, logger = twigger)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRepository(application: ZcashWalletApplication, walletConfig: WalletConfig, converter: JniConverter): TransactionRepository {
|
||||
return PollingTransactionRepository(application, walletConfig.dataDbName, 10_000L)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWallet(application: ZcashWalletApplication, walletConfig: WalletConfig, converter: JniConverter): Wallet {
|
||||
return Wallet(
|
||||
context = application,
|
||||
converter = converter,
|
||||
dataDbPath = application.getDatabasePath(walletConfig.dataDbName).absolutePath,
|
||||
paramDestinationDir = "${application.cacheDir.absolutePath}/params",
|
||||
seedProvider = walletConfig.seedProvider,
|
||||
spendingKeyProvider = walletConfig.spendingKeyProvider
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideManager(wallet: Wallet, repository: TransactionRepository, downloader: CompactBlockStream, twigger: Twig): ActiveTransactionManager {
|
||||
return ActiveTransactionManager(repository, downloader.connection, wallet, twigger)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJniConverter(): JniConverter {
|
||||
return JniConverter().also {
|
||||
if (BuildConfig.DEBUG) it.initLogs()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSynchronizer(
|
||||
downloader: CompactBlockStream,
|
||||
processor: CompactBlockProcessor,
|
||||
repository: TransactionRepository,
|
||||
manager: ActiveTransactionManager,
|
||||
wallet: Wallet
|
||||
): Synchronizer {
|
||||
return SdkSynchronizer(
|
||||
downloader,
|
||||
processor,
|
||||
repository,
|
||||
manager,
|
||||
wallet,
|
||||
batchSize = 100,
|
||||
blockPollFrequency = 50_000L
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="cash.z.android.wallet">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!--<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />-->
|
||||
<!--<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />-->
|
||||
|
||||
<dist:module dist:instant="true" />
|
||||
|
||||
<application
|
||||
|
@ -12,11 +18,13 @@
|
|||
android:roundIcon="@mipmap/ic_launcher_shield_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/ZcashTheme">
|
||||
android:theme="@style/ZcashTheme"
|
||||
tools:replace="android:name">
|
||||
<activity
|
||||
android:name=".ui.activity.MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/ZcashTheme.NoActionBar"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -25,6 +33,8 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ui.util.CameraQrScanner" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -1,15 +1,22 @@
|
|||
package cash.z.android.wallet
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.content.Context
|
||||
import androidx.multidex.MultiDex
|
||||
import cash.z.android.wallet.di.component.DaggerApplicationComponent
|
||||
import cash.z.wallet.sdk.data.TroubleshootingTwig
|
||||
import cash.z.wallet.sdk.data.Twig
|
||||
import com.facebook.stetho.Stetho
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DaggerApplication
|
||||
|
||||
|
||||
class ZcashWalletApplication : DaggerApplication() {
|
||||
|
||||
override fun onCreate() {
|
||||
instance = this
|
||||
super.onCreate()
|
||||
Stetho.initializeWithDefaults(this)
|
||||
Twig.plant(TroubleshootingTwig())
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -19,6 +26,11 @@ class ZcashWalletApplication : DaggerApplication() {
|
|||
return DaggerApplicationComponent.builder().create(this)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
MultiDex.install(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: ZcashWalletApplication
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package cash.z.android.wallet.data
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.produce
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.roundToLong
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import kotlin.random.nextLong
|
||||
|
||||
class SampleTransactionRepository(val scope: CoroutineScope) : TransactionRepository {
|
||||
/**
|
||||
* Just send a sample stream of balances, every so often
|
||||
*/
|
||||
override fun balance() = scope.produce {
|
||||
var currentBalance = 0.0
|
||||
while (isActive) {
|
||||
send(BigDecimal(currentBalance))
|
||||
delay(500)
|
||||
currentBalance += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Just send a sample stream of transactions, every so often
|
||||
*/
|
||||
override fun transactions(): ReceiveChannel<WalletTransaction> = scope.produce {
|
||||
var oldestTimestamp = System.currentTimeMillis() - (4 * DateUtils.WEEK_IN_MILLIS)
|
||||
while (isActive) {
|
||||
delay(1500L)
|
||||
send(createSampleTransaction(oldestTimestamp).also { oldestTimestamp = it.timeInSeconds * 1000 })
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSampleTransaction(oldestTimestamp: Long): WalletTransaction {
|
||||
// up to 20% of the delta
|
||||
val upperBound = System.currentTimeMillis() + Math.round(0.2 * (System.currentTimeMillis() - oldestTimestamp))
|
||||
val txId = Random.nextLong(0L..(Long.MAX_VALUE - 1L))
|
||||
val value = Random.nextLong(1L..1_500_000_000L) - 750_000_000L
|
||||
val height = Random.nextInt(0..(Int.MAX_VALUE - 1))
|
||||
val isSend = value > 0L
|
||||
val time = Random.nextLong(oldestTimestamp..upperBound)
|
||||
val isMined = Random.nextBoolean()
|
||||
return WalletTransaction(
|
||||
txId = txId,
|
||||
value = value,
|
||||
height = height,
|
||||
isSend = isSend,
|
||||
timeInSeconds = time/1000,
|
||||
isMined = isMined
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package cash.z.android.wallet.data
|
||||
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface TransactionRepository {
|
||||
fun balance(): ReceiveChannel<BigDecimal>
|
||||
fun transactions(): ReceiveChannel<WalletTransaction>
|
||||
}
|
|
@ -3,4 +3,5 @@ package cash.z.android.wallet.di.annotation
|
|||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
annotation class ActivityScoped
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class ActivityScope
|
|
@ -0,0 +1,7 @@
|
|||
package cash.z.android.wallet.di.annotation
|
||||
|
||||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class ApplicationScope
|
|
@ -3,4 +3,5 @@ package cash.z.android.wallet.di.annotation
|
|||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
annotation class ApplicationScoped
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class FragmentScope
|
|
@ -3,8 +3,8 @@ package cash.z.android.wallet.di.component
|
|||
import cash.z.android.wallet.ui.activity.MainActivityModule
|
||||
import cash.z.android.wallet.ZcashWalletApplication
|
||||
import cash.z.android.wallet.di.module.ApplicationModule
|
||||
import cash.z.android.wallet.ui.fragment.HomeFragmentModule
|
||||
import cash.z.android.wallet.ui.fragment.ReceiveFragmentModule
|
||||
import cash.z.android.wallet.di.module.SynchronizerModule
|
||||
import cash.z.android.wallet.ui.fragment.*
|
||||
import dagger.Component
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.support.AndroidSupportInjectionModule
|
||||
|
@ -19,9 +19,22 @@ import javax.inject.Singleton
|
|||
modules = [
|
||||
AndroidSupportInjectionModule::class,
|
||||
ApplicationModule::class,
|
||||
SynchronizerModule::class,
|
||||
MainActivityModule::class,
|
||||
|
||||
// Injected Fragments
|
||||
HomeFragmentModule::class,
|
||||
ReceiveFragmentModule::class
|
||||
AboutFragmentModule::class,
|
||||
HistoryFragmentModule::class,
|
||||
WelcomeFragmentModule::class,
|
||||
ReceiveFragmentModule::class,
|
||||
RequestFragmentModule::class,
|
||||
SendFragmentModule::class,
|
||||
ScanFragmentModule::class,
|
||||
SettingsFragmentModule::class,
|
||||
WelcomeFragmentModule::class,
|
||||
FirstrunFragmentModule::class,
|
||||
SyncFragmentModule::class
|
||||
]
|
||||
)
|
||||
interface ApplicationComponent : AndroidInjector<ZcashWalletApplication> {
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package cash.z.android.wallet.di.module
|
||||
|
||||
import cash.z.android.qrecycler.QRecycler
|
||||
import cash.z.android.wallet.ui.fragment.HomeFragment
|
||||
import cash.z.android.wallet.ui.presenter.HomePresenter
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.jni.JniConverter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Module that contributes all the objects with application scope. Anything that should live globally belongs here.
|
||||
|
@ -18,7 +22,4 @@ internal object ApplicationModule {
|
|||
@Provides
|
||||
fun provideQRecycler(): QRecycler = QRecycler()
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
fun provideJniConverter(): JniConverter = JniConverter()
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package cash.z.android.wallet.extention
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
||||
internal val NO_ACTION = {}
|
||||
|
||||
/**
|
||||
* Calls context.alert with the given string.
|
||||
*/
|
||||
internal fun Context.alert(
|
||||
@StringRes messageResId: Int,
|
||||
@StringRes positiveButtonResId: Int = android.R.string.ok,
|
||||
@StringRes negativeButtonResId: Int = android.R.string.cancel,
|
||||
negativeAction: () -> Unit = NO_ACTION,
|
||||
positiveAction: () -> Unit = NO_ACTION
|
||||
) {
|
||||
alert(
|
||||
message = getString(messageResId),
|
||||
positiveButtonResId = positiveButtonResId,
|
||||
negativeButtonResId = negativeButtonResId,
|
||||
negativeAction = negativeAction,
|
||||
positiveAction = positiveAction
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert with the given message, if the block exists, it will execute after the user clicks the positive button,
|
||||
* while clicking the negative button will abort the block. If no block exists, there will only be a positive button.
|
||||
*/
|
||||
internal fun Context.alert(
|
||||
message: String,
|
||||
@StringRes positiveButtonResId: Int = android.R.string.ok,
|
||||
@StringRes negativeButtonResId: Int = android.R.string.cancel,
|
||||
negativeAction: (() -> Unit) = NO_ACTION,
|
||||
positiveAction: (() -> Unit) = NO_ACTION
|
||||
) {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(positiveButtonResId) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
positiveAction()
|
||||
}
|
||||
if (positiveAction !== NO_ACTION || negativeAction !== NO_ACTION) {
|
||||
builder.setNegativeButton(negativeButtonResId) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
negativeAction()
|
||||
}
|
||||
}
|
||||
builder.show()
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package cash.z.android.wallet.extention
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.EditText
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
inline fun EditText.afterTextChanged(crossinline block: (String) -> Unit) {
|
||||
this.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
block.invoke(s.toString())
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
}
|
||||
|
||||
inline fun EditText.doOnDone(crossinline block: (String) -> Unit) {
|
||||
setOnEditorActionListener { v, actionId, _ ->
|
||||
return@setOnEditorActionListener if ((actionId == EditorInfo.IME_ACTION_DONE)) {
|
||||
v.clearFocus()
|
||||
// v.clearComposingText()
|
||||
v.context.getSystemService<InputMethodManager>()
|
||||
?.hideSoftInputFromWindow(v.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
block(this.text.toString())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun EditText.doOnFocusLost(crossinline block: (String) -> Unit) {
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) block(this.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun EditText.doOnDoneOrFocusLost(crossinline block: (String) -> Unit) {
|
||||
doOnDone(block)
|
||||
doOnFocusLost(block)
|
||||
}
|
|
@ -7,3 +7,7 @@ internal inline fun tryIgnore(block: () -> Unit) {
|
|||
internal inline fun <T> tryNull(block: () -> T): T? {
|
||||
return try { block() } catch(ignored: Throwable) { null }
|
||||
}
|
||||
|
||||
internal inline fun String.truncate(): String {
|
||||
return "${substring(0..4)}...${substring(length-5, length)}"
|
||||
}
|
|
@ -2,6 +2,7 @@ package cash.z.android.wallet.extention
|
|||
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import cash.z.android.wallet.ZcashWalletApplication
|
||||
|
@ -20,3 +21,10 @@ internal inline fun @receiver:ColorRes Int.toAppColor(): Int {
|
|||
internal inline fun @receiver:StringRes Int.toAppString(): String {
|
||||
return ZcashWalletApplication.instance.getString(this)}
|
||||
|
||||
|
||||
/**
|
||||
* Grab an integer from the application resources
|
||||
*/
|
||||
internal inline fun @receiver:IntegerRes Int.toAppInt(): Int {
|
||||
return ZcashWalletApplication.instance.resources.getInteger(this)}
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package cash.z.android.wallet.extention
|
||||
|
||||
import android.text.format.DateUtils.SECOND_IN_MILLIS
|
||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||
|
||||
internal inline fun Long.toRelativeTimeString(): CharSequence {
|
||||
return getRelativeTimeSpanString(
|
||||
this,
|
||||
System.currentTimeMillis(),
|
||||
SECOND_IN_MILLIS
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cash.z.android.wallet.extention
|
||||
|
||||
import android.view.View
|
||||
import cash.z.android.wallet.R
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
||||
/**
|
||||
* Show a snackbar with an "OK" button
|
||||
*/
|
||||
internal inline fun Snackbar?.showOk(view: View, message: String): Snackbar {
|
||||
return if (this == null) {
|
||||
Snackbar.make(view, "$message", Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(view.context.getString(R.string.ok_allcaps)){/*auto-close*/}
|
||||
} else {
|
||||
setText(message)
|
||||
}.also {
|
||||
if (!it.isShownOrQueued) it.show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package cash.z.android.wallet.sample
|
||||
|
||||
import android.provider.Settings
|
||||
import cash.z.wallet.sdk.data.SampleSeedProvider
|
||||
import java.math.BigDecimal
|
||||
import java.math.MathContext
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
|
||||
interface WalletConfig {
|
||||
val displayName: String
|
||||
val seedName: String
|
||||
val seedProvider: ReadOnlyProperty<kotlin.Any?, kotlin.ByteArray>
|
||||
val spendingKeyProvider: ReadWriteProperty<Any?, String>
|
||||
val cacheDbName: String
|
||||
val dataDbName: String
|
||||
val defaultSendAddress: String
|
||||
|
||||
companion object {
|
||||
fun create(name: String): WalletConfig {
|
||||
return object : WalletConfig {
|
||||
override val displayName = name
|
||||
override val seedName = "test.reference.${name}_${Settings.Secure.ANDROID_ID}".sanitizeName()
|
||||
override val seedProvider = SampleSeedProvider(seedName)
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${name.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${name.sanitizeName()}.db"
|
||||
override val defaultSendAddress = BobWallet.defaultSendAddress // send to Alice by default, in other words, behave like Bob, which is the default wallet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal inline fun String.sanitizeName(): String {
|
||||
return this.toLowerCase().filter {
|
||||
it in 'a'..'z' || it in '0'..'9'
|
||||
}
|
||||
}
|
||||
|
||||
object AliceWallet : WalletConfig {
|
||||
override val displayName = "Alice"
|
||||
override val seedName = "test.reference.$displayName".sanitizeName()
|
||||
override val seedProvider = SampleSeedProvider(seedName)
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${displayName.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${displayName.sanitizeName()}.db"
|
||||
override val defaultSendAddress =
|
||||
"ztestsapling1wrjqt8et9elq7p0ejlgfpt4j9m7r7d4qlt7cke7ppp7dwrpev3yln30c37mrnzzekceajk66h0n" // bob's address
|
||||
}
|
||||
|
||||
object BobWallet : WalletConfig {
|
||||
override val displayName = "Bob"
|
||||
override val seedName = "test.reference.$displayName".sanitizeName()
|
||||
override val seedProvider = SampleSeedProvider(seedName)
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${displayName.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${displayName.sanitizeName()}.db"
|
||||
override val defaultSendAddress =
|
||||
"ztestsapling12pxv67r0kdw58q8tcn8kxhfy9n4vgaa7q8vp0dg24aueuz2mpgv2x7mw95yetcc37efc6q3hewn" // alice's address
|
||||
}
|
||||
|
||||
object CarolWallet : WalletConfig {
|
||||
override val displayName = "Carol"
|
||||
override val seedName = "test.reference.$displayName".sanitizeName()
|
||||
override val seedProvider = SampleSeedProvider(seedName)
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${displayName.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${displayName.sanitizeName()}.db"
|
||||
override val defaultSendAddress =
|
||||
"ztestsapling1y480yqw6h7lwmvw9wsn3h2xxg0np93cv8nq0j3m6g8edc79faevq5adrtzyxgsmu9jfc2hdf6al" // dave's address
|
||||
}
|
||||
|
||||
object DaveWallet : WalletConfig {
|
||||
override val displayName = "Dave"
|
||||
override val seedName = "test.reference.$displayName".sanitizeName()
|
||||
override val seedProvider = SampleSeedProvider(seedName)
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${displayName.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${displayName.sanitizeName()}.db"
|
||||
override val defaultSendAddress =
|
||||
"ztestsapling1efxqj5256ywqdc3zntfa0hw6yn4f83k2h7fgngwmxr3h3w7zydlencvh30730ez6p8fwg56htgz" // carol's address
|
||||
}
|
||||
|
||||
object MyWallet : WalletConfig {
|
||||
override val displayName = "MyWallet"
|
||||
override val seedName = "test.reference.$displayName".sanitizeName()
|
||||
override val seedProvider = SampleImportedSeedProvider("295761fce7fdc89fa1095259f5be6375c4a36f7a214767d668f9ef6e17aa6314")
|
||||
override val spendingKeyProvider = SampleSpendingKeySharedPref(seedName)
|
||||
override val cacheDbName = "test_cache_${displayName.sanitizeName()}.db"
|
||||
override val dataDbName = "test_data_${displayName.sanitizeName()}.db"
|
||||
override val defaultSendAddress =
|
||||
"ztestsapling1snmqdnfqnc407pvqw7sld8w5zxx6nd0523kvlj4jf39uvxvh7vn0hs3q38n07806dwwecqwke3t" // dummyseed
|
||||
}
|
||||
|
||||
enum class Servers(val host: String, val displayName: String) {
|
||||
LOCALHOST("10.0.0.191", "Localhost"),
|
||||
// WLAN("10.0.0.26"),
|
||||
WLAN1("10.0.2.24", "WLAN Conference"),
|
||||
WLAN2("192.168.1.235", "WLAN Office"),
|
||||
BOLT_TESTNET("ec2-34-228-10-162.compute-1.amazonaws.com", "Bolt Labs Testnet"),
|
||||
ZCASH_TESTNET("lightwalletd.z.cash", "Zcash Testnet")
|
||||
}
|
||||
|
||||
|
||||
// TODO: load most of these properties in later, perhaps from settings
|
||||
object SampleProperties {
|
||||
|
||||
const val PREFS_WALLET_DISPLAY_NAME = "prefs_wallet_name"
|
||||
const val PREFS_SERVER_NAME = "prefs_server_name"
|
||||
|
||||
val COMPACT_BLOCK_SERVER = Servers.ZCASH_TESTNET.host
|
||||
const val COMPACT_BLOCK_PORT = 9067
|
||||
val wallet = DaveWallet
|
||||
// TODO: placeholder until we have a network service for this
|
||||
val USD_PER_ZEC = BigDecimal("52.86", MathContext.DECIMAL128)
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package cash.z.android.wallet.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import cash.z.android.wallet.ZcashWalletApplication
|
||||
import okio.ByteString
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
@Deprecated(message = InsecureWarning.message)
|
||||
class SampleImportedSeedProvider(private val seedHex: String) : ReadOnlyProperty<Any?, ByteArray> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray {
|
||||
val bytes = ByteString.decodeHex(seedHex).toByteArray()
|
||||
val stringBytes = String(bytes, Charset.forName("UTF-8"))
|
||||
Log.e("TWIG-x", "byteString: $stringBytes")
|
||||
return decodeHex(seedHex).also { Log.e("TWIG-x", "$it") }
|
||||
}
|
||||
|
||||
fun decodeHex(hex: String): ByteArray {
|
||||
val result = ByteArray(hex.length / 2)
|
||||
for (i in result.indices) {
|
||||
val d1 = decodeHexDigit(hex[i * 2]) shl 4
|
||||
val d2 = decodeHexDigit(hex[i * 2 + 1])
|
||||
result[i] = (d1 + d2).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeHexDigit(c: Char): Int {
|
||||
if (c in '0'..'9') return c - '0'
|
||||
if (c in 'a'..'f') return c - 'a' + 10
|
||||
if (c in 'A'..'F') return c - 'A' + 10
|
||||
throw IllegalArgumentException("Unexpected hex digit: $c")
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(message = InsecureWarning.message)
|
||||
class SampleSpendingKeySharedPref(private val fileName: String) : ReadWriteProperty<Any?, String> {
|
||||
|
||||
private fun getPrefs() = ZcashWalletApplication.instance
|
||||
.getSharedPreferences(fileName, Context.MODE_PRIVATE)
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
|
||||
val preferences = getPrefs()
|
||||
return preferences.getString("spending", null)
|
||||
?: throw IllegalStateException(
|
||||
"Spending key was not there when we needed it! Make sure it was saved " +
|
||||
"during the first run of the app, when accounts were created!"
|
||||
)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
|
||||
Log.e("TWIG", "Spending key is being stored")
|
||||
val preferences = getPrefs()
|
||||
val editor = preferences.edit()
|
||||
editor.putString("spending", value)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal object InsecureWarning {
|
||||
const val message = "Do not use this because it is insecure and only intended for test code and samples. " +
|
||||
"Instead, use the Android Keystore system or a 3rd party library that leverages it."
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package cash.z.android.wallet.sample
|
||||
|
||||
import cash.z.android.qrecycler.QScanner
|
||||
|
||||
class SampleQrScanner : QScanner {
|
||||
override fun scanBarcode(callback: (Result<String>) -> Unit) {
|
||||
callback(Result.success("sampleqrcode_scan_success"))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package cash.z.android.wallet.ui.activity
|
||||
|
||||
import android.os.Bundle
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class BaseActivity : DaggerAppCompatActivity(), CoroutineScope {
|
||||
private lateinit var job: Job
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = job + Dispatchers.Main
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
job = Job()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package cash.z.android.wallet.ui.activity
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import dagger.android.AndroidInjection
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasFragmentInjector
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import dagger.android.support.HasSupportFragmentInjector
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class BaseMainActivity : DaggerAppCompatActivity(), CoroutineScope, HasFragmentInjector,
|
||||
HasSupportFragmentInjector {
|
||||
private lateinit var job: Job
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = job + Dispatchers.Main
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var supportFragmentInjector: DispatchingAndroidInjector<Fragment>
|
||||
@Inject
|
||||
lateinit var frameworkFragmentInjector: DispatchingAndroidInjector<android.app.Fragment>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
job = Job()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun supportFragmentInjector(): AndroidInjector<androidx.fragment.app.Fragment>? {
|
||||
return supportFragmentInjector
|
||||
}
|
||||
|
||||
override fun fragmentInjector(): AndroidInjector<android.app.Fragment>? {
|
||||
return frameworkFragmentInjector
|
||||
}
|
||||
}
|
|
@ -1,9 +1,16 @@
|
|||
package cash.z.android.wallet.ui.activity
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.view.inputmethod.InputMethodManager.HIDE_NOT_ALWAYS
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
|
@ -12,26 +19,48 @@ import androidx.navigation.ui.setupActionBarWithNavController
|
|||
import androidx.navigation.ui.setupWithNavController
|
||||
import cash.z.android.wallet.BuildConfig
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.ActivityMainBinding
|
||||
import cash.z.android.wallet.sample.WalletConfig
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.nav_header_main.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class MainActivity : DaggerAppCompatActivity() {
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
@Inject
|
||||
lateinit var walletConfig: WalletConfig
|
||||
|
||||
lateinit var binding: ActivityMainBinding
|
||||
lateinit var loadMessages: List<String>
|
||||
|
||||
// used to manage the drawer and drawerToggle interactions
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
lateinit var navController: NavController
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
||||
initAppBar()
|
||||
loadMessages = generateFunLoadMessages().shuffled()
|
||||
synchronizer.start(this)
|
||||
}
|
||||
|
||||
private fun initAppBar() {
|
||||
setSupportActionBar(findViewById(R.id.main_toolbar))
|
||||
setupNavigation()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
synchronizer.stop()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (drawer_layout.isDrawerOpen(GravityCompat.START)) {
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
@ -46,28 +75,71 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
|
||||
}
|
||||
|
||||
fun setDrawerLocked(isLocked: Boolean) {
|
||||
binding.drawerLayout.setDrawerLockMode(if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
}
|
||||
|
||||
fun openDrawer(view: View) {
|
||||
binding.drawerLayout.openDrawer(GravityCompat.START)
|
||||
}
|
||||
|
||||
fun setToolbarShown(isShown: Boolean) {
|
||||
binding.mainAppBar.visibility = if (isShown) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
fun setupNavigation() {
|
||||
// create and setup the navController and appbarConfiguration
|
||||
navController = Navigation.findNavController(this, R.id.nav_host_fragment).also { n ->
|
||||
appBarConfiguration = AppBarConfiguration(n.graph, drawer_layout).also { a ->
|
||||
nav_view.setupWithNavController(n)
|
||||
setupActionBarWithNavController(n, a)
|
||||
appBarConfiguration = AppBarConfiguration(n.graph, binding.drawerLayout).also { a ->
|
||||
binding.navView.setupWithNavController(n)
|
||||
setupActionBarWithNavController(n, binding.drawerLayout)
|
||||
}
|
||||
}
|
||||
navController.addOnDestinationChangedListener { _, _, _ ->
|
||||
// hide the keyboard anytime we change destinations
|
||||
getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(binding.navView.windowToken, HIDE_NOT_ALWAYS)
|
||||
}
|
||||
|
||||
// remove icon tint so that our colored nav icons show through
|
||||
nav_view.itemIconTintList = null
|
||||
binding.navView.itemIconTintList = null
|
||||
|
||||
nav_view.doOnLayout {
|
||||
text_nav_header_subtitle.text = "Version ${BuildConfig.VERSION_NAME}"
|
||||
binding.navView.doOnLayout {
|
||||
binding.navView.findViewById<TextView>(R.id.text_nav_header_subtitle).text = "Version ${BuildConfig.VERSION_NAME} (${walletConfig.displayName})"
|
||||
}
|
||||
}
|
||||
|
||||
fun nextLoadMessage(index: Int = -1): String {
|
||||
return if (index < 0) loadMessages.random() else loadMessages[index]
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
// Enable vector drawable magic
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
}
|
||||
|
||||
// TODO: move these lists, once approved
|
||||
fun generateSeriousLoadMessages(): List<String> {
|
||||
return listOf(
|
||||
"Initializing your shielded address",
|
||||
"Connecting to testnet",
|
||||
"Downloading historical blocks",
|
||||
"Synchronizing to current blockchain",
|
||||
"Searching for past transactions",
|
||||
"Validating your balance"
|
||||
)
|
||||
}
|
||||
|
||||
fun generateFunLoadMessages(): List<String> {
|
||||
return listOf(
|
||||
"Reticulating splines",
|
||||
"Making the sausage",
|
||||
"Drinking the kool-aid",
|
||||
"Learning to spell Lamborghini",
|
||||
"Asking Zooko, \"when moon?!\"",
|
||||
"Pretending to look busy"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,60 +1,65 @@
|
|||
package cash.z.android.wallet.ui.adapter
|
||||
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.extention.toAppColor
|
||||
import cash.z.android.wallet.vo.WalletTransaction
|
||||
import java.math.BigDecimal
|
||||
import java.math.MathContext
|
||||
import java.math.RoundingMode
|
||||
import cash.z.android.wallet.extention.toRelativeTimeString
|
||||
import cash.z.android.wallet.extention.truncate
|
||||
import cash.z.wallet.sdk.ext.convertZatoshiToZec
|
||||
import cash.z.wallet.sdk.ext.toZec
|
||||
import java.text.SimpleDateFormat
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import java.util.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
|
||||
class TransactionAdapter(private val transactions: MutableList<WalletTransaction>) :
|
||||
RecyclerView.Adapter<TransactionViewHolder>() {
|
||||
|
||||
init {
|
||||
transactions.sortBy { it.timestamp * -1 }
|
||||
}
|
||||
|
||||
class TransactionAdapter(@LayoutRes val itemResId: Int = R.layout.item_transaction) : ListAdapter<WalletTransaction, TransactionViewHolder>(DIFF_CALLBACK) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransactionViewHolder {
|
||||
val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_transaction, parent, false)
|
||||
val itemView = LayoutInflater.from(parent.context).inflate(itemResId, parent, false)
|
||||
return TransactionViewHolder(itemView)
|
||||
}
|
||||
override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) = holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = transactions.size
|
||||
|
||||
override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) = holder.bind(transactions[position])
|
||||
|
||||
fun setTransactions(txs: List<WalletTransaction>) {
|
||||
transactions.clear()
|
||||
transactions.addAll(txs)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<WalletTransaction>() {
|
||||
override fun areItemsTheSame(oldItem: WalletTransaction, newItem: WalletTransaction) = oldItem.height == newItem.height
|
||||
override fun areContentsTheSame(oldItem: WalletTransaction, newItem: WalletTransaction) = oldItem == newItem
|
||||
}
|
||||
|
||||
class TransactionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val status = itemView.findViewById<View>(R.id.view_transaction_status)
|
||||
private val icon = itemView.findViewById<ImageView>(R.id.image_transaction_type)
|
||||
private val timestamp = itemView.findViewById<TextView>(R.id.text_transaction_timestamp)
|
||||
private val amount = itemView.findViewById<TextView>(R.id.text_transaction_amount)
|
||||
private val background = itemView.findViewById<View>(R.id.container_transaction)
|
||||
private val address = itemView.findViewById<TextView>(R.id.text_transaction_address)
|
||||
private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault())
|
||||
|
||||
fun bind(tx: WalletTransaction) {
|
||||
val sign = if(tx.amount > BigDecimal.ZERO) "+" else "-"
|
||||
val rowColor = if(adapterPosition.rem(2) == 0) R.color.zcashBlueGray else R.color.zcashWhite
|
||||
val amountColor = if(tx.amount > BigDecimal.ZERO) R.color.colorPrimary else R.color.text_dark_dimmed
|
||||
background.setBackgroundColor(rowColor.toAppColor())
|
||||
status.setBackgroundColor(tx.status.color.toAppColor())
|
||||
timestamp.text = formatter.format(tx.timestamp)
|
||||
amount.text = String.format("$sign %,.3f", tx.amount.round(MathContext(3, RoundingMode.HALF_EVEN )).abs())
|
||||
val isHistory = icon != null
|
||||
val sign = if (tx.isSend) "-" else "+"
|
||||
val amountColor = if (tx.isSend) R.color.text_dark_dimmed else R.color.colorPrimary
|
||||
val transactionColor = if (tx.isSend) R.color.send_associated else R.color.receive_associated
|
||||
val transactionIcon = if (tx.isSend) R.drawable.ic_sent_transaction else R.drawable.ic_received_transaction
|
||||
val zecAbsoluteValue = tx.value.absoluteValue.convertZatoshiToZec(6)
|
||||
val toOrFrom = if (tx.isSend) "to" else "from"
|
||||
val srcOrDestination = tx.address?.truncate() ?: "shielded mystery person"
|
||||
timestamp.text = if (!tx.isMined || tx.timeInSeconds == 0L) "Pending"
|
||||
else (if (isHistory) formatter.format(tx.timeInSeconds * 1000) else (tx.timeInSeconds * 1000L).toRelativeTimeString())
|
||||
amount.text = "$sign$zecAbsoluteValue"
|
||||
amount.setTextColor(amountColor.toAppColor())
|
||||
|
||||
// maybes - and if this gets to be too much, then pass in a custom holder when constructing the adapter, instead
|
||||
status?.setBackgroundColor(transactionColor.toAppColor())
|
||||
address?.text = "$toOrFrom $srcOrDestination"
|
||||
icon?.setImageResource(transactionIcon)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,56 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import cash.z.android.wallet.BuildConfig
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentAboutBinding
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
class AboutFragment : PlaceholderFragment()
|
||||
|
||||
class AboutFragment : BaseFragment() {
|
||||
lateinit var binding: cash.z.android.wallet.databinding.FragmentAboutBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return DataBindingUtil
|
||||
.inflate<FragmentAboutBinding>(inflater, R.layout.fragment_about, container, false)
|
||||
.also { binding = it }
|
||||
.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.textAboutVersionValue.text = BuildConfig.VERSION_NAME
|
||||
binding.textAboutLicensingValue.setOnClickListener {
|
||||
openUrl("https://z.cash/trademark-policy/")
|
||||
}
|
||||
binding.textAboutWhatsNewValue.setOnClickListener {
|
||||
openUrl("https://github.com/gmale/zcash-android-wallet-poc/blob/feature/scan-blocks-integration/CHANGELOG.md")
|
||||
}
|
||||
binding.textAboutZcashBlogValue.setOnClickListener {
|
||||
openUrl("https://z.cash/blog/")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(url: String) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(true)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@Module
|
||||
abstract class AboutFragmentModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeAboutFragment(): AboutFragment
|
||||
}
|
|
@ -1,24 +1,32 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.Fragment
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.support.AndroidSupportInjection
|
||||
import dagger.android.support.HasSupportFragmentInjector
|
||||
import javax.inject.Inject
|
||||
import android.os.Bundle
|
||||
import cash.z.android.wallet.ui.activity.MainActivity
|
||||
import dagger.android.support.DaggerFragment
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class BaseFragment : Fragment(), HasSupportFragmentInjector {
|
||||
abstract class BaseFragment : DaggerFragment(), CoroutineScope {
|
||||
|
||||
@Inject
|
||||
internal lateinit var childFragmentInjector: DispatchingAndroidInjector<Fragment>
|
||||
private lateinit var job: Job
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
AndroidSupportInjection.inject(this)
|
||||
super.onAttach(context)
|
||||
val mainActivity: MainActivity? get() = activity as MainActivity?
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = job + Dispatchers.Main
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
job = Job()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun supportFragmentInjector(): AndroidInjector<Fragment>? {
|
||||
return childFragmentInjector
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.transition.Transition
|
||||
import androidx.transition.TransitionInflater
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentFirstrunBinding
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
class FirstrunFragment : ProgressFragment(R.id.progress_firstrun), Transition.TransitionListener {
|
||||
|
||||
private lateinit var binding: FragmentFirstrunBinding
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
setupSharedElementTransitions()
|
||||
return DataBindingUtil.inflate<FragmentFirstrunBinding>(
|
||||
inflater, R.layout.fragment_firstrun, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
postponeEnterTransition()
|
||||
binding.buttonNext.setOnClickListener {
|
||||
mainActivity?.navController?.navigate(
|
||||
R.id.action_firstrun_fragment_to_sync_fragment,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
(view?.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.setDrawerLocked(true)
|
||||
mainActivity?.setToolbarShown(false)
|
||||
}
|
||||
|
||||
private fun setupSharedElementTransitions() {
|
||||
TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
|
||||
duration = 250L
|
||||
addListener(this@FirstrunFragment)
|
||||
this@FirstrunFragment.sharedElementEnterTransition = this
|
||||
this@FirstrunFragment.sharedElementReturnTransition = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun showProgress(progress: Int) {
|
||||
super.showProgress(progress)
|
||||
binding.textProgressFirstrun.text = getProgressText(progress)
|
||||
|
||||
}
|
||||
|
||||
override fun onProgressComplete() {
|
||||
super.onProgressComplete()
|
||||
binding.textProgressFirstrun.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onTransitionStart(transition: Transition) {
|
||||
binding.buttonNext.alpha = 0f
|
||||
}
|
||||
|
||||
override fun onTransitionEnd(transition: Transition) {
|
||||
binding.buttonNext.animate().apply {
|
||||
duration = 300L
|
||||
}.alpha(1.0f)
|
||||
binding.textProgressFirstrun.animate().apply {
|
||||
duration = 300L
|
||||
}.alpha(1.0f)
|
||||
}
|
||||
|
||||
override fun onTransitionResume(transition: Transition) {}
|
||||
override fun onTransitionPause(transition: Transition) {}
|
||||
override fun onTransitionCancel(transition: Transition) {}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class FirstrunFragmentModule {
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeFirstrunFragment(): FirstrunFragment
|
||||
}
|
|
@ -1,4 +1,76 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentHistoryBinding
|
||||
import cash.z.android.wallet.ui.adapter.TransactionAdapter
|
||||
import cash.z.android.wallet.ui.presenter.HistoryPresenter
|
||||
import cash.z.android.wallet.ui.presenter.HistoryPresenterModule
|
||||
import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class HistoryFragment : PlaceholderFragment()
|
||||
|
||||
class HistoryFragment : BaseFragment(), HistoryPresenter.HistoryView {
|
||||
|
||||
@Inject
|
||||
lateinit var historyPresenter: HistoryPresenter
|
||||
private lateinit var binding: FragmentHistoryBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return DataBindingUtil
|
||||
.inflate<FragmentHistoryBinding>(inflater, R.layout.fragment_history, container, false)
|
||||
.also { binding = it }
|
||||
.root
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
if (mainActivity != null) {
|
||||
mainActivity?.setToolbarShown(true)
|
||||
binding.recyclerTransactionsHistory.apply {
|
||||
layoutManager = LinearLayoutManager(mainActivity, RecyclerView.VERTICAL, false)
|
||||
adapter = TransactionAdapter(R.layout.item_transaction_history)
|
||||
addItemDecoration(AlternatingRowColorDecoration())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launch {
|
||||
historyPresenter.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
historyPresenter.stop()
|
||||
}
|
||||
|
||||
override fun setTransactions(transactions: List<WalletTransaction>) {
|
||||
mainActivity?.supportActionBar?.title = resources.getQuantityString(R.plurals.history_transaction_count_title,
|
||||
transactions.size, transactions.size)
|
||||
with (binding.recyclerTransactionsHistory) {
|
||||
(adapter as TransactionAdapter).submitList(transactions)
|
||||
postDelayed({
|
||||
smoothScrollToPosition(0)
|
||||
}, 100L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class HistoryFragmentModule {
|
||||
@ContributesAndroidInjector(modules = [HistoryPresenterModule::class])
|
||||
abstract fun contributeHistoryFragment(): HistoryFragment
|
||||
}
|
|
@ -1,99 +1,406 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import androidx.transition.Transition
|
||||
import androidx.transition.TransitionInflater
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.extention.toAppColor
|
||||
import cash.z.android.wallet.extention.toAppString
|
||||
import cash.z.android.wallet.extention.tryIgnore
|
||||
import cash.z.android.wallet.ui.activity.MainActivity
|
||||
import cash.z.android.wallet.databinding.FragmentHomeBinding
|
||||
import cash.z.android.wallet.di.annotation.FragmentScope
|
||||
import cash.z.android.wallet.extention.*
|
||||
import cash.z.android.wallet.sample.SampleProperties
|
||||
import cash.z.android.wallet.ui.adapter.TransactionAdapter
|
||||
import cash.z.android.wallet.ui.presenter.HomePresenter
|
||||
import cash.z.android.wallet.ui.presenter.HomePresenterModule
|
||||
import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration
|
||||
import cash.z.android.wallet.ui.util.LottieLooper
|
||||
import cash.z.android.wallet.ui.util.TopAlignedSpan
|
||||
import cash.z.android.wallet.vo.WalletTransaction
|
||||
import cash.z.android.wallet.vo.WalletTransactionStatus
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import cash.z.wallet.sdk.data.ActiveSendTransaction
|
||||
import cash.z.wallet.sdk.data.ActiveTransaction
|
||||
import cash.z.wallet.sdk.data.TransactionState
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import cash.z.wallet.sdk.ext.*
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import kotlinx.android.synthetic.main.include_home_content.*
|
||||
import kotlinx.android.synthetic.main.include_home_header.*
|
||||
import java.math.BigDecimal
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextLong
|
||||
|
||||
|
||||
/**
|
||||
* Fragment representing the home screen of the app. This is the screen most often seen by the user when launching the
|
||||
* application.
|
||||
*/
|
||||
class HomeFragment : BaseFragment() {
|
||||
class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomePresenter.HomeView {
|
||||
|
||||
@Inject
|
||||
lateinit var homePresenter: HomePresenter
|
||||
|
||||
private lateinit var binding: FragmentHomeBinding
|
||||
private lateinit var zcashLogoAnimation: LottieLooper
|
||||
private var maxTransactionsShown: Int = 12
|
||||
private var snackbar: Snackbar? = null
|
||||
private var viewsInitialized = false
|
||||
|
||||
//testing this
|
||||
private var clock: Handler = Handler()
|
||||
private val tickIfNeeded = Ticker()
|
||||
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_home, container, false)
|
||||
viewsInitialized = false
|
||||
// setupSharedElementTransitions()
|
||||
return DataBindingUtil.inflate<FragmentHomeBinding>(
|
||||
inflater, R.layout.fragment_home, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSharedElementTransitions() {
|
||||
TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
|
||||
duration = 3000L
|
||||
addListener(HomeTransitionListener())
|
||||
this@HomeFragment.sharedElementEnterTransition = this
|
||||
this@HomeFragment.sharedElementReturnTransition = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as MainActivity).let { mainActivity ->
|
||||
mainActivity.setSupportActionBar(home_toolbar)
|
||||
mainActivity.setupNavigation()
|
||||
mainActivity.supportActionBar?.setTitle(R.string.destination_title_home)
|
||||
initTemp()
|
||||
init()
|
||||
}
|
||||
headerFullViews = arrayOf(text_balance_usd, text_balance_includes_info, text_balance_zec, image_zec_symbol_balance_shadow, image_zec_symbol_balance)
|
||||
headerEmptyViews = arrayOf(text_balance_zec_info, text_balance_zec_empty, image_zec_symbol_balance_shadow_empty, image_zec_symbol_balance_empty)
|
||||
|
||||
// TODO: remove this test behavior
|
||||
image_logo.setOnClickListener {
|
||||
toggle(!empty)
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(false)
|
||||
mainActivity?.setDrawerLocked(false)
|
||||
initFab()
|
||||
|
||||
binding.includeContent.recyclerTransactions.apply {
|
||||
layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
|
||||
adapter = TransactionAdapter()
|
||||
addItemDecoration(AlternatingRowColorDecoration())
|
||||
}
|
||||
binding.includeContent.textTransactionHeaderSeeAll.setOnClickListener {
|
||||
mainActivity?.navController?.navigate(R.id.nav_history_fragment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
view!!.postDelayed( {toggle(false)}, delay * 2L)
|
||||
launch {
|
||||
homePresenter.start()
|
||||
}
|
||||
clock.postDelayed(tickIfNeeded, 1000L)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
initFab(activity!!)
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
clock.removeCallbacks(tickIfNeeded)
|
||||
homePresenter.stop()
|
||||
binding.lottieZcashBadge.cancelAnimation()
|
||||
}
|
||||
|
||||
recycler_transactions.layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
|
||||
recycler_transactions.adapter = TransactionAdapter(createDummyTransactions(60))
|
||||
|
||||
//
|
||||
// SendView Implementation
|
||||
//
|
||||
|
||||
override fun setTransactions(transactions: List<WalletTransaction>) {
|
||||
val recent = if(transactions.size > maxTransactionsShown) transactions.subList(0, maxTransactionsShown) else transactions
|
||||
with (binding.includeContent.recyclerTransactions) {
|
||||
(adapter as TransactionAdapter).submitList(recent)
|
||||
postDelayed({
|
||||
smoothScrollToPosition(0)
|
||||
}, 100L)
|
||||
}
|
||||
// Show "See All" when we have a sublist on screen
|
||||
if (recent.size != transactions.size) {
|
||||
binding.includeContent.textTransactionHeaderSeeAll.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
onContentRefreshComplete(transactions.isEmpty())
|
||||
}
|
||||
|
||||
//TODO: pull some of this logic into the presenter, particularly the part that deals with ZEC <-> USD price conversion
|
||||
override fun updateBalance(old: Long, new: Long) {
|
||||
val zecValue = new.convertZatoshiToZec()
|
||||
setZecValue(zecValue.toZecString(3))
|
||||
setUsdValue(zecValue.convertZecToUsd(SampleProperties.USD_PER_ZEC).toUsdString())
|
||||
|
||||
onContentRefreshComplete(new <= 0)
|
||||
}
|
||||
|
||||
override fun setActiveTransactions(activeTransactionMap: Map<ActiveTransaction, TransactionState>) {
|
||||
if (activeTransactionMap.isEmpty()) {
|
||||
twig("A.T.: setActiveTransactionsShown(false) because map is empty")
|
||||
setActiveTransactionsShown(false)
|
||||
return
|
||||
}
|
||||
|
||||
val transactions = activeTransactionMap.entries.toTypedArray()
|
||||
// primary is the last one that was inserted
|
||||
val primaryEntry = transactions[transactions.size - 1]
|
||||
updatePrimaryTransaction(primaryEntry.key, primaryEntry.value)
|
||||
|
||||
onContentRefreshComplete(false)
|
||||
}
|
||||
|
||||
override fun onCancelledTooLate() {
|
||||
snackbar = snackbar.showOk(view!!, "Oops! It was too late to cancel!")
|
||||
}
|
||||
|
||||
override fun onSynchronizerError(error: Throwable?): Boolean {
|
||||
context?.alert(
|
||||
message = "WARNING: A critical error has occurred and " +
|
||||
"this app will not function properly until that is corrected!",
|
||||
positiveButtonResId = R.string.ignore,
|
||||
negativeButtonResId = R.string.details,
|
||||
negativeAction = { context?.alert("Synchronization error:\n\n$error") }
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
//
|
||||
// View API
|
||||
//
|
||||
|
||||
fun setContentViewShown(isShown: Boolean) {
|
||||
// with(binding.includeContent) {
|
||||
// groupEmptyViewItems.visibility = if (isShown) View.GONE else View.VISIBLE
|
||||
// groupContentViewItems.visibility = if (isShown) View.VISIBLE else View.GONE
|
||||
// }
|
||||
toggleViews(!isShown)
|
||||
}
|
||||
|
||||
private val stopAnimation = Runnable {
|
||||
setRefreshAnimationPlaying(false).also { twig("refresh false from onRefresh") }
|
||||
}
|
||||
override fun onRefresh() {
|
||||
setRefreshAnimationPlaying(true).also { twig("refresh true from onRefresh") }
|
||||
|
||||
with(binding.includeContent.refreshLayout) {
|
||||
isRefreshing = false
|
||||
val fauxRefresh = Random.nextLong(750L..3000L)
|
||||
postDelayed(stopAnimation, fauxRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun setRefreshAnimationPlaying(isPlaying: Boolean) {
|
||||
twig("set refresh to: $isPlaying for $zcashLogoAnimation")
|
||||
if (isPlaying) {
|
||||
zcashLogoAnimation.start()
|
||||
} else {
|
||||
zcashLogoAnimation.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInitialLoadComplete() {
|
||||
val isEmpty = (binding.includeContent.recyclerTransactions?.adapter?.itemCount ?: 0).let { it == 0 }
|
||||
twig("onInitialLoadComplete and isEmpty == $isEmpty")
|
||||
setContentViewShown(!isEmpty)
|
||||
if (isEmpty) {
|
||||
binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet)
|
||||
}
|
||||
setRefreshAnimationPlaying(false).also { twig("refresh false from onInitialLoadComplete") }
|
||||
}
|
||||
|
||||
private fun updatePrimaryTransaction(transaction: ActiveTransaction, transactionState: TransactionState) {
|
||||
|
||||
twig("setting transaction state to ${transactionState::class.simpleName}")
|
||||
var title = binding.includeContent.textActiveTransactionTitle.text?.toString() ?: ""
|
||||
var subtitle: CharSequence = binding.includeContent.textActiveTransactionSubtitle.text?.toString() ?: ""
|
||||
var isShown = binding.includeContent.textActiveTransactionHeader.visibility == View.VISIBLE
|
||||
var isShownDelay = 10L
|
||||
when (transactionState) {
|
||||
TransactionState.Creating -> {
|
||||
binding.includeContent.headerActiveTransaction.visibility = View.VISIBLE
|
||||
title = "Preparing ${transaction.value.convertZatoshiToZecString(3)} ZEC"
|
||||
subtitle = "to ${(transaction as ActiveSendTransaction).toAddress.truncate()}"
|
||||
setTransactionActive(transaction, true)
|
||||
isShown = true
|
||||
}
|
||||
TransactionState.SendingToNetwork -> {
|
||||
title = "Sending Transaction"
|
||||
subtitle = "to ${(transaction as ActiveSendTransaction).toAddress.truncate()}"
|
||||
binding.includeContent.textActiveTransactionValue.text = "${transaction.value.convertZatoshiToZecString(3)}"
|
||||
binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE
|
||||
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
|
||||
setTransactionActive(transaction, true)
|
||||
isShown = true
|
||||
}
|
||||
is TransactionState.Failure -> {
|
||||
binding.includeContent.lottieActiveTransaction.setAnimation(R.raw.lottie_send_failure)
|
||||
binding.includeContent.lottieActiveTransaction.playAnimation()
|
||||
title = "Failed"
|
||||
subtitle = when(transactionState.failedStep) {
|
||||
TransactionState.Creating -> "Failed to create transaction"
|
||||
TransactionState.SendingToNetwork -> "Failed to submit transaction to the network"
|
||||
else -> "Unrecoginzed error"
|
||||
}
|
||||
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
|
||||
binding.includeContent.textActiveTransactionValue.visibility = View.GONE
|
||||
setTransactionActive(transaction, false)
|
||||
isShown = false
|
||||
isShownDelay = 10_000L
|
||||
}
|
||||
is TransactionState.AwaitingConfirmations -> {
|
||||
if (transactionState.confirmationCount < 1) {
|
||||
binding.includeContent.lottieActiveTransaction.setAnimation(R.raw.lottie_send_success)
|
||||
binding.includeContent.lottieActiveTransaction.playAnimation()
|
||||
title = "ZEC Sent"
|
||||
subtitle = "Waiting to be mined..."
|
||||
binding.includeContent.textActiveTransactionValue.text = transaction.value.convertZatoshiToZecString(3)
|
||||
binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE
|
||||
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
|
||||
isShown = true
|
||||
} else if (transactionState.confirmationCount > 1) {
|
||||
isShown = false
|
||||
} else {
|
||||
title = "Confirmation Received"
|
||||
subtitle = transactionState.timestamp.toRelativeTimeString()
|
||||
isShown = false
|
||||
isShownDelay = 5_000L
|
||||
// take it out of the list in a bit and skip counting confirmation animation for now (i.e. one is enough)
|
||||
}
|
||||
}
|
||||
is TransactionState.Cancelled -> {
|
||||
title = binding.includeContent.textActiveTransactionTitle.text.toString()
|
||||
subtitle = binding.includeContent.textActiveTransactionSubtitle.text.toString()
|
||||
setTransactionActive(transaction, false)
|
||||
isShown = false
|
||||
isShownDelay = 10_000L
|
||||
}
|
||||
else -> {
|
||||
Log.e(javaClass.simpleName, "Warning: unrecognized transaction state $transactionState is being ignored")
|
||||
return
|
||||
}
|
||||
}
|
||||
binding.includeContent.textActiveTransactionTitle.text = title
|
||||
binding.includeContent.textActiveTransactionSubtitle.text = subtitle
|
||||
twig("A.T.: setActiveTransactionsShown($isShown, $isShownDelay) because ${transactionState}")
|
||||
setActiveTransactionsShown(isShown, isShownDelay)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Internal View Logic
|
||||
//
|
||||
|
||||
private fun setActiveTransactionsShown(isShown: Boolean, delay: Long = 0L) {
|
||||
binding.includeContent.headerActiveTransaction.postDelayed({
|
||||
binding.includeContent.groupActiveTransactionItems.visibility = if (isShown) View.VISIBLE else View.GONE
|
||||
// do not animate if visibility is already in the right state
|
||||
// binding.includeContent.headerActiveTransaction.animate().alpha(if(isShown) 1f else 0f).setDuration(250).setListener(
|
||||
// AnimatorCompleteListener{ }
|
||||
// )
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Fab button and all its action items
|
||||
*
|
||||
* @param activity a helper parameter that forces this method to be called after the activity is created and not null
|
||||
* General initialization called during onViewCreated. Mostly responsible for applying the default empty state of
|
||||
* the view, before any data or information is known.
|
||||
*/
|
||||
private fun initFab(activity: Activity) {
|
||||
val speedDial = sd_fab
|
||||
val nav = (activity as MainActivity).navController
|
||||
private fun init() {
|
||||
zcashLogoAnimation = LottieLooper(binding.lottieZcashBadge, 20..47, 69)
|
||||
binding.includeContent.buttonActiveTransactionCancel.setOnClickListener {
|
||||
val transaction = it.tag as? ActiveSendTransaction
|
||||
if (transaction != null) {
|
||||
homePresenter.onCancelActiveTransaction(transaction)
|
||||
} else {
|
||||
Toaster.short("Error: unable to find transaction to cancel!")
|
||||
}
|
||||
}
|
||||
binding.lottieZcashBadge.setOnClickListener {
|
||||
binding.lottieZcashBadge.playAnimation()
|
||||
}
|
||||
|
||||
binding.includeContent.refreshLayout.setProgressViewEndTarget(false, (38f * resources.displayMetrics.density).toInt())
|
||||
|
||||
with(binding.includeContent.refreshLayout) {
|
||||
setOnRefreshListener(this@HomeFragment)
|
||||
setColorSchemeColors(R.color.zcashBlack.toAppColor())
|
||||
setProgressBackgroundColorSchemeColor(R.color.zcashYellow.toAppColor())
|
||||
}
|
||||
maxTransactionsShown = calculateMaxTransactions()
|
||||
|
||||
// hide content
|
||||
setContentViewShown(false)
|
||||
binding.includeContent.textEmptyWalletMessage.setText(R.string.home_empty_wallet_updating)
|
||||
setRefreshAnimationPlaying(true).also { twig("refresh true from init") }
|
||||
}
|
||||
|
||||
private fun calculateMaxTransactions(): Int {
|
||||
return 12 //TODO: measure the screen and get optimal number for this device
|
||||
}
|
||||
|
||||
// initialize the stuff that is temporary and needs to go ASAP
|
||||
private fun initTemp() {
|
||||
|
||||
with(binding.includeHeader) {
|
||||
headerFullViews = arrayOf(textBalanceUsd, textBalanceIncludesInfo, textBalanceZec, imageZecSymbolBalanceShadow, imageZecSymbolBalance)
|
||||
headerEmptyViews = arrayOf(textBalanceZecInfo, textBalanceZecEmpty, imageZecSymbolBalanceShadowEmpty, imageZecSymbolBalanceEmpty)
|
||||
headerFullViews.forEach { containerHomeHeader.removeView(it) }
|
||||
headerEmptyViews.forEach { containerHomeHeader.removeView(it) }
|
||||
binding.includeHeader.containerHomeHeader.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
// toggling determines visibility. hide it all.
|
||||
binding.includeContent.groupEmptyViewItems.visibility = View.GONE
|
||||
binding.includeContent.groupContentViewItems.visibility = View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Fab button and all its action items. Should be called during onActivityCreated.
|
||||
*/
|
||||
private fun initFab() {
|
||||
val speedDial = binding.sdFab
|
||||
val nav = mainActivity?.navController
|
||||
|
||||
HomeFab.values().forEach {
|
||||
speedDial.addActionItem(it.createItem())
|
||||
}
|
||||
|
||||
speedDial.setOnActionSelectedListener { item ->
|
||||
HomeFab.fromId(item.id)?.destination?.apply { nav.navigate(this) }
|
||||
HomeFab.fromId(item.id)?.destination?.apply { nav?.navigate(this) }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for creating fablets--those little buttons that pop up when the fab is tapped.
|
||||
*/
|
||||
private val createItem: HomeFab.() -> SpeedDialActionItem = {
|
||||
SpeedDialActionItem.Builder(id, icon)
|
||||
.setFabBackgroundColor(bgColor.toAppColor())
|
||||
|
@ -103,24 +410,103 @@ class HomeFragment : BaseFragment() {
|
|||
.create()
|
||||
}
|
||||
|
||||
fun setUsdValue(value: Double) {
|
||||
val valueString = String.format("$ %,.2f",value)
|
||||
val hairSpace = "\u200A"
|
||||
private fun setUsdValue(value: String) {
|
||||
val valueString = String.format("$ $value")
|
||||
// val hairSpace = "\u200A"
|
||||
// val adjustedValue = "$$hairSpace$valueString"
|
||||
val textSpan = SpannableString(valueString)
|
||||
textSpan.setSpan(TopAlignedSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
textSpan.setSpan(TopAlignedSpan(), valueString.length - 3, valueString.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
text_balance_usd.text = textSpan
|
||||
binding.includeHeader.textBalanceUsd.text = textSpan
|
||||
}
|
||||
|
||||
fun setZecValue(value: Double) {
|
||||
text_balance_zec.text = if(value == 0.0) "0" else String.format("%.3f",value)
|
||||
private fun setZecValue(value: String) {
|
||||
binding.includeHeader.textBalanceZec.text = value
|
||||
|
||||
|
||||
// // bugfix: there is a bug in motionlayout that causes text to flicker as it is resized because the last character doesn't fit. Padding both sides with a thin space works around this bug.
|
||||
// val hairSpace = "\u200A"
|
||||
// val adjustedValue = "$hairSpace$valueString$hairSpace"
|
||||
// text_balance_zec.text = adjustedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the content has been refreshed on the screen. When it is time to show and hide things.
|
||||
* If the balance goes to zero, the wallet is now empty so show the empty view.
|
||||
* If the balance changes from zero, the wallet is no longer empty so hide the empty view.
|
||||
* But don't do either of these things if the situation has not changed.
|
||||
*/
|
||||
private fun onContentRefreshComplete(isEmpty: Boolean) {
|
||||
val isAdapterEmpty = (binding.includeContent.recyclerTransactions.adapter?.itemCount ?: 0) == 0
|
||||
val isBalanceZero = binding.includeHeader.textBalanceZec.text.toString() == "0"
|
||||
val isActiveHidden = binding.includeContent.groupActiveTransactionItems.visibility != View.VISIBLE
|
||||
val isActuallyEmpty = isEmpty && isAdapterEmpty && isBalanceZero && isActiveHidden
|
||||
|
||||
// wasEmpty isn't enough info. it must be considered along with whether these views were ever initialized
|
||||
val wasEmpty = binding.includeContent.groupEmptyViewItems.visibility == View.VISIBLE
|
||||
// situation has changed when we weren't initialized but now we have a balance or emptiness has changed
|
||||
val situationHasChanged = !viewsInitialized || (isActuallyEmpty != wasEmpty)
|
||||
|
||||
twig("onContentRefreshComplete called initialized: $viewsInitialized isEmpty: $isActuallyEmpty wasEmpty: $wasEmpty")
|
||||
if (situationHasChanged) {
|
||||
twig("The situation has changed! toggling views!")
|
||||
setContentViewShown(!isActuallyEmpty)
|
||||
}
|
||||
|
||||
setRefreshAnimationPlaying(false).also { twig("refresh false from onContentRefreshComplete") }
|
||||
binding.includeHeader.containerHomeHeader.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun onActiveTransactionTransitionStart() {
|
||||
binding.includeContent.buttonActiveTransactionCancel.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun onActiveTransactionTransitionEnd() {
|
||||
// TODO: investigate if this fix is still required after getting transition animation working again
|
||||
// fixes a bug where the translation gets lost, during animation. As a nice side effect, visually, it makes the view appear to settle in to position
|
||||
binding.includeContent.headerActiveTransaction.translationZ = 10.0f
|
||||
binding.includeContent.buttonActiveTransactionCancel.apply {
|
||||
postDelayed({text = "cancel"}, 50L)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTransactionActive(transaction: ActiveTransaction, isActive: Boolean) {
|
||||
// TODO: get view for transaction, mostly likely keep a sparse array of these or something
|
||||
if (isActive) {
|
||||
binding.includeContent.buttonActiveTransactionCancel.setText(R.string.cancel)
|
||||
binding.includeContent.buttonActiveTransactionCancel.isEnabled = true
|
||||
binding.includeContent.buttonActiveTransactionCancel.tag = transaction
|
||||
binding.includeContent.headerActiveTransaction.animate().apply {
|
||||
translationZ(10f)
|
||||
duration = 200L
|
||||
interpolator = DecelerateInterpolator()
|
||||
}
|
||||
} else {
|
||||
binding.includeContent.buttonActiveTransactionCancel.setText(R.string.cancelled)
|
||||
binding.includeContent.buttonActiveTransactionCancel.isEnabled = false
|
||||
binding.includeContent.buttonActiveTransactionCancel.tag = null
|
||||
binding.includeContent.headerActiveTransaction.animate().apply {
|
||||
translationZ(2f)
|
||||
duration = 300L
|
||||
interpolator = AccelerateInterpolator()
|
||||
}
|
||||
binding.includeContent.lottieActiveTransaction.cancelAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Ticker : Runnable {
|
||||
override fun run() {
|
||||
if (mainActivity == null) return
|
||||
binding.includeContent.recyclerTransactions.apply {
|
||||
if ((adapter?.itemCount ?: 0) > 0) {
|
||||
adapter?.notifyDataSetChanged()
|
||||
}
|
||||
clock.postDelayed(this@Ticker, 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the basic properties of each FAB button for use while initializing the FAB
|
||||
*/
|
||||
|
@ -132,12 +518,12 @@ class HomeFragment : BaseFragment() {
|
|||
@IdRes val destination:Int
|
||||
) {
|
||||
/* ordered by when they need to be added to the speed dial (i.e. reverse display order) */
|
||||
REQUEST(
|
||||
R.id.fab_request,
|
||||
R.drawable.ic_receipt_24dp,
|
||||
HISTORY(
|
||||
R.id.fab_history,
|
||||
R.drawable.ic_history_24dp,
|
||||
R.color.icon_request,
|
||||
R.string.destination_menu_label_request,
|
||||
R.id.nav_request_fragment
|
||||
R.string.destination_menu_label_history,
|
||||
R.id.nav_history_fragment
|
||||
),
|
||||
RECEIVE(
|
||||
R.id.fab_receive,
|
||||
|
@ -160,114 +546,75 @@ class HomeFragment : BaseFragment() {
|
|||
}
|
||||
|
||||
|
||||
//// ---------------------------------------------------------------------------------------------------------------------
|
||||
//// TODO: Delete these test functions
|
||||
//// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// TODO: Delete these test functions
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
var empty = false
|
||||
val delay = 20L
|
||||
val delay = 50L
|
||||
lateinit var headerEmptyViews: Array<View>
|
||||
lateinit var headerFullViews: Array<View>
|
||||
|
||||
fun shrink(): Double {
|
||||
return text_balance_zec.text.toString().trim().toDouble() - Random.nextDouble(5.0)
|
||||
}
|
||||
fun grow(): Double {
|
||||
return text_balance_zec.text.toString().trim().toDouble() + Random.nextDouble(5.0)
|
||||
}
|
||||
fun reduceValue() {
|
||||
shrink().let {
|
||||
if(it < 0) { setZecValue(0.0); toggleViews(empty); forceRedraw() }
|
||||
else view?.postDelayed({
|
||||
setZecValue(it)
|
||||
setUsdValue(it*75.0)
|
||||
reduceValue()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
fun increaseValue(target: Double) {
|
||||
grow().let {
|
||||
if(it > target) { setZecValue(target); setUsdValue(target*75.0); toggleViews(empty) }
|
||||
else view?.postDelayed({
|
||||
setZecValue(it)
|
||||
setUsdValue(it*75.0)
|
||||
increaseValue(target)
|
||||
if (headerFullViews[0].parent == null || headerEmptyViews[0].parent != null) toggleViews(false)
|
||||
forceRedraw()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRedraw() {
|
||||
view?.postDelayed({
|
||||
container_home_header.progress = container_home_header.progress - 0.1f
|
||||
binding.includeHeader.containerHomeHeader.progress = binding.includeHeader.containerHomeHeader.progress - 0.1f
|
||||
}, delay * 2)
|
||||
}
|
||||
internal fun toggle(isEmpty: Boolean) {
|
||||
toggleValues(isEmpty)
|
||||
}
|
||||
|
||||
internal fun toggleViews(isEmpty: Boolean) {
|
||||
if(isEmpty) {
|
||||
view?.postDelayed({
|
||||
group_empty_view_items.visibility = View.VISIBLE
|
||||
group_full_view_items.visibility = View.GONE
|
||||
headerFullViews.forEach { container_home_header.removeView(it) }
|
||||
twig("toggling views to isEmpty == $isEmpty")
|
||||
var action: () -> Unit
|
||||
if (isEmpty) {
|
||||
action = {
|
||||
binding.includeContent.groupEmptyViewItems.visibility = View.VISIBLE
|
||||
binding.includeContent.groupContentViewItems.visibility = View.GONE
|
||||
headerFullViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
|
||||
headerEmptyViews.forEach {
|
||||
tryIgnore {
|
||||
container_home_header.addView(it)
|
||||
binding.includeHeader.containerHomeHeader.addView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
} else {
|
||||
view?.postDelayed({
|
||||
group_empty_view_items.visibility = View.GONE
|
||||
group_full_view_items.visibility = View.VISIBLE
|
||||
headerEmptyViews.forEach { container_home_header.removeView(it) }
|
||||
action = {
|
||||
binding.includeContent.groupEmptyViewItems.visibility = View.GONE
|
||||
binding.includeContent.groupContentViewItems.visibility = View.VISIBLE
|
||||
headerEmptyViews.forEach { binding.includeHeader.containerHomeHeader.removeView(it) }
|
||||
headerFullViews.forEach {
|
||||
tryIgnore {
|
||||
container_home_header.addView(it)
|
||||
binding.includeHeader.containerHomeHeader.addView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
view?.postDelayed({
|
||||
binding.includeHeader.containerHomeHeader.visibility = View.VISIBLE
|
||||
action()
|
||||
viewsInitialized = true
|
||||
}, delay)
|
||||
}
|
||||
// TODO: the motion layout does not begin in the right state for some reason. Debug this later.
|
||||
view?.postDelayed(::forceRedraw, delay * 2)
|
||||
}
|
||||
|
||||
internal fun toggleValues(isEmpty: Boolean) {
|
||||
empty = isEmpty
|
||||
if(empty) {
|
||||
reduceValue()
|
||||
} else {
|
||||
increaseValue(Random.nextDouble(20.0, 100.0))
|
||||
|
||||
inner class HomeTransitionListener : Transition.TransitionListener {
|
||||
override fun onTransitionStart(transition: Transition) {
|
||||
}
|
||||
|
||||
override fun onTransitionEnd(transition: Transition) {
|
||||
}
|
||||
|
||||
override fun onTransitionResume(transition: Transition) {}
|
||||
override fun onTransitionPause(transition: Transition) {}
|
||||
override fun onTransitionCancel(transition: Transition) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class HomeFragmentModule {
|
||||
@ContributesAndroidInjector
|
||||
@FragmentScope
|
||||
@ContributesAndroidInjector(modules = [HomePresenterModule::class])
|
||||
abstract fun contributeHomeFragment(): HomeFragment
|
||||
}
|
||||
|
||||
|
||||
//TODO: delete this test code
|
||||
|
||||
internal fun createDummyTransactions(size: Int): MutableList<WalletTransaction> {
|
||||
val transactions = mutableListOf<WalletTransaction>()
|
||||
repeat(size) {
|
||||
transactions.add(createDummyTransaction())
|
||||
}
|
||||
return transactions
|
||||
}
|
||||
|
||||
internal fun createDummyTransaction(): WalletTransaction {
|
||||
val now = System.currentTimeMillis()
|
||||
val before = now - (4 * DateUtils.WEEK_IN_MILLIS)
|
||||
val amount = BigDecimal(Random.nextDouble(0.1, 15.0) * arrayOf(-1, 1).random())
|
||||
val status = if(amount > BigDecimal.ZERO) WalletTransactionStatus.SENT else WalletTransactionStatus.RECEIVED
|
||||
return WalletTransaction(
|
||||
status,
|
||||
Random.nextLong(before, now),
|
||||
amount
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.IncludeHomeHeaderBinding
|
||||
|
||||
class HomeHeaderEmptyFragment : Fragment() {
|
||||
private lateinit var binding: IncludeHomeHeaderBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return DataBindingUtil
|
||||
.inflate<IncludeHomeHeaderBinding>(inflater, R.layout.include_home_header, parent, false).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
|
||||
class ImportFragment : PlaceholderFragment()
|
|
@ -13,7 +13,7 @@ import cash.z.android.wallet.ui.activity.MainActivity
|
|||
* Fragment for sending Zcash.
|
||||
*
|
||||
*/
|
||||
open class PlaceholderFragment : Fragment() {
|
||||
open class PlaceholderFragment : BaseFragment() {
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
|
@ -23,15 +23,4 @@ open class PlaceholderFragment : Fragment() {
|
|||
return inflater.inflate(R.layout.fragment_placeholder, container, false)
|
||||
}
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as MainActivity).let { mainActivity ->
|
||||
mainActivity.setSupportActionBar(view.findViewById(R.id.toolbar))
|
||||
mainActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
mainActivity.supportActionBar?.setTitle(R.string.destination_title_placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import androidx.annotation.IdRes
|
||||
import cash.z.android.wallet.ui.presenter.ProgressPresenter
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class ProgressFragment(
|
||||
@IdRes private val progressBarId: Int
|
||||
) : BaseFragment(),
|
||||
ProgressPresenter.ProgressView {
|
||||
|
||||
@Inject
|
||||
protected lateinit var synchronizer: Synchronizer
|
||||
|
||||
protected lateinit var progressPresenter: ProgressPresenter
|
||||
private lateinit var progressBar: ProgressBar
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
progressBar = view.findViewById(progressBarId)
|
||||
// progressBar.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
// construct here rather than inject to make scoping easier
|
||||
// so that we reduce the chances of updating progress on the wrong fragment
|
||||
progressPresenter = ProgressPresenter(this, synchronizer)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launch {
|
||||
progressPresenter.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
progressPresenter.stop()
|
||||
}
|
||||
|
||||
override fun showProgress(progress: Int) {
|
||||
if (progress >= 100) {
|
||||
onProgressComplete()
|
||||
// progressBar.animate().(0.0f).apply {
|
||||
// duration = 250L
|
||||
// setListener(AnimatorCompleteListener {
|
||||
progressBar.visibility = View.GONE
|
||||
// })
|
||||
// }
|
||||
} else if (progress > 0 && progressBar.visibility != View.VISIBLE) {
|
||||
progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
progressBar.progress = progress
|
||||
}
|
||||
|
||||
|
||||
// TODO: replace this quick and dirty logic with something permanent
|
||||
open fun getProgressText(progress: Int): String {
|
||||
if (mainActivity == null) return ""
|
||||
// cycle twice
|
||||
val factor = 100 / (mainActivity!!.loadMessages.size * 2)
|
||||
val index = (progress/factor).rem(mainActivity!!.loadMessages.size)
|
||||
var message = "$progress% ${mainActivity?.nextLoadMessage(index)}"
|
||||
if (progress > 98) message = "Done!"
|
||||
if (progress >= 50) message = message.replace("Zooko", "Zooko AGAIN", true).replace("Learning to spell", "Double-checking the spelling of").replace("the kool", "MORE kool", true).replace("Making the sausage", "Getting a little hangry by now!", true)
|
||||
return message
|
||||
}
|
||||
|
||||
open fun onProgressComplete() {}
|
||||
}
|
|
@ -3,6 +3,7 @@ package cash.z.android.wallet.ui.fragment
|
|||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -11,7 +12,9 @@ import cash.z.android.qrecycler.QRecycler
|
|||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.ui.activity.MainActivity
|
||||
import cash.z.android.wallet.ui.util.AddressPartNumberSpan
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.jni.JniConverter
|
||||
import cash.z.wallet.sdk.secure.Wallet
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.android.synthetic.main.fragment_receive.*
|
||||
|
@ -26,7 +29,7 @@ class ReceiveFragment : BaseFragment() {
|
|||
lateinit var qrecycler: QRecycler
|
||||
|
||||
@Inject
|
||||
lateinit var converter: JniConverter
|
||||
lateinit var synchronizer: Synchronizer
|
||||
|
||||
lateinit var addressParts: Array<TextView>
|
||||
|
||||
|
@ -40,11 +43,6 @@ class ReceiveFragment : BaseFragment() {
|
|||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as MainActivity).let { mainActivity ->
|
||||
mainActivity.setSupportActionBar(view.findViewById(R.id.toolbar))
|
||||
mainActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
mainActivity.supportActionBar?.setTitle(R.string.destination_title_receive)
|
||||
}
|
||||
addressParts = arrayOf(
|
||||
text_address_part_1,
|
||||
text_address_part_2,
|
||||
|
@ -56,14 +54,21 @@ class ReceiveFragment : BaseFragment() {
|
|||
)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(true)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// TODO: replace these with channels. For now just wire the logic together
|
||||
onAddressLoaded(loadAddress())
|
||||
// converter.scanBlocks()
|
||||
}
|
||||
|
||||
private fun onAddressLoaded(address: String) {
|
||||
Log.e("TWIG", "onAddressLoaded: $address")
|
||||
qrecycler.load(address)
|
||||
.withQuietZoneSize(3)
|
||||
.withCorrectionLevel(QRecycler.CorrectionLevel.MEDIUM)
|
||||
|
@ -85,7 +90,7 @@ class ReceiveFragment : BaseFragment() {
|
|||
|
||||
// TODO: replace with tiered load. First check memory reference (textview contents?) then check DB, then load from JNI and write to DB
|
||||
private fun loadAddress(): String {
|
||||
return converter.getAddress("dummyseed".toByteArray())
|
||||
return synchronizer.getAddress()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import android.view.ViewGroup
|
|||
import androidx.fragment.app.Fragment
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.ui.activity.MainActivity
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
||||
// TODO: Rename parameter arguments, choose names that match
|
||||
|
@ -25,7 +27,7 @@ private const val ARG_PARAM2 = "param2"
|
|||
* create an instance of this fragment.
|
||||
*
|
||||
*/
|
||||
class RequestFragment : Fragment() {
|
||||
class RequestFragment : BaseFragment() {
|
||||
// TODO: Rename and change types of parameters
|
||||
private var param1: String? = null
|
||||
private var param2: String? = null
|
||||
|
@ -47,12 +49,6 @@ class RequestFragment : Fragment() {
|
|||
return inflater.inflate(R.layout.fragment_request, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as MainActivity).setSupportActionBar(view.findViewById(R.id.toolbar))
|
||||
(activity as MainActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
// TODO: Rename method, update argument and hook method into UI event
|
||||
fun onButtonPressed(uri: Uri) {
|
||||
listener?.onFragmentInteraction(uri)
|
||||
|
@ -67,6 +63,11 @@ class RequestFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(true)
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
listener = null
|
||||
|
@ -108,3 +109,9 @@ class RequestFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class RequestFragmentModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeRequestFragment(): RequestFragment
|
||||
}
|
|
@ -0,0 +1,280 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.media.Image
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewAnimationUtils
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import cash.z.android.cameraview.CameraView
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentScanBinding
|
||||
import cash.z.android.wallet.extention.Toaster
|
||||
import com.google.firebase.ml.vision.FirebaseVision
|
||||
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode
|
||||
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetectorOptions
|
||||
import com.google.firebase.ml.vision.common.FirebaseVisionImage
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
|
||||
/**
|
||||
* Fragment for scanning addresss, hopefully.
|
||||
*/
|
||||
class ScanFragment : BaseFragment() {
|
||||
|
||||
lateinit var binding: FragmentScanBinding
|
||||
var barcodeCallback: BarcodeCallback? = null
|
||||
|
||||
interface BarcodeCallback {
|
||||
fun onBarcodeScanned(value: String)
|
||||
}
|
||||
|
||||
private val revealCamera = Runnable {
|
||||
binding.overlayBarcodeScan.apply {
|
||||
val cX = measuredWidth / 2
|
||||
val cY = measuredHeight / 2
|
||||
ViewAnimationUtils.createCircularReveal(this, cX, cY, 0.0f, cX.toFloat()).start()
|
||||
postDelayed({
|
||||
val v:View = this
|
||||
v.animate().alpha(0.0f).apply { duration = 2400L }.setListener(object : Animator.AnimatorListener {
|
||||
override fun onAnimationRepeat(animation: Animator?) {
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
binding.overlayBarcodeScan.visibility = View.GONE
|
||||
}
|
||||
override fun onAnimationCancel(animation: Animator?) {
|
||||
binding.overlayBarcodeScan.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
},500L)
|
||||
}
|
||||
}
|
||||
|
||||
private val requiredPermissions: Array<String?>
|
||||
get() {
|
||||
return try {
|
||||
val info = mainActivity?.packageManager
|
||||
?.getPackageInfo(mainActivity?.packageName, PackageManager.GET_PERMISSIONS)
|
||||
val ps = info?.requestedPermissions
|
||||
if (ps != null && ps.isNotEmpty()) {
|
||||
ps
|
||||
} else {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return DataBindingUtil.inflate<FragmentScanBinding>(
|
||||
inflater, R.layout.fragment_scan, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// binding.previewCameraSource.doOnLayout {
|
||||
// if (allPermissionsGranted()) {
|
||||
// createCameraSource(it.width, it.height)
|
||||
// } else {
|
||||
// getRuntimePermissions()
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
if(!allPermissionsGranted()) getRuntimePermissions()
|
||||
|
||||
|
||||
// sendPresenter = SendPresenter(this, mainActivity?.synchronizer)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.overlayBarcodeScan.post(revealCamera)
|
||||
System.err.println("camoorah : onResume ScanFragment")
|
||||
if(allPermissionsGranted()) onStartCamera()
|
||||
// launch {
|
||||
// sendPresenter.start()
|
||||
// }
|
||||
// startCameraSource()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
binding.cameraView.stop()
|
||||
super.onPause()
|
||||
// sendPresenter.stop()
|
||||
// binding.previewCameraSource?.stop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// cameraSource?.release()
|
||||
}
|
||||
|
||||
/* Camera */
|
||||
// private fun createCameraSource(width: Int, height: Int) {
|
||||
// Toaster.short("w: $width h: $height")
|
||||
// // If there's no existing cameraSource, create one.
|
||||
// if (cameraSource == null) {
|
||||
// cameraSource = CameraSource(mainActivity, binding.graphicOverlay)
|
||||
// }
|
||||
//
|
||||
// try {
|
||||
// cameraSource?.setMachineLearningFrameProcessor(BarcodeScanningProcessor())
|
||||
// } catch (e: FirebaseMLException) {
|
||||
// Log.e("temporaryBehavior", "can not create camera source")
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Starts or restarts the camera source, if it exists. If the camera source doesn't exist yet
|
||||
* (e.g., because onResume was called before the camera source was created), this will be called
|
||||
* again when the camera source is created.
|
||||
*/
|
||||
// private fun startCameraSource() {
|
||||
// cameraSource?.let {
|
||||
// try {
|
||||
// binding.previewCameraSource?.start(cameraSource!!, binding.graphicOverlay)
|
||||
// } catch (e: IOException) {
|
||||
// Log.e("temporaryBehavior", "Unable to start camera source.", e)
|
||||
// cameraSource?.release()
|
||||
// cameraSource = null
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/* Permissions */
|
||||
|
||||
private fun allPermissionsGranted(): Boolean {
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getRuntimePermissions() {
|
||||
val allNeededPermissions = arrayListOf<String>()
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
allNeededPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allNeededPermissions.isEmpty()) {
|
||||
requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (allPermissionsGranted()) {
|
||||
view!!.postDelayed({
|
||||
onStartCamera()
|
||||
},2000L) // TODO: remove this temp hack to sidestep crash when permissions were not available
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStartCamera() {
|
||||
with(binding.cameraView) {
|
||||
// workaround race conditions with google play services downloading the binaries for Firebase Vision APIs
|
||||
postDelayed({
|
||||
firebaseCallback = PoCallback()
|
||||
start()
|
||||
}, 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
inner class PoCallback : CameraView.FirebaseCallback {
|
||||
val options = FirebaseVisionBarcodeDetectorOptions.Builder()
|
||||
.setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
val barcodeDetector = FirebaseVision.getInstance().getVisionBarcodeDetector(options)
|
||||
var cameraId = getBackCameraId()
|
||||
|
||||
private fun getBackCameraId(): String {
|
||||
val manager = mainActivity?.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
|
||||
for (cameraId in manager.cameraIdList) {
|
||||
val characteristics = manager.getCameraCharacteristics(cameraId)
|
||||
val cOrientation = characteristics.get(CameraCharacteristics.LENS_FACING)!!
|
||||
if (cOrientation == CameraCharacteristics.LENS_FACING_BACK) return cameraId
|
||||
}
|
||||
throw IllegalArgumentException("no rear-facing camera found!")
|
||||
}
|
||||
|
||||
override fun onImageAvailable(image: Image) {
|
||||
if(mainActivity == null) return
|
||||
|
||||
try {
|
||||
System.err.println("camoorah : onImageAvailable: $image width: ${image.width} height: ${image.height}")
|
||||
var firebaseImage =
|
||||
FirebaseVisionImage.fromMediaImage(image, getRotationCompensation(cameraId, mainActivity!!))
|
||||
barcodeDetector
|
||||
.detectInImage(firebaseImage)
|
||||
.addOnSuccessListener { results ->
|
||||
if (results.isNotEmpty()) {
|
||||
val barcode = results[0]
|
||||
val value = barcode.rawValue
|
||||
onScanSuccess(value!!)
|
||||
// TODO: highlight the barcode
|
||||
var bounds = barcode.boundingBox
|
||||
var corners = barcode.cornerPoints
|
||||
binding.cameraView.setBarcode(barcode)
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("camoorah : error while processing onImageAvailable: $t\n\tcaused by: ${t.cause}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingSuccess = false
|
||||
private fun onScanSuccess(value: String) {
|
||||
binding.cameraView.stop()
|
||||
if (!pendingSuccess) {
|
||||
pendingSuccess = true
|
||||
binding.cameraView.post {
|
||||
barcodeCallback?.onBarcodeScanned(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TODO: continue doing permissions here in a more specific, less general way
|
||||
private const val CAMERA_PERMISSION_REQUEST = 1001
|
||||
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class ScanFragmentModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeScanFragment(): ScanFragment
|
||||
}
|
|
@ -1,37 +1,389 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
import cash.z.android.wallet.BuildConfig
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.ui.activity.MainActivity
|
||||
|
||||
import cash.z.android.wallet.databinding.FragmentSendBinding
|
||||
import cash.z.android.wallet.extention.*
|
||||
import cash.z.android.wallet.sample.SampleProperties
|
||||
import cash.z.android.wallet.ui.presenter.SendPresenter
|
||||
import cash.z.android.wallet.ui.presenter.SendPresenterModule
|
||||
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fragment for sending Zcash.
|
||||
*
|
||||
*/
|
||||
class SendFragment : Fragment() {
|
||||
class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.BarcodeCallback {
|
||||
|
||||
private val zec = R.string.zec_abbreviation.toAppString()
|
||||
private val usd = R.string.usd_abbreviation.toAppString()
|
||||
|
||||
@Inject
|
||||
lateinit var sendPresenter: SendPresenter
|
||||
private lateinit var binding: FragmentSendBinding
|
||||
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_send, container, false)
|
||||
return DataBindingUtil.inflate<FragmentSendBinding>(
|
||||
inflater, R.layout.fragment_send, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachFragment(childFragment: Fragment) {
|
||||
super.onAttachFragment(childFragment)
|
||||
(childFragment as? ScanFragment)?.barcodeCallback = this
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as MainActivity).let { mainActivity ->
|
||||
mainActivity.setSupportActionBar(view.findViewById(R.id.toolbar))
|
||||
mainActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
mainActivity.supportActionBar?.setTitle(R.string.destination_title_send)
|
||||
init()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(true)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
launch {
|
||||
sendPresenter.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
sendPresenter.stop()
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// SendView Implementation
|
||||
//
|
||||
|
||||
override fun exit() {
|
||||
mainActivity?.navController?.navigate(R.id.nav_home_fragment)
|
||||
}
|
||||
|
||||
override fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String) {
|
||||
showCurrencySymbols(isUsdSelected)
|
||||
setHeaderValue(headerString)
|
||||
setSubheaderValue(subheaderString, isUsdSelected)
|
||||
}
|
||||
|
||||
override fun setHeaderValue(value: String) {
|
||||
binding.textValueHeader.setText(value)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n") // SetTextI18n lint logic has errors and does not recognize that the entire string contains variables, formatted per locale and loaded from string resources.
|
||||
override fun setSubheaderValue(value: String, isUsdSelected: Boolean) {
|
||||
val subheaderLabel = if (isUsdSelected) zec else usd
|
||||
binding.textValueSubheader.text = "$value $subheaderLabel" //ignore SetTextI18n error here because it is invalid
|
||||
}
|
||||
|
||||
override fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean) {
|
||||
hideKeyboard()
|
||||
setSendEnabled(false) // partially because we need to lower the button elevation
|
||||
binding.dialogTextTitle.text = getString(R.string.send_dialog_title, zecString, zec, usdString)
|
||||
binding.dialogTextAddress.text = toAddress
|
||||
binding.dialogTextMemoIncluded.visibility = if(hasMemo) View.VISIBLE else View.GONE
|
||||
binding.groupDialogSend.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun updateAvailableBalance(new: Long) {
|
||||
// TODO: use a formatted string resource here
|
||||
val availableTextSpan = "${new.convertZatoshiToZecString(8)} $zec Available".toSpannable()
|
||||
availableTextSpan.setSpan(ForegroundColorSpan(R.color.colorPrimary.toAppColor()), availableTextSpan.length - "Available".length, availableTextSpan.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
availableTextSpan.setSpan(StyleSpan(Typeface.BOLD), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
binding.textZecValueAvailable.text = availableTextSpan
|
||||
}
|
||||
|
||||
override fun setSendEnabled(isEnabled: Boolean) {
|
||||
binding.buttonSendZec.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// ScanFragment.BarcodeCallback implemenation
|
||||
//
|
||||
|
||||
override fun onBarcodeScanned(value: String) {
|
||||
exitScanMode()
|
||||
binding.inputZcashAddress.setText(value)
|
||||
sendPresenter.inputAddressUpdated(value)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Internal View Logic
|
||||
//
|
||||
|
||||
/**
|
||||
* Initialize view logic only. Click listeners, text change handlers and tooltips.
|
||||
*/
|
||||
private fun init() {
|
||||
|
||||
/* Init - Text Input */
|
||||
|
||||
binding.textValueHeader.apply {
|
||||
setSelectAllOnFocus(true)
|
||||
afterTextChanged { if (it.isNotEmpty()) sendPresenter.inputHeaderUpdating(it) }
|
||||
doOnDoneOrFocusLost { sendPresenter.inputHeaderUpdated(it) }
|
||||
}
|
||||
|
||||
binding.inputZcashAddress.apply {
|
||||
afterTextChanged { if (it.isNotEmpty()) sendPresenter.inputAddressUpdating(it) }
|
||||
doOnDoneOrFocusLost { sendPresenter.inputAddressUpdated(it) }
|
||||
}
|
||||
|
||||
binding.textAreaMemo.apply {
|
||||
afterTextChanged {
|
||||
if (it.isNotEmpty()) sendPresenter.inputMemoUpdating(it)
|
||||
binding.textMemoCharCount.text = "${text.length} / ${resources.getInteger(R.integer.memo_max_length)}"
|
||||
}
|
||||
doOnDoneOrFocusLost { sendPresenter.inputMemoUpdated(it) }
|
||||
}
|
||||
|
||||
/* Init - Taps */
|
||||
|
||||
binding.imageSwapCurrency.setOnClickListener {
|
||||
// validate the amount before we toggle (or else we lose their uncommitted change)
|
||||
sendPresenter.inputHeaderUpdated(binding.textValueHeader.text.toString())
|
||||
sendPresenter.inputToggleCurrency()
|
||||
}
|
||||
binding.buttonSendZec.setOnClickListener{
|
||||
exitScanMode()
|
||||
sendPresenter.inputSendPressed()
|
||||
}
|
||||
|
||||
// allow background taps to dismiss the keyboard and clear focus
|
||||
binding.contentFragmentSend.setOnClickListener {
|
||||
sendPresenter.invalidate()
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
/* Non-Presenter calls (UI-only logic) */
|
||||
|
||||
binding.imageScanQr.apply {
|
||||
TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_scan_qr))
|
||||
}
|
||||
|
||||
binding.imageAddressShortcut?.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
visibility = View.VISIBLE
|
||||
TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_address_shortcut))
|
||||
setOnClickListener(::onPasteShortcutAddress)
|
||||
} else {
|
||||
visibility = View.GONE
|
||||
}
|
||||
}
|
||||
binding.dialogSendBackground.setOnClickListener { hideSendDialog() }
|
||||
binding.dialogSubmitButton.setOnClickListener { onSendZec() }
|
||||
binding.imageScanQr.setOnClickListener(::onScanQrCode)
|
||||
binding.buttonSendZec.text = getString(R.string.send_button_label, zec)
|
||||
setSendEnabled(false)
|
||||
}
|
||||
|
||||
private fun showCurrencySymbols(isUsdSelected: Boolean) {
|
||||
// visibility has some kind of bug that appears to be related to layout groups. So using alpha instead since our API level is high enough to support that
|
||||
if (isUsdSelected) {
|
||||
binding.textDollarSymbolHeader.alpha = 1.0f
|
||||
binding.imageZecSymbolSubheader.alpha = 1.0f
|
||||
binding.imageZecSymbolHeader.alpha = 0.0f
|
||||
binding.textDollarSymbolSubheader.alpha = 0.0f
|
||||
} else {
|
||||
binding.imageZecSymbolHeader.alpha = 1.0f
|
||||
binding.textDollarSymbolSubheader.alpha = 1.0f
|
||||
binding.textDollarSymbolHeader.alpha = 0.0f
|
||||
binding.imageZecSymbolSubheader.alpha = 0.0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScanQrCode(view: View) {
|
||||
hideKeyboard()
|
||||
val fragment = ScanFragment()
|
||||
val ft = childFragmentManager.beginTransaction()
|
||||
.add(R.id.camera_placeholder, fragment, "camera_fragment")
|
||||
.addToBackStack("camera_fragment_scanning")
|
||||
.commit()
|
||||
|
||||
binding.groupHiddenDuringScan.visibility = View.INVISIBLE
|
||||
binding.buttonCancelScan.apply {
|
||||
visibility = View.VISIBLE
|
||||
animate().alpha(1.0f).apply {
|
||||
duration = 3000L
|
||||
}
|
||||
setOnClickListener {
|
||||
exitScanMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: possibly move this behavior to only live in the debug build. Perhaps with a viewholder that I just delegate to. Then inject the holder in this class with production verstion getting an empty implementation that just hides the icon.
|
||||
private fun onPasteShortcutAddress(view: View) {
|
||||
view.context.alert(R.string.send_alert_shortcut_clicked) {
|
||||
val address = SampleProperties.wallet.defaultSendAddress
|
||||
binding.inputZcashAddress.setText(address)
|
||||
sendPresenter.inputAddressUpdated(address)
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after confirmation dialog is affirmed. Begins the process of actually sending ZEC.
|
||||
*/
|
||||
private fun onSendZec() {
|
||||
setSendEnabled(false)
|
||||
sendPresenter.sendFunds()
|
||||
}
|
||||
|
||||
private fun exitScanMode() {
|
||||
val cameraFragment = childFragmentManager.findFragmentByTag("camera_fragment")
|
||||
if (cameraFragment != null) {
|
||||
val ft = childFragmentManager.beginTransaction()
|
||||
.remove(cameraFragment)
|
||||
.commit()
|
||||
}
|
||||
binding.buttonCancelScan.visibility = View.GONE
|
||||
binding.groupHiddenDuringScan.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
mainActivity?.getSystemService<InputMethodManager>()
|
||||
?.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
checkAllInput()
|
||||
}
|
||||
|
||||
private fun hideSendDialog() {
|
||||
setSendEnabled(true)
|
||||
binding.groupDialogSend.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) {
|
||||
if (mainActivity != null) {
|
||||
DrawableCompat.setTint(
|
||||
binding.inputZcashAddress.background,
|
||||
ContextCompat.getColor(mainActivity!!, colorRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Error handling */
|
||||
|
||||
override fun setAmountError(message: String?) {
|
||||
if (message == null) {
|
||||
binding.textValueError.visibility = View.GONE
|
||||
binding.textValueError.text = null
|
||||
} else {
|
||||
binding.textValueError.text = message
|
||||
binding.textValueError.visibility = View.VISIBLE
|
||||
setSendEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setAddressError(message: String?) {
|
||||
if (message == null) {
|
||||
setAddressLineColor()
|
||||
binding.textAddressError.text = null
|
||||
binding.textAddressError.visibility = View.GONE
|
||||
} else {
|
||||
setAddressLineColor(R.color.zcashRed)
|
||||
binding.textAddressError.text = message
|
||||
binding.textAddressError.visibility = View.VISIBLE
|
||||
setSendEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMemoError(message: String?) {
|
||||
val validColor = R.color.zcashBlack_12.toAppColor()
|
||||
val errorColor = R.color.zcashRed.toAppColor()
|
||||
if (message == null) {
|
||||
binding.dividerMemo.setBackgroundColor(validColor)
|
||||
binding.textMemoCharCount.setTextColor(validColor)
|
||||
binding.textAreaMemo.setTextColor(R.color.text_dark.toAppColor())
|
||||
} else {
|
||||
binding.dividerMemo.setBackgroundColor(errorColor)
|
||||
binding.textMemoCharCount.setTextColor(errorColor)
|
||||
binding.textAreaMemo.setTextColor(errorColor)
|
||||
setSendEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all input. This is essentially the same as extracting a model out of the view and validating it with the
|
||||
* presenter. Basically, this needs to happen anytime something is edited, in order to try and enable Send. Right
|
||||
* now this method is called 1) any time the model is updated with valid input, 2) anytime the keyboard is hidden,
|
||||
* and 3) anytime send is pressed. It also triggers the only logic that can set "requiresValidation" to false.
|
||||
*/
|
||||
override fun checkAllInput(): Boolean {
|
||||
with(binding) {
|
||||
return sendPresenter.inputHeaderUpdated(textValueHeader.text.toString())
|
||||
&& sendPresenter.inputAddressUpdated(inputZcashAddress.text.toString())
|
||||
&& sendPresenter.inputMemoUpdated(textAreaMemo.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO: come back to this test code later and fix the shared element transitions
|
||||
//
|
||||
// fun submitWithSharedElements() {
|
||||
// var extras = with(binding) {
|
||||
// listOf(dialogSendBackground, dialogSendContents, dialogTextTitle, dialogTextAddress)
|
||||
// .map{ it to it.transitionName }
|
||||
// .let { FragmentNavigatorExtras(*it.toTypedArray()) }
|
||||
// }
|
||||
// val extras = FragmentNavigatorExtras(
|
||||
// binding.dialogSendContents to binding.dialogSendContents.transitionName,
|
||||
// binding.dialogTextTitle to getString(R.string.transition_active_transaction_title),
|
||||
// binding.dialogTextAddress to getString(R.string.transition_active_transaction_address),
|
||||
// binding.dialogSendBackground to getString(R.string.transition_active_transaction_background)
|
||||
// )
|
||||
//
|
||||
// mainActivity?.navController.navigate(R.id.nav_home_fragment,
|
||||
// null,
|
||||
// null,
|
||||
// extras)
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class SendFragmentModule {
|
||||
@ContributesAndroidInjector(modules = [SendPresenterModule::class])
|
||||
abstract fun contributeSendFragment(): SendFragment
|
||||
}
|
||||
|
|
|
@ -1,4 +1,118 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentSettingsBinding
|
||||
import cash.z.android.wallet.extention.Toaster
|
||||
import cash.z.android.wallet.extention.alert
|
||||
import cash.z.android.wallet.sample.SampleProperties
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class SettingsFragment : PlaceholderFragment()
|
||||
|
||||
class SettingsFragment : BaseFragment() {
|
||||
|
||||
@Inject
|
||||
lateinit var prefs: SharedPreferences
|
||||
lateinit var binding: FragmentSettingsBinding
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return DataBindingUtil
|
||||
.inflate<FragmentSettingsBinding>(inflater, R.layout.fragment_settings, container, false)
|
||||
.also { binding = it }
|
||||
.root
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.setToolbarShown(false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonResetApp.setOnClickListener {
|
||||
view.context.alert(R.string.settings_alert_reset_app) {
|
||||
Toaster.short("Not Yet Implemented!")
|
||||
mainActivity?.navController?.navigateUp()
|
||||
}
|
||||
}
|
||||
binding.includeToolbar.toolbarApplyOrClose.findViewById<ImageView>(R.id.image_close).apply {
|
||||
setOnClickListener {
|
||||
mainActivity?.navController?.navigateUp()
|
||||
}
|
||||
}
|
||||
binding.includeToolbar.toolbarApplyOrClose.findViewById<ImageView>(R.id.image_apply).apply {
|
||||
setOnClickListener {
|
||||
val userName = binding.spinnerDemoUser.selectedItem.toString()
|
||||
val server = binding.spinnerServers.selectedItem.toString()
|
||||
view.context.alert("Are you sure you want to apply these changes?\n\nUser: $userName\nServer: $server\n\nTHIS WILL EXIT THE APP!") {
|
||||
onApplySettings(userName, server)
|
||||
// TODO: handle this whole reset thing better. For now, just aggressively kill the app. A better
|
||||
// approach is to create a custom scope for the synchronizer and then just manage that like any
|
||||
// other subcomponent. In that scenario, we would simply navigate up from this fragment at this
|
||||
// point (after installing a new synchronizer subcomponent)
|
||||
view.postDelayed({
|
||||
mainActivity?.finish()
|
||||
Thread.sleep(1000L) // if you're going to cut a corner, lean into it! sleep FTW!
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
}, 2000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
binding.spinnerServers.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
setCustomServerUiShown(false)
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val item = binding.spinnerDemoUser.selectedItem.toString()
|
||||
setCustomServerUiShown(item.startsWith("Custom"))
|
||||
}
|
||||
}
|
||||
|
||||
binding.spinnerDemoUser.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
setCustomUserUiShown(false)
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val item = binding.spinnerDemoUser.selectedItem.toString()
|
||||
setCustomUserUiShown(item.startsWith("Custom"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCustomServerUiShown(isShown: Boolean) {
|
||||
if (isShown) Toaster.short("Custom servers are not yet implemented")
|
||||
}
|
||||
|
||||
private fun setCustomUserUiShown(isShown: Boolean) {
|
||||
if (isShown) Toaster.short("Custom users are not yet implemented")
|
||||
}
|
||||
|
||||
private fun onApplySettings(userName: String, server: String) {
|
||||
AlertDialog.Builder(mainActivity!!).setMessage("Changing everything...").show()
|
||||
prefs.edit().apply {
|
||||
putString(SampleProperties.PREFS_SERVER_NAME, server)
|
||||
putString(SampleProperties.PREFS_WALLET_DISPLAY_NAME, userName)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class SettingsFragmentModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeSettingsFragment(): SettingsFragment
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.transition.TransitionInflater
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentSyncBinding
|
||||
import cash.z.android.wallet.extention.alert
|
||||
import cash.z.android.wallet.extention.showOk
|
||||
import cash.z.android.wallet.ui.presenter.Presenter
|
||||
import cash.z.android.wallet.ui.presenter.ProgressPresenter
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
class SyncFragment : ProgressFragment(R.id.progress_sync) {
|
||||
|
||||
|
||||
private lateinit var binding: FragmentSyncBinding
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
setupSharedElementTransitions()
|
||||
return DataBindingUtil.inflate<FragmentSyncBinding>(
|
||||
inflater, R.layout.fragment_sync, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
postponeEnterTransition()
|
||||
binding.buttonNext.setOnClickListener {
|
||||
mainActivity?.navController?.navigate(R.id.action_sync_fragment_to_home_fragment,
|
||||
null,
|
||||
NavOptions.Builder().setPopUpTo(R.id.mobile_navigation, true).build(),
|
||||
null
|
||||
)
|
||||
}
|
||||
binding.progressSync.visibility = View.INVISIBLE
|
||||
binding.textProgressSync.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
(view?.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
synchronizer.onSynchronizerErrorListener = ::onSynchronizerError
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.setDrawerLocked(true)
|
||||
mainActivity?.setToolbarShown(true)
|
||||
}
|
||||
|
||||
private fun setupSharedElementTransitions() {
|
||||
TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
|
||||
duration = 250L
|
||||
// addListener(this@SyncFragment)
|
||||
this@SyncFragment.sharedElementEnterTransition = this
|
||||
this@SyncFragment.sharedElementReturnTransition = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun showProgress(progress: Int) {
|
||||
binding.textProgressSync.text = getProgressText(progress)
|
||||
binding.textProgressSync.visibility = View.VISIBLE
|
||||
super.showProgress(progress)
|
||||
}
|
||||
|
||||
override fun onProgressComplete() {
|
||||
super.onProgressComplete()
|
||||
binding.textProgressSync.visibility = View.GONE
|
||||
with (binding.buttonNext) {
|
||||
isEnabled = true
|
||||
alpha = 0.3f
|
||||
animate().alpha(1.0f).duration = 300L
|
||||
text = "Start"
|
||||
}
|
||||
}
|
||||
|
||||
fun onSynchronizerError(error: Throwable?): Boolean {
|
||||
context?.alert(
|
||||
message = "WARNING: A critical error has occurred and " +
|
||||
"this app will not function properly until that is corrected!",
|
||||
positiveButtonResId = R.string.ignore,
|
||||
negativeButtonResId = R.string.details,
|
||||
negativeAction = { context?.alert("Synchronization error:\n\n$error") }
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class SyncFragmentModule {
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeSyncFragment(): SyncFragment
|
||||
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
package cash.z.android.wallet.ui.fragment
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.navigation.fragment.FragmentNavigatorExtras
|
||||
import androidx.transition.TransitionInflater
|
||||
import cash.z.android.wallet.BuildConfig
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.databinding.FragmentWelcomeBinding
|
||||
import cash.z.android.wallet.sample.SampleProperties
|
||||
import cash.z.android.wallet.sample.WalletConfig
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
|
||||
class WelcomeFragment : ProgressFragment(R.id.progress_welcome) {
|
||||
|
||||
@Inject
|
||||
lateinit var walletConfig: WalletConfig
|
||||
|
||||
@Inject
|
||||
lateinit var prefs: SharedPreferences
|
||||
|
||||
private lateinit var binding: FragmentWelcomeBinding
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
setupSharedElementTransitions()
|
||||
return DataBindingUtil.inflate<FragmentWelcomeBinding>(
|
||||
inflater, R.layout.fragment_welcome, container, false
|
||||
).let {
|
||||
binding = it
|
||||
it.root
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val userName = walletConfig.displayName.substringAfterLast('.').capitalize()
|
||||
val serverName = prefs.getString(SampleProperties.PREFS_SERVER_NAME, "Unknown")
|
||||
val network = if (resources.getBoolean(R.bool.is_testnet)) "Testnet 2.0.1" else "Mainnet 2.0.1"
|
||||
var buildInfo = "PoC v${BuildConfig.VERSION_NAME} $network\n" +
|
||||
"Zcash Company - For demo purposes only\nUser: $userName\nServer: $serverName"
|
||||
binding.textWelcomeBuildInfo.text = buildInfo
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
view!!.postDelayed({
|
||||
launch {
|
||||
onNext()
|
||||
}
|
||||
}, 5000L)
|
||||
|
||||
// this.setExitSharedElementCallback(object : SharedElementCallback() {
|
||||
// override fun onCaptureSharedElementSnapshot(
|
||||
// sharedElement: View,
|
||||
// viewToGlobalMatrix: Matrix,
|
||||
// screenBounds: RectF
|
||||
// ): Parcelable? {
|
||||
// val width = Math.round(screenBounds.width())
|
||||
// val height = Math.round(screenBounds.height())
|
||||
// var bitmap: Bitmap? = null
|
||||
// if (width > 0 && height > 0) {
|
||||
// val matrix = Matrix()
|
||||
// matrix.set(viewToGlobalMatrix)
|
||||
// matrix.postTranslate(screenBounds.left, screenBounds.top)
|
||||
// bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
// val canvas = Canvas(bitmap)
|
||||
// canvas.concat(matrix)
|
||||
// sharedElement.draw(canvas)
|
||||
// }
|
||||
// return bitmap
|
||||
// }
|
||||
// })
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.setDrawerLocked(true)
|
||||
mainActivity?.setToolbarShown(false)
|
||||
}
|
||||
|
||||
private fun setupSharedElementTransitions() {
|
||||
TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
|
||||
duration = 2500L
|
||||
this@WelcomeFragment.sharedElementReturnTransition = this
|
||||
}
|
||||
}
|
||||
private suspend fun onNext() = coroutineScope {
|
||||
if (mainActivity != null) {
|
||||
val isFirstRun = mainActivity!!.synchronizer.isFirstRun()
|
||||
val destination =
|
||||
if (isFirstRun) R.id.action_welcome_fragment_to_firstrun_fragment
|
||||
else R.id.action_welcome_fragment_to_sync_fragment
|
||||
|
||||
// var extras = with(binding) {
|
||||
// listOf(progressWelcome, textProgressWelcome)
|
||||
// .map { it to it.transitionName }
|
||||
// .let { FragmentNavigatorExtras(*it.toTypedArray()) }
|
||||
// }
|
||||
val extras = FragmentNavigatorExtras(
|
||||
binding.progressWelcome to binding.progressWelcome.transitionName
|
||||
)
|
||||
|
||||
mainActivity?.navController?.navigate(
|
||||
destination,
|
||||
null,
|
||||
null,
|
||||
// NavOptions.Builder().setPopUpTo(R.id.mobile_navigation, true).build(),
|
||||
extras
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class WelcomeFragmentModule {
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeWelcomeFragment(): WelcomeFragment
|
||||
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package cash.z.android.wallet.ui.presenter
|
||||
|
||||
import cash.z.android.wallet.di.annotation.FragmentScope
|
||||
import cash.z.android.wallet.ui.fragment.HistoryFragment
|
||||
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class HistoryPresenter @Inject constructor(
|
||||
private val view: HistoryFragment,
|
||||
private var synchronizer: Synchronizer
|
||||
) : Presenter {
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
interface HistoryView : PresenterView {
|
||||
fun setTransactions(transactions: List<WalletTransaction>)
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
job?.cancel()
|
||||
job = Job()
|
||||
twig("historyPresenter starting!")
|
||||
view.launchTransactionBinder(synchronizer.allTransactions())
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
twig("historyPresenter stopping!")
|
||||
job?.cancel()?.also { job = null }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchTransactionBinder(channel: ReceiveChannel<List<WalletTransaction>>) = launch {
|
||||
twig("transaction binder starting!")
|
||||
for (walletTransactionList in channel) {
|
||||
twig("received ${walletTransactionList.size} transactions for presenting")
|
||||
bind(walletTransactionList)
|
||||
}
|
||||
twig("transaction binder exiting!")
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// View Callbacks on Main Thread
|
||||
//
|
||||
|
||||
private fun bind(transactions: List<WalletTransaction>) {
|
||||
twig("binding ${transactions.size} walletTransactions")
|
||||
view.setTransactions(transactions.sortedByDescending {
|
||||
if (!it.isMined && it.isSend) Long.MAX_VALUE else it.timeInSeconds
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class HistoryPresenterModule {
|
||||
@Binds
|
||||
@FragmentScope
|
||||
abstract fun providePresenter(historyPresenter: HistoryPresenter): Presenter
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
package cash.z.android.wallet.ui.presenter
|
||||
|
||||
import cash.z.android.wallet.di.annotation.FragmentScope
|
||||
import cash.z.android.wallet.extention.alert
|
||||
import cash.z.android.wallet.ui.fragment.HomeFragment
|
||||
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
|
||||
import cash.z.wallet.sdk.dao.WalletTransaction
|
||||
import cash.z.wallet.sdk.data.*
|
||||
import cash.z.wallet.sdk.secure.Wallet
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class HomePresenter @Inject constructor(
|
||||
private val view: HomeFragment,
|
||||
private val synchronizer: Synchronizer
|
||||
) : Presenter {
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
interface HomeView : PresenterView {
|
||||
fun setTransactions(transactions: List<WalletTransaction>)
|
||||
fun updateBalance(old: Long, new: Long)
|
||||
fun setActiveTransactions(activeTransactionMap: Map<ActiveTransaction, TransactionState>)
|
||||
fun onCancelledTooLate()
|
||||
fun onSynchronizerError(error: Throwable?): Boolean
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
job?.cancel()
|
||||
job = Job()
|
||||
twig("homePresenter starting! from ${this.hashCode()}")
|
||||
with(view) {
|
||||
launchBalanceBinder(synchronizer.balance())
|
||||
launchTransactionBinder(synchronizer.allTransactions())
|
||||
launchActiveTransactionMonitor(synchronizer.activeTransactions())
|
||||
}
|
||||
synchronizer.onSynchronizerErrorListener = view::onSynchronizerError
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
twig("homePresenter stopping!")
|
||||
job?.cancel()?.also { job = null }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel<Wallet.WalletBalance>) = launch {
|
||||
var old: Long? = null
|
||||
twig("balance binder starting!")
|
||||
for (new in channel) {
|
||||
twig("polled a balance item")
|
||||
bind(old, new.total).also { old = new.total }
|
||||
}
|
||||
twig("balance binder exiting!")
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchTransactionBinder(channel: ReceiveChannel<List<WalletTransaction>>) = launch {
|
||||
twig("transaction binder starting!")
|
||||
for (walletTransactionList in channel) {
|
||||
twig("received ${walletTransactionList.size} transactions for presenting")
|
||||
bind(walletTransactionList)
|
||||
}
|
||||
twig("transaction binder exiting!")
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchActiveTransactionMonitor(channel: ReceiveChannel<Map<ActiveTransaction, TransactionState>>) = launch {
|
||||
twig("active transaction monitor starting!")
|
||||
for (i in channel) {
|
||||
bind(i)
|
||||
}
|
||||
twig("active transaction monitor exiting!")
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// View Callbacks on Main Thread
|
||||
//
|
||||
|
||||
private fun bind(old: Long?, new: Long) {
|
||||
twig("binding balance of $new")
|
||||
view.updateBalance(old ?: 0L, new)
|
||||
}
|
||||
|
||||
|
||||
private fun bind(transactions: List<WalletTransaction>) {
|
||||
twig("binding ${transactions.size} walletTransactions")
|
||||
view.setTransactions(transactions.sortedByDescending {
|
||||
if (!it.isMined && it.isSend) Long.MAX_VALUE else it.timeInSeconds
|
||||
})
|
||||
}
|
||||
|
||||
private fun bind(activeTransactionMap: Map<ActiveTransaction, TransactionState>) {
|
||||
twig("binding a.t. map of size ${activeTransactionMap.size}")
|
||||
if (activeTransactionMap.isNotEmpty()) view.setActiveTransactions(activeTransactionMap)
|
||||
}
|
||||
|
||||
fun onCancelActiveTransaction(transaction: ActiveSendTransaction) {
|
||||
twig("requesting to cancel send for transaction ${transaction.internalId}")
|
||||
val isTooLate = !synchronizer.cancelSend(transaction)
|
||||
if (isTooLate) {
|
||||
view.onCancelledTooLate()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
abstract class HomePresenterModule {
|
||||
@Binds
|
||||
@FragmentScope
|
||||
abstract fun providePresenter(homePresenter: HomePresenter): Presenter
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cash.z.android.wallet.ui.presenter
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
interface Presenter {
|
||||
suspend fun start()
|
||||
fun stop()
|
||||
|
||||
/**
|
||||
* A presenter collaborates with a scoped view. The presenter lives within that scope,
|
||||
* meaning, when the view dies, the presenter dies, too. To achieve this, the presenter's
|
||||
* [start] method should be launched from the view's scope via structured concurrency.
|
||||
*/
|
||||
interface PresenterView : CoroutineScope
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package cash.z.android.wallet.ui.presenter
|
||||
|
||||
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.data.Twig
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class ProgressPresenter @Inject constructor(
|
||||
private val view: ProgressView,
|
||||
private var synchronizer: Synchronizer
|
||||
) : Presenter {
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
interface ProgressView : PresenterView {
|
||||
fun showProgress(progress: Int)
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override suspend fun start() {
|
||||
job?.cancel()
|
||||
job = Job()
|
||||
Twig.sprout("ProgressPresenter")
|
||||
twig("starting")
|
||||
view.launchProgressMonitor(synchronizer.progress())
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Twig.clip("ProgressPresenter")
|
||||
twig("stopping")
|
||||
job?.cancel()?.also { job = null }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.launchProgressMonitor(channel: ReceiveChannel<Int>) = launch {
|
||||
twig("progress monitor starting on thread ${Thread.currentThread().name}!")
|
||||
for (i in channel) {
|
||||
bind(i)
|
||||
}
|
||||
// "receive" and send 100, whenever the channel is closed for send
|
||||
bind(100)
|
||||
twig("progress monitor exiting!")
|
||||
}
|
||||
|
||||
private fun bind(progress: Int) = view.launch {
|
||||
twig("binding progress of $progress on thread ${Thread.currentThread().name}!")
|
||||
view.showProgress(progress)
|
||||
twig("done binding progress of $progress on thread ${Thread.currentThread().name}!")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,379 @@
|
|||
package cash.z.android.wallet.ui.presenter
|
||||
|
||||
import cash.z.android.wallet.R
|
||||
import cash.z.android.wallet.di.annotation.FragmentScope
|
||||
import cash.z.android.wallet.extention.toAppString
|
||||
import cash.z.android.wallet.sample.SampleProperties
|
||||
import cash.z.android.wallet.ui.fragment.SendFragment
|
||||
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
|
||||
import cash.z.wallet.sdk.data.Synchronizer
|
||||
import cash.z.wallet.sdk.data.Twig
|
||||
import cash.z.wallet.sdk.data.twig
|
||||
import cash.z.wallet.sdk.ext.*
|
||||
import cash.z.wallet.sdk.secure.Wallet
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendPresenter @Inject constructor(
|
||||
private val view: SendFragment,
|
||||
private val synchronizer: Synchronizer
|
||||
) : Presenter {
|
||||
|
||||
interface SendView : PresenterView {
|
||||
fun updateAvailableBalance(new: Long)
|
||||
fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String)
|
||||
fun setHeaderValue(usdString: String)
|
||||
fun setSubheaderValue(usdString: String, isUsdSelected: Boolean)
|
||||
fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean)
|
||||
fun exit()
|
||||
|
||||
// error handling
|
||||
fun setAmountError(message: String?)
|
||||
fun setAddressError(message: String?)
|
||||
fun setMemoError(message: String?)
|
||||
fun setSendEnabled(isEnabled: Boolean)
|
||||
fun checkAllInput(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* We require the user to send more than this amount. Right now, we just use the miner's fee as a minimum but other
|
||||
* lower bounds may also be useful for validation.
|
||||
*/
|
||||
private val minersFee = 10_000L
|
||||
private var balanceJob: Job? = null
|
||||
private var requiresValidation = false
|
||||
var sendUiModel = SendUiModel()
|
||||
|
||||
// TODO: find the best set of characters here. Possibly add something to the rust layer to help with this.
|
||||
private val validMemoChars = " \t\n\r.?!,\"':;-_=+@#%*"
|
||||
|
||||
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override suspend fun start() {
|
||||
Twig.sprout("SendPresenter")
|
||||
twig("sendPresenter starting!")
|
||||
// set the currency to zec and update the view, initializing everything to zero
|
||||
inputToggleCurrency()
|
||||
balanceJob?.cancel()
|
||||
balanceJob = Job()
|
||||
balanceJob = view.launchBalanceBinder(synchronizer.balance())
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
twig("sendPresenter stopping!")
|
||||
Twig.clip("SendPresenter")
|
||||
balanceJob?.cancel()?.also { balanceJob = null }
|
||||
}
|
||||
|
||||
fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel<Wallet.WalletBalance>) = launch {
|
||||
twig("send balance binder starting!")
|
||||
for (new in channel) {
|
||||
twig("send polled a balance item")
|
||||
bind(new)
|
||||
}
|
||||
twig("send balance binder exiting!")
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Public API
|
||||
//
|
||||
|
||||
fun sendFunds() {
|
||||
//TODO: prehaps grab the activity scope or let the sycnchronizer have scope and make that function not suspend
|
||||
// also, we need to handle cancellations. So yeah, definitely do this differently
|
||||
GlobalScope.launch {
|
||||
twig("Process: cash.z.android.wallet. checking....")
|
||||
twig("Process: cash.z.android.wallet. is it null??? $sendUiModel")
|
||||
synchronizer.sendToAddress(sendUiModel.zatoshiValue!!, sendUiModel.toAddress)
|
||||
}
|
||||
view.exit()
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// User Input
|
||||
//
|
||||
|
||||
/**
|
||||
* Called when the user has tapped on the button for toggling currency, swapping zec for usd
|
||||
*/
|
||||
fun inputToggleCurrency() {
|
||||
// tricky: this is not really a model update, instead it is a byproduct of using `isUsdSelected` for the
|
||||
// currency instead of strong types. There are several todo's to fix that. if we update the model here then
|
||||
// the UI will think the user took action and display errors prematurely.
|
||||
sendUiModel = sendUiModel.copy(isUsdSelected = !sendUiModel.isUsdSelected)
|
||||
with(sendUiModel) {
|
||||
view.setHeaders(
|
||||
isUsdSelected = isUsdSelected,
|
||||
headerString = if (isUsdSelected) usdValue.toUsdString() else zatoshiValue.convertZatoshiToZecString(),
|
||||
subheaderString = if (isUsdSelected) zatoshiValue.convertZatoshiToZecString() else usdValue.toUsdString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* As the user is typing the header string, update the subheader string. Do not modify our own internal model yet.
|
||||
* Internal model is only modified after [headerUpdated] is called (with valid data).
|
||||
*/
|
||||
fun inputHeaderUpdating(headerValue: String) {
|
||||
headerValue.safelyConvertToBigDecimal()?.let { headerValueAsDecimal ->
|
||||
val subheaderValue = headerValueAsDecimal.convertCurrency(SampleProperties.USD_PER_ZEC, sendUiModel.isUsdSelected)
|
||||
|
||||
// subheader string contains opposite currency of the selected one. so if usd is selected, format the subheader as zec
|
||||
val subheaderString = if(sendUiModel.isUsdSelected) subheaderValue.toZecString() else subheaderValue.toUsdString()
|
||||
|
||||
view.setSubheaderValue(subheaderString, sendUiModel.isUsdSelected)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* As the user updates the address, update the error that gets displayed in real-time
|
||||
*
|
||||
* @param addressValue the address that the user has typed, so far
|
||||
*/
|
||||
fun inputAddressUpdating(addressValue: String) {
|
||||
validateAddress(addressValue, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* As the user updates the memo, update the error that gets displayed in real-time
|
||||
*
|
||||
* @param memoValue the memo that the user has typed, so far
|
||||
*/
|
||||
fun inputMemoUpdating(memoValue: String) {
|
||||
// treat the memo a little differently because it is more likely for the user to go back and edit invalid chars
|
||||
// and we want the send button to be active the moment that happens
|
||||
if(validateMemo(memoValue)) {
|
||||
updateModel(sendUiModel.copy(memo = memoValue))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user has completed their update to the header value, typically on focus change.
|
||||
*
|
||||
* @return true when the given amount is parsable, positive and less than the available amount.
|
||||
*/
|
||||
fun inputHeaderUpdated(amountString: String): Boolean {
|
||||
if (!validateAmount(amountString)) return false
|
||||
|
||||
// either USD or ZEC -- TODO: use strong typing (and polymorphism) instead of isUsdSelected checks
|
||||
val amount = amountString.safelyConvertToBigDecimal()!! // we've already validated this as not null and it's immutable
|
||||
with(sendUiModel) {
|
||||
if (isUsdSelected) {
|
||||
// amount represents USD
|
||||
val headerString = amount.toUsdString()
|
||||
val zatoshiValue = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC).convertZecToZatoshi()
|
||||
val subheaderString = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC).toUsdString()
|
||||
updateModel(sendUiModel.copy(zatoshiValue = zatoshiValue, usdValue = amount))
|
||||
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
|
||||
} else {
|
||||
// amount represents ZEC
|
||||
val headerString = amount.toZecString()
|
||||
val usdValue = amount.convertZecToUsd(SampleProperties.USD_PER_ZEC)
|
||||
val subheaderString = usdValue.toUsdString()
|
||||
updateModel(sendUiModel.copy(zatoshiValue = amount.convertZecToZatoshi(), usdValue = usdValue))
|
||||
twig("calling setHeaders with $headerString $subheaderString")
|
||||
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user has updated the toAddress, typically on focus change.
|
||||
*
|
||||
* @return true when the given address' length and content are valid
|
||||
*/
|
||||
fun inputAddressUpdated(newAddress: String): Boolean {
|
||||
if (!validateAddress(newAddress)) return false
|
||||
updateModel(sendUiModel.copy(toAddress = newAddress))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user has updated the memo field, typically after pressing the 'done' key.
|
||||
*
|
||||
* @return true when the given memo's content does not contain invalid characters
|
||||
*/
|
||||
fun inputMemoUpdated(newMemo: String): Boolean {
|
||||
if (!validateMemo(newMemo)) return false
|
||||
updateModel(sendUiModel.copy(memo = newMemo))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the user has pressed the send button and should be shown a confirmation dialog, next.
|
||||
*
|
||||
* @return true when all input fields contained valid data
|
||||
*/
|
||||
fun inputSendPressed(): Boolean {
|
||||
// double sanity check. Make sure view and model agree and are each valid and if not, highlight the error.
|
||||
if (!view.checkAllInput() || !validateAll()) return false
|
||||
|
||||
with(sendUiModel) {
|
||||
view.showSendDialog(
|
||||
zecString = zatoshiValue.convertZatoshiToZecString(),
|
||||
usdString = usdValue.toUsdString(),
|
||||
toAddress = toAddress,
|
||||
hasMemo = !memo.isBlank()
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun bind(balanceInfo: Wallet.WalletBalance) {
|
||||
val available = balanceInfo.available
|
||||
if (available >= 0) {
|
||||
twig("binding balance of $available")
|
||||
view.updateAvailableBalance(available)
|
||||
updateModel(sendUiModel.copy(availableBalance = available))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateModel(newModel: SendUiModel) {
|
||||
sendUiModel = newModel.apply { hasBeenUpdated = true }
|
||||
// now that we have new data, check and see if we can clear errors and re-enable the send button
|
||||
if (requiresValidation) validateAll()
|
||||
}
|
||||
|
||||
//
|
||||
// Validation
|
||||
//
|
||||
|
||||
/**
|
||||
* Called after any user interaction. This is a potential time that errors should be shown, but only if data has
|
||||
* already been entered. The view should call this method on focus change.
|
||||
*/
|
||||
fun invalidate() {
|
||||
requiresValidation = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given memo, ensuring that it does not contain unsupported characters. For now, we're very
|
||||
* restrictive until we define more clear requirements for the values that can safely be entered in this field
|
||||
* without introducing security risks.
|
||||
*
|
||||
* @param memo the memo to consider for validation
|
||||
*
|
||||
* @return true when the memo contains valid characters, which includes being blank
|
||||
*/
|
||||
private fun validateMemo(memo: String): Boolean {
|
||||
return if (memo.all { it.isLetterOrDigit() || it in validMemoChars }) {
|
||||
view.setMemoError(null)
|
||||
true
|
||||
} else {
|
||||
view.setMemoError("Only letters and numbers are allowed in memo at this time")
|
||||
requiresValidation = true
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given address
|
||||
*
|
||||
* @param toAddress the address to consider for validation
|
||||
* @param ignoreLength whether to ignore the length while validating, this is helpful when the user is still
|
||||
* actively typing the address
|
||||
*/
|
||||
private fun validateAddress(toAddress: String, ignoreLength: Boolean = false): Boolean {
|
||||
// TODO: later expose a method in the synchronizer for validating addresses.
|
||||
// Right now it's not available so we do it ourselves
|
||||
return if (!ignoreLength && sendUiModel.hasBeenUpdated && toAddress.length < 20) {// arbitrary length for now
|
||||
view.setAddressError(R.string.send_error_address_too_short.toAppString())
|
||||
requiresValidation = true
|
||||
false
|
||||
} else if (!toAddress.startsWith("zt") && !toAddress.startsWith("zs")) {
|
||||
view.setAddressError(R.string.send_error_address_invalid_contents.toAppString())
|
||||
requiresValidation = true
|
||||
false
|
||||
} else if (toAddress.any { !it.isLetterOrDigit() }) {
|
||||
view.setAddressError(R.string.send_error_address_invalid_char.toAppString())
|
||||
requiresValidation = true
|
||||
false
|
||||
} else {
|
||||
view.setAddressError(null)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given amount, calling the related `showError` methods on the view, when appropriate
|
||||
*
|
||||
* @param amount the amount to consider for validation, for now this can be either USD or ZEC. In the future we will
|
||||
* will separate those into types.
|
||||
*
|
||||
* @return true when the given amount is valid and all errors have been cleared on the view
|
||||
*/
|
||||
private fun validateAmount(amountString: String): Boolean {
|
||||
if (!sendUiModel.hasBeenUpdated) return true // don't mark zero as bad until the model has been updated
|
||||
|
||||
var amount = amountString.safelyConvertToBigDecimal()
|
||||
// no need to convert when we know it's null
|
||||
return if (amount == null ) {
|
||||
validateZatoshiAmount(null)
|
||||
} else {
|
||||
val zecAmount =
|
||||
if (sendUiModel.isUsdSelected) amount.convertUsdToZec(SampleProperties.USD_PER_ZEC) else amount
|
||||
validateZatoshiAmount(zecAmount.convertZecToZatoshi())
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateZatoshiAmount(zatoshiValue: Long?): Boolean {
|
||||
return if (zatoshiValue == null || zatoshiValue <= minersFee) {
|
||||
view.setAmountError("Please specify a larger amount")
|
||||
requiresValidation = true
|
||||
false
|
||||
} else if (sendUiModel.availableBalance != null
|
||||
&& zatoshiValue >= sendUiModel.availableBalance!!) {
|
||||
view.setAmountError("Exceeds available balance of " +
|
||||
"${sendUiModel.availableBalance.convertZatoshiToZecString(3)}")
|
||||
requiresValidation = true
|
||||
false
|
||||
} else {
|
||||
view.setAmountError(null)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAll(): Boolean {
|
||||
with(sendUiModel) {
|
||||
val isValid = validateZatoshiAmount(zatoshiValue)
|
||||
&& validateAddress(toAddress)
|
||||
&& validateMemo(memo)
|
||||
requiresValidation = !isValid
|
||||
view.setSendEnabled(isValid)
|
||||
return isValid
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class SendUiModel(
|
||||
val availableBalance: Long? = null,
|
||||
var hasBeenUpdated: Boolean = false,
|
||||
val isUsdSelected: Boolean = true,
|
||||
val zatoshiValue: Long? = null,
|
||||
val usdValue: BigDecimal = BigDecimal.ZERO,
|
||||
val toAddress: String = "",
|
||||
val memo: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
abstract class SendPresenterModule {
|
||||
@Binds
|
||||
@FragmentScope
|
||||
abstract fun providePresenter(sendPresenter: SendPresenter): Presenter
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package cash.z.android.wallet.ui.util
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.android.wallet.R
|
||||
|
||||
class AlternatingRowColorDecoration(@DrawableRes val evenBackground: Int = R.color.zcashBlueGray, @DrawableRes val oddBackground: Int = R.color.zcashWhite) :
|
||||
RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
val adapterPosition = parent.getChildAdapterPosition(view)
|
||||
val rowBackground = if (adapterPosition.rem(2) == 0) evenBackground else oddBackground
|
||||
view.setBackgroundResource(rowBackground)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package cash.z.android.wallet.ui.util
|
||||
|
||||
import android.animation.Animator
|
||||
|
||||
class AnimatorCompleteListener(val block: (Animator) -> Unit) : Animator.AnimatorListener {
|
||||
override fun onAnimationRepeat(animation: Animator?) {
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
block(animation)
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
package cash.z.android.wallet.ui.util
|
||||
|
||||
import android.animation.Animator
|
||||
import cash.z.android.wallet.extention.Toaster
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieDrawable
|
||||
|
||||
/**
|
||||
* Utility to help with looping a lottie animation over a particular range. It will start the animation and play it up
|
||||
* to the end of the range and then set it to loop over the range and, once stopped, it will proceed from the current
|
||||
* frame to the end of the animation. Visually: BEGIN...LOOP...LOOP...LOOP...END
|
||||
*/
|
||||
class LottieLooper(private val lottie: LottieAnimationView, private val loopRange: IntRange, private val lastFrame: Int = Int.MAX_VALUE) :
|
||||
Animator.AnimatorListener {
|
||||
|
||||
var isPlaying = false
|
||||
|
||||
fun start() {
|
||||
if (isPlaying) return
|
||||
with(lottie) {
|
||||
setMinAndMaxFrame(1, loopRange.last)
|
||||
progress = 0f
|
||||
repeatCount = 0
|
||||
addAnimatorListener(this@LottieLooper)
|
||||
lottie.post { playAnimation() }
|
||||
}
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
with(lottie) {
|
||||
setMinAndMaxFrame(lottie.frame, lastFrame)
|
||||
repeatCount = 0
|
||||
// we don't want to just cancel the animation. We want it to finish it's final frames but the moment it is
|
||||
// done, we need it to freeze on that final frame and then die
|
||||
addAnimatorListener(LottieAssassin())
|
||||
}
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animator?) {
|
||||
onAnimationEnd(animation)
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
with(lottie) {
|
||||
removeAllAnimatorListeners()
|
||||
setMinAndMaxFrame(loopRange.first, loopRange.last)
|
||||
repeatCount = LottieDrawable.INFINITE
|
||||
playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) {
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
}
|
||||
|
||||
/** I have one job: kill lottie */
|
||||
inner class LottieAssassin : Animator.AnimatorListener {
|
||||
override fun onAnimationRepeat(animation: Animator?) {
|
||||
finishingMove()
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
finishingMove()
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) {
|
||||
finishingMove()
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animator?) {
|
||||
finishingMove()
|
||||
}
|
||||
|
||||
/** Agressively force it to freeze on the lastframe */
|
||||
private fun finishingMove() {
|
||||
lottie.pauseAnimation()
|
||||
lottie.setMinAndMaxFrame(lastFrame, lastFrame)
|
||||
lottie.progress = 1.0f
|
||||
// wait around a bit to see if my listeners detect any movement, then quietly make my getaway
|
||||
lottie.postDelayed({
|
||||
lottie.removeAllAnimatorListeners()
|
||||
// lottie.removeAnimatorListener(this)
|
||||
lottie.setMinAndMaxFrame(1, lastFrame)
|
||||
}, 500L)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (C) 2018 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package cash.z.android.wallet.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
|
||||
class ViewpagerHeader @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : MotionLayout(context, attrs, defStyleAttr), ViewPager.OnPageChangeListener {
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
}
|
||||
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
|
||||
val numPages = 3
|
||||
progress = (position + positionOffset) / (numPages - 1)
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package cash.z.android.wallet.ui.view
|
|||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
|
||||
|
@ -11,6 +12,7 @@ class CollapsingMotionToolbar @JvmOverloads constructor(
|
|||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
progress = -verticalOffset / appBarLayout.totalScrollRange.toFloat()
|
||||
Log.e("MotionL", "progress: $progress verticalOffset: $verticalOffset scrollRange: ${appBarLayout.totalScrollRange.toFloat()}")
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
package cash.z.android.wallet.vo
|
||||
|
||||
import cash.z.android.wallet.R
|
||||
import androidx.annotation.ColorRes
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class WalletTransaction(val status: WalletTransactionStatus, val timestamp: Long, val amount: BigDecimal)
|
||||
|
||||
enum class WalletTransactionStatus(@ColorRes val color: Int) {
|
||||
SENT(R.color.colorPrimary),
|
||||
RECEIVED(R.color.colorAccent);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:state_activated="true">
|
||||
|
||||
<set android:ordering="together">
|
||||
|
||||
<objectAnimator
|
||||
android:propertyName="backgroundColor"
|
||||
android:valueFrom="@color/fragment_home_background"
|
||||
android:valueTo="@color/zcashWhite"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:valueType="colorType" />
|
||||
|
||||
<objectAnimator
|
||||
android:propertyName="translationZ"
|
||||
android:valueTo="4dp"
|
||||
android:startOffset="300"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in" />
|
||||
|
||||
</set>
|
||||
|
||||
</item>
|
||||
|
||||
<item>
|
||||
|
||||
<set android:ordering="together">
|
||||
|
||||
<objectAnimator
|
||||
android:propertyName="backgroundColor"
|
||||
android:valueFrom="@color/zcashWhite"
|
||||
android:valueTo="@color/fragment_home_background"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:valueType="colorType" />
|
||||
|
||||
<objectAnimator
|
||||
android:propertyName="translationZ"
|
||||
android:valueTo="0dp"
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in" />
|
||||
|
||||
</set>
|
||||
|
||||
</item>
|
||||
|
||||
</selector>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:state_enabled="false" android:color="@color/text_dark_dimmed"/>
|
||||
<item android:state_enabled="true" android:color="@color/zcashRed" />
|
||||
</selector>
|
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 9.3 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 65 KiB |
After Width: | Height: | Size: 66 KiB |
After Width: | Height: | Size: 102 KiB |
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="6dp" />
|
||||
<solid android:color="@color/zcashWhite_87" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="@color/zcashWhite" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="2dp" />
|
||||
<solid android:color="@color/zcashWhite" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_enabled="false" android:drawable="@color/zcashGray"/>
|
||||
<item android:state_enabled="true" android:drawable="@color/colorPrimary" />
|
||||
</selector>
|
After Width: | Height: | Size: 865 B |
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,2h-4.18C14.4,0.84 13.3,0 12,0c-1.3,0 -2.4,0.84 -2.82,2L5,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,4c0,-1.1 -0.9,-2 -2,-2zM12,2c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM19,20L5,20L5,4h2v3h10L17,4h2v16z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24.0" android:viewportWidth="24.0"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
|
||||
</vector>
|
|
@ -4,6 +4,6 @@
|
|||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:fillColor="@color/zcashBlue"
|
||||
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"/>
|
||||
</vector>
|
|
@ -0,0 +1,4 @@
|
|||
<vector android:height="48dp" android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"/>
|
||||
</vector>
|
|
@ -0,0 +1,37 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="160dp"
|
||||
android:height="160dp"
|
||||
android:viewportWidth="160"
|
||||
android:viewportHeight="160">
|
||||
<group
|
||||
android:name="_R_G_L_1_G"
|
||||
android:rotation="-20"
|
||||
android:scaleX="1"
|
||||
android:scaleY="1"
|
||||
android:translateX="80"
|
||||
android:translateY="80">
|
||||
<path
|
||||
android:name="_R_G_L_1_G_D_0_P_0"
|
||||
android:fillAlpha="1"
|
||||
android:fillColor="#009688"
|
||||
android:fillType="nonZero"
|
||||
android:pathData=" M66 0 C66,36.45 36.45,66 0,66 C-36.45,66 -66,36.45 -66,0 C-66,-36.45 -36.45,-66 0,-66 C36.45,-66 66,-36.45 66,0c " />
|
||||
</group>
|
||||
<group
|
||||
android:name="_R_G_L_0_G_T_1"
|
||||
android:rotation="180"
|
||||
android:translateX="80"
|
||||
android:translateY="80">
|
||||
<group
|
||||
android:name="_R_G_L_0_G"
|
||||
android:translateY="-3">
|
||||
<path
|
||||
android:name="_R_G_L_0_G_D_0_P_0"
|
||||
android:fillAlpha="1"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData=" M-17.21 17.58 C-17.21,17.58 -25.85,9.26 -25.85,9.26 C-25.85,9.26 0,-17.58 0,-17.58 C0,-17.58 25.85,9.26 25.85,9.26 C25.85,9.26 17.21,17.58 17.21,17.58 C17.21,17.58 0,-0.28 0,-0.28 C0,-0.28 -17.21,17.58 -17.21,17.58c " />
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,31 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="160dp"
|
||||
android:height="160dp"
|
||||
android:viewportWidth="160"
|
||||
android:viewportHeight="160">
|
||||
<group
|
||||
android:name="_R_G_L_3_G"
|
||||
android:translateX="80"
|
||||
android:translateY="80">
|
||||
<path
|
||||
android:name="_R_G_L_3_G_D_0_P_0"
|
||||
android:fillAlpha="1"
|
||||
android:fillColor="#f5a623"
|
||||
android:fillType="nonZero"
|
||||
android:pathData=" M55.59 0 C55.59,30.7 30.7,55.59 0,55.59 C-30.7,55.59 -55.58,30.7 -55.58,0 C-55.58,-30.7 -30.7,-55.58 0,-55.58 C30.7,-55.58 55.59,-30.7 55.59,0c " />
|
||||
</group>
|
||||
<group
|
||||
android:name="_R_G_L_2_G"
|
||||
android:translateX="76"
|
||||
android:translateY="84">
|
||||
<path
|
||||
android:name="_R_G_L_2_G_D_0_P_0"
|
||||
android:fillAlpha="1"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData=" M10.76 -10.09 C10.76,-10.09 -4.17,8.75 -4.17,8.75 C-4.17,8.75 5.67,25.78 5.67,25.78 C7.07,27.55 9.88,27.06 10.6,24.92 C10.6,24.92 26.8,-23.19 26.8,-23.19 C27.55,-25.42 25.42,-27.55 23.19,-26.8 C23.19,-26.8 -24.92,-10.6 -24.92,-10.6 C-27.06,-9.88 -27.55,-7.07 -25.77,-5.67 C-25.77,-5.67 -8.75,4.18 -8.75,4.18 C-8.75,4.18 10.09,-10.76 10.09,-10.76 C10.54,-11.12 11.12,-10.54 10.76,-10.09c " />
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
|
||||
</vector>
|
|
@ -0,0 +1,64 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="50dp"
|
||||
android:height="50dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50">
|
||||
|
||||
<path
|
||||
android:pathData="M25,25m-22.75,0a22.75,22.75 0,1 1,45.5 0a22.75,22.75 0,1 1,-45.5 0"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0.5"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="evenOdd">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startY="2.25"
|
||||
android:startX="25"
|
||||
android:endY="47.304756"
|
||||
android:endX="25"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#00000000"/>
|
||||
<item android:offset="0.8" android:color="#05000000"/>
|
||||
<item android:offset="1" android:color="#0A000000"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M25,25m-22.75,0a22.75,22.75 0,1 1,45.5 0a22.75,22.75 0,1 1,-45.5 0"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0.5"
|
||||
android:fillColor="#00000000"
|
||||
android:fillType="evenOdd">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startY="2.25"
|
||||
android:startX="25"
|
||||
android:endY="47.75"
|
||||
android:endX="25"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#1EFFFFFF"/>
|
||||
<item android:offset="0.2" android:color="#0FFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M25.0005,2C12.3176,2 2,12.3176 2,24.9995C2,37.6814 12.3176,48 25.0005,48C37.6834,48 48,37.6824 48,24.9995C48,12.3166 37.6824,2 25.0005,2ZM25.0005,44.203C14.4115,44.203 5.797,35.5885 5.797,24.9995C5.797,14.4105 14.4115,5.797 25.0005,5.797C35.5895,5.797 44.203,14.4115 44.203,24.9995C44.203,35.5875 35.5885,44.203 25.0005,44.203Z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#231F20"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M24.9747,24.9747m-18.0462,0a18.0462,18.0462 0,1 1,36.0923 0a18.0462,18.0462 0,1 1,-36.0923 0"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#F4B728"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M33.0626,17.5611l0,-3.5028l-6.2269,0l0,-3.8441l-3.8242,0l0,3.8441l-6.2259,0l0,4.6398l9.6521,0l-7.8934,10.9311l-1.7587,2.2496l0,3.5028l6.2259,0l0,3.8318l0.4587,0l0,0.0163l2.9069,0l0,-0.0163l0.4587,0l0,-3.8318l6.2269,0l0,-4.6398l-9.6532,0l7.8934,-10.9311z"
|
||||
android:strokeWidth="1"
|
||||
android:fillColor="#231F20"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 4.4 KiB |