Skip to content

Commit 29732f6

Browse files
committed
feat(direct-send): add invite-to-contact flow
Add the send flow module with phone gate → contacts permission gate → contact list screens. Change AppRoute.Send from an object to a data class with a resumed parameter, wire SendFlowScreen into AppScreenContent, add SendStep sealed interface, and add contact list strings. Direct send to contacts on Flipcash is coming in a follow-up commit. This unlocks invites to contacts not yet on flipcash. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 1aa2c0b commit 29732f6

12 files changed

Lines changed: 1093 additions & 3 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen
2929
import com.flipcash.app.core.AppRoute
3030
import com.flipcash.app.currency.RegionSelectionScreen
3131
import com.flipcash.app.deposit.DepositFlowScreen
32+
import com.flipcash.app.directsend.SendFlowScreen
3233
import com.flipcash.app.invite.InviteContactScreen
3334
import com.flipcash.app.discovery.TokenDiscoveryScreen
3435
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
@@ -84,7 +85,7 @@ fun appEntryProvider(
8485

8586
// Sheets (inner content — wrapped in Main.Sheet by navigateTo())
8687
annotatedEntry<AppRoute.Sheets.Give> { key -> CashScreen(key.mint, key.fromTokenInfo) }
87-
annotatedEntry<AppRoute.Sheets.Send> { }
88+
annotatedEntry<AppRoute.Sheets.Send> { SendFlowScreen(resultStateRegistry = resultStateRegistry) }
8889
annotatedEntry<AppRoute.Sheets.TokenSelection> { key -> TokenSelectScreen(key.purpose) }
8990
annotatedEntry<AppRoute.Sheets.Wallet> { BalanceScreen() }
9091
annotatedEntry<AppRoute.Sheets.ShareApp> { ShareAppScreen() }

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,23 @@ sealed interface AppRoute : NavKey, Parcelable {
142142
@Serializable
143143
data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets
144144

145+
/**
146+
* Direct send flow — phone-verified user picks a contact and sends funds.
147+
*
148+
* @param resumed `true` when the flow is re-entered after an interrupting gate
149+
* (e.g. phone verification). A distinct value produces a new route instance so
150+
* Nav3 treats `replaceAll` as a forward push instead of a pop.
151+
*/
145152
@Serializable
146-
data object Send: Sheets
153+
data class Send(val resumed: Boolean = false): Sheets
147154
@Serializable
148155
data object Wallet : Sheets
149156
@Serializable
150157
data object Menu : Sheets
151158

152159
@Serializable
153160
data object ShareApp : Sheets
161+
154162
}
155163

156164
@Serializable
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.flipcash.app.core.send
2+
3+
import android.os.Parcelable
4+
import com.getcode.navigation.flow.FlowStep
5+
import kotlinx.parcelize.Parcelize
6+
import kotlinx.serialization.Serializable
7+
8+
/**
9+
* Steps inside the Send flow. Rendered inside a [com.getcode.navigation.flow.FlowHost]
10+
* by `SendFlowScreen` in the `direct-send` feature module.
11+
*/
12+
@Serializable
13+
sealed interface SendStep : FlowStep, Parcelable {
14+
@Parcelize
15+
@Serializable
16+
data object PhoneGate : SendStep
17+
18+
@Parcelize
19+
@Serializable
20+
data object ContactsGate : SendStep
21+
22+
@Parcelize
23+
@Serializable
24+
data object ContactList : SendStep
25+
}
26+
27+
@Serializable
28+
sealed interface SendResult : Parcelable

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,5 +697,17 @@
697697
<string name="prompt_description_learnAboutLeaderboard">People must have a minimum balance of %1$s to be counted</string>
698698

699699
<string name="action_invite">Invite</string>
700+
<string name="action_remove">Remove</string>
700701
<string name="action_inviteMoreOptions">More</string>
702+
703+
<string name="title_noContacts">No Contacts Yet</string>
704+
<string name="subtitle_noContacts">Tap the + button to add contacts</string>
705+
706+
<string name="title_flipcashContacts">On Flipcash</string>
707+
<string name="title_nonFlipcashContacts">Not On Flipcash Yet</string>
708+
<plurals name="prompt_title_contactsAlreadyOnFlipcash">
709+
<item quantity="one">1 Contact Already On Flipcash</item>
710+
<item quantity="other">%1$s Contacts Already On Flipcash</item>
711+
</plurals>
712+
<string name="prompt_description_contactsAlreadyOnFlipcash">Send them money, or invite other contacts to sign up for Flipcash</string>
701713
</resources>

apps/flipcash/features/direct-send/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ dependencies {
2121
implementation(project(":libs:logging"))
2222
implementation(project(":libs:messaging"))
2323
implementation(project(":libs:permissions:bindings"))
24+
implementation(project(":apps:flipcash:shared:featureflags"))
25+
implementation(project(":apps:flipcash:shared:permissions"))
26+
implementation(project(":apps:flipcash:shared:contacts"))
2427
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.flipcash.app.directsend
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.getValue
5+
import androidx.hilt.navigation.compose.hiltViewModel
6+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
7+
import androidx.navigation3.runtime.NavEntry
8+
import androidx.navigation3.runtime.NavKey
9+
import androidx.navigation3.runtime.entryProvider
10+
import com.flipcash.app.core.send.SendResult
11+
import com.flipcash.app.core.send.SendStep
12+
import com.flipcash.app.directsend.internal.SendFlowViewModel
13+
import com.flipcash.app.directsend.internal.screens.ContactListScreen
14+
import com.flipcash.app.directsend.internal.screens.ContactsPermissionGateScreen
15+
import com.flipcash.app.directsend.internal.screens.PhoneGateLandingScreen
16+
import com.getcode.navigation.annotatedEntry
17+
import com.getcode.navigation.flow.FlowExitReason
18+
import com.getcode.navigation.flow.FlowHost
19+
import com.getcode.navigation.results.NavResultStateRegistry
20+
import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher
21+
22+
@Composable
23+
fun SendFlowScreen(resultStateRegistry: NavResultStateRegistry) {
24+
val sheetDismiss = LocalBottomSheetDismissDispatcher.current
25+
val viewModel = hiltViewModel<SendFlowViewModel>()
26+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
27+
28+
FlowHost<SendStep, SendResult>(
29+
steps = state.steps,
30+
resumeAt = 0,
31+
resultStateRegistry = resultStateRegistry,
32+
onExit = { reason, _ ->
33+
when (reason) {
34+
is FlowExitReason.Completed,
35+
FlowExitReason.BackedOutOfRoot,
36+
FlowExitReason.Canceled -> sheetDismiss()
37+
}
38+
},
39+
entryProvider = sendEntryProvider(),
40+
)
41+
}
42+
43+
private fun sendEntryProvider(): (NavKey) -> NavEntry<NavKey> = entryProvider {
44+
annotatedEntry<SendStep.PhoneGate> {
45+
PhoneGateLandingScreen()
46+
}
47+
annotatedEntry<SendStep.ContactsGate> {
48+
ContactsPermissionGateScreen()
49+
}
50+
annotatedEntry<SendStep.ContactList> {
51+
ContactListScreen()
52+
}
53+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.flipcash.app.directsend.internal
2+
3+
import com.flipcash.app.contacts.device.DeviceContact
4+
5+
internal sealed interface ContactListItem {
6+
data class Header(val title: String) : ContactListItem
7+
data class ContactRow(val contact: DeviceContact, val isOnFlipcash: Boolean) : ContactListItem
8+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package com.flipcash.app.directsend.internal
2+
3+
import androidx.compose.foundation.text.input.TextFieldState
4+
import androidx.compose.material3.ExperimentalMaterial3Api
5+
import androidx.compose.material3.SearchBarState
6+
import androidx.compose.runtime.snapshotFlow
7+
import androidx.lifecycle.viewModelScope
8+
import com.flipcash.app.contacts.ContactCoordinator
9+
import com.flipcash.app.contacts.ContactCoordinator.ContactState
10+
import com.flipcash.app.contacts.device.DeviceContact
11+
import com.flipcash.app.contacts.device.PickedContactData
12+
import com.flipcash.app.core.send.SendStep
13+
import com.flipcash.app.featureflags.FeatureFlag
14+
import com.flipcash.app.featureflags.FeatureFlagController
15+
import com.flipcash.app.permissions.PickedContact
16+
import com.flipcash.features.directsend.R
17+
import com.flipcash.services.user.UserManager
18+
import com.getcode.util.resources.ResourceHelper
19+
import com.getcode.view.BaseViewModel
20+
import com.getcode.view.LoadingSuccessState
21+
import dagger.hilt.android.lifecycle.HiltViewModel
22+
import kotlinx.coroutines.delay
23+
import kotlinx.coroutines.flow.combine
24+
import kotlinx.coroutines.flow.filter
25+
import kotlinx.coroutines.flow.filterIsInstance
26+
import kotlinx.coroutines.flow.flatMapLatest
27+
import kotlinx.coroutines.flow.launchIn
28+
import kotlinx.coroutines.flow.map
29+
import kotlinx.coroutines.flow.onEach
30+
import javax.inject.Inject
31+
import kotlin.time.Duration.Companion.seconds
32+
33+
@HiltViewModel
34+
internal class SendFlowViewModel @Inject constructor(
35+
userManager: UserManager,
36+
featureFlags: FeatureFlagController,
37+
private val contactCoordinator: ContactCoordinator,
38+
private val resources: ResourceHelper,
39+
) : BaseViewModel<SendFlowViewModel.State, SendFlowViewModel.Event>(
40+
initialState = State(),
41+
updateStateForEvent = updateStateForEvent,
42+
) {
43+
44+
data class State @OptIn(ExperimentalMaterial3Api::class) constructor(
45+
val steps: List<SendStep> = listOf(SendStep.ContactList),
46+
val searchState: TextFieldState = TextFieldState(),
47+
val isPickerMode: Boolean = false,
48+
val contactSyncState: LoadingSuccessState = LoadingSuccessState(),
49+
val listItems: List<ContactListItem> = emptyList(),
50+
)
51+
52+
sealed interface Event {
53+
data class StepsUpdated(val steps: List<SendStep>, val isPickerMode: Boolean) : Event
54+
55+
data object ContactsGranted : Event
56+
data class ContactsPicked(val contacts: List<PickedContact>) : Event
57+
data class OnItemsPopulated(val items: List<ContactListItem>) : Event
58+
data class ContactSyncStateUpdated(
59+
val loading: Boolean = false,
60+
val success: Boolean = false,
61+
val error: Boolean = false,
62+
) : Event
63+
64+
data object ContactSyncComplete : Event
65+
data class OnContactClicked(val contact: ContactListItem.ContactRow) : Event
66+
data class ContactRemoved(val e164: String) : Event
67+
data class SendInvite(val contact: DeviceContact) : Event
68+
data class SendCashToContact(val contact: DeviceContact) : Event
69+
}
70+
71+
init {
72+
combine(
73+
userManager.state,
74+
featureFlags.observe(FeatureFlag.PhoneNumberSend),
75+
featureFlags.observe(FeatureFlag.ContactPickerMode),
76+
contactCoordinator.state,
77+
) { userState, phoneNumberSendFlag, contactPickerMode, contactState ->
78+
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
79+
val phoneNumberSendEnabled = phoneNumberSendFlag ||
80+
userState.flags?.enablePhoneNumberSend == true
81+
val hasContacts = contactState.contacts.isNotEmpty()
82+
val needsContacts = phoneNumberSendEnabled && !hasContacts && !contactState.hasEverSynced
83+
84+
val steps = buildList {
85+
if (!hasLinkedPhone) add(SendStep.PhoneGate)
86+
if (needsContacts) add(SendStep.ContactsGate)
87+
add(SendStep.ContactList)
88+
}
89+
Event.StepsUpdated(steps = steps, isPickerMode = contactPickerMode)
90+
}.onEach { event ->
91+
dispatchEvent(event)
92+
}.launchIn(viewModelScope)
93+
94+
combine(
95+
contactCoordinator.state,
96+
stateFlow.map { it.searchState }.flatMapLatest { snapshotFlow { it.text } }
97+
) { contactState, searchText ->
98+
generateListItems(contactState, searchText.toString())
99+
}.onEach { items ->
100+
dispatchEvent(Event.OnItemsPopulated(items))
101+
}.launchIn(viewModelScope)
102+
103+
eventFlow
104+
.filterIsInstance<Event.ContactsGranted>()
105+
.onEach {
106+
dispatchEvent(Event.ContactSyncStateUpdated(loading = true))
107+
contactCoordinator.sync()
108+
.onSuccess {
109+
dispatchEvent(Event.ContactSyncStateUpdated(success = true))
110+
delay(1.seconds)
111+
dispatchEvent(Event.ContactSyncComplete)
112+
}
113+
.onFailure {
114+
dispatchEvent(Event.ContactSyncStateUpdated(error = true))
115+
delay(1.seconds)
116+
}
117+
dispatchEvent(Event.ContactSyncStateUpdated())
118+
}.launchIn(viewModelScope)
119+
120+
eventFlow
121+
.filterIsInstance<Event.ContactsPicked>()
122+
.map {
123+
it.contacts.map { contact ->
124+
PickedContactData(
125+
phoneNumber = contact.phoneNumber,
126+
displayName = contact.displayName,
127+
photoUri = contact.photoUri,
128+
)
129+
}
130+
}
131+
.onEach { contacts ->
132+
dispatchEvent(Event.ContactSyncStateUpdated(loading = true))
133+
contactCoordinator.addPickedContacts(contacts)
134+
.onSuccess {
135+
dispatchEvent(Event.ContactSyncStateUpdated(success = true))
136+
delay(1.seconds)
137+
dispatchEvent(Event.ContactSyncComplete)
138+
}
139+
.onFailure {
140+
dispatchEvent(Event.ContactSyncStateUpdated(error = true))
141+
delay(1.seconds)
142+
}
143+
dispatchEvent(Event.ContactSyncStateUpdated())
144+
}.launchIn(viewModelScope)
145+
146+
eventFlow
147+
.filterIsInstance<Event.OnContactClicked>()
148+
.map { it.contact }
149+
.onEach { (contact, isOnFlipcash) ->
150+
if (isOnFlipcash) {
151+
dispatchEvent(Event.SendCashToContact(contact))
152+
} else {
153+
dispatchEvent(Event.SendInvite(contact))
154+
}
155+
}.launchIn(viewModelScope)
156+
157+
eventFlow
158+
.filterIsInstance<Event.ContactRemoved>()
159+
.onEach { event -> contactCoordinator.removeContact(event.e164) }
160+
.launchIn(viewModelScope)
161+
162+
// SendInvite is observed by the UI layer (ContactListScreen) for navigation
163+
}
164+
165+
private fun generateListItems(
166+
contactState: ContactState,
167+
searchString: String,
168+
): List<ContactListItem> = buildList {
169+
val allContacts = contactState.contacts.values.toList()
170+
val filtered = if (searchString.isBlank()) {
171+
allContacts
172+
} else {
173+
allContacts.filter {
174+
it.displayName.contains(searchString, ignoreCase = true) ||
175+
it.e164.contains(searchString, ignoreCase = true)
176+
}
177+
}
178+
179+
val flipcash = filtered
180+
.filter { it.e164 in contactState.flipcashE164s }
181+
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
182+
val other = filtered
183+
.filter { it.e164 !in contactState.flipcashE164s }
184+
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
185+
186+
if (flipcash.isNotEmpty()) {
187+
add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts)))
188+
flipcash.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = true)) }
189+
}
190+
if (other.isNotEmpty()) {
191+
add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts)))
192+
other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) }
193+
}
194+
}
195+
196+
companion object {
197+
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
198+
when (event) {
199+
is Event.StepsUpdated -> { state ->
200+
state.copy(steps = event.steps, isPickerMode = event.isPickerMode)
201+
}
202+
203+
is Event.ContactsGranted -> { state -> state }
204+
is Event.ContactsPicked -> { state -> state }
205+
is Event.ContactSyncStateUpdated -> { state ->
206+
state.copy(
207+
contactSyncState = LoadingSuccessState(
208+
event.loading,
209+
event.success,
210+
event.error
211+
)
212+
)
213+
}
214+
215+
is Event.ContactRemoved -> { state -> state }
216+
is Event.ContactSyncComplete -> { state -> state }
217+
is Event.OnItemsPopulated -> { state -> state.copy(listItems = event.items) }
218+
is Event.OnContactClicked -> { state -> state }
219+
is Event.SendInvite -> { state -> state }
220+
is Event.SendCashToContact -> { state -> state }
221+
}
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)