[#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:
Carter Jernigan 2021-10-09 10:37:03 -04:00
parent 403f5d6467
commit 1f5d7eb966
23 changed files with 798 additions and 36 deletions

View File

@ -15,15 +15,6 @@ android {
versionName = "1.0"
}
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
}
flavorDimensions.add("network")
productFlavors {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 dont 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">Lets 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>