Skip to content

Commit d9a1d1e

Browse files
authored
feat: notifications feature promo (#1540)
* feat: notifications feature promo * fix formatting * fix tests
1 parent ae36a0e commit d9a1d1e

File tree

31 files changed

+845
-19
lines changed

31 files changed

+845
-19
lines changed

androidApp/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ dependencies {
124124
implementation(libs.koin.androidxCompose)
125125
implementation(libs.kotlinx.coroutines.core)
126126
implementation(libs.kotlinx.datetime)
127+
implementation(libs.lottie.compose)
127128
implementation(libs.mapbox.android)
128129
implementation(libs.mapbox.compose)
129130
implementation(libs.mapbox.turf)

androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/onboarding/OnboardingScreenViewTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ class OnboardingScreenViewTest {
139139
assertTrue(advanced)
140140
}
141141

142+
@Test
143+
fun testNotificationsBetaFlow() {
144+
var advanced = false
145+
composeTestRule.setContent {
146+
OnboardingScreenView(
147+
screen = OnboardingScreen.NotificationsBeta,
148+
advance = { advanced = true },
149+
locationDataManager = MockLocationDataManager(),
150+
)
151+
}
152+
composeTestRule.onNodeWithText("Now get disruption notifications").assertIsDisplayed()
153+
composeTestRule.onNodeWithText("Add a Favorite stop").assertIsDisplayed()
154+
composeTestRule.onNodeWithText("Set your own schedule").assertIsDisplayed()
155+
composeTestRule
156+
.onNodeWithText("We’ll tell you about any problems before you go!")
157+
.assertIsDisplayed()
158+
composeTestRule.onNodeWithText("Got it").performClick()
159+
160+
composeTestRule.waitForIdle()
161+
assertTrue(advanced)
162+
}
163+
142164
@Test
143165
fun testFeedbackFlow() {
144166
var advanced = false

androidApp/src/androidTest/java/com/mbta/tid/mbta_app/android/pages/OnboardingPageTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ class OnboardingPageTest {
4747
composeTestRule.waitUntilDefaultTimeout { completedScreens.size == 3 }
4848
assertEquals(3, completedScreens.size)
4949

50-
composeTestRule.onNodeWithText("Get started").performClick()
50+
composeTestRule.onNodeWithText("Got it").performClick()
5151
composeTestRule.waitUntilDefaultTimeout { completedScreens.size == 4 }
52+
assertEquals(4, completedScreens.size)
53+
54+
composeTestRule.onNodeWithText("Get started").performClick()
55+
composeTestRule.waitUntilDefaultTimeout { completedScreens.size == 5 }
5256
assertEquals(OnboardingScreen.entries.toSet(), completedScreens)
5357
assertTrue(finished)
5458
}

androidApp/src/main/java/com/mbta/tid/mbta_app/android/onboarding/OnboardingScreenView.kt

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,88 @@
11
package com.mbta.tid.mbta_app.android.onboarding
22

3+
import androidx.compose.animation.animateColor
34
import androidx.compose.animation.core.EaseInOut
45
import androidx.compose.animation.core.RepeatMode
56
import androidx.compose.animation.core.animateFloat
67
import androidx.compose.animation.core.infiniteRepeatable
78
import androidx.compose.animation.core.rememberInfiniteTransition
89
import androidx.compose.animation.core.tween
10+
import androidx.compose.animation.core.updateTransition
911
import androidx.compose.foundation.Image
1012
import androidx.compose.foundation.background
13+
import androidx.compose.foundation.border
14+
import androidx.compose.foundation.isSystemInDarkTheme
1115
import androidx.compose.foundation.layout.Arrangement
16+
import androidx.compose.foundation.layout.Box
17+
import androidx.compose.foundation.layout.Column
1218
import androidx.compose.foundation.layout.Row
1319
import androidx.compose.foundation.layout.Spacer
1420
import androidx.compose.foundation.layout.height
21+
import androidx.compose.foundation.layout.offset
1522
import androidx.compose.foundation.layout.padding
23+
import androidx.compose.foundation.layout.requiredSize
1624
import androidx.compose.foundation.layout.size
25+
import androidx.compose.foundation.layout.width
26+
import androidx.compose.foundation.shape.CircleShape
1727
import androidx.compose.foundation.shape.RoundedCornerShape
28+
import androidx.compose.material.icons.Icons
29+
import androidx.compose.material.icons.filled.KeyboardArrowDown
30+
import androidx.compose.material3.Icon
31+
import androidx.compose.material3.Switch
1832
import androidx.compose.material3.Text
1933
import androidx.compose.runtime.Composable
34+
import androidx.compose.runtime.LaunchedEffect
2035
import androidx.compose.runtime.getValue
2136
import androidx.compose.runtime.mutableStateOf
37+
import androidx.compose.runtime.remember
2238
import androidx.compose.runtime.saveable.rememberSaveable
2339
import androidx.compose.runtime.setValue
2440
import androidx.compose.ui.Alignment
2541
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.draw.alpha
43+
import androidx.compose.ui.draw.clip
44+
import androidx.compose.ui.draw.scale
45+
import androidx.compose.ui.graphics.Color
2646
import androidx.compose.ui.platform.LocalConfiguration
2747
import androidx.compose.ui.platform.LocalDensity
2848
import androidx.compose.ui.res.colorResource
2949
import androidx.compose.ui.res.painterResource
3050
import androidx.compose.ui.res.stringResource
51+
import androidx.compose.ui.semantics.contentDescription
52+
import androidx.compose.ui.semantics.heading
53+
import androidx.compose.ui.semantics.paneTitle
54+
import androidx.compose.ui.semantics.semantics
55+
import androidx.compose.ui.text.font.FontFamily
56+
import androidx.compose.ui.text.font.FontWeight
3157
import androidx.compose.ui.tooling.preview.Preview
3258
import androidx.compose.ui.unit.dp
3359
import androidx.compose.ui.unit.sp
60+
import com.airbnb.lottie.compose.LottieAnimation
61+
import com.airbnb.lottie.compose.LottieCompositionSpec
62+
import com.airbnb.lottie.compose.animateLottieCompositionAsState
63+
import com.airbnb.lottie.compose.rememberLottieComposition
3464
import com.google.accompanist.permissions.ExperimentalPermissionsApi
3565
import com.google.accompanist.permissions.isGranted
3666
import com.mbta.tid.mbta_app.android.MyApplicationTheme
3767
import com.mbta.tid.mbta_app.android.R
68+
import com.mbta.tid.mbta_app.android.component.StarIcon
3869
import com.mbta.tid.mbta_app.android.location.LocationDataManager
70+
import com.mbta.tid.mbta_app.android.util.FormattedAlert
3971
import com.mbta.tid.mbta_app.android.util.SettingsCache
4072
import com.mbta.tid.mbta_app.android.util.Typography
73+
import com.mbta.tid.mbta_app.model.Alert
74+
import com.mbta.tid.mbta_app.model.AlertSummary
4175
import com.mbta.tid.mbta_app.model.OnboardingScreen
76+
import com.mbta.tid.mbta_app.repositories.MockSettingsRepository
4277
import com.mbta.tid.mbta_app.repositories.Settings
78+
import com.mbta.tid.mbta_app.utils.EasternTimeInstant
79+
import kotlin.time.Duration.Companion.seconds
80+
import kotlinx.coroutines.delay
81+
import kotlinx.datetime.Month
4382
import org.koin.compose.koinInject
83+
import org.koin.core.context.startKoin
84+
import org.koin.dsl.module
85+
import org.koin.mp.KoinPlatformTools
4486

4587
@OptIn(ExperimentalPermissionsApi::class)
4688
@Composable
@@ -182,6 +224,9 @@ fun OnboardingScreenView(
182224
}
183225
}
184226
}
227+
OnboardingScreen.NotificationsBeta -> {
228+
NotificationsBetaPage(advance)
229+
}
185230
OnboardingScreen.StationAccessibility -> {
186231
OnboardingPieces.PageBox(painterResource(R.mipmap.onboarding_background_map)) {
187232
OnboardingContentColumn {
@@ -219,9 +264,225 @@ fun OnboardingScreenView(
219264
}
220265
}
221266

267+
private enum class NotificationsScreenState {
268+
Initial,
269+
AfterFavorite,
270+
AfterSchedule,
271+
Final,
272+
}
273+
274+
@Composable
275+
private fun NotificationsBetaPage(advance: () -> Unit) {
276+
var screenState by remember { mutableStateOf(NotificationsScreenState.Initial) }
277+
val transition = updateTransition(screenState, label = "state")
278+
LaunchedEffect(null) {
279+
delay(2.seconds)
280+
screenState = NotificationsScreenState.AfterFavorite
281+
delay(2.seconds)
282+
screenState = NotificationsScreenState.AfterSchedule
283+
delay(2.seconds)
284+
screenState = NotificationsScreenState.Final
285+
}
286+
OnboardingPieces.PageBox(
287+
colorResource(if (isSystemInDarkTheme()) R.color.fill1 else R.color.fill2)
288+
) {
289+
Column(
290+
Modifier.align(Alignment.TopCenter)
291+
.offset(y = (-200).dp)
292+
.scale(0.6f)
293+
.background(colorResource(R.color.key), RoundedCornerShape(32.dp))
294+
.border(16.dp, Color.Black, RoundedCornerShape(32.dp))
295+
.padding(8.dp)
296+
) {
297+
val isDarkMode = isSystemInDarkTheme()
298+
Spacer(Modifier.height(250.dp))
299+
Row(
300+
Modifier.padding(16.dp)
301+
.background(
302+
if (isDarkMode) Color.Black.copy(alpha = 0.5f)
303+
else Color.White.copy(alpha = 0.75f),
304+
RoundedCornerShape(26.dp),
305+
)
306+
.padding(horizontal = 16.dp, vertical = 14.dp),
307+
horizontalArrangement = Arrangement.spacedBy(16.dp),
308+
verticalAlignment = Alignment.CenterVertically,
309+
) {
310+
Box(Modifier.size(36.dp).clip(CircleShape)) {
311+
Image(
312+
painterResource(R.drawable.ic_launcher_background),
313+
contentDescription = null,
314+
modifier = Modifier.requiredSize(48.dp),
315+
)
316+
Image(
317+
painterResource(R.drawable.ic_launcher_foreground),
318+
contentDescription = null,
319+
modifier = Modifier.requiredSize(48.dp),
320+
)
321+
}
322+
323+
Column {
324+
Row(
325+
horizontalArrangement = Arrangement.spacedBy(4.dp),
326+
verticalAlignment = Alignment.CenterVertically,
327+
) {
328+
Text(
329+
"Orange Line",
330+
color = colorResource(R.color.text),
331+
fontSize = 16.sp,
332+
fontWeight = FontWeight.SemiBold,
333+
fontFamily = FontFamily.Default,
334+
)
335+
Spacer(Modifier.weight(1f))
336+
Text(
337+
"now",
338+
Modifier.alpha(0.6f),
339+
color = colorResource(R.color.text),
340+
fontSize = 14.sp,
341+
fontFamily = FontFamily.Default,
342+
)
343+
Icon(
344+
Icons.Default.KeyboardArrowDown,
345+
contentDescription = null,
346+
Modifier.alpha(0.6f).size(18.dp),
347+
)
348+
}
349+
val alert =
350+
FormattedAlert(
351+
alert = null,
352+
alertSummary =
353+
AlertSummary(
354+
effect = Alert.Effect.Suspension,
355+
location =
356+
AlertSummary.Location.SuccessiveStops(
357+
startStopName = "Back Bay",
358+
endStopName = "Wellington",
359+
),
360+
timeframe =
361+
AlertSummary.Timeframe.ThisWeek(
362+
EasternTimeInstant(2026, Month.JANUARY, 25, 12, 0)
363+
),
364+
),
365+
)
366+
Text(
367+
alert.alertCardMajorBody.toString(),
368+
color = colorResource(R.color.text),
369+
fontSize = 16.sp,
370+
fontWeight = FontWeight.Light,
371+
fontFamily = FontFamily.Default,
372+
)
373+
}
374+
}
375+
Spacer(Modifier.height(32.dp))
376+
}
377+
OnboardingContentColumn {
378+
val header = stringResource(R.string.promo_notifications_header)
379+
// setting explicit paneTitle resets focus to the text rather than the continue button
380+
// when navigating to the next screen
381+
// https://issuetracker.google.com/issues/272065229#comment8
382+
Spacer(Modifier.weight(1f))
383+
Column(
384+
Modifier.semantics { paneTitle = header },
385+
verticalArrangement = Arrangement.spacedBy(32.dp),
386+
) {
387+
val headerDescription =
388+
stringResource(R.string.new_feature_screen_reader_header_prefix, header)
389+
Text(
390+
header,
391+
modifier =
392+
Modifier.semantics {
393+
contentDescription = headerDescription
394+
heading()
395+
},
396+
color = colorResource(R.color.text),
397+
style = Typography.title1Bold,
398+
)
399+
Row(
400+
horizontalArrangement = Arrangement.spacedBy(12.dp),
401+
verticalAlignment = Alignment.CenterVertically,
402+
) {
403+
val starColor by
404+
transition.animateColor {
405+
if (it >= NotificationsScreenState.AfterFavorite)
406+
colorResource(R.color.key)
407+
else colorResource(R.color.text).copy(alpha = 0.4f)
408+
}
409+
val textAlpha by
410+
transition.animateFloat {
411+
if (it >= NotificationsScreenState.AfterFavorite) 1f else 0.4f
412+
}
413+
StarIcon(
414+
starred = screenState >= NotificationsScreenState.AfterFavorite,
415+
color = starColor,
416+
modifier = Modifier.width(52.dp),
417+
size = 36.dp,
418+
)
419+
Text(
420+
stringResource(R.string.promo_notifications_step1),
421+
modifier = Modifier.alpha(textAlpha),
422+
color = colorResource(R.color.text),
423+
style = Typography.title3,
424+
)
425+
}
426+
Row(
427+
horizontalArrangement = Arrangement.spacedBy(12.dp),
428+
verticalAlignment = Alignment.CenterVertically,
429+
) {
430+
val textAlpha by
431+
transition.animateFloat {
432+
if (it >= NotificationsScreenState.AfterSchedule) 1f else 0.4f
433+
}
434+
Switch(
435+
checked = screenState >= NotificationsScreenState.AfterSchedule,
436+
onCheckedChange = null,
437+
)
438+
Text(
439+
stringResource(R.string.promo_notifications_step2),
440+
modifier = Modifier.alpha(textAlpha),
441+
color = colorResource(R.color.text),
442+
style = Typography.title3,
443+
)
444+
}
445+
val textAlpha by
446+
transition.animateFloat {
447+
if (it >= NotificationsScreenState.Final) 1f else 0.4f
448+
}
449+
Text(
450+
stringResource(R.string.promo_notifications_body),
451+
modifier = Modifier.alpha(textAlpha),
452+
color = colorResource(R.color.text),
453+
style = Typography.title3,
454+
)
455+
}
456+
Box(Modifier.weight(1f), contentAlignment = Alignment.BottomCenter) {
457+
val composition by
458+
rememberLottieComposition(
459+
LottieCompositionSpec.RawRes(
460+
if (isSystemInDarkTheme()) R.raw.notification_pop_dark
461+
else R.raw.notification_pop_light
462+
)
463+
)
464+
val progress by
465+
animateLottieCompositionAsState(
466+
composition,
467+
isPlaying = screenState >= NotificationsScreenState.Final,
468+
)
469+
LottieAnimation(
470+
composition,
471+
progress = { progress },
472+
Modifier.align(Alignment.BottomCenter),
473+
)
474+
Column { OnboardingPieces.KeyButton(R.string.got_it, onClick = advance) }
475+
}
476+
}
477+
}
478+
}
479+
222480
@Preview(name = "Feedback")
223481
@Composable
224482
private fun OnboardingScreenViewFeedbackPreview() {
483+
if (KoinPlatformTools.defaultContext().getOrNull() == null) {
484+
startKoin { modules(module { single { SettingsCache(MockSettingsRepository()) } }) }
485+
}
225486
MyApplicationTheme {
226487
OnboardingScreenView(
227488
OnboardingScreen.Feedback,
@@ -255,6 +516,21 @@ private fun OnboardingScreenViewLocationPreview() {
255516
}
256517
}
257518

519+
@Preview(name = "Notifications Beta")
520+
@Composable
521+
private fun OnboardingScreenViewNotificationsBetaPreview() {
522+
if (KoinPlatformTools.defaultContext().getOrNull() == null) {
523+
startKoin { modules(module { single { SettingsCache(MockSettingsRepository()) } }) }
524+
}
525+
MyApplicationTheme {
526+
OnboardingScreenView(
527+
OnboardingScreen.NotificationsBeta,
528+
advance = {},
529+
locationDataManager = LocationDataManager(),
530+
)
531+
}
532+
}
533+
258534
@Preview(name = "StationAccessibility")
259535
@Composable
260536
private fun OnboardingScreenViewStationAccessibiityPreview() {

0 commit comments

Comments
 (0)