-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(Android): add basic onboarding UX #592
Changes from 2 commits
11eb87f
a536e0d
751452e
3892aa3
f262a21
10011ba
7c9279c
64c575c
d98033b
d943295
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
package com.mbta.tid.mbta_app.android.onboarding | ||
|
||
import android.location.Location | ||
import androidx.compose.ui.test.assertIsDisplayed | ||
import androidx.compose.ui.test.junit4.createComposeRule | ||
import androidx.compose.ui.test.onNodeWithText | ||
import androidx.compose.ui.test.performClick | ||
import androidx.test.rule.GrantPermissionRule | ||
import com.mbta.tid.mbta_app.android.location.MockLocationDataManager | ||
import com.mbta.tid.mbta_app.model.OnboardingScreen | ||
import com.mbta.tid.mbta_app.repositories.MockSettingsRepository | ||
import com.mbta.tid.mbta_app.repositories.Settings | ||
import kotlin.test.assertEquals | ||
import kotlin.test.assertTrue | ||
import org.junit.Rule | ||
import org.junit.Test | ||
|
||
class OnboardingScreenViewTest { | ||
@get:Rule val composeTestRule = createComposeRule() | ||
|
||
// We can't easily mock the permission request, so we grant the permission eagerly. | ||
@get:Rule | ||
val runtimePermissionRule = | ||
GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION) | ||
|
||
@Test | ||
fun testLocationFlow() { | ||
var advanced = false | ||
val locationDataManager = MockLocationDataManager(Location("mock")) | ||
composeTestRule.setContent { | ||
OnboardingScreenView( | ||
screen = OnboardingScreen.Location, | ||
advance = { advanced = true }, | ||
locationDataManager = locationDataManager, | ||
) | ||
} | ||
|
||
composeTestRule | ||
.onNodeWithText( | ||
"We use your location to show you nearby transit options.", | ||
substring = true | ||
) | ||
.assertIsDisplayed() | ||
composeTestRule.onNodeWithText("Continue").performClick() | ||
|
||
composeTestRule.waitForIdle() | ||
assertTrue(advanced) | ||
} | ||
|
||
@Test | ||
fun testHideMapsFlow() { | ||
var savedSetting = false | ||
val settingsRepo = | ||
MockSettingsRepository( | ||
settings = mapOf(Settings.HideMaps to false), | ||
onSaveSettings = { | ||
assertEquals(mapOf(Settings.HideMaps to true), it) | ||
savedSetting = true | ||
} | ||
) | ||
var advanced = false | ||
composeTestRule.setContent { | ||
OnboardingScreenView( | ||
screen = OnboardingScreen.HideMaps, | ||
advance = { advanced = true }, | ||
locationDataManager = MockLocationDataManager(Location("mock")), | ||
settingsRepository = settingsRepo | ||
) | ||
} | ||
composeTestRule | ||
.onNodeWithText( | ||
"When using TalkBack, we can skip reading out maps to keep you focused on transit information." | ||
) | ||
.assertIsDisplayed() | ||
composeTestRule.onNodeWithText("Show maps").assertIsDisplayed() | ||
composeTestRule.onNodeWithText("Hide maps").performClick() | ||
|
||
composeTestRule.waitForIdle() | ||
assertTrue(savedSetting) | ||
assertTrue(advanced) | ||
} | ||
|
||
@Test | ||
fun testFeedbackFlow() { | ||
var advanced = false | ||
composeTestRule.setContent { | ||
OnboardingScreenView( | ||
screen = OnboardingScreen.Feedback, | ||
advance = { advanced = true }, | ||
locationDataManager = MockLocationDataManager(Location("mock")), | ||
) | ||
} | ||
composeTestRule | ||
.onNodeWithText( | ||
"MBTA Go is in the early stages! We want your feedback" + | ||
" as we continue making improvements and adding new features." | ||
) | ||
.assertIsDisplayed() | ||
composeTestRule.onNodeWithText("Get started").performClick() | ||
|
||
composeTestRule.waitForIdle() | ||
assertTrue(advanced) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package com.mbta.tid.mbta_app.android.pages | ||
|
||
import android.location.Location | ||
import androidx.compose.ui.test.junit4.createComposeRule | ||
import androidx.compose.ui.test.onNodeWithText | ||
import androidx.compose.ui.test.performClick | ||
import com.mbta.tid.mbta_app.android.location.MockLocationDataManager | ||
import com.mbta.tid.mbta_app.model.OnboardingScreen | ||
import com.mbta.tid.mbta_app.repositories.MockOnboardingRepository | ||
import kotlin.test.assertEquals | ||
import kotlin.test.assertTrue | ||
import org.junit.Rule | ||
import org.junit.Test | ||
|
||
class OnboardingPageTest { | ||
@get:Rule val composeTestRule = createComposeRule() | ||
|
||
@Test | ||
fun testFlow() { | ||
val completedScreens = mutableSetOf<OnboardingScreen>() | ||
val onboardingRepository = | ||
MockOnboardingRepository( | ||
pendingOnboarding = OnboardingScreen.entries, | ||
onMarkComplete = completedScreens::add | ||
) | ||
var finished = false | ||
|
||
composeTestRule.setContent { | ||
OnboardingPage( | ||
screens = OnboardingScreen.entries, | ||
locationDataManager = MockLocationDataManager(Location("mock")), | ||
onFinish = { finished = true }, | ||
onboardingRepository = onboardingRepository, | ||
skipLocationDialogue = true, | ||
) | ||
} | ||
|
||
composeTestRule.onNodeWithText("Continue").performClick() | ||
composeTestRule.waitForIdle() | ||
assertEquals(1, completedScreens.size) | ||
|
||
composeTestRule.onNodeWithText("Show maps").performClick() | ||
composeTestRule.waitForIdle() | ||
assertEquals(2, completedScreens.size) | ||
|
||
composeTestRule.onNodeWithText("Get started").performClick() | ||
composeTestRule.waitForIdle() | ||
assertEquals(OnboardingScreen.entries.toSet(), completedScreens) | ||
assertTrue(finished) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
package com.mbta.tid.mbta_app.android.onboarding | ||
|
||
import androidx.compose.foundation.layout.Arrangement | ||
import androidx.compose.foundation.layout.Column | ||
import androidx.compose.material3.Button | ||
import androidx.compose.material3.Text | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.runtime.rememberCoroutineScope | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.res.stringResource | ||
import androidx.compose.ui.unit.dp | ||
import com.google.accompanist.permissions.ExperimentalPermissionsApi | ||
import com.google.accompanist.permissions.isGranted | ||
import com.mbta.tid.mbta_app.android.R | ||
import com.mbta.tid.mbta_app.android.location.LocationDataManager | ||
import com.mbta.tid.mbta_app.model.OnboardingScreen | ||
import com.mbta.tid.mbta_app.repositories.ISettingsRepository | ||
import com.mbta.tid.mbta_app.repositories.Settings | ||
import kotlinx.coroutines.launch | ||
import org.koin.compose.koinInject | ||
|
||
@OptIn(ExperimentalPermissionsApi::class) | ||
@Composable | ||
fun OnboardingScreenView( | ||
screen: OnboardingScreen, | ||
advance: () -> Unit, | ||
locationDataManager: LocationDataManager, | ||
skipLocationDialogue: Boolean = false, | ||
settingsRepository: ISettingsRepository = koinInject() | ||
) { | ||
val coroutineScope = rememberCoroutineScope() | ||
var sharingLocation by remember { mutableStateOf(false) } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Is it safe to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not! If the orientation changes, the permission response is ignored, because this will have been forgotten. Good catch. |
||
val permissions = | ||
locationDataManager.rememberPermissions( | ||
onPermissionsResult = { | ||
// This only fires after the permissions state has changed | ||
if (sharingLocation) { | ||
advance() | ||
} | ||
} | ||
) | ||
|
||
fun hideMaps(hide: Boolean) { | ||
coroutineScope.launch { | ||
settingsRepository.setSettings(mapOf(Settings.HideMaps to hide)) | ||
advance() | ||
} | ||
} | ||
|
||
fun shareLocation() { | ||
if (skipLocationDialogue || permissions.permissions.any { it.status.isGranted }) { | ||
advance() | ||
} else { | ||
sharingLocation = true | ||
permissions.launchMultiplePermissionRequest() | ||
} | ||
} | ||
|
||
Column { | ||
when (screen) { | ||
OnboardingScreen.Feedback -> { | ||
Column( | ||
verticalArrangement = Arrangement.spacedBy(16.dp), | ||
horizontalAlignment = Alignment.Start | ||
) { | ||
Text(stringResource(R.string.onboarding_feedback_header)) | ||
Text(stringResource(R.string.onboarding_feedback_body)) | ||
Button(onClick = advance) { | ||
Text(stringResource(R.string.onboarding_feedback_advance)) | ||
} | ||
} | ||
} | ||
OnboardingScreen.HideMaps -> { | ||
Column( | ||
verticalArrangement = Arrangement.spacedBy(16.dp), | ||
horizontalAlignment = Alignment.Start | ||
) { | ||
Text(stringResource(R.string.onboarding_hide_maps_header)) | ||
Text(stringResource(R.string.onboarding_hide_maps_body)) | ||
Button(onClick = { hideMaps(true) }) { | ||
Text(stringResource(R.string.onboarding_hide_maps_hide)) | ||
} | ||
Button(onClick = { hideMaps(false) }) { | ||
Text(stringResource(R.string.onboarding_hide_maps_show)) | ||
} | ||
} | ||
} | ||
OnboardingScreen.Location -> { | ||
Column( | ||
verticalArrangement = Arrangement.spacedBy(16.dp), | ||
horizontalAlignment = Alignment.Start | ||
) { | ||
Text(stringResource(R.string.onboarding_location_header)) | ||
Text(stringResource(R.string.onboarding_location_body)) | ||
Button(onClick = ::shareLocation) { | ||
Text(stringResource(R.string.onboarding_location_advance)) | ||
} | ||
Text(stringResource(R.string.onboarding_location_footer)) | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package com.mbta.tid.mbta_app.android.pages | ||
|
||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableIntStateOf | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.runtime.rememberCoroutineScope | ||
import androidx.compose.runtime.setValue | ||
import com.mbta.tid.mbta_app.android.location.LocationDataManager | ||
import com.mbta.tid.mbta_app.android.onboarding.OnboardingScreenView | ||
import com.mbta.tid.mbta_app.model.OnboardingScreen | ||
import com.mbta.tid.mbta_app.repositories.IOnboardingRepository | ||
import kotlinx.coroutines.launch | ||
import org.koin.compose.koinInject | ||
|
||
@Composable | ||
fun OnboardingPage( | ||
screens: List<OnboardingScreen>, | ||
locationDataManager: LocationDataManager, | ||
onFinish: () -> Unit, | ||
onAdvance: () -> Unit = {}, | ||
onboardingRepository: IOnboardingRepository = koinInject(), | ||
skipLocationDialogue: Boolean = false | ||
) { | ||
var selectedIndex by remember { mutableIntStateOf(0) } | ||
val coroutineScope = rememberCoroutineScope() | ||
|
||
val screen = screens[selectedIndex] | ||
OnboardingScreenView( | ||
screen, | ||
{ | ||
coroutineScope.launch { | ||
onboardingRepository.markOnboardingCompleted(screen) | ||
if (selectedIndex < screens.size - 1) { | ||
selectedIndex += 1 | ||
onAdvance() | ||
} else { | ||
onFinish() | ||
} | ||
} | ||
}, | ||
locationDataManager, | ||
skipLocationDialogue | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 nice