Skip to content

Commit 479198a

Browse files
authored
Merge pull request #526 from code-payments/feat/prompt-user-for-notifications-perm-with-tip-card
feat(tipcard): prompt user for notification permissions when bringing out tip card
2 parents fa8a61e + a98a65b commit 479198a

12 files changed

Lines changed: 381 additions & 2 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.getcode.manager
2+
3+
import androidx.annotation.DrawableRes
4+
import kotlinx.coroutines.flow.MutableStateFlow
5+
import kotlinx.coroutines.flow.StateFlow
6+
import kotlinx.coroutines.flow.asStateFlow
7+
import kotlinx.coroutines.flow.update
8+
import java.util.UUID
9+
10+
object ModalManager {
11+
data class Message(
12+
@DrawableRes
13+
val icon: Int? = null,
14+
val title: String,
15+
val subtitle: String = "",
16+
val positiveText: String,
17+
val negativeText: String? = null,
18+
val tertiaryText: String? = null,
19+
val onPositive: () -> Unit,
20+
val onNegative: () -> Unit = {},
21+
val onTertiary: () -> Unit = {},
22+
val onClose: (actionType: ActionType?) -> Unit = {},
23+
val type: MessageType = MessageType.DEFAULT,
24+
// val isDismissibleByTouchOutside: Boolean = true,
25+
val isDismissibleByBackButton: Boolean = true,
26+
val timeoutSeconds: Int? = null,
27+
val id: Long = UUID.randomUUID().mostSignificantBits,
28+
)
29+
30+
private val _messages: MutableStateFlow<List<Message>> = MutableStateFlow(
31+
listOf()
32+
)
33+
val messages: StateFlow<List<Message>> get() = _messages.asStateFlow()
34+
35+
fun showMessage(message: Message) {
36+
_messages.update { currentMessages ->
37+
currentMessages + message
38+
}
39+
}
40+
41+
fun setMessageShown(messageId: Long) {
42+
_messages.update { currentMessages ->
43+
currentMessages.filterNot { it.id == messageId }
44+
}
45+
}
46+
47+
fun clear() = _messages.update { listOf() }
48+
49+
fun clearByType(type: MessageType) = _messages.update { it.filterNot { m -> m.type == type } }
50+
51+
enum class MessageType { DEFAULT }
52+
53+
enum class ActionType {
54+
Positive,
55+
Negative,
56+
Tertiary
57+
}
58+
59+
}

app/src/main/java/com/getcode/CodeApp.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.getcode.theme.LocalCodeColors
4040
import com.getcode.ui.components.AuthCheck
4141
import com.getcode.ui.components.bars.BottomBarContainer
4242
import com.getcode.ui.components.CodeScaffold
43+
import com.getcode.ui.components.ModalContainer
4344
import com.getcode.ui.components.OnLifecycleEvent
4445
import com.getcode.ui.components.TitleBar
4546
import com.getcode.ui.components.bars.TopBarContainer
@@ -149,6 +150,7 @@ fun CodeApp(tipsEngine: TipsEngine) {
149150
)
150151
}
151152
}
153+
ModalContainer(codeNavigator, appState)
152154
}
153155
}
154156
}

