Merge pull request #1 from zcash/feature/scan-blocks-integration

Librustzcash integration
This commit is contained in:
Kevin Gorham 2019-04-05 06:26:03 -04:00 committed by GitHub
commit 74bba5a5e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
209 changed files with 10764 additions and 639 deletions

22
CHANGELOG.md Normal file
View File

@ -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

View File

@ -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)

View File

@ -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')
}

View File

@ -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
)
}
}

View File

@ -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>

View File

@ -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
}

View File

@ -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
)
}
}

View File

@ -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>
}

View File

@ -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

View File

@ -0,0 +1,7 @@
package cash.z.android.wallet.di.annotation
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.SOURCE)
annotation class ApplicationScope

View File

@ -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

View File

@ -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> {

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -6,4 +6,8 @@ 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)}"
}

View File

@ -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)}

View File

@ -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
)
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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."
}

View File

@ -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"))
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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"
)
}
}
}
@ -75,4 +147,4 @@ class MainActivity : DaggerAppCompatActivity() {
abstract class MainActivityModule {
@ContributesAndroidInjector
abstract fun contributeMainActivity(): MainActivity
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
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)
initTemp()
init()
}
// 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)
}
}
}, delay)
}
}
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
)
}

View File

@ -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
}
}
}

View File

@ -1,4 +0,0 @@
package cash.z.android.wallet.ui.fragment
class ImportFragment : PlaceholderFragment()

View File

@ -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)
}
}
}

View File

@ -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() {}
}

View File

@ -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,
@ -55,15 +53,22 @@ class ReceiveFragment : BaseFragment() {
text_address_part_8
)
}
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()
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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}!")
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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?) {
}
}

View File

@ -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)
}
}
}

View File

@ -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) {
}
}

View File

@ -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() {

View File

@ -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);
}

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

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