Skip to content

Commit 21bf35c

Browse files
bmc08gtclaude
andauthored
feat(permissions): add contact permission screen and reorganize notification internals (#776)
* feat(verification): support full-screen and target navigation for drop-in use cases Add `target` and `fullScreen` params to AppRoute.Verification so the flow can forward-navigate on success (replace) and render with status bar padding when used outside of a modal sheet (e.g. onboarding). Wire phone verification into the onboarding flow from AccessKeyScreen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * feat(contacts): add shared contacts module with persistence layer Introduces the contacts module with: - ContactCoordinator for orchestrating contact sync - ScopeAwareContactReader with full-access and picker-based strategies - ContactChecksum for detecting contact list changes - Hilt module for DI wiring Adds persistence support: - ContactDao with Room queries for contact mappings and sync state - ContactMappingEntity and ContactSyncStateEntity - FlipcashDatabase v16 schema migration Signed-off-by: Brandon McAnsh <git@bmcreations.dev> * feat(contacts): reset contact state on logout via SessionController * feat(permissions): add contact permission screen and reorganize notification internals Add ContactPermissionScreen with animated preview and bottom bar. Move notification permission internals into notifications/ subpackage. Update PermissionConfig and Permissions to support contact permission handling. --------- Signed-off-by: Brandon McAnsh <git@bmcreations.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49de9ae commit 21bf35c

20 files changed

Lines changed: 468 additions & 23 deletions

File tree

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.flipcash.app.login.router.LoginRouter
4040
import com.flipcash.app.login.seed.SeedInputScreen
4141
import com.flipcash.app.menu.MenuScreen
4242
import com.flipcash.app.myaccount.MyAccountScreen
43+
import com.flipcash.app.permissions.ContactPermissionScreen
4344
import com.flipcash.app.permissions.NotificationPermissionRationaleScreen
4445
import com.flipcash.app.permissions.NotificationPermissionScreen
4546
import com.flipcash.app.purchase.PurchaseAccountScreen
@@ -80,6 +81,7 @@ fun appEntryProvider(
8081
annotatedEntry<AppRoute.Onboarding.AccessKey> { AccessKeyScreen() }
8182
annotatedEntry<AppRoute.Onboarding.AccessKeySavedLocation> { PhotoAccessKeyScreen() }
8283
annotatedEntry<AppRoute.Onboarding.Purchase> { key -> PurchaseAccountScreen(key.fromLogin) }
84+
annotatedEntry<AppRoute.Onboarding.ContactPermission> { key -> ContactPermissionScreen(key.postCreate) }
8385
annotatedEntry<AppRoute.Onboarding.NotificationPermission> { key -> NotificationPermissionScreen(key.postCreate) }
8486
annotatedEntry<AppRoute.Onboarding.NotificationPermissionRationale> { key -> NotificationPermissionRationaleScreen(key.permanentlyDenied) }
8587
annotatedEntry<AppRoute.Onboarding.CameraPermission> { }

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/DeviceFrame.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@ import androidx.compose.ui.res.painterResource
1212
import androidx.compose.ui.unit.dp
1313
import com.flipcash.core.R
1414

15+
@Composable
16+
fun ScreenFrame(
17+
modifier: Modifier = Modifier,
18+
contentAlignment: Alignment = Alignment.TopCenter,
19+
contents: @Composable () -> Unit,
20+
) {
21+
Box(modifier = modifier, contentAlignment = Alignment.TopCenter) {
22+
Image(
23+
painter = painterResource(id = R.drawable.ic_screen_frame),
24+
contentDescription = "",
25+
)
26+
Box(
27+
modifier = Modifier
28+
.matchParentSize()
29+
.clip(RoundedCornerShape(35.dp)),
30+
contentAlignment = contentAlignment,
31+
) {
32+
contents()
33+
}
34+
}
35+
}
36+
1537
@Composable
1638
fun DeviceFrame(
1739
modifier: Modifier = Modifier,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!--
2+
~ Copyright (C) 2026 The Android Open Source Project
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
17+
xmlns:aapt="http://schemas.android.com/aapt"
18+
android:width="276dp"
19+
android:height="475dp"
20+
android:viewportWidth="276"
21+
android:viewportHeight="475">
22+
<path
23+
android:pathData="M35,0L240.77,0A35,35 0,0 1,275.77 35L275.77,439.26A35,35 0,0 1,240.77 474.26L35,474.26A35,35 0,0 1,0 439.26L0,35A35,35 0,0 1,35 0z">
24+
<aapt:attr name="android:fillColor">
25+
<gradient
26+
android:startX="137.88"
27+
android:startY="0"
28+
android:endX="137.88"
29+
android:endY="394.91"
30+
android:type="linear">
31+
<item android:offset="0" android:color="#0CFFFFFF"/>
32+
<item android:offset="1" android:color="#00FFFFFF"/>
33+
</gradient>
34+
</aapt:attr>
35+
</path>
36+
</vector>

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@
9292
<string name="error_description_ignoredPushPermissions">You won\'t receive updates when your balance changes</string>
9393
<string name="action_okAllow">Ok Allow</string>
9494
<string name="action_imSure">I\'m Sure</string>
95+
<string name="action_notNow">Not Now</string>
96+
97+
<string name="permissions_title_contacts">Find Your Friends</string>
98+
<string name="permissions_description_contacts">Sync your contacts to find,\ninvite, and pay friends.</string>
99+
<string name="action_giveAccessToContacts">Give Access To Contacts</string>
100+
<string name="error_title_ignoredContactPermissions">Are You Sure?</string>
101+
<string name="error_description_ignoredContactPermissions">You won\'t be able to send cash to your contacts</string>
95102

96103
<string name="subtitle_allowCameraAccess">Start your camera to grab cash</string>
97104
<string name="action_allowCameraAccess">Start Camera</string>

apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) {
8989
)
9090
}
9191

92-
item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) }
93-
item {
94-
ListItem(
95-
headline = stringResource(R.string.title_settingsButtonOrder),
96-
icon = painterResource(R.drawable.ic_bottom_navigation),
97-
) {
98-
navigator.navigate(AppRoute.Menu.NavBarSettings)
99-
}
100-
}
101-
10292
if (betaFlags.isEmpty()) {
10393
item {
10494
Box {
@@ -126,6 +116,16 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) {
126116
}
127117
}
128118

119+
item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) }
120+
item {
121+
ListItem(
122+
headline = stringResource(R.string.title_settingsButtonOrder),
123+
icon = painterResource(R.drawable.ic_bottom_navigation),
124+
) {
125+
navigator.navigate(AppRoute.Menu.NavBarSettings)
126+
}
127+
}
128+
129129
if (isStaff) {
130130
item { SectionHeader(stringResource(R.string.title_settingsSectionDeveloper)) }
131131
item {

apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ sealed interface Button: AppAction {
3333
override val value: String = "Button: Skip Push"
3434
}
3535

36+
data object AllowContacts : Button {
37+
override val value: String = "Button: Allow Contacts"
38+
}
39+
40+
data object SkipContacts: Button {
41+
override val value: String = "Button: Skip Contacts"
42+
}
43+
3644
data object TokenBuyWithReserves : Button {
3745
override val value: String = "Button: Buy With Reserves"
3846
}

apps/flipcash/shared/permissions/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ android {
88

99
dependencies {
1010
implementation(project(":apps:flipcash:shared:analytics"))
11+
implementation(project(":apps:flipcash:shared:contacts"))
1112
implementation(project(":libs:datetime"))
1213
implementation(project(":libs:messaging"))
1314
implementation(project(":libs:permissions:bindings"))
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.flipcash.app.permissions
2+
3+
import androidx.activity.compose.BackHandler
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.LaunchedEffect
6+
import com.flipcash.app.analytics.Action
7+
import com.flipcash.app.analytics.Button
8+
import com.flipcash.app.core.AppRoute
9+
import com.flipcash.app.permissions.internal.contacts.ContactScreenContent
10+
import com.getcode.libs.analytics.LocalAnalytics
11+
import com.getcode.navigation.core.LocalCodeNavigator
12+
import com.getcode.navigation.core.NavOptions
13+
import com.getcode.util.permissions.PermissionResult
14+
import com.getcode.util.permissions.rememberContactPermission
15+
16+
@Composable
17+
fun ContactPermissionScreen(fromOnboarding: Boolean) {
18+
val navigator = LocalCodeNavigator.current
19+
val analytics = LocalAnalytics.current
20+
21+
val permissionState = rememberContactPermission { result ->
22+
when (result) {
23+
PermissionResult.Granted -> {
24+
analytics.action(Button.AllowContacts)
25+
if (fromOnboarding) analytics.action(Action.CompletedOnboarding)
26+
navigator.push(
27+
AppRoute.Onboarding.NotificationPermission(fromOnboarding)
28+
)
29+
}
30+
PermissionResult.Denied -> {
31+
navigator.push(
32+
AppRoute.Onboarding.NotificationPermission(fromOnboarding)
33+
)
34+
}
35+
PermissionResult.PermanentlyDenied -> {
36+
navigator.push(
37+
AppRoute.Onboarding.NotificationPermission(fromOnboarding)
38+
)
39+
}
40+
PermissionResult.NotRequested -> Unit
41+
}
42+
}
43+
44+
LaunchedEffect(Unit) {
45+
when (permissionState.status) {
46+
PermissionResult.Granted -> navigator.navigate(
47+
route = AppRoute.Main.Scanner,
48+
options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll)
49+
)
50+
PermissionResult.PermanentlyDenied -> navigator.push(
51+
AppRoute.Onboarding.NotificationPermissionRationale(true)
52+
)
53+
// NotRequested + Denied both render screen 1
54+
// Denied = show rationale (screen 1) then re-trigger dialog on OK
55+
PermissionResult.NotRequested,
56+
PermissionResult.Denied -> Unit
57+
}
58+
}
59+
60+
// Only reached when status is NotRequested
61+
ContactScreenContent(
62+
permissionState = permissionState,
63+
onSkip = {
64+
analytics.action(Button.SkipContacts)
65+
navigator.push(
66+
AppRoute.Onboarding.NotificationPermission(fromOnboarding)
67+
)
68+
}
69+
)
70+
71+
BackHandler(fromOnboarding) { }
72+
}

apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import androidx.compose.runtime.LaunchedEffect
66
import com.flipcash.app.analytics.Action
77
import com.flipcash.app.analytics.Button
88
import com.flipcash.app.core.AppRoute
9-
import com.flipcash.app.permissions.internal.NotificationRationalePermissionContent
10-
import com.flipcash.app.permissions.internal.NotificationScreenContent
9+
import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent
10+
import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent
1111
import com.getcode.libs.analytics.LocalAnalytics
1212
import com.getcode.navigation.core.LocalCodeNavigator
1313
import com.getcode.navigation.core.NavOptions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.flipcash.app.permissions.internal.contacts
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.material3.Text
9+
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.CompositionLocalProvider
11+
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.res.stringResource
14+
import androidx.compose.ui.text.style.TextAlign
15+
import androidx.compose.ui.tooling.preview.Preview
16+
import com.flipcash.app.analytics.StubFlipcashAnalytics
17+
import com.flipcash.app.permissions.internal.contacts.components.AnimatedContactListPreview
18+
import com.flipcash.app.permissions.internal.contacts.components.ContactPermissionBottomBar
19+
import com.flipcash.app.theme.FlipcashPreview
20+
import com.flipcash.shared.permissions.R
21+
import com.getcode.libs.analytics.LocalAnalytics
22+
import com.getcode.theme.CodeTheme
23+
import com.getcode.ui.theme.CodeScaffold
24+
import com.getcode.util.permissions.PermissionHandle
25+
import com.getcode.util.permissions.ProvideTestPermissions
26+
import com.getcode.util.permissions.rememberContactPermission
27+
28+
@Composable
29+
internal fun ContactScreenContent(
30+
permissionState: PermissionHandle,
31+
onSkip: () -> Unit,
32+
) {
33+
CodeScaffold(
34+
bottomBar = {
35+
ContactPermissionBottomBar(
36+
permission = permissionState,
37+
onSkip = onSkip,
38+
)
39+
}
40+
) {
41+
Box(Modifier.fillMaxSize()) {
42+
Column(
43+
modifier = Modifier.align(Alignment.Center),
44+
horizontalAlignment = Alignment.CenterHorizontally,
45+
) {
46+
AnimatedContactListPreview(animate = false)
47+
48+
Text(
49+
text = stringResource(R.string.permissions_title_contacts),
50+
style = CodeTheme.typography.displaySmall,
51+
color = CodeTheme.colors.textMain,
52+
)
53+
Text(
54+
modifier = Modifier
55+
.fillMaxWidth(0.8f)
56+
.padding(horizontal = CodeTheme.dimens.inset),
57+
text = stringResource(R.string.permissions_description_contacts),
58+
style = CodeTheme.typography.textSmall,
59+
color = CodeTheme.colors.textSecondary,
60+
textAlign = TextAlign.Center,
61+
)
62+
}
63+
}
64+
}
65+
}
66+
67+
@Composable
68+
@Preview
69+
private fun PreviewContactPermissionScreen() {
70+
FlipcashPreview(showBackground = true) {
71+
CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) {
72+
ProvideTestPermissions(granted = emptySet()) {
73+
val state = rememberContactPermission()
74+
ContactScreenContent(state) { }
75+
}
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)