Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen
import com.flipcash.app.core.AppRoute
import com.flipcash.app.currency.RegionSelectionScreen
import com.flipcash.app.deposit.DepositFlowScreen
import com.flipcash.app.directsend.SendFlowScreen
import com.flipcash.app.invite.InviteContactScreen
import com.flipcash.app.discovery.TokenDiscoveryScreen
import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator
Expand Down Expand Up @@ -84,7 +85,7 @@ fun appEntryProvider(

// Sheets (inner content — wrapped in Main.Sheet by navigateTo())
annotatedEntry<AppRoute.Sheets.Give> { key -> CashScreen(key.mint, key.fromTokenInfo) }
annotatedEntry<AppRoute.Sheets.Send> { }
annotatedEntry<AppRoute.Sheets.Send> { SendFlowScreen(resultStateRegistry = resultStateRegistry) }
annotatedEntry<AppRoute.Sheets.TokenSelection> { key -> TokenSelectScreen(key.purpose) }
annotatedEntry<AppRoute.Sheets.Wallet> { BalanceScreen() }
annotatedEntry<AppRoute.Sheets.ShareApp> { ShareAppScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,23 @@ sealed interface AppRoute : NavKey, Parcelable {
@Serializable
data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets

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

@Serializable
data object ShareApp : Sheets

}

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.flipcash.app.core.send

import android.os.Parcelable
import com.getcode.navigation.flow.FlowStep
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* Steps inside the Send flow. Rendered inside a [com.getcode.navigation.flow.FlowHost]
* by `SendFlowScreen` in the `direct-send` feature module.
*/
@Serializable
sealed interface SendStep : FlowStep, Parcelable {
@Parcelize
@Serializable
data object PhoneGate : SendStep

@Parcelize
@Serializable
data object ContactsGate : SendStep

@Parcelize
@Serializable
data object ContactList : SendStep
}

@Serializable
sealed interface SendResult : Parcelable
12 changes: 12 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -697,5 +697,17 @@
<string name="prompt_description_learnAboutLeaderboard">People must have a minimum balance of %1$s to be counted</string>

<string name="action_invite">Invite</string>
<string name="action_remove">Remove</string>
<string name="action_inviteMoreOptions">More</string>

<string name="title_noContacts">No Contacts Yet</string>
<string name="subtitle_noContacts">Tap the + button to add contacts</string>

<string name="title_flipcashContacts">On Flipcash</string>
<string name="title_nonFlipcashContacts">Not On Flipcash Yet</string>
<plurals name="prompt_title_contactsAlreadyOnFlipcash">
<item quantity="one">1 Contact Already On Flipcash</item>
<item quantity="other">%1$s Contacts Already On Flipcash</item>
</plurals>
<string name="prompt_description_contactsAlreadyOnFlipcash">Send them money, or invite other contacts to sign up for Flipcash</string>
</resources>
3 changes: 3 additions & 0 deletions apps/flipcash/features/direct-send/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ dependencies {
implementation(project(":libs:logging"))
implementation(project(":libs:messaging"))
implementation(project(":libs:permissions:bindings"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:permissions"))
implementation(project(":apps:flipcash:shared:contacts"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.flipcash.app.directsend

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import com.flipcash.app.core.send.SendResult
import com.flipcash.app.core.send.SendStep
import com.flipcash.app.directsend.internal.SendFlowViewModel
import com.flipcash.app.directsend.internal.screens.ContactListScreen
import com.flipcash.app.directsend.internal.screens.ContactsPermissionGateScreen
import com.flipcash.app.directsend.internal.screens.PhoneGateLandingScreen
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.flow.FlowExitReason
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.results.NavResultStateRegistry
import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher

@Composable
fun SendFlowScreen(resultStateRegistry: NavResultStateRegistry) {
val sheetDismiss = LocalBottomSheetDismissDispatcher.current
val viewModel = hiltViewModel<SendFlowViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

FlowHost<SendStep, SendResult>(
steps = state.steps,
resumeAt = 0,
resultStateRegistry = resultStateRegistry,
onExit = { reason, _ ->
when (reason) {
is FlowExitReason.Completed,
FlowExitReason.BackedOutOfRoot,
FlowExitReason.Canceled -> sheetDismiss()
}
},
entryProvider = sendEntryProvider(),
)
}

private fun sendEntryProvider(): (NavKey) -> NavEntry<NavKey> = entryProvider {
annotatedEntry<SendStep.PhoneGate> {
PhoneGateLandingScreen()
}
annotatedEntry<SendStep.ContactsGate> {
ContactsPermissionGateScreen()
}
annotatedEntry<SendStep.ContactList> {
ContactListScreen()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.flipcash.app.directsend.internal

import com.flipcash.app.contacts.device.DeviceContact

internal sealed interface ContactListItem {
data class Header(val title: String) : ContactListItem
data class ContactRow(val contact: DeviceContact, val isOnFlipcash: Boolean) : ContactListItem
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package com.flipcash.app.directsend.internal

import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarState
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.ContactCoordinator.ContactState
import com.flipcash.app.contacts.device.DeviceContact
import com.flipcash.app.contacts.device.PickedContactData
import com.flipcash.app.core.send.SendStep
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.permissions.PickedContact
import com.flipcash.features.directsend.R
import com.flipcash.services.user.UserManager
import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds

@HiltViewModel
internal class SendFlowViewModel @Inject constructor(
userManager: UserManager,
featureFlags: FeatureFlagController,
private val contactCoordinator: ContactCoordinator,
private val resources: ResourceHelper,
) : BaseViewModel<SendFlowViewModel.State, SendFlowViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
) {

data class State @OptIn(ExperimentalMaterial3Api::class) constructor(
val steps: List<SendStep> = listOf(SendStep.ContactList),
val searchState: TextFieldState = TextFieldState(),
val isPickerMode: Boolean = false,
val contactSyncState: LoadingSuccessState = LoadingSuccessState(),
val listItems: List<ContactListItem> = emptyList(),
)

sealed interface Event {
data class StepsUpdated(val steps: List<SendStep>, val isPickerMode: Boolean) : Event

data object ContactsGranted : Event
data class ContactsPicked(val contacts: List<PickedContact>) : Event
data class OnItemsPopulated(val items: List<ContactListItem>) : Event
data class ContactSyncStateUpdated(
val loading: Boolean = false,
val success: Boolean = false,
val error: Boolean = false,
) : Event

data object ContactSyncComplete : Event
data class OnContactClicked(val contact: ContactListItem.ContactRow) : Event
data class ContactRemoved(val e164: String) : Event
data class SendInvite(val contact: DeviceContact) : Event
data class SendCashToContact(val contact: DeviceContact) : Event
}

init {
combine(
userManager.state,
featureFlags.observe(FeatureFlag.PhoneNumberSend),
featureFlags.observe(FeatureFlag.ContactPickerMode),
contactCoordinator.state,
) { userState, phoneNumberSendFlag, contactPickerMode, contactState ->
val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null
val phoneNumberSendEnabled = phoneNumberSendFlag ||
userState.flags?.enablePhoneNumberSend == true
val hasContacts = contactState.contacts.isNotEmpty()
val needsContacts = phoneNumberSendEnabled && !hasContacts && !contactState.hasEverSynced

val steps = buildList {
if (!hasLinkedPhone) add(SendStep.PhoneGate)
if (needsContacts) add(SendStep.ContactsGate)
add(SendStep.ContactList)
}
Event.StepsUpdated(steps = steps, isPickerMode = contactPickerMode)
}.onEach { event ->
dispatchEvent(event)
}.launchIn(viewModelScope)

combine(
contactCoordinator.state,
stateFlow.map { it.searchState }.flatMapLatest { snapshotFlow { it.text } }
) { contactState, searchText ->
generateListItems(contactState, searchText.toString())
}.onEach { items ->
dispatchEvent(Event.OnItemsPopulated(items))
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ContactsGranted>()
.onEach {
dispatchEvent(Event.ContactSyncStateUpdated(loading = true))
contactCoordinator.sync()
.onSuccess {
dispatchEvent(Event.ContactSyncStateUpdated(success = true))
delay(1.seconds)
dispatchEvent(Event.ContactSyncComplete)
}
.onFailure {
dispatchEvent(Event.ContactSyncStateUpdated(error = true))
delay(1.seconds)
}
dispatchEvent(Event.ContactSyncStateUpdated())
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ContactsPicked>()
.map {
it.contacts.map { contact ->
PickedContactData(
phoneNumber = contact.phoneNumber,
displayName = contact.displayName,
photoUri = contact.photoUri,
)
}
}
.onEach { contacts ->
dispatchEvent(Event.ContactSyncStateUpdated(loading = true))
contactCoordinator.addPickedContacts(contacts)
.onSuccess {
dispatchEvent(Event.ContactSyncStateUpdated(success = true))
delay(1.seconds)
dispatchEvent(Event.ContactSyncComplete)
}
.onFailure {
dispatchEvent(Event.ContactSyncStateUpdated(error = true))
delay(1.seconds)
}
dispatchEvent(Event.ContactSyncStateUpdated())
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.OnContactClicked>()
.map { it.contact }
.onEach { (contact, isOnFlipcash) ->
if (isOnFlipcash) {
dispatchEvent(Event.SendCashToContact(contact))
} else {
dispatchEvent(Event.SendInvite(contact))
}
}.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ContactRemoved>()
.onEach { event -> contactCoordinator.removeContact(event.e164) }
.launchIn(viewModelScope)

// SendInvite is observed by the UI layer (ContactListScreen) for navigation
}

private fun generateListItems(
contactState: ContactState,
searchString: String,
): List<ContactListItem> = buildList {
val allContacts = contactState.contacts.values.toList()
val filtered = if (searchString.isBlank()) {
allContacts
} else {
allContacts.filter {
it.displayName.contains(searchString, ignoreCase = true) ||
it.e164.contains(searchString, ignoreCase = true)
}
}

val flipcash = filtered
.filter { it.e164 in contactState.flipcashE164s }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
val other = filtered
.filter { it.e164 !in contactState.flipcashE164s }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })

if (flipcash.isNotEmpty()) {
add(ContactListItem.Header(resources.getString(R.string.title_flipcashContacts)))
flipcash.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = true)) }
}
if (other.isNotEmpty()) {
add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts)))
other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) }
}
}

companion object {
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
is Event.StepsUpdated -> { state ->
state.copy(steps = event.steps, isPickerMode = event.isPickerMode)
}

is Event.ContactsGranted -> { state -> state }
is Event.ContactsPicked -> { state -> state }
is Event.ContactSyncStateUpdated -> { state ->
state.copy(
contactSyncState = LoadingSuccessState(
event.loading,
event.success,
event.error
)
)
}

is Event.ContactRemoved -> { state -> state }
is Event.ContactSyncComplete -> { state -> state }
is Event.OnItemsPopulated -> { state -> state.copy(listItems = event.items) }
is Event.OnContactClicked -> { state -> state }
is Event.SendInvite -> { state -> state }
is Event.SendCashToContact -> { state -> state }
}
}
}
}
Loading
Loading