diff --git a/app/build.gradle b/app/build.gradle index 0fa5e3f..5ffb4e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,11 +5,13 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' apply plugin: 'com.google.gms.google-services' +apply plugin: 'io.fabric' +apply plugin: 'com.google.firebase.firebase-perf' //apply plugin: 'com.github.ben-manes.versions' archivesBaseName = 'zcash-android-wallet' group = 'cash.z.ecc.android' -version = '1.0.0-alpha05' +version = '1.0.0-alpha10' android { compileSdkVersion Deps.compileSdkVersion @@ -19,12 +21,13 @@ android { applicationId 'cash.z.ecc.android' minSdkVersion Deps.minSdkVersion targetSdkVersion Deps.targetSdkVersion - versionCode = 1_00_00_005 + versionCode = 1_00_00_010 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release. versionName = "$version" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunnerArguments clearPackageData: 'true' multiDexEnabled true +// manifestPlaceholders = [rollbarToken: properties["rollbarToken"]] } flavorDimensions 'network' productFlavors { @@ -87,6 +90,10 @@ android { } } +crashlytics { + enableNdk true +} + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':qrecycler') @@ -134,7 +141,12 @@ dependencies { // per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ implementation 'com.google.guava:guava:27.0.1-android' + // Analytics implementation 'com.mixpanel.android:mixpanel-android:5.6.3' + implementation 'com.google.firebase:firebase-analytics:17.2.1' + implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' + implementation 'com.crashlytics.sdk.android:crashlytics-ndk:2.1.1' + implementation 'com.google.firebase:firebase-perf:19.0.4' // QR Scanning implementation 'com.google.firebase:firebase-ml-vision:24.0.1' @@ -144,10 +156,16 @@ dependencies { implementation "androidx.camera:camera-extensions:1.0.0-alpha05" implementation "androidx.camera:camera-lifecycle:1.0.0-alpha02" + // Misc. + implementation 'com.airbnb.android:lottie:3.1.0' + implementation 'com.facebook.stetho:stetho:1.5.1' + // Tests implementation 'androidx.legacy:legacy-support-v4:1.0.0' testImplementation Deps.Test.JUNIT + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.3' androidTestImplementation Deps.Test.Android.JUNIT androidTestImplementation Deps.Test.Android.ESPRESSO } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b075b1d..2f33ef1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" + android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" android:theme="@style/ZcashTheme"> diff --git a/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt b/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt index 5817fc8..674b396 100644 --- a/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt +++ b/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt @@ -7,27 +7,37 @@ import androidx.camera.camera2.Camera2Config import androidx.camera.core.CameraXConfig import cash.z.ecc.android.di.component.AppComponent import cash.z.ecc.android.di.component.DaggerAppComponent -import cash.z.ecc.android.feedback.Feedback import cash.z.ecc.android.feedback.FeedbackCoordinator +import cash.z.wallet.sdk.ext.SilentTwig import cash.z.wallet.sdk.ext.TroubleshootingTwig import cash.z.wallet.sdk.ext.Twig import cash.z.wallet.sdk.ext.twig -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import javax.inject.Inject -import javax.inject.Provider class ZcashWalletApp : Application(), CameraXConfig.Provider { @Inject - lateinit var feedbackCoordinator: FeedbackCoordinator + lateinit var coordinator: FeedbackCoordinator var creationTime: Long = 0 private set var creationMeasured: Boolean = false + /** + * Intentionally private Scope for use with launching Feedback jobs. The feedback object has the + * longest scope in the app because it needs to be around early in order to measure launch times + * and stick around late in order to catch crashes. We intentionally don't expose this because + * application objects can have odd lifecycles, given that there is no clear onDestroy moment in + * many cases. + */ + private var feedbackScope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + override fun onCreate() { + Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler())) + Twig.plant(SilentTwig()) creationTime = System.currentTimeMillis() instance = this // Setup handler for uncaught exceptions. @@ -35,8 +45,9 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider { component = DaggerAppComponent.factory().create(this) component.inject(this) - Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(feedbackCoordinator, Thread.getDefaultUncaughtExceptionHandler())) - Twig.plant(TroubleshootingTwig()) + feedbackScope.launch { + coordinator.feedback.start() + } } override fun attachBaseContext(base: Context) { @@ -58,17 +69,18 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider { * is complete, we can lazily initialize all the feedback objects at this moment so that we * don't have to add any time to startup. */ - class ExceptionReporter(private val coordinator: FeedbackCoordinator, private val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler { + inner class ExceptionReporter(private val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler { override fun uncaughtException(t: Thread?, e: Throwable?) { - twig("Uncaught Exception: $e") + twig("Uncaught Exception: $e caused by: ${e?.cause}") coordinator.feedback.report(e) coordinator.flush() // can do this if necessary but first verify that we need it runBlocking { - coordinator.await() - coordinator.feedback.stop() + coordinator.await() + coordinator.feedback.stop() } ogHandler.uncaughtException(t, e) + Thread.sleep(2000L) } } } diff --git a/app/src/main/java/cash/z/ecc/android/di/module/ViewModelsSynchronizerModule.kt b/app/src/main/java/cash/z/ecc/android/di/module/ViewModelsSynchronizerModule.kt index 85067e1..7ff6470 100644 --- a/app/src/main/java/cash/z/ecc/android/di/module/ViewModelsSynchronizerModule.kt +++ b/app/src/main/java/cash/z/ecc/android/di/module/ViewModelsSynchronizerModule.kt @@ -8,6 +8,7 @@ import cash.z.ecc.android.di.annotation.ViewModelKey import cash.z.ecc.android.di.viewmodel.ViewModelFactory import cash.z.ecc.android.ui.detail.WalletDetailViewModel import cash.z.ecc.android.ui.home.HomeViewModel +import cash.z.ecc.android.ui.profile.ProfileViewModel import cash.z.ecc.android.ui.receive.ReceiveViewModel import cash.z.ecc.android.ui.scan.ScanViewModel import cash.z.ecc.android.ui.send.SendViewModel @@ -51,6 +52,12 @@ abstract class ViewModelsSynchronizerModule { @ViewModelKey(ScanViewModel::class) abstract fun bindScanViewModel(implementation: ScanViewModel): ViewModel + @SynchronizerScope + @Binds + @IntoMap + @ViewModelKey(ProfileViewModel::class) + abstract fun bindProfileViewModel(implementation: ProfileViewModel): ViewModel + /** * Factory for view models that are not created until the Synchronizer exists. Only VMs that * require the Synchronizer should wait until it is created. In other words, these are the VMs diff --git a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt index 3f5cfc2..3d89763 100644 --- a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt +++ b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt @@ -19,10 +19,20 @@ object Report { SEED_PHRASE_LOADED("metric.seedphrase.loaded", "seed phrase loaded"), WALLET_CREATED("metric.wallet.created", "wallet created"), WALLET_IMPORTED("metric.wallet.imported", "wallet imported"), - ACCOUNT_CREATED("metric.account.created", "account created") + ACCOUNT_CREATED("metric.account.created", "account created"), + + // Transactions + TRANSACTION_INITIALIZED("metric.tx.initialized", "transaction initialized"), + TRANSACTION_CREATED("metric.tx.created", "transaction created successfully"), + TRANSACTION_SUBMITTED("metric.tx.submitted", "transaction submitted successfully"), + TRANSACTION_MINED("metric.tx.mined", "transaction mined") } } +/** + * Creates a metric with a start time of ZcashWalletApp.creationTime and an end time of when this + * instance was created. This can then be passed to [Feedback.report]. + */ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) : Feedback.Metric by metric { constructor() : this( diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt index 3434c92..033e8cf 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt @@ -1,10 +1,13 @@ package cash.z.ecc.android.ui +import android.Manifest import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.pm.PackageManager import android.graphics.Color import android.media.MediaPlayer +import android.os.Build import android.os.Bundle import android.os.Vibrator import android.util.Log @@ -15,6 +18,7 @@ import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -46,16 +50,18 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var clipboard: ClipboardManager - private val mediaPlayer: MediaPlayer = MediaPlayer() - private var snackbar: Snackbar? = null lateinit var navController: NavController - lateinit var component: MainActivitySubcomponent lateinit var synchronizerComponent: SynchronizerSubcomponent + private val hasCameraPermission + get() = ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED override fun onCreate(savedInstanceState: Bundle?) { component = ZcashWalletApp.component.mainActivitySubcomponent().create(this).also { @@ -163,6 +169,13 @@ class MainActivity : AppCompatActivity() { } } + fun copyText(textToCopy: String, label: String = "zECC Wallet Text") { + clipboard.setPrimaryClip( + ClipData.newPlainText(label, textToCopy) + ) + showMessage("$label copied!", "Sweet") + } + fun preventBackPress(fragment: Fragment) { onFragmentBackPressed(fragment){} } @@ -204,4 +217,43 @@ class MainActivity : AppCompatActivity() { if (!it.isShownOrQueued) it.show() } } + + /** + * @param popUpToInclusive the destination to remove from the stack before opening the camera. + * This only takes effect in the common case where the permission is granted. + */ + fun maybeOpenScan(popUpToInclusive: Int? = null) { + if (hasCameraPermission) { + openCamera(popUpToInclusive) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(arrayOf(Manifest.permission.CAMERA), 101) + } else { + onNoCamera() + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 101) { + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + openCamera() + } else { + onNoCamera() + } + } + } + + private fun openCamera(popUpToInclusive: Int? = null) { + navController.navigate(popUpToInclusive ?: R.id.action_global_nav_scan) + } + + private fun onNoCamera() { + showSnackbar("Well, this is awkward. You denied permission for the camera.") + } } diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionAdapter.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionAdapter.kt index 4393e48..93c8c6a 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionAdapter.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionAdapter.kt @@ -13,12 +13,12 @@ class TransactionAdapter : override fun areItemsTheSame( oldItem: T, newItem: T - ) = oldItem.minedHeight == newItem.minedHeight + ) = oldItem.minedHeight == newItem.minedHeight && oldItem.noteId == newItem.noteId override fun areContentsTheSame( oldItem: T, newItem: T - ) = oldItem.equals(newItem) + ) = oldItem == newItem } ) { @@ -33,5 +33,4 @@ class TransactionAdapter : holder: TransactionViewHolder, position: Int ) = holder.bindTo(getItem(position)) - } diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt index 5a3a053..3dbaa22 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt @@ -4,10 +4,14 @@ import android.view.View import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import cash.z.ecc.android.R +import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.toAppColor +import cash.z.ecc.android.ui.MainActivity import cash.z.wallet.sdk.entity.ConfirmedTransaction -import cash.z.wallet.sdk.ext.toAbbreviatedAddress import cash.z.wallet.sdk.ext.convertZatoshiToZecString +import cash.z.wallet.sdk.ext.isShielded +import cash.z.wallet.sdk.ext.toAbbreviatedAddress +import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.text.SimpleDateFormat import java.util.* @@ -16,16 +20,22 @@ class TransactionViewHolder(itemView: View) : Recycler private val amountText = itemView.findViewById(R.id.text_transaction_amount) private val topText = itemView.findViewById(R.id.text_transaction_top) private val bottomText = itemView.findViewById(R.id.text_transaction_bottom) + private val shieldIcon = itemView.findViewById(R.id.image_shield) private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault()) fun bindTo(transaction: T?) { + + // update view var lineOne: String = "" var lineTwo: String = "" var amount: String = "" var amountColor: Int = 0 var indicatorBackground: Int = 0 - + transaction?.apply { + itemView.setOnClickListener { + onTransactionClicked(this) + } amount = value.convertZatoshiToZecString() // TODO: these might be good extension functions val timestamp = formatter.format(blockTimeInSeconds * 1000L) @@ -58,5 +68,33 @@ class TransactionViewHolder(itemView: View) : Recycler amountText.setTextColor(amountColor.toAppColor()) val context = itemView.context indicator.background = context.resources.getDrawable(indicatorBackground) + shieldIcon.goneIf((transaction?.raw != null || transaction?.expiryHeight != null) && !transaction?.toAddress.isShielded()) } -} \ No newline at end of file + + private fun onTransactionClicked(transaction: ConfirmedTransaction) { + val txId = transaction.rawTransactionId.toTxId() + val detailsMessage: String = "Zatoshi amount: ${transaction.value}\n\n" + + "Transaction: $txId" + MaterialAlertDialogBuilder(itemView.context) + .setMessage(detailsMessage) + .setTitle("Transaction Details") + .setCancelable(true) + .setPositiveButton("Ok") { dialog, _ -> + dialog.dismiss() + } + .setNegativeButton("Copy TX") { dialog, _ -> + (itemView.context as MainActivity).copyText(txId, "Transaction Id") + dialog.dismiss() + } + .show() + } +} + +private fun ByteArray.toTxId(): String { + val sb = StringBuilder(size * 2) + for(i in (size - 1) downTo 0) { + sb.append(String.format("%02x", this[i])) + } + return sb.toString() +} + diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsDrawableFooter.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsDrawableFooter.kt new file mode 100644 index 0000000..fd9ade3 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsDrawableFooter.kt @@ -0,0 +1,52 @@ +package cash.z.ecc.android.ui.detail +// +//import android.content.Context +//import android.graphics.Canvas +//import android.graphics.Rect +//import android.view.LayoutInflater +//import android.view.View +//import androidx.recyclerview.widget.RecyclerView +//import cash.z.ecc.android.R +// +// +//class TransactionsDrawableFooter(context: Context) : RecyclerView.ItemDecoration() { +// +// private var footer: View = +// LayoutInflater.from(context).inflate(R.layout.footer_transactions, null, false) +// +// override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { +// super.onDraw(c, parent, state!!) +// footer.measure( +// View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.AT_MOST), +// View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) +// ) +// // layout basically just gets drawn on the reserved space on top of the first view +// footer.layout(parent.left, 0, parent.right, footer.measuredHeight) +// for (i in 0 until parent.childCount) { +// val view: View = parent.getChildAt(i) +// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) { +// c.save() +// val height: Int = footer.measuredHeight +// val top: Int = view.top - height +// c.translate(0.0f, top.toFloat()) +// footer.draw(c) +// c.restore() +// break +// } +// } +// } +// +// override fun getItemOffsets( +// outRect: Rect, +// view: View, +// parent: RecyclerView, +// state: RecyclerView.State +// ) { +// super.getItemOffsets(outRect, view, parent, state) +// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) { +// outRect.set(0, 0, 0, 150) +// } else { +// outRect.setEmpty() +// } +// } +//} diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsFooter.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsFooter.kt new file mode 100644 index 0000000..35df4d6 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionsFooter.kt @@ -0,0 +1,49 @@ +package cash.z.ecc.android.ui.detail + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import cash.z.ecc.android.R + + +class TransactionsFooter(context: Context) : RecyclerView.ItemDecoration() { + + private var footer: Drawable = context.resources.getDrawable(R.drawable.background_footer) + val bounds = Rect() + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + c.save() + val left: Int = 0 + val right: Int = parent.width + val childCount = parent.childCount + val adapterItemCount = parent.adapter!!.itemCount + for (i in 0 until childCount) { + val child = parent.getChildAt(i) + if (parent.getChildAdapterPosition(child) == adapterItemCount - 1) { + parent.getDecoratedBoundsWithMargins(child, bounds) + val bottom: Int = bounds.bottom + Math.round(child.translationY) + val top: Int = bottom - footer.intrinsicHeight + footer.setBounds(left, top, right, bottom) + footer.draw(c) + } + } + c.restore() + } + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) { + outRect.set(0, 0, 0, footer.intrinsicHeight) + } else { + outRect.setEmpty() + } + } +} diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt index c1074ae..2409f98 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt @@ -49,16 +49,18 @@ class WalletDetailFragment : BaseFragment() { private fun onBalanceUpdated(balance: WalletBalance) { binding.textBalanceAvailable.text = balance.availableZatoshi.convertZatoshiToZecString() - val change = balance.totalZatoshi - balance.availableZatoshi + val change = (balance.totalZatoshi - balance.availableZatoshi) binding.textBalanceDescription.apply { - goneIf(change <= 0) - text = "(expecting +$change ZEC in change)".toColoredSpan(R.color.text_light, "+$change") + goneIf(change <= 0L) + val changeString = change.convertZatoshiToZecString() + text = "(expecting +$changeString ZEC)".toColoredSpan(R.color.text_light, "+${changeString}") } } private fun initTransactionUI() { binding.recyclerTransactions.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) + binding.recyclerTransactions.addItemDecoration(TransactionsFooter(binding.recyclerTransactions.context)) adapter = TransactionAdapter() viewModel.transactions.collectWith(resumedScope) { onTransactionsUpdated(it) } binding.recyclerTransactions.adapter = adapter @@ -66,6 +68,12 @@ class WalletDetailFragment : BaseFragment() { private fun onTransactionsUpdated(transactions: PagedList) { twig("got a new paged list of transactions") + binding.groupEmptyViews.goneIf(transactions.size > 0) adapter.submitList(transactions) } + + // TODO: maybe implement this for better fade behavior. Or do an actual scroll behavior instead, yeah do that. Or an item decoration. + fun onLastItemShown(item: ConfirmedTransaction, position: Int) { + binding.footerFade.alpha = position.toFloat() / (binding.recyclerTransactions.adapter?.itemCount ?: 1) + } } \ No newline at end of file diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt index 6115f4e..90ff7ec 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt @@ -2,32 +2,30 @@ package cash.z.ecc.android.ui.home import android.content.Context import android.content.res.ColorStateList +import android.graphics.PorterDuff import android.os.Bundle -import android.text.Spanned -import android.text.style.ForegroundColorSpan import android.view.LayoutInflater import android.view.View import android.widget.TextView -import androidx.core.text.toSpannable import androidx.lifecycle.lifecycleScope import cash.z.ecc.android.R import cash.z.ecc.android.databinding.FragmentHomeBinding import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.ext.* -import cash.z.ecc.android.ext.toAppColor import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.* import cash.z.ecc.android.ui.send.SendViewModel import cash.z.ecc.android.ui.setup.WalletSetupViewModel import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED -import cash.z.wallet.sdk.Synchronizer.Status.SYNCING +import cash.z.wallet.sdk.Synchronizer +import cash.z.wallet.sdk.Synchronizer.Status.* import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.convertZecToZatoshi import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal import cash.z.wallet.sdk.ext.twig import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -41,8 +39,7 @@ class HomeFragment : BaseFragment() { private val sendViewModel: SendViewModel by activityViewModel() private val viewModel: HomeViewModel by viewModel() - private val _typedChars = ConflatedBroadcastChannel() - private val typedChars = _typedChars.asFlow() + lateinit var snake: MagicSnakeLoader override fun inflate(inflater: LayoutInflater): FragmentHomeBinding = FragmentHomeBinding.inflate(inflater) @@ -54,6 +51,10 @@ class HomeFragment : BaseFragment() { override fun onAttach(context: Context) { twig("HomeFragment.onAttach") + twig("ZZZ") + twig("ZZZ") + twig("ZZZ") + twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================") super.onAttach(context) // this will call startSync either now or later (after initializing with newly created seed) @@ -92,15 +93,19 @@ class HomeFragment : BaseFragment() { hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) - hitAreaScan.onClickNavTo(R.id.action_nav_home_to_nav_scan) + hitAreaScan.setOnClickListener { + mainActivity?.maybeOpenScan() + } textBannerAction.setOnClickListener { onBannerAction(BannerAction.from((it as? TextView)?.text?.toString())) } - buttonSend.setOnClickListener { + buttonSendAmount.setOnClickListener { onSend() } - setSendAmount("0") + setSendAmount("0", false) + + snake = MagicSnakeLoader(binding.lottieButtonLoading) } binding.buttonNumberPadBack.setOnLongClickListener { @@ -108,46 +113,60 @@ class HomeFragment : BaseFragment() { true } -// if (::uiModel.isInitialized) { -// twig("uiModel exists!") -// onModelUpdated(HomeViewModel.UiModel(), uiModel) -// } + if (::uiModel.isInitialized) { + twig("uiModel exists!") + onModelUpdated(null, uiModel) + } } private fun onClearAmount() { - repeat(binding.textSendAmount.text.length) { + if (::uiModel.isInitialized) { resumedScope.launch { - _typedChars.send('<') + binding.textSendAmount.text.apply { + while (uiModel.pendingSend != "0") { + viewModel.onChar('<') + delay(5) + } + } } } } override fun onResume() { super.onResume() - viewModel.initialize(typedChars) twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope") + viewModel.initializeMaybe() +twig("onResume (A)") onClearAmount() +twig("onResume (B)") viewModel.uiModels.scanReduce { old, new -> onModelUpdated(old, new) new + }.onCompletion { + twig("uiModel.scanReduce completed.") }.catch { e -> twig("exception while processing uiModels $e") + throw e }.launchIn(resumedScope) +twig("onResume (C)") // TODO: see if there is a better way to trigger a refresh of the uiModel on resume // the latest one should just be in the viewmodel and we should just "resubscribe" // but for some reason, this doesn't always happen, which kind of defeats the purpose // of having a cold stream in the view model resumedScope.launch { +twig("onResume (pre-fresh)") viewModel.refreshBalance() +twig("onResume (post-fresh)") } +twig("onResume (D)") } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) twig("HomeFragment.onSaveInstanceState") if (::uiModel.isInitialized) { - outState.putParcelable("uiModel", uiModel) +// outState.putParcelable("uiModel", uiModel) } } @@ -155,7 +174,7 @@ class HomeFragment : BaseFragment() { super.onViewStateRestored(savedInstanceState) savedInstanceState?.let { inState -> twig("HomeFragment.onViewStateRestored") - onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!) +// onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!) } } @@ -165,29 +184,64 @@ class HomeFragment : BaseFragment() { // fun setSendEnabled(enabled: Boolean) { - binding.buttonSend.apply { + binding.buttonSendAmount.apply { isEnabled = enabled - backgroundTintList = ColorStateList.valueOf( resources.getColor( if(enabled) R.color.colorPrimary else R.color.zcashWhite_24) ) + if (enabled) { +// setTextColor(resources.getColorStateList(R.color.selector_button_text_dark)) + binding.lottieButtonLoading.alpha = 1.0f + } else { +// setTextColor(R.color.zcashGray.toAppColor()) + binding.lottieButtonLoading.alpha = 0.32f + } } } - fun setProgress(progress: Int) { - progress.let { - if (it < 100) { - setBanner("Downloading . . . $it%", NONE) - } else { - setBanner("Scanning . . .", NONE) - } + fun setProgress(uiModel: HomeViewModel.UiModel) { + if (!uiModel.processorInfo.hasData) { + twig("Warning: ignoring progress update because the processor has not started.") + return } + + snake.isSynced = uiModel.isSynced + if (!uiModel.isSynced) { + snake.downloadProgress = uiModel.downloadProgress + snake.scanProgress = uiModel.scanProgress + } + + val sendText = when { + uiModel.isSynced -> if (uiModel.hasFunds) "SEND AMOUNT" else "NO FUNDS AVAILABLE" + uiModel.status == Synchronizer.Status.DISCONNECTED -> "DISCONNECTED" + uiModel.status == Synchronizer.Status.STOPPED -> "IDLE" + uiModel.isDownloading -> "Downloading . . . ${snake.downloadProgress}%" + uiModel.isValidating -> "Validating . . ." + uiModel.isScanning -> "Scanning . . . ${snake.scanProgress}%" + else -> "Updating" + } + +// binding.lottieButtonLoading.progress = if (uiModel.isSynced) 1.0f else uiModel.totalProgress * 0.82f // line fully closes at 82% mark + binding.buttonSendAmount.text = sendText +// twig("Lottie progress set to ${binding.lottieButtonLoading.progress} (isSynced? ${uiModel.isSynced})") + twig("Send button set to: $sendText") + + val resId = if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light + binding.buttonSendAmount.setTextColor(resources.getColorStateList(resId)) + +// if (uiModel.status == DISCONNECTED || uiModel.status == STOPPED) { +// binding.buttonSendAmount.backgroundTintList = ColorStateList.valueOf(resources.getColor(R.color.zcashGray)) +// } else { +// binding.buttonSendAmount.backgroundTintList = null +// } } /** * @param amount the amount to send represented as ZEC, without the dollar sign. */ - fun setSendAmount(amount: String) { + fun setSendAmount(amount: String, updateModel: Boolean = true) { binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$") - sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi() - binding.buttonSend.disabledIf(amount == "0") + if (updateModel) { + sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi() + } + binding.buttonSendAmount.disabledIf(amount == "0") } fun setAvailable(availableBalance: Long = -1L, totalBalance: Long = -1L) { @@ -197,17 +251,13 @@ class HomeFragment : BaseFragment() { goneIf(availableBalance < 0) text = if (availableBalance != -1L && (availableBalance < totalBalance)) { val change = (totalBalance - availableBalance).convertZatoshiToZecString() - "(expecting +$change ZEC in change)".toColoredSpan(R.color.text_light, "+$change") + "(expecting +$change ZEC)".toColoredSpan(R.color.text_light, "+$change") } else { "(enter an amount to send)" } } } - fun setSendText(buttonText: String = "Send Amount") { - binding.buttonSend.text = buttonText - } - fun setBanner(message: String = "", action: BannerAction = CLEAR) { with(binding) { val hasMessage = !message.isEmpty() || action != CLEAR @@ -225,30 +275,38 @@ class HomeFragment : BaseFragment() { // Private UI Events // - private fun onModelUpdated(old: HomeViewModel.UiModel, new: HomeViewModel.UiModel) { - twig(new.toString()) + private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) { + twig("onModelUpdated: $new") uiModel = new - if (old.pendingSend != new.pendingSend) { +twig("onModelUpdated (A)") + if (old?.pendingSend != new.pendingSend) { +twig("onModelUpdated (B)") setSendAmount(new.pendingSend) +twig("onModelUpdated (C)") } +twig("onModelUpdated (D)") // TODO: handle stopped and disconnected flows - if (new.status == SYNCING) onSyncing(new) else onSynced(new) + setProgress(uiModel) // TODO: we may not need to separate anymore +twig("onModelUpdated (E)") +// if (new.status = SYNCING) onSyncing(new) else onSynced(new) + if (new.status == SYNCED) onSynced(new) else onSyncing(new) +twig("onModelUpdated (F)") setSendEnabled(new.isSendEnabled) +twig("onModelUpdated (G) sendEnabled? ${new.isSendEnabled}") + twig("DONE onModelUpdated") } private fun onSyncing(uiModel: HomeViewModel.UiModel) { - setProgress(uiModel.progress) // calls setBanner setAvailable() - setSendText("Syncing Blockchain…") } private fun onSynced(uiModel: HomeViewModel.UiModel) { + snake.isSynced = true if (!uiModel.hasBalance) { onNoFunds() } else { setBanner("") setAvailable(uiModel.availableBalance, uiModel.totalBalance) - setSendText() } } @@ -260,18 +318,27 @@ class HomeFragment : BaseFragment() { when (action) { FUND_NOW -> { MaterialAlertDialogBuilder(activity) - .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!") + .setMessage("To make full use of this wallet, deposit funds to your address.") .setTitle("No Balance") .setCancelable(true) - .setPositiveButton("Tap Faucet") { dialog, _ -> - dialog.dismiss() - setBanner("Tapping faucet...", CANCEL) - } - .setNegativeButton("View Address") { dialog, _ -> + .setPositiveButton("View Address") { dialog, _ -> dialog.dismiss() mainActivity?.navController?.navigate(R.id.action_nav_home_to_nav_receive) } .show() +// MaterialAlertDialogBuilder(activity) +// .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!") +// .setTitle("No Balance") +// .setCancelable(true) +// .setPositiveButton("Tap Faucet") { dialog, _ -> +// dialog.dismiss() +// setBanner("Tapping faucet...", CANCEL) +// } +// .setNegativeButton("View Address") { dialog, _ -> +// dialog.dismiss() +// mainActivity?.navController?.navigate(R.id.action_nav_home_to_nav_receive) +// } +// .show() } CANCEL -> { // TODO: trigger banner / balance update @@ -310,7 +377,7 @@ class HomeFragment : BaseFragment() { setOnClickListener { lifecycleScope.launch { twig("CHAR TYPED: $c") - _typedChars.send(c) + viewModel.onChar(c) } } return this diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt index 70a0412..1c5516d 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt @@ -1,20 +1,17 @@ package cash.z.ecc.android.ui.home -import android.os.Parcelable import androidx.lifecycle.ViewModel import cash.z.wallet.sdk.SdkSynchronizer import cash.z.wallet.sdk.Synchronizer -import cash.z.wallet.sdk.Synchronizer.Status.DISCONNECTED -import cash.z.wallet.sdk.Synchronizer.Status.SYNCED +import cash.z.wallet.sdk.Synchronizer.Status.* +import cash.z.wallet.sdk.block.CompactBlockProcessor import cash.z.wallet.sdk.ext.ZcashSdk.MINERS_FEE_ZATOSHI import cash.z.wallet.sdk.ext.ZcashSdk.ZATOSHI_PER_ZEC import cash.z.wallet.sdk.ext.twig -import kotlinx.android.parcel.Parcelize -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.* import javax.inject.Inject +import kotlin.math.roundToInt class HomeViewModel @Inject constructor() : ViewModel() { @@ -23,11 +20,12 @@ class HomeViewModel @Inject constructor() : ViewModel() { lateinit var uiModels: Flow + private val _typedChars = ConflatedBroadcastChannel() + private val typedChars = _typedChars.asFlow() + var initialized = false - fun initialize( - typedChars: Flow - ) { + fun initializeMaybe() { twig("init called") if (initialized) { twig("Warning already initialized HomeViewModel. Ignoring call to initialize.") @@ -55,8 +53,9 @@ class HomeViewModel @Inject constructor() : ViewModel() { } } } + twig("initializing view models stream") uiModels = synchronizer.run { - combine(status, progress, balances, zec) { s, p, b, z-> + combine(status, processorInfo, balances, zec) { s, p, b, z-> UiModel(s, p, b.availableZatoshi, b.totalZatoshi, z) } }.conflate() @@ -67,22 +66,64 @@ class HomeViewModel @Inject constructor() : ViewModel() { twig("HomeViewModel cleared!") } + suspend fun onChar(c: Char) { + _typedChars.send(c) + } + suspend fun refreshBalance() { (synchronizer as SdkSynchronizer).refreshBalance() } - @Parcelize data class UiModel( // <- THIS ERROR IS AN IDE BUG WITH PARCELIZE val status: Synchronizer.Status = DISCONNECTED, - val progress: Int = 0, + val processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(), val availableBalance: Long = -1L, val totalBalance: Long = -1L, val pendingSend: String = "0" - ): Parcelable { + ) { // Note: the wallet is effectively empty if it cannot cover the miner's fee val hasFunds: Boolean get() = availableBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001 - val hasBalance: Boolean get() = totalBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001 + val hasBalance: Boolean get() = totalBalance > 0 val isSynced: Boolean get() = status == SYNCED val isSendEnabled: Boolean get() = isSynced && hasFunds + + // Processor Info + val isDownloading = status == DOWNLOADING + val isScanning = status == SCANNING + val isValidating = status == VALIDATING + val downloadProgress: Int get() { + return processorInfo.run { + if (lastDownloadRange.isEmpty()) { + 100 + } else { + twig("NUMERATOR: $lastDownloadedHeight - ${lastDownloadRange.first} + 1 = ${lastDownloadedHeight - lastDownloadRange.first + 1} block(s) downloaded") + twig("DENOMINATOR: ${lastDownloadRange.last} - ${lastDownloadRange.first} + 1 = ${lastDownloadRange.last - lastDownloadRange.first + 1} block(s) to download") + val progress = + (((lastDownloadedHeight - lastDownloadRange.first + 1).coerceAtLeast(0).toFloat() / (lastDownloadRange.last - lastDownloadRange.first + 1)) * 100.0f).coerceAtMost( + 100.0f + ).roundToInt() + twig("RESULT: $progress") + progress + } + } + } + val scanProgress: Int get() { + return processorInfo.run { + if (lastScanRange.isEmpty()) { + 100 + } else { + twig("NUMERATOR: ${lastScannedHeight - lastScanRange.first + 1} block(s) scanned") + twig("DENOMINATOR: ${lastScanRange.last - lastScanRange.first + 1} block(s) to scan") + val progress = (((lastScannedHeight - lastScanRange.first + 1).coerceAtLeast(0).toFloat() / (lastScanRange.last - lastScanRange.first + 1)) * 100.0f).coerceAtMost(100.0f).roundToInt() + twig("RESULT: $progress") + progress + } + } + } + val totalProgress: Float get() { + val downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f) + val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f) + return downloadWeighted.coerceAtLeast(0.0f) + scanWeighted.coerceAtLeast(0.0f) + } } } diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt b/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt new file mode 100644 index 0000000..c9e7908 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt @@ -0,0 +1,191 @@ +package cash.z.ecc.android.ui.home + +import android.animation.ValueAnimator +import cash.z.wallet.sdk.ext.twig +import com.airbnb.lottie.LottieAnimationView + +class MagicSnakeLoader( + val lottie: LottieAnimationView, + private val scanningStartFrame: Int = 100, + private val scanningEndFrame: Int = 175, + val totalFrames: Int = 200 +) : ValueAnimator.AnimatorUpdateListener { + private var isPaused: Boolean = true + private var isStarted: Boolean = false + + var isSynced: Boolean = false + set(value) { + twig("ZZZ isSynced=$value isStarted=$isStarted") + if (value && !isStarted) { + twig("ZZZ isSynced=$value TURBO sync") + lottie.progress = 1.0f + field = value + return + } + + // it is started but it hadn't reached the synced state yet + if (value && !field) { + twig("ZZZ synced was $field but now is $value so playing to completion since we are now synced") + field = value + playToCompletion() + } else { + field = value + twig("ZZZ isSynced=$value and lottie.progress=${lottie.progress}") + } + } + + var scanProgress: Int = 0 + set(value) { + field = value + twig("ZZZ scanProgress=$value") + if (value > 0) { + startMaybe() + onScanUpdated() + } + } + + var downloadProgress: Int = 0 + set(value) { + field = value + twig("ZZZ downloadProgress=$value") + if (value > 0) startMaybe() + } + + private fun startMaybe() { + + if (!isSynced && !isStarted) lottie.postDelayed({ + // after some delay, if we're still not synced then we better start animating (unless we already are)! + if (!isSynced && isPaused) { + twig("ZZZ yes start!") + lottie.resumeAnimation() + isPaused = false + isStarted = true + } else { + twig("ZZZ I would have started but we're already synced!") + } + }, 200L).also { twig("ZZZ startMaybe???") } + } +// set(value) { +// field = value +// if (value in 1..99 && isStopped) { +// lottie.playAnimation() +// isStopped = false +// } else if (value >= 100) { +// isStopped = true +// } +// } + + + private val isDownloading get() = downloadProgress in 1..99 + private val isScanning get() = scanProgress in 1..99 + + init { + lottie.addAnimatorUpdateListener(this) + } + + // downloading = true +// lottieAnimationView.playAnimation() +// lottieAnimationView.addAnimatorUpdateListener { valueAnimator -> +// // Set animation progress +// val progress = (valueAnimator.animatedValue as Float * 100).toInt() +// progressTv.text = "Progress: $progress%" +// +// if (downloading && progress >= 40) { +// lottieAnimationView.progress = 0f +// } +// } + + override fun onAnimationUpdate(animation: ValueAnimator) { + if (isSynced || isPaused) { +// playToCompletion() + return + } + twig("ZZZ") + twig("ZZZ\t\tonAnimationUpdate(${animation.animatedValue})") + + // if we are scanning, then set the animation progress, based on the scan progress + // if we're not scanning, then we're looping + animation.currentFrame().let { frame -> + if (isDownloading) allowLoop(frame) else applyScanProgress(frame) + } + } + + private val acceptablePauseFrames = arrayOf(33,34,67,68,99) + private fun applyScanProgress(frame: Int) { + twig("ZZZ applyScanProgress($frame) : isPaused=$isPaused isStarted=$isStarted min=${lottie.minFrame} max=${lottie.maxFrame}") + // don't hardcode the progress until the loop animation has completed, cleanly + if (isPaused) { + onScanUpdated() + } else { + // once we're ready to show scan progress, do it! Don't do extra loops. + if (frame >= scanningStartFrame || frame in acceptablePauseFrames) { + twig("ZZZ pausing so we can scan! ${if(frame + if (frame in 33..67) { + twig("ZZZ removing 1 loop!") + lottie.frame = frame + 34 + } else if (frame in 0..33) { + twig("ZZZ removing 2 loops!") + lottie.frame = frame + 67 + } + } + } + + private fun allowLoop(frame: Int) { + twig("ZZZ allowLoop($frame) : isPaused=$isPaused") + unpause() + if (frame >= scanningStartFrame) { + twig("ZZZ resetting to 0f (LOOPING)") + lottie.progress = 0f + } + } + + fun unpause() { + if (isPaused) { + twig("ZZZ unpausing") + lottie.resumeAnimation() + isPaused = false + } + } + + fun pause() { + if (!isPaused) { + twig("ZZZ pausing") + lottie.pauseAnimation() + isPaused = true + } + } + + private fun ValueAnimator.currentFrame(): Int { + return ((animatedValue as Float) * totalFrames).toInt() + } +} + diff --git a/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt index 3a557b0..529005f 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt @@ -7,14 +7,20 @@ import android.view.View import cash.z.ecc.android.BuildConfig import cash.z.ecc.android.R import cash.z.ecc.android.databinding.FragmentProfileBinding +import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.ext.onClick import cash.z.ecc.android.ext.onClickNavBack import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.feedback.FeedbackFile import cash.z.ecc.android.ui.base.BaseFragment +import cash.z.wallet.sdk.ext.toAbbreviatedAddress +import kotlinx.coroutines.launch import okio.Okio class ProfileFragment : BaseFragment() { + + private val viewModel: ProfileViewModel by viewModel() + override fun inflate(inflater: LayoutInflater): FragmentProfileBinding = FragmentProfileBinding.inflate(inflater) @@ -31,6 +37,13 @@ class ProfileFragment : BaseFragment() { } } + override fun onResume() { + super.onResume() + resumedScope.launch { + binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress(12, 12) + } + } + private fun onViewLogs() { loadLogFileAsText().let { logText -> if (logText == null) { diff --git a/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..5b8e351 --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileViewModel.kt @@ -0,0 +1,19 @@ +package cash.z.ecc.android.ui.profile + +import androidx.lifecycle.ViewModel +import cash.z.wallet.sdk.Synchronizer +import cash.z.wallet.sdk.ext.twig +import javax.inject.Inject + +class ProfileViewModel @Inject constructor() : ViewModel() { + + @Inject + lateinit var synchronizer: Synchronizer + + suspend fun getAddress(): String = synchronizer.getAddress() + + override fun onCleared() { + super.onCleared() + twig("ProfileViewModel cleared!") + } +} diff --git a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt index 862c916..3c95eb2 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt @@ -39,7 +39,9 @@ class ReceiveFragment : BaseFragment() { // text_address_part_7, // text_address_part_8 // ) - binding.buttonScan.onClickNavTo(R.id.action_nav_receive_to_nav_scan) + binding.buttonScan.setOnClickListener { + mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan) + } binding.backButtonHitArea.onClickNavBack() } diff --git a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveViewModel.kt index 5f0a7e7..1b4cfbc 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveViewModel.kt @@ -14,6 +14,6 @@ class ReceiveViewModel @Inject constructor() : ViewModel() { override fun onCleared() { super.onCleared() - twig("WalletDetailViewModel cleared!") + twig("ReceiveViewModel cleared!") } } diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt index 6c3a936..1ffed6e 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt @@ -2,15 +2,20 @@ package cash.z.ecc.android.ui.send import android.content.ClipboardManager import android.content.Context +import android.content.res.ColorStateList import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.widget.EditText +import androidx.core.widget.doAfterTextChanged import cash.z.ecc.android.R import cash.z.ecc.android.databinding.FragmentSendAddressBinding import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.ext.* import cash.z.ecc.android.ui.base.BaseFragment +import cash.z.wallet.sdk.Synchronizer +import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance import cash.z.wallet.sdk.ext.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -18,6 +23,8 @@ import kotlinx.coroutines.launch class SendAddressFragment : BaseFragment(), ClipboardManager.OnPrimaryClipChangedListener { + private var maxZatoshi: Long? = null + val sendViewModel: SendViewModel by activityViewModel() override fun inflate(inflater: LayoutInflater): FragmentSendAddressBinding = @@ -25,55 +32,97 @@ class SendAddressFragment : BaseFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) binding.buttonNext.setOnClickListener { onSubmit() } - binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) binding.textBannerAction.setOnClickListener { onPaste() } binding.textBannerMessage.setOnClickListener { onPaste() } + binding.textMax.setOnClickListener { + onMax() + } // Apply View Model if (sendViewModel.zatoshiAmount > 0L) { sendViewModel.zatoshiAmount.convertZatoshiToZecString(8).let { amount -> binding.inputZcashAmount.setText(amount) - binding.textAmount.text = "Sending $amount ZEC" } } else { binding.inputZcashAmount.setText(null) } - if (!sendViewModel.toAddress.isNullOrEmpty()){ - binding.textAmount.text = "Send to ${sendViewModel.toAddress.toAbbreviatedAddress()}" + if (!sendViewModel.toAddress.isNullOrEmpty()) { binding.inputZcashAddress.setText(sendViewModel.toAddress) } else { binding.inputZcashAddress.setText(null) } binding.inputZcashAddress.onEditorActionDone(::onSubmit) + binding.inputZcashAmount.onEditorActionDone(::onSubmit) - binding.imageScanQr.onClickNavTo(R.id.action_nav_send_address_to_nav_scan) + binding.inputZcashAddress.apply { + doAfterTextChanged { + val trim = text.toString().trim() + if (text.toString() != trim) { + binding.inputZcashAddress + .findViewById(R.id.input_zcash_address).setText(trim) + } + onAddressChanged(trim) + } + } + + binding.textLayoutAddress.setEndIconOnClickListener { + mainActivity?.maybeOpenScan() + } + } + + private fun onAddressChanged(address: String) { + resumedScope.launch { + var type = when (sendViewModel.validateAddress(address)) { + is Synchronizer.AddressType.Transparent -> "This is a valid transparent address" to R.color.zcashGreen + is Synchronizer.AddressType.Shielded -> "This is a valid shielded address" to R.color.zcashGreen + is Synchronizer.AddressType.Invalid -> "This address appears to be invalid" to R.color.zcashRed + } + if (address == sendViewModel.synchronizer.getAddress()) type = + "Warning, this appears to be your address!" to R.color.zcashRed + binding.textLayoutAddress.helperText = type.first + binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor())) + } } private fun onSubmit(unused: EditText? = null) { sendViewModel.toAddress = binding.inputZcashAddress.text.toString() binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it } - sendViewModel.validate().onFirstWith(resumedScope) { + sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) { if (it == null) { mainActivity?.navController?.navigate(R.id.action_nav_send_address_to_send_memo) } else { resumedScope.launch { binding.textAddressError.text = it delay(1500L) - binding.textAddressError.text = "" + binding.textAddressError.text = "" } } } } + private fun onMax() { + if (maxZatoshi != null) { + binding.inputZcashAmount.apply { + setText(maxZatoshi.convertZatoshiToZecString(8)) + postDelayed({ + requestFocus() + setSelection(text?.length ?: 0) + }, 10L) + } + } + } + + override fun onAttach(context: Context) { super.onAttach(context) mainActivity?.clipboard?.addPrimaryClipChangedListener(this) @@ -87,6 +136,18 @@ class SendAddressFragment : BaseFragment(), override fun onResume() { super.onResume() updateClipboardBanner() + sendViewModel.synchronizer.balances.collectWith(resumedScope) { + onBalanceUpdated(it) + } + binding.inputZcashAddress.text.toString().let { + if (!it.isNullOrEmpty()) onAddressChanged(it) + } + } + + private fun onBalanceUpdated(balance: WalletBalance) { + binding.textLayoutAmount.helperText = + "You have ${balance.availableZatoshi.convertZatoshiToZecString(8)} available" + maxZatoshi = balance.availableZatoshi - ZcashSdk.MINERS_FEE_ZATOSHI } override fun onPrimaryClipChanged() { diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt index 82cced6..5c89a4b 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt @@ -9,11 +9,15 @@ import cash.z.ecc.android.R import cash.z.ecc.android.databinding.FragmentSendFinalBinding import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.ext.goneIf +import cash.z.ecc.android.feedback.Feedback +import cash.z.ecc.android.feedback.Report +import cash.z.ecc.android.feedback.Report.MetricType.* import cash.z.ecc.android.ui.base.BaseFragment import cash.z.wallet.sdk.entity.* import cash.z.wallet.sdk.ext.toAbbreviatedAddress import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.twig +import com.crashlytics.android.Crashlytics import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn @@ -32,6 +36,9 @@ class SendFinalFragment : BaseFragment() { binding.buttonNext.setOnClickListener { onExit() } + binding.buttonRetry.setOnClickListener { + onRetry() + } binding.backButtonHitArea.setOnClickListener { onExit() } @@ -69,28 +76,47 @@ class SendFinalFragment : BaseFragment() { } private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) { - val id = pendingTransaction?.id ?: -1 - var isSending = true - val message = when { - pendingTransaction == null -> "Transaction not found" - pendingTransaction.isMined() -> "Transaction Mined (id: $id)!\n\nSEND COMPLETE".also { isSending = false } - pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ." - pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false } - pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false } - pendingTransaction.isCreated() -> "Transaction creation complete! (id: $id)" - pendingTransaction.isCreating() -> "Creating transaction . . ." - else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") } + try { + if (pendingTransaction != null) sendViewModel.updateMetrics(pendingTransaction) + val id = pendingTransaction?.id ?: -1 + var isSending = true + var isFailure = false + val message = when { + pendingTransaction == null -> "Transaction not found" + pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false } + pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ." + pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true } + pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true } + pendingTransaction.isCreated() -> "Transaction creation complete!" + pendingTransaction.isCreating() -> "Creating transaction . . ." + else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") } + } + twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message") + binding.textStatus.apply { + text = "$message" + } + binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting")) + binding.buttonNext.goneIf((pendingTransaction?.isSubmitSuccess() != true) && (pendingTransaction?.isCreated() != true) && !isFailure) + binding.buttonNext.text = if (isSending) "Done" else "Finished" + binding.buttonRetry.goneIf(!isFailure) + binding.progressHorizontal.goneIf(!isSending) + + + if (pendingTransaction?.isSubmitSuccess() == true) { + sendViewModel.reset() + } + } catch(t: Throwable) { + twig("ERROR: error while handling pending transaction update! $t") + Crashlytics.logException(t) } - twig("Pending TX Updated: $message") - binding.textStatus.apply { - text = "$text\n$message" - } - binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting")) - binding.buttonNext.goneIf(isSending) - binding.progressHorizontal.goneIf(!isSending) } private fun onExit() { - mainActivity?.navController?.popBackStack(R.id.nav_send_address, true) + mainActivity?.navController?.popBackStack(R.id.nav_home, false) } -} \ No newline at end of file + + private fun onRetry() { + mainActivity?.navController?.popBackStack(R.id.nav_send_address, false) + } + +} diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt index ac397b3..aef0f95 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt @@ -10,6 +10,7 @@ import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.ext.gone import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.onClickNavTo +import cash.z.ecc.android.ext.toColoredSpan import cash.z.ecc.android.ui.base.BaseFragment class SendMemoFragment : BaseFragment() { @@ -22,10 +23,10 @@ class SendMemoFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.buttonNext.setOnClickListener { - onAddMemo() + onTopButton() } binding.buttonSkip.setOnClickListener { - onSkip() + onBottomButton() } R.id.action_nav_send_memo_to_nav_send_address.let { binding.backButtonHitArea.onClickNavTo(it) @@ -38,7 +39,7 @@ class SendMemoFragment : BaseFragment() { binding.inputMemo.setOnEditorActionListener { v, actionId, event -> if (actionId == EditorInfo.IME_ACTION_DONE) { - onAddMemo() + onTopButton() true } else { false @@ -54,8 +55,20 @@ class SendMemoFragment : BaseFragment() { } private fun applyModel() { - binding.inputMemo.setText(sendViewModel.memo) - binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress + sendViewModel.isShielded.let { isShielded -> + binding.groupShielded.goneIf(!isShielded) + binding.groupTransparent.goneIf(isShielded) + if (isShielded) { + binding.inputMemo.setText(sendViewModel.memo) + binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress + binding.buttonNext.text = "ADD MEMO" + binding.buttonSkip.text = "SEND WITHOUT MEMO" + } else { + binding.buttonNext.text = "WAIT, GO BACK" + binding.buttonSkip.text = "PROCEED" + binding.sadTitle.text = binding.sadTitle.text.toString().toColoredSpan(R.color.colorPrimary, "sad") + } + } } private fun onIncludeMemo(checked: Boolean) { @@ -64,18 +77,22 @@ class SendMemoFragment : BaseFragment() { if (checked) binding.inputMemo.setHint("") else binding.inputMemo.setHint("Add a memo here") } - private fun onSkip() { + private fun onTopButton() { + if (sendViewModel.isShielded) { + sendViewModel.memo = binding.inputMemo.text.toString() + onNext() + } else { + mainActivity?.navController?.navigate(R.id.action_nav_send_memo_to_nav_send_address) + } + } + + private fun onBottomButton() { binding.inputMemo.setText("") sendViewModel.memo = "" sendViewModel.includeFromAddress = false onNext() } - private fun onAddMemo() { - sendViewModel.memo = binding.inputMemo.text.toString() - onNext() - } - private fun onNext() { mainActivity?.navController?.navigate(R.id.action_nav_send_memo_to_send_confirm) } diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt index 21ddfc9..114afe5 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt @@ -1,22 +1,36 @@ package cash.z.ecc.android.ui.send +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.feedback.Feedback +import cash.z.ecc.android.feedback.Feedback.Keyed +import cash.z.ecc.android.feedback.Feedback.TimeMetric +import cash.z.ecc.android.feedback.Report +import cash.z.ecc.android.feedback.Report.MetricType +import cash.z.ecc.android.feedback.Report.MetricType.* import cash.z.ecc.android.lockbox.LockBox import cash.z.ecc.android.ui.setup.WalletSetupViewModel import cash.z.wallet.sdk.Initializer import cash.z.wallet.sdk.Synchronizer -import cash.z.wallet.sdk.entity.PendingTransaction +import cash.z.wallet.sdk.annotation.OpenForTesting +import cash.z.wallet.sdk.entity.* import cash.z.wallet.sdk.ext.ZcashSdk +import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.twig +import com.crashlytics.android.Crashlytics +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject class SendViewModel @Inject constructor() : ViewModel() { + private val metrics = mutableMapOf() + @Inject lateinit var lockBox: LockBox @@ -26,6 +40,23 @@ class SendViewModel @Inject constructor() : ViewModel() { @Inject lateinit var initializer: Initializer + @Inject + lateinit var feedback: Feedback + + var fromAddress: String = "" + var toAddress: String = "" + var memo: String = "" + var zatoshiAmount: Long = -1L + var includeFromAddress: Boolean = false + set(value) { + require(!value || (value && !fromAddress.isNullOrEmpty())) { + "Error: from address was empty while attempting to include it in the memo. Verify" + + " that initFromAddress() has previously been called on this viewmodel." + } + field = value + } + val isShielded get() = toAddress.startsWith("z") + fun send(): Flow { val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo val keys = initializer.deriveSpendingKeys( @@ -41,17 +72,20 @@ class SendViewModel @Inject constructor() : ViewModel() { } } - fun validate() = flow { + suspend fun validateAddress(address: String): Synchronizer.AddressType = + synchronizer.validateAddress(address) + + fun validate(maxZatoshi: Long?) = flow { when { synchronizer.validateAddress(toAddress).isNotValid -> { emit("Please enter a valid address") } zatoshiAmount < ZcashSdk.MINERS_FEE_ZATOSHI -> { - emit("Please enter a larger amount") + emit("Too little! Please enter at least 0.0001") } - synchronizer.getAddress() == toAddress -> { - emit("That appears to be your address!") + maxZatoshi != null && zatoshiAmount > maxZatoshi -> { + emit( "Too much! Please enter no more than ${maxZatoshi.convertZatoshiToZecString(8)}") } else -> emit(null) } @@ -64,16 +98,81 @@ class SendViewModel @Inject constructor() : ViewModel() { } } - var fromAddress: String = "" - var toAddress: String = "" - var memo: String = "" - var zatoshiAmount: Long = -1L - var includeFromAddress: Boolean = false - set(value) { - require(!value || (value && !fromAddress.isNullOrEmpty())) { - "Error: from address was empty while attempting to include it in the memo. Verify" + - " that initFromAddress() has previously been called on this viewmodel." + fun reset() { + fromAddress = "" + toAddress = "" + memo = "" + zatoshiAmount = -1L + includeFromAddress = false + } + + fun updateMetrics(tx: PendingTransaction) { + try { + when { + tx.isMined() -> TRANSACTION_SUBMITTED to TRANSACTION_MINED by tx.id + tx.isSubmitSuccess() -> TRANSACTION_CREATED to TRANSACTION_SUBMITTED by tx.id + tx.isCreated() -> TRANSACTION_INITIALIZED to TRANSACTION_CREATED by tx.id + tx.isCreating() -> +TRANSACTION_INITIALIZED by tx.id + else -> null + }?.let { metricId -> + report(metricId) } - field = value + } catch (t: Throwable) { + Crashlytics.logException(RuntimeException("Error while updating Metrics", t)) } -} \ No newline at end of file + } + + fun report(metricId: String?) { + metrics[metricId]?.let { metric -> + metric.takeUnless { (it.elapsedTime ?: 0) <= 0L }?.let { + viewModelScope.launch { + withContext(IO) { + feedback.report(metric) + + // does this metric complete another metric? + metricId!!.toRelatedMetricId().let { relatedId -> + metrics[relatedId]?.let { relatedMetric -> + // then remove the related metric, itself. And the relation. + metrics.remove(relatedMetric.toMetricIdFor(metricId!!.toTxId())) + metrics.remove(relatedId) + } + } + + // remove all top-level metrics + if (metric.key == Report.MetricType.TRANSACTION_MINED.key) metrics.remove(metricId) + } + } + } + } + } + + private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime() + private infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this } + private infix fun Pair.by(txId: Long): String? { + val startMetric = first.toMetricIdFor(txId).let { metricId -> + metrics[metricId].also { if (it == null) println("Warning no start metric for id: $metricId") } + } + return startMetric?.endTime?.let { startMetricEndTime -> + TimeMetric(second.key, second.description, mutableListOf(startMetricEndTime)) + .markTime().let { endMetric -> + endMetric.toMetricIdFor(txId).also { metricId -> + metrics[metricId] = endMetric + metrics[metricId.toRelatedMetricId()] = startMetric + } + } + } + + } + + private fun Keyed.toMetricIdFor(id: Long): String = "$id.$key" + private fun String.toRelatedMetricId(): String = "$this.related" + private fun String.toTxId(): Long = split('.').first().toLong() +} + + + + + + + + diff --git a/app/src/main/java/cash/z/ecc/android/ui/util/PermissionFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/util/PermissionFragment.kt new file mode 100644 index 0000000..dd598ed --- /dev/null +++ b/app/src/main/java/cash/z/ecc/android/ui/util/PermissionFragment.kt @@ -0,0 +1,47 @@ +package cash.z.ecc.android.ui.util +// +//import android.Manifest +//import android.content.Context +//import android.content.pm.PackageManager +//import android.os.Bundle +//import android.widget.Toast +//import androidx.core.content.ContextCompat +//import androidx.fragment.app.Fragment +//import cash.z.ecc.android.ui.MainActivity +// +//class PermissionFragment : Fragment() { +// +// val activity get() = context as MainActivity +// +// override fun onCreate(savedInstanceState: Bundle?) { +// super.onCreate(savedInstanceState) +// if (!hasPermissions(activity)) { +// requestPermissions(PERMISSIONS, REQUEST_CODE) +// } else { +// activity.openCamera() +// } +// } +// +// override fun onRequestPermissionsResult( +// requestCode: Int, permissions: Array, grantResults: IntArray +// ) { +// super.onRequestPermissionsResult(requestCode, permissions, grantResults) +// +// if (requestCode == REQUEST_CODE) { +// if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { +// activity.openCamera() +// } else { +// Toast.makeText(context, "Camera request denied", Toast.LENGTH_LONG).show() +// } +// } +// } +// +// companion object { +// private const val REQUEST_CODE = 101 +// private val PERMISSIONS = arrayOf(Manifest.permission.CAMERA) +// +// fun hasPermissions(context: Context) = PERMISSIONS.all { +// ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_text_dark.xml b/app/src/main/res/color/selector_button_text_dark.xml new file mode 100644 index 0000000..916fae4 --- /dev/null +++ b/app/src/main/res/color/selector_button_text_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_button_text.xml b/app/src/main/res/color/selector_button_text_light.xml similarity index 100% rename from app/src/main/res/color/selector_button_text.xml rename to app/src/main/res/color/selector_button_text_light.xml diff --git a/app/src/main/res/color/selector_button_text_light_dimmed.xml b/app/src/main/res/color/selector_button_text_light_dimmed.xml new file mode 100644 index 0000000..73a1f22 --- /dev/null +++ b/app/src/main/res/color/selector_button_text_light_dimmed.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 7361bc1..0000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/background_footer.xml b/app/src/main/res/drawable/background_footer.xml new file mode 100644 index 0000000..5fc5b96 --- /dev/null +++ b/app/src/main/res/drawable/background_footer.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/background_gradient_bottom.xml b/app/src/main/res/drawable/background_gradient_bottom.xml new file mode 100644 index 0000000..7cd7c0e --- /dev/null +++ b/app/src/main/res/drawable/background_gradient_bottom.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/background_header.xml b/app/src/main/res/drawable/background_header.xml index 430ae42..d206fe1 100644 --- a/app/src/main/res/drawable/background_header.xml +++ b/app/src/main/res/drawable/background_header.xml @@ -22,9 +22,6 @@ android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" /> - diff --git a/app/src/main/res/drawable/ic_info_24dp.xml b/app/src/main/res/drawable/ic_info_24dp.xml new file mode 100644 index 0000000..7be0147 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 74852f7..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_profile_zebra_01.xml b/app/src/main/res/drawable/ic_profile_zebra_01.xml new file mode 100644 index 0000000..a861b77 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_zebra_01.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_sadzebra.xml b/app/src/main/res/drawable/ic_sadzebra.xml new file mode 100644 index 0000000..3c9bb97 --- /dev/null +++ b/app/src/main/res/drawable/ic_sadzebra.xml @@ -0,0 +1,496 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_warning_24dp.xml b/app/src/main/res/drawable/ic_warning_24dp.xml new file mode 100644 index 0000000..29d8a44 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/footer_transactions.xml b/app/src/main/res/layout/footer_transactions.xml new file mode 100644 index 0000000..453df3d --- /dev/null +++ b/app/src/main/res/layout/footer_transactions.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index 5a8c076..afe478a 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -101,6 +101,7 @@ android:layout_height="wrap_content" android:text="(enter an amount to send)" android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" + android:textColor="@color/text_light_dimmed" app:layout_constraintBottom_toBottomOf="@id/back_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -110,10 +111,10 @@ android:id="@+id/header" android:layout_width="0dp" android:layout_height="wrap_content" + android:background="@drawable/background_header" + android:onClick="copyAddress" android:paddingBottom="24dp" android:paddingTop="24dp" - android:onClick="copyAddress" - android:background="@drawable/background_header" app:layout_constraintEnd_toEndOf="@id/guideline_keyline_end" app:layout_constraintStart_toStartOf="@id/guideline_keyline_start" app:layout_constraintTop_toBottomOf="@id/back_button_hit_area"> @@ -144,28 +145,51 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" - tools:text="zs1g7cqw...9qmvyzgm" android:textColor="@color/colorPrimaryMedium" app:layout_constraintStart_toEndOf="@id/label_address" - app:layout_constraintTop_toBottomOf="@+id/text_header_title" /> + app:layout_constraintTop_toBottomOf="@+id/text_header_title" + tools:text="zs1g7cqw...9qmvyzgm" /> + app:layout_constraintStart_toEndOf="@id/text_address" + app:layout_constraintTop_toTopOf="@id/label_address" /> + + + + + tools:orientation="vertical" + tools:visibility="gone" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 4168aac..4964c1e 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -241,23 +241,17 @@ app:layout_constraintTop_toBottomOf="@id/button_number_pad_9" app:layout_constraintWidth_percent="@dimen/calculator_button_width_percent" /> - - - + + + + + + + + + + + + + + + + + + app:srcCompat="@drawable/ic_profile_zebra_01" /> @@ -181,7 +182,7 @@ android:paddingTop="8dp" android:text="zECC App" android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" - android:textColor="@color/text_light" + android:textColor="@color/selector_button_text_light" app:layout_constraintEnd_toEndOf="@id/guideline_content_end" app:layout_constraintStart_toStartOf="@id/guideline_content_start" app:layout_constraintTop_toBottomOf="@id/button_logs" diff --git a/app/src/main/res/layout/fragment_receive_new.xml b/app/src/main/res/layout/fragment_receive_new.xml index 2bfb845..e020ab2 100644 --- a/app/src/main/res/layout/fragment_receive_new.xml +++ b/app/src/main/res/layout/fragment_receive_new.xml @@ -183,7 +183,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.065" - app:srcCompat="@drawable/ic_arrow_back_black_24dp" /> + app:srcCompat="@drawable/ic_close_black_24dp" /> + app:srcCompat="@drawable/ic_close_black_24dp" /> + android:background="@drawable/background_home"> + app:srcCompat="@drawable/ic_arrow_back_black_24dp" /> + app:layout_constraintStart_toEndOf="@id/back_button_hit_area" + app:layout_constraintTop_toTopOf="@id/back_button" /> - - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text_banner_message" + app:layout_constraintVertical_bias="0.08" + app:layout_constraintWidth_percent="0.84"> + + + + + + + + + + + @@ -110,14 +136,15 @@ android:id="@+id/image_scan_qr" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingBottom="24dp" + android:paddingEnd="1dp" android:paddingStart="6dp" android:paddingTop="10dp" - android:paddingEnd="1dp" - android:paddingBottom="24dp" android:tint="@color/zcashWhite" - app:layout_constraintBottom_toBottomOf="@id/input_zcash_address" - app:layout_constraintEnd_toEndOf="@id/input_zcash_address" - app:layout_constraintTop_toTopOf="@id/input_zcash_address" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@id/text_layout_address" + app:layout_constraintEnd_toEndOf="@id/text_layout_address" + app:layout_constraintTop_toTopOf="@id/text_layout_address" app:srcCompat="@drawable/ic_qrcode_24dp" /> + app:layout_constraintEnd_toEndOf="@+id/text_layout_address" + app:layout_constraintTop_toBottomOf="@+id/text_layout_amount" /> @@ -146,19 +173,17 @@ android:text="Address on clipboard!" android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" android:textColor="@color/text_light" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@+id/input_zcash_address" + app:layout_constraintEnd_toEndOf="@+id/text_layout_address" app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="@+id/input_zcash_address" - app:layout_constraintTop_toBottomOf="@id/button_next" - app:layout_constraintVertical_bias="0.07" /> + app:layout_constraintStart_toStartOf="@+id/text_layout_address" + app:layout_constraintTop_toBottomOf="@id/back_button_hit_area" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_send_confirm.xml b/app/src/main/res/layout/fragment_send_confirm.xml index 583346d..2061522 100644 --- a/app/src/main/res/layout/fragment_send_confirm.xml +++ b/app/src/main/res/layout/fragment_send_confirm.xml @@ -81,6 +81,7 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="Tap\nto send ZEC" + android:padding="12dp" android:textColor="@color/text_dark" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/fragment_send_final.xml b/app/src/main/res/layout/fragment_send_final.xml index f51fde8..bdd758a 100644 --- a/app/src/main/res/layout/fragment_send_final.xml +++ b/app/src/main/res/layout/fragment_send_final.xml @@ -34,6 +34,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.065" + android:visibility="gone" app:srcCompat="@drawable/ic_close_black_24dp" /> + + + app:layout_constraintStart_toStartOf="@id/input_memo" + tools:text="sent from z23lk4jjl2k3jl43kkj542l3kl4hj2l3k1j41l2kjk423lkj423lklhk2jrhiuhrh2j4hh2hkj23hkj4" /> + app:layout_constraintStart_toStartOf="@id/text_included_address" + app:layout_constraintTop_toTopOf="@id/text_included_address" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_transaction.xml b/app/src/main/res/layout/item_transaction.xml index b581cdb..2a22397 100644 --- a/app/src/main/res/layout/item_transaction.xml +++ b/app/src/main/res/layout/item_transaction.xml @@ -118,6 +118,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="16dp" + android:paddingStart="12dp" android:autoSizeTextType="uniform" android:gravity="center_vertical" android:maxLines="1" @@ -127,6 +128,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/indicator" app:layout_constraintWidth_percent="0.25" - tools:text="+ 4345.2444" /> + tools:text="+ 434.2444234" /> \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 9f69274..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 9f69274..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index a571e60..9d85a30 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 61da551..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index c41dd28..5585c0e 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index db5080a..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 6dba46d..b098f36 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index da31a87..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 15ac681..06aa046 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index b216f2d..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index f25a419..3934c2b 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index e96783c..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index d72fa1b..f127f5f 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -40,7 +40,7 @@ @@ -52,10 +52,11 @@ + app:popUpTo="@id/nav_scan" + app:popUpToInclusive="true"/> @@ -134,6 +135,11 @@ android:id="@+id/nav_send_final" android:name="cash.z.ecc.android.ui.send.SendFinalFragment" tools:layout="@layout/fragment_send_final" > + @@ -157,4 +163,16 @@ android:name="cash.z.ecc.android.ui.setup.BackupFragment" tools:layout="@layout/fragment_backup" > + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/lottie_button_forever.json b/app/src/main/res/raw/lottie_button_forever.json new file mode 100644 index 0000000..e8b157d --- /dev/null +++ b/app/src/main/res/raw/lottie_button_forever.json @@ -0,0 +1 @@ +{"v":"5.6.0","fr":30,"ip":0,"op":30,"w":324,"h":64,"nm":"BalanceHome","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79,"s":[0]},{"t":90,"s":[100]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":30,"op":127,"st":30,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[4]},{"t":90,"s":[2]}],"ix":5},"lc":2,"lj":1,"ml":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0]},{"t":85,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[15]},{"t":85,"s":[100]}],"ix":2},"o":{"a":0,"k":-90,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":30,"op":127,"st":30,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"t":30,"s":[15]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-90]},{"t":30,"s":[270]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/raw/lottie_button_loading.json b/app/src/main/res/raw/lottie_button_loading.json new file mode 100644 index 0000000..b8c24fd --- /dev/null +++ b/app/src/main/res/raw/lottie_button_loading.json @@ -0,0 +1 @@ +{"v":"5.6.0","fr":30,"ip":0,"op":97,"w":324,"h":64,"nm":"BalanceHome","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":15,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"t":15,"s":[15]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-90]},{"t":15,"s":[270]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":30,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[4]},{"t":90,"s":[2]}],"ix":5},"lc":2,"lj":1,"ml":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[0]},{"t":85,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[15]},{"t":85,"s":[100]}],"ix":2},"o":{"a":0,"k":-90,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":15,"op":127,"st":30,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"GoldButton","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79,"s":[0]},{"t":90,"s":[100]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":127,"st":30,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"DisabledButton","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[30]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[30]},{"t":75,"s":[30]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":97,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/raw/lottie_button_loading_new.json b/app/src/main/res/raw/lottie_button_loading_new.json new file mode 100644 index 0000000..ccddc61 --- /dev/null +++ b/app/src/main/res/raw/lottie_button_loading_new.json @@ -0,0 +1 @@ +{"v":"5.6.0","fr":30,"ip":0,"op":200,"w":324,"h":64,"nm":"FullSync","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"StrokeFirst","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":100,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"t":100,"s":[15]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-90]},{"t":100,"s":[990]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"StrokeLater","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":99,"s":[4]},{"t":174,"s":[2]}],"ix":5},"lc":2,"lj":1,"ml":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":99,"s":[0]},{"t":199,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":99,"s":[15]},{"t":199,"s":[100]}],"ix":2},"o":{"a":0,"k":-90,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":99,"op":211,"st":114,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"GoldButton","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":175,"s":[0]},{"t":186,"s":[100]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":201,"st":30,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"DisabledButton","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":100,"s":[15]},{"t":140,"s":[30]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":249,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2ac2a3e..cbbbb10 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -24,6 +24,7 @@ and should map directly to the design style guide --> #F5F5F5 + #616161 #1FFFFFFF #3DFFFFFF #66FFFFFF @@ -44,9 +45,9 @@ #26DAB6 - #FFD649 - #FFB727 - #FFA918 + #FFCF4A + #FFB900 + #FFAA17 @@ -59,6 +60,7 @@ @color/zcashBlack_dark @color/zcashBlack_87 #1FBB666A + @color/text_light #FFFFFF diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4db4a6f..f6109ed 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -43,7 +43,7 @@ + + + + diff --git a/app/src/test/java/cash/z/ecc/android/SendViewModelTest.kt b/app/src/test/java/cash/z/ecc/android/SendViewModelTest.kt new file mode 100644 index 0000000..db6a34d --- /dev/null +++ b/app/src/test/java/cash/z/ecc/android/SendViewModelTest.kt @@ -0,0 +1,111 @@ +package cash.z.ecc.android + +import cash.z.ecc.android.feedback.Feedback +import cash.z.ecc.android.ui.send.SendViewModel +import cash.z.wallet.sdk.entity.* +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.Spy + +class SendViewModelTest { + + @Mock lateinit var creatingTx: PendingTransaction + @Mock lateinit var createdTx: PendingTransaction + @Mock lateinit var submittedTx: PendingTransaction + @Mock lateinit var minedTx: PendingTransaction + + @Mock + lateinit var feedback: Feedback + + @Spy + lateinit var sendViewModel: SendViewModel + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + Dispatchers.setMain(newSingleThreadContext("Main thread")) + + whenever(creatingTx.id).thenReturn(7) + whenever(creatingTx.submitAttempts).thenReturn(0) + + whenever(createdTx.id).thenReturn(7) + whenever(createdTx.raw).thenReturn(byteArrayOf(0x1)) + + whenever(submittedTx.id).thenReturn(7) + whenever(submittedTx.raw).thenReturn(byteArrayOf(0x1)) + whenever(submittedTx.submitAttempts).thenReturn(1) + + whenever(minedTx.id).thenReturn(7) + whenever(minedTx.raw).thenReturn(byteArrayOf(0x1)) + whenever(minedTx.submitAttempts).thenReturn(1) + whenever(minedTx.minedHeight).thenReturn(500_001) + + sendViewModel.feedback = feedback + } + + @Test + fun testUpdateMetrics_creating() { +// doNothing().whenever(sendViewModel).report(any()) + + assertEquals(true, creatingTx.isCreating()) + sendViewModel.updateMetrics(creatingTx) + + verify(sendViewModel).report("7.metric.tx.initialized") + assertEquals(1, sendViewModel.metrics.size) + verifyZeroInteractions(feedback) + } + + @Test + fun testUpdateMetrics_created() { + assertEquals(false, createdTx.isCreating()) + assertEquals(true, createdTx.isCreated()) + sendViewModel.updateMetrics(creatingTx) + sendViewModel.updateMetrics(createdTx) + Thread.sleep(100) + println(sendViewModel.metrics) + + verify(sendViewModel).report("7.metric.tx.created") + assertEquals(1, sendViewModel.metrics.size) + } + + @Test + fun testUpdateMetrics_submitted() { + assertEquals(false, submittedTx.isCreating()) + assertEquals(false, submittedTx.isCreated()) + assertEquals(true, submittedTx.isSubmitSuccess()) + sendViewModel.updateMetrics(creatingTx) + sendViewModel.updateMetrics(createdTx) + sendViewModel.updateMetrics(submittedTx) + assertEquals(5, sendViewModel.metrics.size) + + Thread.sleep(100) + assertEquals(1, sendViewModel.metrics.size) + + verify(feedback).report(sendViewModel.metrics.values.first()) + } + + + @Test + fun testUpdateMetrics_mined() { + assertEquals(true, minedTx.isMined()) + assertEquals(true, minedTx.isSubmitSuccess()) + sendViewModel.updateMetrics(creatingTx) + sendViewModel.updateMetrics(createdTx) + sendViewModel.updateMetrics(submittedTx) + sendViewModel.updateMetrics(minedTx) + assertEquals(7, sendViewModel.metrics.size) + + Thread.sleep(100) + assertEquals(0, sendViewModel.metrics.size) + } + +} \ No newline at end of file diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/build.gradle b/build.gradle index 63c1515..3db2588 100644 --- a/build.gradle +++ b/build.gradle @@ -4,11 +4,16 @@ buildscript { repositories { google() jcenter() + maven { + url 'https://maven.fabric.io/public' + } } dependencies { classpath 'com.android.tools.build:gradle:3.6.0-rc01' classpath 'com.google.gms:google-services:4.3.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Deps.kotlinVersion}" + classpath 'io.fabric.tools:gradle:1.31.2' + classpath 'com.google.firebase:perf-plugin:1.3.1' } } diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt index f9d1dd1..e33e098 100644 --- a/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt +++ b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt @@ -1,12 +1,15 @@ package cash.z.ecc.android.feedback +import android.util.Log import cash.z.ecc.android.feedback.util.CompositeJob import kotlinx.coroutines.* import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import java.io.OutputStreamWriter import java.io.PrintWriter import java.io.StringWriter +import java.lang.StringBuilder import kotlin.coroutines.coroutineContext class Feedback(capacity: Int = 256) { @@ -34,8 +37,17 @@ class Feedback(capacity: Int = 256) { * [actions] channels will remain open unless [stop] is also called on this instance. */ suspend fun start(): Feedback { - check(!::scope.isInitialized) { - "Error: cannot initialize feedback because it has already been initialized." + val callStack = StringBuilder().let { s -> + Thread.currentThread().stackTrace.forEach {element -> + s.append("$element\n") + } + s.toString() + } + if(::scope.isInitialized) { + Log.e("@TWIG","Warning: did not initialize feedback because it has already been initialized. Call stack: $callStack") + return this + } else { + Log.e("@TWIG","Debug: Initializing feedback for the first time. Call stack: $callStack") } scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job])) invokeOnCompletion { @@ -162,8 +174,8 @@ class Feedback(capacity: Int = 256) { } - interface Metric : Mappable { - val key: String + interface Metric : Mappable, Keyed { + override val key: String val startTime: Long? val endTime: Long? val elapsedTime: Long? @@ -180,13 +192,17 @@ class Feedback(capacity: Int = 256) { } } - interface Action : Feedback.Mappable { - val key: String + interface Action : Feedback.Mappable, Keyed { + override val key: String override fun toMap(): Map { return mapOf("key" to key) } } + interface Keyed { + val key: T + } + interface Mappable { fun toMap(): Map }