From f3d297787646c0b427341c0445a5aeb761c8f8f3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 Jul 2026 12:55:18 -0400 Subject: [PATCH] feat(send): update contact/chat list row item * Deliniate received vs sent messages * Show a message preview for contact joins (on flipcash, no chat yet) * Color timestamps to match unread indicator * Remove vertically centered chevrons for non chat rows; uses the timestamp aligned/top row chevron Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../directsend/internal/ContactListBuilder.kt | 9 +- .../screens/components/ContactList.kt | 206 ++++++++++++++++-- 3 files changed, 194 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4aca3a74e..503869268 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -827,6 +827,7 @@ %1$s of %2$s You sent %1$s You received %1$s + You: %1$s Is Typing… Unknown Contact diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt index 5416391c4..961743888 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt @@ -154,7 +154,14 @@ internal class ContactListBuilder @Inject constructor( val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId return lastMsg.content.firstOrNull()?.let { content -> when (content) { - is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() } + is MessageContent.Text -> { + val message = content.text.takeIf { it.isNotEmpty() } ?: return null + if (sentBySelf) { + resources.getString(R.string.label_chat_preview_sentMessage, message) + } else { + message + } + } is MessageContent.Cash -> { val formatted = content.amount.formatted() val name = content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } 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 index e9f049ac6..ef6a58eb8 100644 --- 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 @@ -1,6 +1,7 @@ package com.flipcash.app.directsend.internal.screens.components import android.text.format.DateFormat +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -21,7 +22,9 @@ 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.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -30,6 +33,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -51,9 +55,11 @@ 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.directsend.internal.Conversation import com.flipcash.app.permissions.ContactAccessHandle import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.directsend.R +import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview import com.getcode.theme.CodeTheme import com.getcode.theme.White10 @@ -66,6 +72,9 @@ import com.getcode.util.permissions.PermissionResult import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant @Composable @@ -396,11 +405,14 @@ private fun ContactRowItem( color = CodeTheme.colors.textSecondary, ) } - lastActivity != null && isActiveChat -> { + lastActivity != null -> { + val activityTextColor by animateColorAsState( + if (unreadCount > 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary + ) Text( text = formatLastActivity(lastActivity), style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, + color = activityTextColor, ) } } @@ -413,7 +425,7 @@ private fun ContactRowItem( ), count = unreadCount ) - } else if (isActiveChat) { + } else if (isOnFlipcash) { Icon( modifier = Modifier.scale(0.6f), painter = painterResource(id = R.drawable.ic_chevron_right), @@ -423,8 +435,6 @@ private fun ContactRowItem( } } - val showSubtitle = lastMessagePreview != null || !isOnFlipcash - when { isTyping -> Text( text = stringResource(R.string.label_isTyping), @@ -432,11 +442,11 @@ private fun ContactRowItem( color = CodeTheme.colors.textSecondary, ) - showSubtitle -> Text( + else -> Text( text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) { lastMessagePreview } else { - contact.displayNumber.ifEmpty { contact.e164 } + "${contact.displayName} joined Flipcash" }, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, @@ -446,15 +456,7 @@ private fun ContactRowItem( } } - if (isOnFlipcash) { - if (!isActiveChat) { - Icon( - painter = painterResource(id = R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - } - } else { + if (!isOnFlipcash) { Text( modifier = Modifier .background( @@ -583,7 +585,7 @@ 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", + "Isaac Ingram", "Julia Jones", "Kevin Kim", "Lenny Johnson", "Maya Martinez", "Noah Nguyen", "Olivia Ortiz", "Paul Park", "Quinn Quinn", "Rachel Robinson", "Sam Smith", "Tina Torres", ) @@ -613,11 +615,51 @@ private fun ContactListPreview() { ) } + val now = Clock.System.now() + val recents = listOf( + // Unread conversation with a message preview + ContactListItem.ContactRow( + contact = flipcashContacts[0].contact, + isOnFlipcash = true, + lastActivity = now - 5.minutes, + conversation = Conversation( + chatId = ChatId(byteArrayOf(1)), + lastMessagePreview = "Sent you $10.00", + unreadCount = 2, + ), + ), + // Contact is currently typing + ContactListItem.ContactRow( + contact = flipcashContacts[1].contact, + isOnFlipcash = true, + lastActivity = now - 1.hours, + conversation = Conversation( + chatId = ChatId(byteArrayOf(2)), + lastMessagePreview = "See you soon", + isTyping = true, + ), + ), + // Read conversation from yesterday + ContactListItem.ContactRow( + contact = flipcashContacts[2].contact, + isOnFlipcash = true, + lastActivity = now - 26.hours, + conversation = Conversation( + chatId = ChatId(byteArrayOf(3)), + lastMessagePreview = "You: Thanks!", + ), + ), + ) + val items = buildList { - add(ContactListItem.Header("Recents")) - addAll(flipcashContacts.take(3)) - add(ContactListItem.Header("On Flipcash")) - addAll(flipcashContacts.drop(3)) + addAll(recents) + // On-Flipcash contacts without a chat still rank by joinedAt, + // so they carry a lastActivity timestamp too. + addAll( + flipcashContacts.drop(3).mapIndexed { i, row -> + row.copy(lastActivity = now - (i + 2).days) + } + ) add(ContactListItem.Header("Not On Flipcash Yet")) addAll(otherContacts) } @@ -628,4 +670,126 @@ private fun ContactListPreview() { listState = rememberLazyListState(), accessHandle = ContactAccessHandle(launch = {}), ) +} + +@Preview(heightDp = 1400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun ContactRowItemStatesPreview() { + val now = Clock.System.now() + val known = DeviceContact( + e164 = "+15550001234", + androidContactId = 1L, + displayName = "Bob Baker", + photoUri = null, + displayNumber = "(555) 000-1234", + ) + val unknown = DeviceContact.unknownContact( + e164 = "+15559998888", + displayName = "+1 (555) 999-8888", + displayNumber = "+1 (555) 999-8888", + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .verticalScroll(rememberScrollState()), + ) { + // On Flipcash · active chat · read (message preview, timestamp, chevron) + StateLabel("On Flipcash · active chat · read") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "See you tomorrow!", + lastActivity = now - 30.minutes, + ) {} + + // On Flipcash · active chat · unread (badge + indicator-colored timestamp) + StateLabel("On Flipcash · active chat · unread") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "Sent you $5.00", + lastActivity = now - 5.minutes, + unreadCount = 3, + ) {} + + // On Flipcash · typing (subtitle overridden by "is typing…") + StateLabel("On Flipcash · typing") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "Old message", + lastActivity = now - 2.hours, + isTyping = true, + ) {} + + // On Flipcash · typing + unread + StateLabel("On Flipcash · typing · unread") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "Old message", + lastActivity = now - 2.hours, + unreadCount = 1, + isTyping = true, + ) {} + + // On Flipcash · no chat yet (still shows joinedAt timestamp, no subtitle) + StateLabel("On Flipcash · no chat yet") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastActivity = now - 3.days, + ) {} + + // On Flipcash · long preview (single line, ellipsized) + StateLabel("On Flipcash · long preview (ellipsized)") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "This is a really long message preview that should be truncated with an ellipsis when it runs out of room", + lastActivity = now - 26.hours, + ) {} + + // On Flipcash · older activity (date formatting instead of time) + StateLabel("On Flipcash · older activity") + ContactRowItem( + contact = known, + isOnFlipcash = true, + lastMessagePreview = "Thanks!", + lastActivity = now - 4.days, + ) {} + + // On Flipcash · unknown contact ("Unknown" label replaces timestamp) + StateLabel("On Flipcash · unknown contact") + ContactRowItem( + contact = unknown, + isOnFlipcash = true, + lastMessagePreview = "Hey, is this you?", + lastActivity = now - 10.minutes, + ) {} + + // Not on Flipcash · invite (phone number subtitle + Invite chip) + StateLabel("Not on Flipcash · invite") + ContactRowItem( + contact = known, + isOnFlipcash = false, + ) {} + } +} + +@Composable +private fun StateLabel(text: String) { + Text( + modifier = Modifier.padding( + start = CodeTheme.dimens.inset, + top = CodeTheme.dimens.grid.x3, + bottom = CodeTheme.dimens.grid.x1, + ), + text = text, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) } \ No newline at end of file