Skip to content
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

Merged
merged 10 commits into from
Dec 17, 2024
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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 nice

@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
Expand Up @@ -24,8 +24,10 @@ import com.mbta.tid.mbta_app.android.location.rememberLocationDataManager
import com.mbta.tid.mbta_app.android.pages.MorePage
import com.mbta.tid.mbta_app.android.pages.NearbyTransit
import com.mbta.tid.mbta_app.android.pages.NearbyTransitPage
import com.mbta.tid.mbta_app.android.pages.OnboardingPage
import com.mbta.tid.mbta_app.android.phoenix.PhoenixSocketWrapper
import com.mbta.tid.mbta_app.android.state.getGlobalData
import com.mbta.tid.mbta_app.android.state.getPendingOnboarding
import com.mbta.tid.mbta_app.android.state.subscribeToAlerts
import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse
import com.mbta.tid.mbta_app.network.PhoenixSocket
Expand All @@ -40,6 +42,8 @@ fun ContentView(
val navController = rememberNavController()
var alertData: AlertsStreamDataResponse? = subscribeToAlerts()
val globalResponse = getGlobalData()
val pendingOnboarding = getPendingOnboarding()
var onboardingJustCompleted by remember { mutableStateOf(false) }
val locationDataManager = rememberLocationDataManager()
val mapViewportState = rememberMapViewportState {
setCameraOptions {
Expand All @@ -62,6 +66,16 @@ fun ContentView(
(socket as? PhoenixSocketWrapper)?.attachLogging()
onDispose { socket.detach() }
}

if (!pendingOnboarding.isNullOrEmpty() && !onboardingJustCompleted) {
OnboardingPage(
pendingOnboarding,
onFinish = { onboardingJustCompleted = true },
boringcactus marked this conversation as resolved.
Show resolved Hide resolved
locationDataManager = locationDataManager
)
return
}

val sheetModifier = Modifier.fillMaxSize().background(colorResource(id = R.color.fill1))
NavHost(navController = navController, startDestination = Routes.NearbyTransit) {
composable<Routes.NearbyTransit> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,13 @@ open class LocationDataManager {

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberPermissions() =
fun rememberPermissions(onPermissionsResult: (Map<String, Boolean>) -> Unit = {}) =
rememberMultiplePermissionsState(
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
),
onPermissionsResult
)

open val currentLocation = _currentLocation.asStateFlow()
Expand Down
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) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is it safe to use remember instead of rememberSaveable here?

Copy link
Member Author

Choose a reason for hiding this comment

The 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
)
}
Loading
Loading