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 index 9dfa57a48..6faad1dd5 100644 --- 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 @@ -1,81 +1,37 @@ 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.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.size -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.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.core.contacts.DeviceContact 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.directsend.internal.screens.components.ContactList 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.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview 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.theme.extraLarge -import com.getcode.theme.extraSmall import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.PullToReveal import com.getcode.ui.components.SearchInput -import com.getcode.ui.core.debugBounds -import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.CodeButton +import com.getcode.ui.components.rememberPullToRevealState import com.getcode.ui.theme.CodeScaffold -import com.getcode.util.permissions.PermissionResult +import com.getcode.ui.utils.LocalSheetGesturesState import kotlinx.coroutines.flow.filterIsInstance @@ -119,23 +75,50 @@ internal fun ContactListScreen() { CodeScaffold( topBar = { - Column( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) - ) { - AppBarWithTitle( - title = stringResource(R.string.title_send), - titleAlignment = Alignment.CenterHorizontally, - endContent = { - AppBarDefaults.Close { flowNavigator.exitCanceled() } - }, - ) + AppBarWithTitle( + title = stringResource(R.string.title_send), + titleAlignment = Alignment.CenterHorizontally, + endContent = { + AppBarDefaults.Close { flowNavigator.exitCanceled() } + }, + ) + }, + ) { innerPadding -> + val listState = rememberLazyListState() + + // Search bar is hidden by default; an explicit pull-down on the list reveals it. + val pullToRevealState = rememberPullToRevealState() + + // PullToReveal drives this: the sheet's drag-to-dismiss is suppressed while a + // pull is being captured (and while search is hidden), so a downward pull + // reveals search instead of dismissing — even on a single hard pull — and is + // restored once the gesture ends with search shown. Always re-enable on exit. + val setSheetGesturesEnabled = LocalSheetGesturesState.current + DisposableEffect(setSheetGesturesEnabled) { + onDispose { setSheetGesturesEnabled(true) } + } + + // Collapse the search bar again once the user scrolls into the list without + // an active query. + LaunchedEffect(pullToRevealState) { + snapshotFlow { + pullToRevealState.isRevealed && + listState.firstVisibleItemIndex > 0 && + state.searchState.text.isEmpty() + }.collect { shouldHide -> + if (shouldHide) pullToRevealState.hide() + } + } + PullToReveal( + state = pullToRevealState, + modifier = Modifier.padding(innerPadding), + onSuppressDismissChange = { suppress -> setSheetGesturesEnabled(!suppress) }, + revealContent = { 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, + .padding(vertical = CodeTheme.dimens.grid.x3), ) { SearchInput( modifier = Modifier.weight(1f), @@ -143,512 +126,21 @@ internal fun ContactListScreen() { contentPadding = PaddingValues(start = CodeTheme.dimens.grid.x1), ) } - } - }, - ) { innerPadding -> - AnimatedContent( - targetState = state.listItems.isEmpty(), - modifier = Modifier.padding(innerPadding), - transitionSpec = { - fadeIn() togetherWith fadeOut() }, - label = "contact-list", - ) { isEmpty -> - when { - isEmpty && state.searchState.text.isNotEmpty() -> { - EmptySearchState(state.searchState.text.toString()) - } - - isEmpty -> { - EmptyContactsState() - } - - else -> { - ContactList( - items = state.listItems, - isPickerMode = state.isPickerMode, - onAddMoreContacts = { accessHandle.launch() }, - 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() - .padding(bottom = CodeTheme.dimens.grid.x12), - 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 EmptySearchState( - searchQuery: String, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxSize() - .padding(bottom = CodeTheme.dimens.grid.x12), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Image( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x13), - imageVector = Icons.Filled.Search, - contentDescription = null, - colorFilter = ColorFilter.tint(CodeTheme.colors.textSecondary), - ) - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x5), - text = stringResource(R.string.title_noSearchResults, searchQuery), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - Text( - text = stringResource(R.string.subtitle_noSearchResults), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - } -} - -@Composable -private fun ContactList( - items: List, - modifier: Modifier = Modifier, - isPickerMode: Boolean = false, - onAddMoreContacts: () -> Unit = {}, - onItemClick: (ContactListItem.ContactRow) -> Unit = {}, - onItemDismissed: (ContactListItem.ContactRow) -> Unit = {}, -) { - val context = LocalContext.current - val listState = rememberLazyListState() - val accessHandle = rememberContactAccessHandle( - isPickerMode = isPickerMode, - ) - - LazyColumn( - modifier = Modifier - .verticalScrollStateGradient( - scrollState = listState, - color = CodeTheme.colors.background, - isLongGradient = true, - ) - .then(modifier), - state = listState, - contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x3), - ) { - itemsIndexed( - items = items, - key = { _, item -> - when (item) { - is ContactListItem.Header -> item.title - is ContactListItem.ContactRow -> item.chatId?.toString() ?: 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 - - ContactRowItem( - contact = item.contact, - isOnFlipcash = item.isOnFlipcash, - lastMessagePreview = item.lastMessagePreview, - unreadCount = item.unreadCount, - showDivider = !isLastInSection, - ) { - onItemClick(item) - } - } - } - } - - if (accessHandle.permissionStatus != PermissionResult.Granted && !isPickerMode) { - item { - Column( - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .border( - width = CodeTheme.dimens.border, - color = CodeTheme.colors.divider, - shape = CodeTheme.shapes.extraLarge, - ) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x2, - ), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AnimatedConversationPaymentsPreview( - modifier = Modifier.fillMaxWidth(), - animate = false, - ) - Text( - text = stringResource(R.string.permissions_title_contactsRationale), - style = CodeTheme.typography.displayExtraSmall.copy(fontWeight = FontWeight.SemiBold), - color = CodeTheme.colors.textMain, - ) - CodeButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.action_allowContactAccessInSettings), - ) { - context.launchAppSettings() - } - } - } - } - - if (isPickerMode) { - item { - PickerModeHeader( - onAddMoreContacts = onAddMoreContacts, - ) - } - } - } -} - -@Composable -private fun PickerModeHeader( - onAddMoreContacts: () -> Unit, - modifier: Modifier = Modifier, -) { - val avatarSize = CodeTheme.dimens.staticGrid.x8 - val overlap = CodeTheme.dimens.staticGrid.x4 - - Column(modifier = modifier.fillMaxWidth()) { - 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, - ), - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onAddMoreContacts) - .padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x3, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(-overlap)) { - Box( - modifier = Modifier - .zIndex(4f) - .requiredSize(avatarSize) - .border( - width = CodeTheme.dimens.border, - color = CodeTheme.colors.background, - shape = CircleShape, - ) - .background( - brush = Brush.linearGradient(CodeTheme.colors.contactAvatar.colors), - shape = CircleShape - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) - } - repeat(2) { i -> - ContactAvatar( - contact = null, - modifier = Modifier - .zIndex((3 - i).toFloat()) - .requiredSize(avatarSize) - .border( - width = CodeTheme.dimens.border, - color = CodeTheme.colors.background, - shape = CircleShape, - ) - .clip(CircleShape), - ) - } - } - - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.title_addMoreContacts), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - Text( - modifier = Modifier - .background( - color = Color.White, - shape = CodeTheme.shapes.extraSmall, - ) - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1, - ), - text = stringResource(R.string.action_add), - style = CodeTheme.typography.textMedium, - color = Color.Black, - ) - } - } -} - -@Composable -private fun ContactGroupHeader(text: String, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x12), - 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, - lastMessagePreview: String? = null, - unreadCount: Int = 0, - showDivider: Boolean = true, - onClick: () -> Unit, -) { - Column( - modifier = modifier - .fillMaxWidth() - .background(CodeTheme.colors.background) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = CodeTheme.dimens.inset) - .padding(end = CodeTheme.dimens.inset), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (unreadCount > 0) { - UnreadBadge( - modifier = Modifier.padding( - start = CodeTheme.dimens.grid.x1, - end = CodeTheme.dimens.grid.x1, - ), - count = unreadCount - ) - } else { - Box(modifier = Modifier.requiredWidth(CodeTheme.dimens.inset)) - } - ContactAvatar( - contact = contact, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape), - includeBorder = false, - ) - } - - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = contact.displayName, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - if (contact.isUnknown) { - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), - painter = painterResource(R.drawable.ic_unknown_contact), - contentDescription = null, - tint = CodeTheme.colors.textMain, - ) - } - } - - val showSubtitle = lastMessagePreview != null || !isOnFlipcash - - if (showSubtitle) { - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), - text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) { - lastMessagePreview - } else { - contact.displayNumber.ifEmpty { contact.e164 } - }, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - ) - } - } - - 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, - ), + ContactList( + items = state.listItems, + searchState = state.searchState, + listState = listState, + isPickerMode = state.isPickerMode, + onAddMoreContacts = { accessHandle.launch() }, + onItemClick = { contact -> + viewModel.dispatchEvent(SendFlowViewModel.Event.OnContactClicked(contact)) + }, + onItemDismissed = { contact -> + viewModel.dispatchEvent(SendFlowViewModel.Event.ContactRemoved(contact.contact.e164)) + }, ) } } } - -@Composable -private fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(CodeTheme.dimens.grid.x2) - .background( - color = CodeTheme.colors.indicator, - shape = CircleShape, - ), - ) -} - -@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("Recents")) - addAll(flipcashContacts.take(3)) - add(ContactListItem.Header("On Flipcash")) - addAll(flipcashContacts.drop(3)) - add(ContactListItem.Header("Not On Flipcash Yet")) - addAll(otherContacts) - } - - ContactList(items) { } -} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt new file mode 100644 index 000000000..8025a8639 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt @@ -0,0 +1,554 @@ +package com.flipcash.app.directsend.internal.screens.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.flipcash.app.contacts.ui.ContactAvatar +import com.flipcash.app.core.android.extensions.launchAppSettings +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.directsend.internal.ContactListItem +import com.flipcash.app.permissions.rememberContactAccessHandle +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.directsend.R +import com.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.theme.extraLarge +import com.getcode.theme.extraSmall +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeButton +import com.getcode.util.permissions.PermissionResult + +@Composable +internal fun ContactList( + items: List, + searchState: TextFieldState, + listState: LazyListState, + modifier: Modifier = Modifier, + isPickerMode: Boolean = false, + onAddMoreContacts: () -> Unit = {}, + onItemClick: (ContactListItem.ContactRow) -> Unit = {}, + onItemDismissed: (ContactListItem.ContactRow) -> Unit = {}, +) { + val context = LocalContext.current + val accessHandle = rememberContactAccessHandle( + isPickerMode = isPickerMode, + ) + + LazyColumn( + modifier = Modifier + .verticalScrollStateGradient( + scrollState = listState, + color = CodeTheme.colors.background, + isLongGradient = true, + ) + .then(modifier), + state = listState, + contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x3), + ) { + if (items.isEmpty() && searchState.text.isNotEmpty()) { + item(key = "empty_search") { + EmptySearchState( + searchQuery = searchState.text.toString(), + modifier = Modifier.fillParentMaxSize(), + ) + } + } + + if (items.isEmpty() && searchState.text.isEmpty()) { + item(key = "empty_contacts") { + EmptyContactsState( + modifier = Modifier.fillParentMaxSize(), + ) + } + } + + itemsIndexed( + items = items, + key = { _, item -> + when (item) { + is ContactListItem.Header -> item.title + is ContactListItem.ContactRow -> item.chatId?.toString() ?: 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 + + ContactRowItem( + contact = item.contact, + isOnFlipcash = item.isOnFlipcash, + lastMessagePreview = item.lastMessagePreview, + unreadCount = item.unreadCount, + showDivider = !isLastInSection, + ) { + onItemClick(item) + } + } + } + } + + if (accessHandle.permissionStatus != PermissionResult.Granted && !isPickerMode) { + item { + Column( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .border( + width = CodeTheme.dimens.border, + color = CodeTheme.colors.divider, + shape = CodeTheme.shapes.extraLarge, + ) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x2, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedConversationPaymentsPreview( + modifier = Modifier.fillMaxWidth(), + animate = false, + ) + Text( + text = stringResource(R.string.permissions_title_contactsRationale), + style = CodeTheme.typography.displayExtraSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + ) + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_allowContactAccessInSettings), + ) { + context.launchAppSettings() + } + } + } + } + + if (isPickerMode) { + item { + PickerModeHeader( + onAddMoreContacts = onAddMoreContacts, + ) + } + } + } +} + +@Composable +private fun PickerModeHeader( + onAddMoreContacts: () -> Unit, + modifier: Modifier = Modifier, +) { + val avatarSize = CodeTheme.dimens.staticGrid.x8 + val overlap = CodeTheme.dimens.staticGrid.x4 + + Column(modifier = modifier.fillMaxWidth()) { + 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, + ), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onAddMoreContacts) + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(-overlap)) { + Box( + modifier = Modifier + .zIndex(4f) + .requiredSize(avatarSize) + .border( + width = CodeTheme.dimens.border, + color = CodeTheme.colors.background, + shape = CircleShape, + ) + .background( + brush = Brush.linearGradient(CodeTheme.colors.contactAvatar.colors), + shape = CircleShape + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + repeat(2) { i -> + ContactAvatar( + contact = null, + modifier = Modifier + .zIndex((3 - i).toFloat()) + .requiredSize(avatarSize) + .border( + width = CodeTheme.dimens.border, + color = CodeTheme.colors.background, + shape = CircleShape, + ) + .clip(CircleShape), + ) + } + } + + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.title_addMoreContacts), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + Text( + modifier = Modifier + .background( + color = Color.White, + shape = CodeTheme.shapes.extraSmall, + ) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + text = stringResource(R.string.action_add), + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + } + } +} + +@Composable +private fun ContactGroupHeader(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x12), + 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, + lastMessagePreview: String? = null, + unreadCount: Int = 0, + showDivider: Boolean = true, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = CodeTheme.dimens.inset) + .padding(end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (unreadCount > 0) { + UnreadBadge( + modifier = Modifier.padding( + start = CodeTheme.dimens.grid.x1, + end = CodeTheme.dimens.grid.x1, + ), + count = unreadCount + ) + } else { + Box(modifier = Modifier.requiredWidth(CodeTheme.dimens.inset)) + } + ContactAvatar( + contact = contact, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + includeBorder = false, + ) + } + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = contact.displayName, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + if (contact.isUnknown) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + painter = painterResource(R.drawable.ic_unknown_contact), + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + } + } + + val showSubtitle = lastMessagePreview != null || !isOnFlipcash + + if (showSubtitle) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) { + lastMessagePreview + } else { + contact.displayNumber.ifEmpty { contact.e164 } + }, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } + + 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 UnreadBadge(count: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(CodeTheme.dimens.grid.x2) + .background( + color = CodeTheme.colors.indicator, + shape = CircleShape, + ), + ) +} + +@Composable +private fun EmptyContactsState( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = CodeTheme.dimens.grid.x12), + 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 EmptySearchState( + searchQuery: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = CodeTheme.dimens.grid.x12), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x13), + imageVector = Icons.Filled.Search, + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.textSecondary), + ) + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x5), + text = stringResource(R.string.title_noSearchResults, searchQuery), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.subtitle_noSearchResults), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} + +@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("Recents")) + addAll(flipcashContacts.take(3)) + add(ContactListItem.Header("On Flipcash")) + addAll(flipcashContacts.drop(3)) + add(ContactListItem.Header("Not On Flipcash Yet")) + addAll(otherContacts) + } + + ContactList( + items = items, + searchState = TextFieldState(), + listState = rememberLazyListState(), + ) +} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/PullToReveal.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/PullToReveal.kt new file mode 100644 index 000000000..270e27ef6 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/PullToReveal.kt @@ -0,0 +1,175 @@ +package com.getcode.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp + +/** + * Hoisted state for [PullToReveal]. + * + * The reveal slot is hidden by default and only shown by an explicit pull-down + * (over-scroll) gesture once the pull passes the threshold — analogous to + * `PullToRefreshState`, except the revealed content stays in place until [hide] + * is called rather than auto-collapsing on release. + */ +@Stable +class PullToRevealState internal constructor(initiallyRevealed: Boolean) { + + /** Whether the reveal slot is currently shown. */ + var isRevealed: Boolean by mutableStateOf(initiallyRevealed) + internal set + + /** Downward distance (px) accumulated toward the reveal threshold while hidden. */ + var distancePulled: Float by mutableFloatStateOf(0f) + internal set + + /** + * Whether a pull-down gesture is currently being captured. A capture begins on a + * downward over-pull while hidden and lasts until the gesture ends — so the + * component keeps owning that drag even after the reveal threshold is crossed. + */ + var isCapturing: Boolean by mutableStateOf(false) + internal set + + /** Show the reveal slot and reset the accumulated pull. */ + fun reveal() { + distancePulled = 0f + isRevealed = true + } + + /** Hide the reveal slot and reset any in-progress pull / capture. */ + fun hide() { + distancePulled = 0f + isCapturing = false + isRevealed = false + } + + companion object { + val Saver: Saver = Saver( + save = { it.isRevealed }, + restore = { PullToRevealState(initiallyRevealed = it) }, + ) + } +} + +/** Remembers a [PullToRevealState] that survives configuration changes. */ +@Composable +fun rememberPullToRevealState(initiallyRevealed: Boolean = false): PullToRevealState = + rememberSaveable(saver = PullToRevealState.Saver) { + PullToRevealState(initiallyRevealed = initiallyRevealed) + } + +object PullToRevealDefaults { + /** Default pull distance required before the reveal slot is shown. */ + val Threshold: Dp = 56.dp +} + +/** + * Vertically stacks [revealContent] above [content]. + * + * [revealContent] is hidden by default and animates in (expand + fade) when the + * user pulls [content] down past [threshold] while it is already scrolled to the + * top. The reveal is driven by nested-scroll over-pull, so [content] must contain + * a scrollable that dispatches nested scroll (e.g. a `LazyColumn`). + * + * Unlike pull-to-refresh, the revealed slot remains visible until + * [PullToRevealState.hide] is called — the caller decides when to collapse it. + * + * @param onSuppressDismissChange invoked when the component starts/stops "owning" a + * downward gesture. While `true`, an ancestor's competing drag (e.g. a bottom + * sheet's drag-to-dismiss) should be disabled so the pull reveals instead of + * dismissing; the value stays `true` for the whole reveal gesture — not just while + * hidden — so a single hard pull cannot reveal and then dismiss in one motion. + */ +@Composable +fun PullToReveal( + revealContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + state: PullToRevealState = rememberPullToRevealState(), + threshold: Dp = PullToRevealDefaults.Threshold, + enabled: Boolean = true, + onSuppressDismissChange: ((Boolean) -> Unit)? = null, + content: @Composable () -> Unit, +) { + val thresholdPx = with(LocalDensity.current) { threshold.toPx() } + val connection = remember(state, thresholdPx, enabled) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Any upward scroll cancels an in-progress pull / capture. + if (enabled && available.y < 0f) { + state.distancePulled = 0f + state.isCapturing = false + } + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + // Leftover downward delta means the child is at the top and is being + // over-pulled. Anything else (upward, or consumed by the list) is left + // alone so normal scrolling is untouched. + if (!enabled || available.y <= 0f) return Offset.Zero + + // Begin capturing only on a user drag while hidden; once we do, we own + // the rest of the gesture (through the reveal threshold) so an ancestor + // can't also act on it mid-drag. + if (!state.isCapturing && !state.isRevealed && + source == NestedScrollSource.UserInput + ) { + state.isCapturing = true + } + if (!state.isCapturing) return Offset.Zero + + // Accumulate toward the threshold from the user's drag only. + if (source == NestedScrollSource.UserInput) { + state.distancePulled += available.y + if (state.distancePulled >= thresholdPx) state.reveal() + } + // Consume the over-pull (drag and its trailing fling) so it never + // reaches an ancestor — the sheet stays perfectly still while revealing. + return Offset(0f, available.y) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // Gesture ended — release ownership so a fresh pull can reach the + // ancestor again (e.g. to dismiss the sheet now that search is shown). + state.distancePulled = 0f + state.isCapturing = false + return Velocity.Zero + } + } + } + + val suppressDismiss = enabled && (!state.isRevealed || state.isCapturing) + LaunchedEffect(suppressDismiss, onSuppressDismissChange) { + onSuppressDismissChange?.invoke(suppressDismiss) + } + + Column(modifier = modifier.nestedScroll(connection)) { + AnimatedVisibility(visible = state.isRevealed) { + revealContent() + } + content() + } +}