[#15] Add onboarding skeleton
This implements the logical flow between the onboarding stages, as well as nearly full test coverage of the flow. There's a lot of followup work, especially to implement design styles. It currently contains various placeholder values, e.g. lorem ipsum text and color blocks instead of images. See #17
This commit is contained in:
parent
403f5d6467
commit
1f5d7eb966
|
@ -15,15 +15,6 @@ android {
|
|||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.get()
|
||||
}
|
||||
|
||||
flavorDimensions.add("network")
|
||||
|
||||
productFlavors {
|
||||
|
|
|
@ -75,6 +75,7 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO [#22]: This doesn't work, so there's a duplicate in build.gradle.kts
|
||||
testOptions {
|
||||
animationsDisabled = true
|
||||
|
||||
|
@ -82,4 +83,23 @@ fun com.android.build.gradle.BaseExtension.configureBaseExtension() {
|
|||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources.excludes.addAll(
|
||||
listOf(
|
||||
"META-INF/AL2.0",
|
||||
"META-INF/ASL2.0",
|
||||
"META-INF/DEPENDENCIES",
|
||||
"META-INF/LGPL2.1",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/LICENSE-notice.md",
|
||||
"META-INF/LICENSE.md",
|
||||
"META-INF/LICENSE.txt",
|
||||
"META-INF/NOTICE",
|
||||
"META-INF/NOTICE.txt",
|
||||
"META-INF/license.txt",
|
||||
"META-INF/notice.txt",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ Start by making sure the command line with Gradle works first, because **all the
|
|||
1. Java 16 is currently recommended. Java 11 is the minimum requirement for Android Studio.
|
||||
1. To simplify installation, use [Oracle's JDK](https://www.oracle.com/java/technologies/javase-jdk16-downloads.html) installer that will place the Java installation in the right place
|
||||
1. Install Android Studio and the Android SDK
|
||||
1. Download the [stable release of Android Studio](https://developer.android.com/studio#downloads)
|
||||
1. Download the [Android Studio Bumblebee Canary](https://developer.android.com/studio/preview) (we're using the Canary version, due to its improved integration with Jetpack Compose)
|
||||
1. TODO: Fill in step-by-step instructions for setting up a new environment and installing the Android SDK from within Android Studio
|
||||
1. Check out the code. _Use the command line (instead of Android Studio) to check out the code. This will ensure that your command line environment is set up correctly and avoids a few pitfalls with trying to use Android Studio directly. Android Studio's built-in git client is not as robust as standalone clients_
|
||||
1. Compile from the command line
|
||||
|
|
|
@ -59,7 +59,7 @@ dependencyResolutionManagement {
|
|||
alias("androidx-compose-foundation").to("androidx.compose.foundation:foundation:$androidxComposeVersion")
|
||||
alias("androidx-compose-material").to("androidx.compose.material:material:$androidxComposeVersion")
|
||||
alias("androidx-compose-material-icons-core").to("androidx.compose.material:material-icons-core:$androidxComposeVersion")
|
||||
alias("androidx-compose-tooling").to("androidx.compose.ui:ui-tooling-preview:$androidxComposeVersion")
|
||||
alias("androidx-compose-tooling").to("androidx.compose.ui:ui-tooling:$androidxComposeVersion")
|
||||
alias("androidx-compose-ui").to("androidx.compose.ui:ui:$androidxComposeVersion")
|
||||
alias("androidx-core").to("androidx.core:core-ktx:$androidxCoreVersion")
|
||||
alias("androidx-lifecycle-livedata").to("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
|
||||
|
@ -70,7 +70,10 @@ dependencyResolutionManagement {
|
|||
alias("kotlinx-coroutines-core").to("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
|
||||
alias("zcash").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
|
||||
// Test libraries
|
||||
alias("androidx-espresso-contrib").to("androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
|
||||
alias("androidx-compose-test-junit").to("androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")
|
||||
alias("androidx-compose-test-manifest").to("androidx.compose.ui:ui-test-manifest:$androidxComposeVersion")
|
||||
// Cannot use espresso-contrib, because it causes a build failure
|
||||
//alias("androidx-espresso-contrib").to("androidx.test.espresso:espresso-contrib:$androidxEspressoVersion")
|
||||
alias("androidx-espresso-core").to("androidx.test.espresso:espresso-core:$androidxEspressoVersion")
|
||||
alias("androidx-espresso-intents").to("androidx.test.espresso:espresso-intents:$androidxEspressoVersion")
|
||||
alias("androidx-junit").to("androidx.test.ext:junit:$androidxTestJunitVersion")
|
||||
|
|
|
@ -43,4 +43,6 @@ dependencies {
|
|||
implementation(libs.zcash)
|
||||
|
||||
androidTestImplementation(libs.bundles.androidx.test)
|
||||
androidTestImplementation(libs.androidx.compose.test.junit)
|
||||
androidTestImplementation(libs.androidx.compose.test.manifest)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.junit.Test
|
||||
|
||||
class IndexTest {
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@SmallTest
|
||||
fun out_of_bounds() {
|
||||
Index(-1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class OnboardingStageTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_first() {
|
||||
val progress = OnboardingStage.values().first().getProgress()
|
||||
|
||||
assertEquals(0, progress.current.value)
|
||||
assertEquals(3, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getProgress_last() {
|
||||
val progress = OnboardingStage.values().last().getProgress()
|
||||
|
||||
assertEquals(3, progress.current.value)
|
||||
assertEquals(3, progress.last.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasNext_boundary() {
|
||||
val last = OnboardingStage.values().last()
|
||||
|
||||
assertFalse(last.hasNext())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun hasPrevious_boundary() {
|
||||
val last = OnboardingStage.values().first()
|
||||
|
||||
assertFalse(last.hasPrevious())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getNext_from_first() {
|
||||
val first = OnboardingStage.values().first()
|
||||
val next = first.getNext()
|
||||
|
||||
assertNotEquals(first, next)
|
||||
assertEquals(OnboardingStage.UnifiedAddresses, next)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getNext_boundary() {
|
||||
val last = OnboardingStage.values().last()
|
||||
|
||||
assertEquals(last, last.getNext())
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_from_last() {
|
||||
val last = OnboardingStage.values().last()
|
||||
val previous = last.getPrevious()
|
||||
|
||||
assertNotEquals(last, previous)
|
||||
assertEquals(OnboardingStage.More, previous)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun getPrevious_boundary() {
|
||||
val first = OnboardingStage.values().first()
|
||||
|
||||
assertEquals(first, first.getPrevious())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.junit.Test
|
||||
|
||||
class PercentDecimalTest {
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@SmallTest
|
||||
fun require_greater_than_zero() {
|
||||
PercentDecimal(-1.0f)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@SmallTest
|
||||
fun require_less_than_one() {
|
||||
PercentDecimal(1.5f)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.junit.Test
|
||||
|
||||
class ProgressTest {
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@SmallTest
|
||||
fun last_greater_than_zero() {
|
||||
Progress(current = Index(0), last = Index(0))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@SmallTest
|
||||
fun last_greater_or_equal_to_current() {
|
||||
Progress(current = Index(5), last = Index(4))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.test
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)
|
|
@ -0,0 +1,276 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.view
|
||||
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.R
|
||||
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
|
||||
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
|
||||
import cash.z.ecc.ui.screen.onboarding.test.getStringResource
|
||||
import cash.z.ecc.ui.theme.MyApplicationTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class OnboardingViewTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// Sanity check the TestSetup
|
||||
@Test
|
||||
@MediumTest
|
||||
fun verify_test_setup_stage_1() {
|
||||
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
|
||||
|
||||
assertEquals(OnboardingStage.ShieldedByDefault, testSetup.getOnboardingStage())
|
||||
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
|
||||
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun verify_test_setup_stage_4() {
|
||||
val testSetup = newTestSetup(OnboardingStage.Wallet)
|
||||
|
||||
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
|
||||
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
|
||||
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun stage_1_layout() {
|
||||
newTestSetup(OnboardingStage.ShieldedByDefault)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_header)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_body)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun stage_2_layout() {
|
||||
newTestSetup(OnboardingStage.UnifiedAddresses)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_2_header)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_2_body)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun stage_3_layout() {
|
||||
newTestSetup(OnboardingStage.More)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
|
||||
it.assertExists()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_3_header)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_3_body)).also {
|
||||
it.assertExists()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun stage_4_layout() {
|
||||
newTestSetup(OnboardingStage.Wallet)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_back)).also {
|
||||
it.assertExists()
|
||||
it.assertIsEnabled()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also {
|
||||
it.assertExists()
|
||||
it.assertIsEnabled()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also {
|
||||
it.assertExists()
|
||||
it.assertIsEnabled()
|
||||
it.assertHasClickAction()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun stage_1_skip() {
|
||||
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
|
||||
|
||||
val skipButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip))
|
||||
skipButton.performClick()
|
||||
|
||||
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun last_stage_click_create_wallet() {
|
||||
val testSetup = newTestSetup(OnboardingStage.Wallet)
|
||||
|
||||
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet))
|
||||
newWalletButton.performClick()
|
||||
|
||||
assertEquals(1, testSetup.getOnCreateWalletCallbackCount())
|
||||
assertEquals(0, testSetup.getOnImportWalletCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun last_stage_click_import_wallet() {
|
||||
val testSetup = newTestSetup(OnboardingStage.Wallet)
|
||||
|
||||
val newWalletButton = composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet))
|
||||
newWalletButton.performClick()
|
||||
|
||||
assertEquals(1, testSetup.getOnImportWalletCallbackCount())
|
||||
assertEquals(0, testSetup.getOnCreateWalletCallbackCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun multi_stage_progression() {
|
||||
val testSetup = newTestSetup(OnboardingStage.ShieldedByDefault)
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also {
|
||||
it.performClick()
|
||||
}
|
||||
|
||||
assertEquals(OnboardingStage.Wallet, testSetup.getOnboardingStage())
|
||||
}
|
||||
|
||||
private fun newTestSetup(initalStage: OnboardingStage) = TestSetup(composeTestRule, initalStage)
|
||||
|
||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initalStage: OnboardingStage) {
|
||||
private val onboardingState = OnboardingState(initalStage)
|
||||
|
||||
private var onCreateWalletCallbackCount = 0
|
||||
private var onImportWalletCallbackCount = 0
|
||||
|
||||
fun getOnCreateWalletCallbackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onCreateWalletCallbackCount
|
||||
}
|
||||
|
||||
fun getOnImportWalletCallbackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onImportWalletCallbackCount
|
||||
}
|
||||
|
||||
fun getOnboardingStage(): OnboardingStage {
|
||||
composeTestRule.waitForIdle()
|
||||
return onboardingState.current.value
|
||||
}
|
||||
|
||||
init {
|
||||
composeTestRule.setContent {
|
||||
MyApplicationTheme {
|
||||
Onboarding(
|
||||
onboardingState,
|
||||
onCreateWallet = { onCreateWalletCallbackCount++ },
|
||||
onImportWallet = { onImportWalletCallbackCount++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="cash.z.ecc">
|
||||
package="cash.z.ecc.ui">
|
||||
|
||||
<application>
|
||||
|
||||
|
|
|
@ -3,36 +3,25 @@ package cash.z.ecc.ui
|
|||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.activity.viewModels
|
||||
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
|
||||
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||
import cash.z.ecc.ui.theme.MyApplicationTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val onboardingViewModel by viewModels<OnboardingViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MyApplicationTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
Greeting("Android")
|
||||
}
|
||||
Onboarding(
|
||||
onboardingState = onboardingViewModel.onboardingState,
|
||||
onImportWallet = { TODO("Implement wallet import") },
|
||||
onCreateWallet = { TODO("Implement wallet create") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String) {
|
||||
Text(text = "Hello $name!")
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun DefaultPreview() {
|
||||
MyApplicationTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
/**
|
||||
* Useful for accessing arrays or lists by index.
|
||||
*
|
||||
* @param value A 0-based index. Must be >= 0
|
||||
*/
|
||||
@JvmInline
|
||||
value class Index(val value: Int) {
|
||||
init {
|
||||
require(value >= 0) { "Index must be >= 0 but actually is $value" }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
enum class OnboardingStage {
|
||||
// Note: the ordinal order is used to manage progression through each stage
|
||||
// so be careful if reordering these
|
||||
ShieldedByDefault,
|
||||
UnifiedAddresses,
|
||||
More,
|
||||
Wallet;
|
||||
|
||||
/**
|
||||
* @see getPrevious
|
||||
*/
|
||||
fun hasPrevious() = ordinal > 0
|
||||
|
||||
/**
|
||||
* @see getNext
|
||||
*/
|
||||
fun hasNext() = ordinal < values().size - 1
|
||||
|
||||
/**
|
||||
* @return Previous item in ordinal order. Returns the first item when it cannot go further back.
|
||||
*/
|
||||
fun getPrevious() = values()[maxOf(0, ordinal - 1)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
*/
|
||||
fun getNext() = values()[minOf(values().size - 1, ordinal + 1)]
|
||||
|
||||
/**
|
||||
* @return Last item in ordinal order. Returns the last item when it cannot go further forward.
|
||||
*/
|
||||
fun getProgress() = Progress(Index(ordinal), Index(values().size - 1))
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
/**
|
||||
* @param decimal A percent represented as a `Double` decimal value in the range of [0, 1].
|
||||
*/
|
||||
@JvmInline
|
||||
value class PercentDecimal(val decimal: Float) {
|
||||
init {
|
||||
require(EXPECTED_RANGE.contains(decimal)) { "$decimal is outside of range $EXPECTED_RANGE" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXPECTED_RANGE = 0.0f..1.0f
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.model
|
||||
|
||||
data class Progress(val current: Index, val last: Index) {
|
||||
init {
|
||||
require(last.value > 0) { "last must be > 0 but was $last" }
|
||||
require(last.value >= current.value) { "last ($last) must be >= current ($current)" }
|
||||
}
|
||||
|
||||
fun percent() = PercentDecimal((current.value + 1).toFloat() / (last.value + 1).toFloat())
|
||||
|
||||
companion object {
|
||||
val EMPTY = Progress(Index(0), Index(1))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.state
|
||||
|
||||
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* @param initialState Allows restoring the state from a different starting point. This is
|
||||
* primarily useful on Android, for automated tests, and for iterative debugging with the Compose
|
||||
* layout preview. The default constructor argument is generally fine for other platforms.
|
||||
*/
|
||||
class OnboardingState(initialState: OnboardingStage = OnboardingStage.values().first()) {
|
||||
|
||||
private val mutableState = MutableStateFlow(initialState)
|
||||
|
||||
val current: StateFlow<OnboardingStage> = mutableState
|
||||
|
||||
fun hasNext() = current.value.hasNext()
|
||||
|
||||
fun hasPrevious() = current.value.hasPrevious()
|
||||
|
||||
fun goNext() {
|
||||
mutableState.value = current.value.getNext()
|
||||
}
|
||||
|
||||
fun goPrevious() {
|
||||
mutableState.value = current.value.getPrevious()
|
||||
}
|
||||
|
||||
fun goToEnd() {
|
||||
mutableState.value = OnboardingStage.values().last()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import cash.z.ecc.R
|
||||
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
|
||||
import cash.z.ecc.ui.screen.onboarding.model.Progress
|
||||
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
|
||||
import cash.z.ecc.ui.theme.MINIMAL_WEIGHT
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ComposablePreview() {
|
||||
Onboarding(
|
||||
OnboardingState(OnboardingStage.UnifiedAddresses),
|
||||
onImportWallet = {},
|
||||
onCreateWallet = {}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onImportWallet Callback when the user decides to import an existing wallet.
|
||||
* @param onCreateWallet Callback when the user decides to create a new wallet.
|
||||
*/
|
||||
@Composable
|
||||
fun Onboarding(
|
||||
onboardingState: OnboardingState,
|
||||
onImportWallet: () -> Unit,
|
||||
onCreateWallet: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
TopNavButtons(onboardingState)
|
||||
|
||||
val onboardingStage = onboardingState.current.collectAsState().value
|
||||
|
||||
when (onboardingStage) {
|
||||
OnboardingStage.ShieldedByDefault -> ShieldedByDefault()
|
||||
OnboardingStage.UnifiedAddresses -> UnifiedAddresses()
|
||||
OnboardingStage.More -> More()
|
||||
OnboardingStage.Wallet -> Wallet(
|
||||
onCreateWallet = onCreateWallet,
|
||||
onImportWallet = onImportWallet
|
||||
)
|
||||
}
|
||||
|
||||
BottomNav(onboardingStage.getProgress(), onboardingState::goNext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopNavButtons(onboardingState: OnboardingState) {
|
||||
Row {
|
||||
if (onboardingState.hasPrevious()) {
|
||||
Button(onboardingState::goPrevious) {
|
||||
Text(stringResource(R.string.onboarding_back))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(MINIMAL_WEIGHT, true))
|
||||
|
||||
if (onboardingState.hasNext()) {
|
||||
Button(onboardingState::goToEnd) {
|
||||
Text(stringResource(R.string.onboarding_skip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNav(progress: Progress, onNext: () -> Unit) {
|
||||
if (progress.current != progress.last) {
|
||||
Column {
|
||||
Button(onNext, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.onboarding_next))
|
||||
}
|
||||
|
||||
// Converts from index to human numbering
|
||||
Text((progress.current.value + 1).toString())
|
||||
|
||||
LinearProgressIndicator(progress = progress.percent().decimal, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShieldedByDefault() {
|
||||
Column {
|
||||
Content(
|
||||
image = ColorPainter(Color.Blue),
|
||||
imageContentDescription = stringResource(R.string.onboarding_1_image_content_description),
|
||||
headline = stringResource(R.string.onboarding_1_header),
|
||||
body = stringResource(R.string.onboarding_1_body)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnifiedAddresses() {
|
||||
Column {
|
||||
Content(
|
||||
image = ColorPainter(Color.Blue),
|
||||
imageContentDescription = stringResource(R.string.onboarding_2_image_content_description),
|
||||
headline = stringResource(R.string.onboarding_2_header),
|
||||
body = stringResource(R.string.onboarding_2_body)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun More() {
|
||||
Column {
|
||||
Content(
|
||||
image = ColorPainter(Color.Blue),
|
||||
imageContentDescription = stringResource(R.string.onboarding_3_image_content_description),
|
||||
headline = stringResource(R.string.onboarding_3_header),
|
||||
body = stringResource(R.string.onboarding_3_body)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Wallet(onCreateWallet: () -> Unit, onImportWallet: () -> Unit) {
|
||||
Column {
|
||||
Button(onCreateWallet, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.onboarding_4_create_new_wallet))
|
||||
}
|
||||
Button(onImportWallet, Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.onboarding_4_import_existing_wallet))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
image: Painter,
|
||||
imageContentDescription: String?,
|
||||
headline: String,
|
||||
body: String
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
// TODO [#17]: This suppression and magic number will get replaced once we have real assets
|
||||
@Suppress("MagicNumber")
|
||||
Image(image, imageContentDescription, Modifier.fillMaxSize(0.50f))
|
||||
}
|
||||
Text(headline)
|
||||
Text(body)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package cash.z.ecc.ui.screen.onboarding.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.ui.screen.onboarding.model.OnboardingStage
|
||||
import cash.z.ecc.ui.screen.onboarding.state.OnboardingState
|
||||
|
||||
/*
|
||||
* Android-specific ViewModel. This is used to save and restore state across Activity recreations
|
||||
* outside of the Compose framework.
|
||||
*/
|
||||
class OnboardingViewModel(
|
||||
application: Application,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
val onboardingState: OnboardingState = run {
|
||||
val initialValue = if (savedStateHandle.contains(KEY_STAGE)) {
|
||||
savedStateHandle.get<OnboardingStage>(KEY_STAGE)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (null == initialValue) {
|
||||
OnboardingState()
|
||||
} else {
|
||||
OnboardingState(initialValue)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// viewModelScope is constructed with Dispatchers.Main.immediate, so this will
|
||||
// update the save state as soon as a change occurs.
|
||||
onboardingState.current.collectWith(viewModelScope) {
|
||||
savedStateHandle.set(KEY_STAGE, it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_STAGE = "stage" // $NON-NLS
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package cash.z.ecc.ui.theme
|
||||
|
||||
/**
|
||||
* A tiny weight, useful for spacers to fill an empty space.
|
||||
*/
|
||||
const val MINIMAL_WEIGHT = 0.0001f
|
|
@ -30,7 +30,7 @@ private val LightColorPalette = lightColors(
|
|||
@Composable
|
||||
fun MyApplicationTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable() () -> Unit
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
|
|
|
@ -1,3 +1,21 @@
|
|||
<resources>
|
||||
<string name="app_name">Demo App</string>
|
||||
|
||||
<string name="onboarding_back">Back</string>
|
||||
<string name="onboarding_skip">Skip</string>
|
||||
<string name="onboarding_next">Next</string>
|
||||
<string name="onboarding_1_header">Shielded by Default</string>
|
||||
<string name="onboarding_1_body">Tired of worrying about which wallet you used last? US TOO! Now you don‘t have to, as all funds will automatically be moved to your shielded wallet (and migrated for you).</string>
|
||||
<string name="onboarding_1_image_content_description"></string>
|
||||
<string name="onboarding_2_header">Unified Addresses</string>
|
||||
<string name="onboarding_2_body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget metus euismod, hendrerit dui vel, faucibus ante. Curabitur tortor elit, varius eu leo id, fringilla auctor odio. Donec fringilla tortor purus.</string>
|
||||
<string name="onboarding_2_image_content_description"></string>
|
||||
<string name="onboarding_3_header">And so much more…</string>
|
||||
<string name="onboarding_3_body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eget metus euismod, hendrerit dui vel, faucibus ante. Curabitur tortor elit, varius eu leo id, fringilla auctor odio. Donec fringilla tortor purus.</string>
|
||||
<string name="onboarding_3_image_content_description"></string>
|
||||
<string name="onboarding_4_header">Ready for the Future</string>
|
||||
<string name="onboarding_4_body">Let‘s get you set up!</string>
|
||||
<string name="onboarding_4_image_content_description"></string>
|
||||
<string name="onboarding_4_create_new_wallet">Create New Wallet</string>
|
||||
<string name="onboarding_4_import_existing_wallet">Import an Existing Wallet</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in New Issue