app/src/main/java/com/getcode/CodeAppState.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Stable
77
import androidx.compose.runtime.remember
88
import androidx.compose.runtime.rememberCoroutineScope
99
import com.getcode.manager.BottomBarManager
10+
import com.getcode.manager.ModalManager
1011
import com.getcode.manager.TopBarManager
1112
import com.getcode.navigation.core.CodeNavigator
1213
import com.getcode.navigation.core.LocalCodeNavigator
@@ -51,6 +52,11 @@ class CodeAppState(
5152
bottomBarMessage.value = currentMessages.firstOrNull()
5253
}
5354
}
55+
coroutineScope.launch {
56+
ModalManager.messages.collect { currentMessages ->
57+
modalMessage.value = currentMessages.firstOrNull()
58+
}
59+
}
5460
}
5561
// ----------------------------------------------------------
5662
// Navigation state source of truth
@@ -84,6 +90,7 @@ class CodeAppState(
8490

8591
val topBarMessage = MutableStateFlow<TopBarManager.TopBarMessage?>(null)
8692
val bottomBarMessage = MutableStateFlow<BottomBarManager.BottomBarMessage?>(null)
93+
val modalMessage = MutableStateFlow<ModalManager.Message?>(null)
8794

8895
fun upPress() {
8996
if (navigator.pop().not()) {

app/src/main/java/com/getcode/inject/AppModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.os.Build
99
import android.os.VibratorManager
1010
import android.telephony.TelephonyManager
1111
import androidx.biometric.BiometricManager
12+
import androidx.core.app.NotificationManagerCompat
1213
import com.getcode.analytics.AnalyticsManager
1314
import com.getcode.analytics.AnalyticsService
1415
import com.getcode.util.AndroidLocale
@@ -91,6 +92,12 @@ object AppModule {
9192
@ApplicationContext context: Context
9293
): ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
9394

95+
96+
@Provides
97+
fun providesCNotificationManager(
98+
@ApplicationContext context: Context
99+
): NotificationManagerCompat = NotificationManagerCompat.from(context)
100+
94101
@SuppressLint("NewApi")
95102
@Provides
96103
@Singleton

app/src/main/java/com/getcode/navigation/core/BottomSheetNavigator.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.material.ModalBottomSheetValue
1414
import androidx.compose.material.rememberModalBottomSheetState
1515
import androidx.compose.runtime.Composable
1616
import androidx.compose.runtime.CompositionLocalProvider
17+
import androidx.compose.runtime.LaunchedEffect
1718
import androidx.compose.runtime.ProvidableCompositionLocal
1819
import androidx.compose.runtime.derivedStateOf
1920
import androidx.compose.runtime.getValue
@@ -34,9 +35,11 @@ import cafe.adriel.voyager.core.stack.Stack
3435
import cafe.adriel.voyager.navigator.CurrentScreen
3536
import cafe.adriel.voyager.navigator.Navigator
3637
import cafe.adriel.voyager.navigator.compositionUniqueId
38+
import com.getcode.manager.ModalManager
3739
import com.getcode.theme.CodeTheme
3840
import com.getcode.theme.extraLarge
3941
import kotlinx.coroutines.CoroutineScope
42+
import kotlinx.coroutines.delay
4043
import kotlinx.coroutines.launch
4144
import timber.log.Timber
4245

@@ -84,7 +87,13 @@ fun BottomSheetNavigator(
8487
BottomSheetNavigator(navigator, sheetState, coroutineScope)
8588
}
8689

87-
hideBottomSheet = bottomSheetNavigator::hide
90+
hideBottomSheet = {
91+
bottomSheetNavigator.hide()
92+
coroutineScope.launch {
93+
delay(1_000)
94+
ModalManager.clear()
95+
}
96+
}
8897

8998
CompositionLocalProvider(LocalBottomSheetNavigator provides bottomSheetNavigator) {
9099
ModalBottomSheetLayout(
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.getcode.navigation.screens
2+
3+
import androidx.activity.compose.BackHandler
4+
import androidx.compose.foundation.Image
5+
import androidx.compose.foundation.background
6+
import androidx.compose.foundation.clickable
7+
import androidx.compose.foundation.interaction.MutableInteractionSource
8+
import androidx.compose.foundation.layout.Arrangement
9+
import androidx.compose.foundation.layout.Box
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.fillMaxSize
12+
import androidx.compose.foundation.layout.fillMaxWidth
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.shape.CircleShape
15+
import androidx.compose.material.Text
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.ui.Alignment
19+
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.res.painterResource
21+
import cafe.adriel.voyager.core.screen.Screen
22+
import com.getcode.manager.ModalManager
23+
import com.getcode.theme.BrandLight
24+
import com.getcode.theme.CodeTheme
25+
import com.getcode.ui.components.ButtonState
26+
import com.getcode.ui.components.CodeButton
27+
import com.getcode.ui.utils.addIf
28+
29+
fun buildMessageContent(
30+
message: ModalManager.Message,
31+
onClose: (ModalManager.ActionType?) -> Unit
32+
): Screen {
33+
return ModalContainerMessage(message, onClose)
34+
}
35+
36+
private data class ModalContainerMessage(
37+
val message: ModalManager.Message,
38+
val onClose: (ModalManager.ActionType?) -> Unit,
39+
) : Screen, NamedScreen, ModalRoot {
40+
41+
@Composable
42+
override fun Content() {
43+
ModalContainer(
44+
modalHeightMetric = ModalHeightMetric.WrapContent,
45+
closeButtonEnabled = { it is ModalContainerMessage },
46+
onCloseClicked = {
47+
onClose(null)
48+
}
49+
) {
50+
Column(
51+
modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
52+
horizontalAlignment = Alignment.CenterHorizontally,
53+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)
54+
) {
55+
message.icon?.let { imageResId ->
56+
Box(
57+
modifier = Modifier
58+
.background(BrandLight, CircleShape),
59+
contentAlignment = Alignment.Center
60+
) {
61+
Image(
62+
modifier = Modifier.padding(CodeTheme.dimens.grid.x3),
63+
painter = painterResource(imageResId),
64+
contentDescription = null,
65+
)
66+
}
67+
}
68+
Text(
69+
modifier = Modifier
70+
.fillMaxWidth()
71+
.addIf(message.icon != null) {
72+
Modifier.padding(top = CodeTheme.dimens.grid.x2)
73+
},
74+
text = message.title,
75+
style = CodeTheme.typography.displaySmall,
76+
color = CodeTheme.colors.onBackground,
77+
)
78+
79+
if (message.subtitle.isNotEmpty()) {
80+
Text(
81+
modifier = Modifier.fillMaxWidth(),
82+
text = message.subtitle,
83+
style = CodeTheme.typography.textSmall,
84+
color = CodeTheme.colors.onBackground,
85+
)
86+
}
87+
88+
89+
CodeButton(
90+
modifier = Modifier
91+
.fillMaxWidth()
92+
.padding(top = CodeTheme.dimens.grid.x2),
93+
buttonState = ButtonState.Filled,
94+
text = message.positiveText,
95+
onClick = {
96+
message.onPositive()
97+
onClose(ModalManager.ActionType.Positive)
98+
}
99+
)
100+
101+
message.negativeText?.let { negativeText ->
102+
if (negativeText.isNotEmpty()) {
103+
CodeButton(
104+
modifier = Modifier.fillMaxWidth(),
105+
buttonState = ButtonState.Filled10,
106+
text = negativeText,
107+
onClick = {
108+
message.onNegative()
109+
onClose(ModalManager.ActionType.Negative)
110+
}
111+
)
112+
}
113+
}
114+
115+
message.tertiaryText?.let { tertiaryText ->
116+
if (tertiaryText.isNotEmpty()) {
117+
CodeButton(
118+
modifier = Modifier.fillMaxWidth(),
119+
buttonState = ButtonState.Bordered,
120+
text = tertiaryText,
121+
onClick = {
122+
message.onTertiary()
123+
onClose(ModalManager.ActionType.Tertiary)
124+
}
125+
)
126+
}
127+
}
128+
}
129+
}
130+
131+
BackHandler(message.isDismissibleByBackButton) {
132+
onClose(null)
133+
}
134+
}
135+
}

app/src/main/java/com/getcode/navigation/screens/Modals.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
1111
import androidx.compose.foundation.layout.fillMaxWidth
1212
import androidx.compose.foundation.layout.navigationBars
1313
import androidx.compose.foundation.layout.windowInsetsPadding
14+
import androidx.compose.foundation.layout.wrapContentHeight
1415
import androidx.compose.runtime.Composable
1516
import androidx.compose.runtime.CompositionLocalProvider
1617
import androidx.compose.runtime.collectAsState
@@ -36,11 +37,17 @@ import com.getcode.ui.utils.getActivityScopedViewModel
3637
import kotlinx.coroutines.delay
3738
import kotlinx.coroutines.launch
3839

40+
sealed interface ModalHeightMetric {
41+
data class Weight(val weight: Float) : ModalHeightMetric
42+
data object WrapContent : ModalHeightMetric
43+
}
44+
3945
@OptIn(ExperimentalFoundationApi::class)
4046
@Composable
4147
internal fun NamedScreen.ModalContainer(
4248
navigator: CodeNavigator = LocalCodeNavigator.current,
4349
modalColor: Color = CodeTheme.colors.background,
50+
modalHeightMetric: ModalHeightMetric = ModalHeightMetric.Weight(CodeTheme.dimens.modalHeightRatio),
4451
displayLogo: Boolean = false,
4552
titleString: @Composable (NamedScreen?) -> String? = { name },
4653
title: @Composable BoxScope.() -> Unit = { },
@@ -56,7 +63,12 @@ internal fun NamedScreen.ModalContainer(
5663
Column(
5764
modifier = Modifier
5865
.fillMaxWidth()
59-
.fillMaxHeight(CodeTheme.dimens.modalHeightRatio)
66+
.then(
67+
when (modalHeightMetric) {
68+
is ModalHeightMetric.Weight -> Modifier.fillMaxHeight(modalHeightMetric.weight)
69+
ModalHeightMetric.WrapContent -> Modifier.wrapContentHeight()
70+
}
71+
)
6072
.background(modalColor)
6173
) {
6274
val lastItem by remember(navigator.lastModalItem) {

0 commit comments

Comments
 (0)