diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index c34df7439..ab0a4e1f7 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -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 @@ -84,7 +85,7 @@ fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } - annotatedEntry { } + annotatedEntry { SendFlowScreen(resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { BalanceScreen() } annotatedEntry { ShareAppScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index b8fd13daf..e1dbdfbea 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -142,8 +142,15 @@ 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 @@ -151,6 +158,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object ShareApp : Sheets + } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt new file mode 100644 index 000000000..282e2e24e --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/send/SendStep.kt @@ -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 diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fed1e7925..749f68dcb 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -697,5 +697,17 @@ People must have a minimum balance of %1$s to be counted Invite + Remove More + + No Contacts Yet + Tap the + button to add contacts + + On Flipcash + Not On Flipcash Yet + + 1 Contact Already On Flipcash + %1$s Contacts Already On Flipcash + + Send them money, or invite other contacts to sign up for Flipcash \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/build.gradle.kts b/apps/flipcash/features/direct-send/build.gradle.kts index 961c145ab..569b6273a 100644 --- a/apps/flipcash/features/direct-send/build.gradle.kts +++ b/apps/flipcash/features/direct-send/build.gradle.kts @@ -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")) } diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt new file mode 100644 index 000000000..1f7926952 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt @@ -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() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + FlowHost( + 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 = entryProvider { + annotatedEntry { + PhoneGateLandingScreen() + } + annotatedEntry { + ContactsPermissionGateScreen() + } + annotatedEntry { + ContactListScreen() + } +} \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt new file mode 100644 index 000000000..6e1e1fdd2 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt @@ -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 +} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt new file mode 100644 index 000000000..caff1ddc7 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -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( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State @OptIn(ExperimentalMaterial3Api::class) constructor( + val steps: List = listOf(SendStep.ContactList), + val searchState: TextFieldState = TextFieldState(), + val isPickerMode: Boolean = false, + val contactSyncState: LoadingSuccessState = LoadingSuccessState(), + val listItems: List = emptyList(), + ) + + sealed interface Event { + data class StepsUpdated(val steps: List, val isPickerMode: Boolean) : Event + + data object ContactsGranted : Event + data class ContactsPicked(val contacts: List) : Event + data class OnItemsPopulated(val items: List) : 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() + .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() + .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() + .map { it.contact } + .onEach { (contact, isOnFlipcash) -> + if (isOnFlipcash) { + dispatchEvent(Event.SendCashToContact(contact)) + } else { + dispatchEvent(Event.SendInvite(contact)) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .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 = 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 } + } + } + } +} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt new file mode 100644 index 000000000..898de2f44 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -0,0 +1,580 @@ +package com.flipcash.app.directsend.internal.screens + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.snapTo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.send.SendResult +import com.flipcash.app.core.send.SendStep +import com.flipcash.app.directsend.internal.ContactListItem +import com.flipcash.app.directsend.internal.SendFlowViewModel +import com.flipcash.app.permissions.ContactAccessResult +import com.flipcash.app.permissions.rememberContactAccessHandle +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.directsend.R +import com.getcode.navigation.flow.LocalOuterCodeNavigator +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.components.SearchInput +import com.getcode.ui.core.rememberedClickable +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeScaffold +import kotlin.math.abs +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch + + +@Composable +internal fun ContactListScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val navigator = LocalOuterCodeNavigator.current + + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.eventFlow + .filterIsInstance() + .collect { event -> + navigator.show(AppRoute.Main.InviteContact(event.contact.e164)) + } + } + + val accessHandle = rememberContactAccessHandle( + isPickerMode = state.isPickerMode, + ) { result -> + when (result) { + is ContactAccessResult.Picked -> { + viewModel.dispatchEvent(SendFlowViewModel.Event.ContactsPicked(result.contacts)) + } + + else -> Unit + } + } + + CodeScaffold( + topBar = { + Column( + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) + ) { + AppBarWithTitle( + title = stringResource(R.string.title_send), + titleAlignment = Alignment.CenterHorizontally, + isInModal = true, + endContent = { + AppBarDefaults.Close { flowNavigator.exitCanceled() } + }, + ) + + Row( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.grid.x3) + .padding(top = CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchInput( + modifier = Modifier.weight(1f), + state = state.searchState, + contentPadding = PaddingValues(start = CodeTheme.dimens.grid.x1), + ) + + if (state.isPickerMode) { + CircularIconButton( + onClick = { accessHandle.launch() }) { size -> + Icon( + imageVector = Icons.Default.GroupAdd, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + } + } + } + }, + ) { innerPadding -> + AnimatedContent( + targetState = state.listItems.isEmpty(), + modifier = Modifier.padding(innerPadding), + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "contact-list", + ) { isEmpty -> + if (isEmpty) { + EmptyContactsState() + } else { + ContactList( + items = state.listItems, + isPickerMode = state.isPickerMode, + onItemClick = { contact -> + viewModel.dispatchEvent(SendFlowViewModel.Event.OnContactClicked(contact)) + }, + onItemDismissed = { contact -> + viewModel.dispatchEvent(SendFlowViewModel.Event.ContactRemoved(contact.contact.e164)) + }, + ) + } + } + } +} + +@Composable +private fun EmptyContactsState(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.title_noContacts), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.subtitle_noContacts), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun ContactList( + items: List, + modifier: Modifier = Modifier, + isPickerMode: Boolean = false, + onItemClick: (ContactListItem.ContactRow) -> Unit = {}, + onItemDismissed: (ContactListItem.ContactRow) -> Unit = {}, +) { + val listState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .verticalScrollStateGradient( + scrollState = listState, + color = CodeTheme.colors.background, + isLongGradient = true, + ) + .then(modifier), + state = listState, + ) { + itemsIndexed( + items = items, + key = { _, item -> + when (item) { + is ContactListItem.Header -> item.title + is ContactListItem.ContactRow -> item.contact.e164 + } + } + ) { index, item -> + when (item) { + is ContactListItem.Header -> ContactGroupHeader( + text = item.title, + modifier = Modifier.animateItem(), + ) + + is ContactListItem.ContactRow -> { + val isLastInSection = + index == items.lastIndex || + items[index + 1] is ContactListItem.Header + + if (isPickerMode) { + SwipeToRevealItem( + modifier = Modifier.animateItem(), + onDelete = { onItemDismissed(item) }, + ) { + ContactRowItem( + contact = item.contact, + isOnFlipcash = item.isOnFlipcash, + showDivider = !isLastInSection, + onClick = { onItemClick(item) }, + ) + } + } else { + ContactRowItem( + contact = item.contact, + isOnFlipcash = item.isOnFlipcash, + showDivider = !isLastInSection, + ) { + onItemClick(item) + } + } + } + } + } + } +} + +private enum class RevealValue { Settled, Revealed, Dismissed } + +@Composable +private fun SwipeToRevealItem( + onDelete: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val actionWidth = CodeTheme.dimens.staticGrid.x10 + CodeTheme.dimens.inset * 2 + val actionWidthPx = with(density) { actionWidth.toPx() } + + val state = remember { AnchoredDraggableState(initialValue = RevealValue.Settled) } + var rowWidthPx by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(rowWidthPx, actionWidthPx) { + if (rowWidthPx > 0f) { + state.updateAnchors(DraggableAnchors { + RevealValue.Settled at 0f + RevealValue.Revealed at -actionWidthPx + RevealValue.Dismissed at -rowWidthPx + }) + } + } + + LaunchedEffect(state.currentValue) { + if (state.currentValue == RevealValue.Dismissed) { + onDelete() + } + } + + val actionPadding = CodeTheme.dimens.inset + val minActionSize = CodeTheme.dimens.staticGrid.x10 + + Box( + modifier = modifier + .clipToBounds() + .onSizeChanged { rowWidthPx = it.width.toFloat() }, + ) { + // Action area — grows from circle to rounded rect as swipe progresses + Box( + modifier = Modifier + .matchParentSize() + .layout { measurable, constraints -> + val absOffset = abs(state.offset) + val paddingPx = actionPadding.roundToPx() + val minPx = minActionSize.roundToPx() + + val w = (absOffset.toInt() - paddingPx * 2).coerceAtLeast(minPx) + val h = (constraints.maxHeight - paddingPx * 2).coerceAtLeast(minPx) + + val placeable = measurable.measure( + constraints.copy( + minWidth = w, maxWidth = w, + minHeight = h, maxHeight = h, + ) + ) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place( + constraints.maxWidth - placeable.width - paddingPx, + (constraints.maxHeight - placeable.height) / 2, + ) + } + } + .clip(RoundedCornerShape(50)) + .background(CodeTheme.colors.error) + .rememberedClickable { + scope.launch { + state.snapTo(RevealValue.Settled) + } + onDelete() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.action_remove), + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + + // Foreground content that slides + Box( + modifier = Modifier + .offset { IntOffset(state.offset.toInt(), 0) } + .anchoredDraggable(state, Orientation.Horizontal), + ) { + content() + } + } +} + +@Composable +private fun ContactGroupHeader(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(60.dp), + contentAlignment = Alignment.BottomStart, + ) { + Column { + Row(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) { + Text( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + text = text, + ) + } + HorizontalDivider( + color = CodeTheme.colors.divider, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .height(1.dp), + ) + } + } +} + +@Composable +private fun ContactRowItem( + contact: DeviceContact, + isOnFlipcash: Boolean, + modifier: Modifier = Modifier, + showDivider: Boolean = true, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .rememberedClickable(onClick = onClick) + .padding( + vertical = CodeTheme.dimens.inset, + horizontal = CodeTheme.dimens.inset, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + photoUri = contact.photoUri, + displayName = contact.displayName, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = contact.displayName, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = contact.displayNumber.ifEmpty { contact.e164 }, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + if (isOnFlipcash) { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } else { + Text( + modifier = Modifier + .background( + color = White10, // ButtonState.Filled10 + shape = CodeTheme.shapes.small, + ) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + text = stringResource(R.string.action_invite), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } + if (showDivider) { + HorizontalDivider( + color = CodeTheme.colors.divider, + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding( + start = CodeTheme.dimens.inset + CodeTheme.dimens.staticGrid.x8 + CodeTheme.dimens.grid.x3, + end = CodeTheme.dimens.inset, + ), + ) + } + } +} + +@Composable +private fun ContactAvatar( + photoUri: String?, + displayName: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.background( + Brush.linearGradient(CodeTheme.colors.contactAvatar.colors) + ) + ) { + if (photoUri != null) { + var isError by rememberSaveable(photoUri) { mutableStateOf(false) } + if (!isError) { + val context = LocalContext.current + val request = remember(photoUri) { + ImageRequest.Builder(context) + .crossfade(true) + .data(photoUri.toUri()) + .build() + } + AsyncImage( + modifier = Modifier.matchParentSize(), + model = request, + contentDescription = null, + onError = { isError = true }, + ) + } + if (isError) { + InitialsText(displayName) + } + } else { + InitialsText(displayName) + } + } +} + +@Composable +private fun BoxScope.InitialsText(displayName: String) { + val initials = remember(displayName) { + displayName.split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercaseChar() } + .joinToString("") + .ifEmpty { "?" } + } + Text( + modifier = Modifier.align(Alignment.Center), + text = initials, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun ContactListPreview() { + val fakeNames = listOf( + "Alice Anderson", "Bob Baker", "Charlie Chen", "Dana Davis", + "Eli Evans", "Fiona Fisher", "George Garcia", "Hannah Hill", + "Isaac Ingram", "Julia Jones", "Kevin Kim", "Latif Peracha", + "Maya Martinez", "Noah Nguyen", "Olivia Ortiz", "Paul Park", + "Quinn Quinn", "Rachel Robinson", "Sam Smith", "Tina Torres", + ) + + val flipcashContacts = fakeNames.take(6).mapIndexed { i, name -> + ContactListItem.ContactRow( + contact = DeviceContact( + e164 = "+1555000${1000 + i}", + androidContactId = i.toLong(), + displayName = name, + photoUri = null, + displayNumber = "(555) 000-${1000 + i}", + ), + isOnFlipcash = true, + ) + } + val otherContacts = fakeNames.drop(6).mapIndexed { i, name -> + ContactListItem.ContactRow( + contact = DeviceContact( + e164 = "+1555000${2000 + i}", + androidContactId = (100 + i).toLong(), + displayName = name, + photoUri = null, + displayNumber = "(555) 000-${2000 + i}", + ), + isOnFlipcash = false, + ) + } + + val items = buildList { + add(ContactListItem.Header("Flipcash Contacts")) + addAll(flipcashContacts) + add(ContactListItem.Header("Other Contacts")) + addAll(otherContacts) + } + + ContactList(items) { } +} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt new file mode 100644 index 000000000..23775602b --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.directsend.internal.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.analytics.Button +import com.flipcash.app.contacts.device.PickedContactData +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.permissions.ContactAccessResult +import com.flipcash.app.permissions.internal.contacts.ContactScreenContent +import com.flipcash.app.permissions.rememberContactAccessHandle +import com.getcode.libs.analytics.LocalAnalytics +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.flow.filterIsInstance + +@Composable +internal fun ContactsPermissionGateScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val analytics = LocalAnalytics.current + + val accessHandle = rememberContactAccessHandle( + isPickerMode = state.isPickerMode, + ) { result -> + when (result) { + ContactAccessResult.Granted -> { + analytics.action(Button.AllowContacts) + viewModel.dispatchEvent(SendFlowViewModel.Event.ContactsGranted) + } + is ContactAccessResult.Picked -> { + analytics.action(Button.AllowContacts) + viewModel.dispatchEvent(SendFlowViewModel.Event.ContactsPicked(result.contacts)) + } + ContactAccessResult.Denied, + ContactAccessResult.PermanentlyDenied -> { + // TODO: need something here like notifications + } + ContactAccessResult.Canceled -> Unit + } + } + + LaunchedEffect(Unit) { + viewModel.eventFlow + .filterIsInstance() + .collect { flowNavigator.proceed() } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = "", + isInModal = true, + endContent = { + AppBarDefaults.Close { flowNavigator.exitCanceled() } + }, + ) + }, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + ContactScreenContent( + accessHandle = accessHandle, + isLoading = state.contactSyncState.loading, + isSuccess = state.contactSyncState.success, + ) + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt new file mode 100644 index 000000000..8a8268d72 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/PhoneGateLandingScreen.kt @@ -0,0 +1,95 @@ +package com.flipcash.app.directsend.internal.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.send.SendResult +import com.flipcash.app.core.send.SendStep +import com.flipcash.features.directsend.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold + + +@Composable +internal fun PhoneGateLandingScreen() { + val flowNavigator = rememberFlowNavigator() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = "", + isInModal = true, + endContent = { + AppBarDefaults.Close { flowNavigator.exitCanceled() } + }, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3) + .navigationBarsPadding(), + buttonState = ButtonState.Filled, + text = stringResource(R.string.action_connectPhoneNumber), + onClick = { + flowNavigator.navigate( + AppRoute.Verification( + origin = AppRoute.Sheets.Send(), + includePhone = true, + includeEmail = false, + linkForPayment = true, + target = AppRoute.Sheets.Send(resumed = true), + ) + ) + }, + ) + } + ) { innerPadding -> + Box( + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.title_connectPhoneToSend), + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.subtitle_connectPhoneToSend), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt index 6a776bf77..d2b1f49e2 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt @@ -10,5 +10,5 @@ sealed class ScannerDecorItem(val screen: AppRoute) { data object Menu : ScannerDecorItem(AppRoute.Sheets.Menu) data object Logo: ScannerDecorItem(AppRoute.Sheets.ShareApp) data object Discover: ScannerDecorItem(AppRoute.Token.Discovery) - data object Send: ScannerDecorItem(AppRoute.Sheets.Send) + data object Send: ScannerDecorItem(AppRoute.Sheets.Send()) } \ No newline at end of file