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 747ca65d32..5eed94567f 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 @@ -45,19 +45,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.directsend.internal.ContactListItem 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.ChatListRow import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.ChatRowSubtitle +import com.flipcash.shared.chat.ui.ChatRowTrailing +import com.flipcash.shared.chat.ui.SubtitleText +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.theme.White10 import com.getcode.theme.extraSmall @@ -65,6 +70,7 @@ import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.util.formatLocalized import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlin.compareTo import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours @@ -294,116 +300,59 @@ private fun ContactRowItem( showDivider: Boolean = true, onClick: () -> Unit, ) { - Column( - modifier = modifier - .fillMaxWidth() - .background(CodeTheme.colors.background) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .testTag("send_contact_row") - .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, - ) { - // Spacer used by the unread indicator that we moved - // left for spacing compatibility and easy add back later - Box(modifier = Modifier.requiredWidth(CodeTheme.dimens.inset)) - ContactAvatar( - contact = contact, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape), - includeBorder = false, + ChatListRow( + modifier = modifier, + avatar = { + ContactAvatar( + contact = contact, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + includeBorder = false, + ) + }, + title = { + Text( + modifier = Modifier.weight(1f), + text = contact.displayName, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + + if (contact.isUnknown) { + Text( + modifier = Modifier.background( + color = CodeTheme.colors.surfaceVariant, + shape = CircleShape, + ).padding( + vertical = CodeTheme.dimens.grid.x1, + horizontal = CodeTheme.dimens.grid.x2, + ), + text = stringResource(R.string.label_unknownContact), + style = CodeTheme.typography.caption.copy(fontSize = 10.sp, lineHeight = 10.sp), + color = CodeTheme.colors.textSecondary, ) } - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Text( - modifier = Modifier.weight(1f), - text = contact.displayName, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - - when { - contact.isUnknown -> { - Text( - modifier = Modifier.background( - color = CodeTheme.colors.surfaceVariant, - shape = CircleShape, - ).padding( - vertical = CodeTheme.dimens.grid.x1, - horizontal = CodeTheme.dimens.grid.x2, - ), - text = stringResource(R.string.label_unknownContact), - style = CodeTheme.typography.caption.copy(fontSize = 10.sp, lineHeight = 10.sp), - color = CodeTheme.colors.textSecondary, - ) - } - 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 = activityTextColor, - ) - } - } - - if (unreadCount > 0) { - UnreadBadge( - modifier = Modifier.padding( - start = CodeTheme.dimens.grid.x1, - end = CodeTheme.dimens.grid.x1, - ), - count = unreadCount - ) - } else if (isOnFlipcash) { - Icon( - modifier = Modifier.scale(0.6f), - painter = painterResource(id = R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - } - } - - when { - isTyping -> Text( - text = stringResource(R.string.label_isTyping), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - else -> Text( - text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) { - lastMessagePreview - } else if (isOnFlipcash) { - "${contact.displayName} joined Flipcash" - } else { - contact.displayNumber - }, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ChatRowTrailing( + lastActivity = lastActivity, + unreadCount = unreadCount, + canOpen = isOnFlipcash, + ) + }, + subtitle = { + ChatRowSubtitle( + isTyping = isTyping, + preview = lastMessagePreview.takeIf { isOnFlipcash }, + fallback = { + SubtitleText( + if (isOnFlipcash) "${contact.displayName} joined Flipcash" + else contact.displayNumber ) } - } - + ) + }, + endAction = { if (!isOnFlipcash) { Text( modifier = Modifier @@ -420,31 +369,9 @@ private fun ContactRowItem( 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, - ), + }, + showDivider = showDivider, + onClick = onClick, ) } @@ -506,26 +433,6 @@ private fun EmptySearchState( } } -@Composable -private fun formatLastActivity(instant: Instant): String { - val context = LocalContext.current - val is24Hour = DateFormat.is24HourFormat(context) - val tz = TimeZone.currentSystemDefault() - val todayDate = Clock.System.now().toLocalDateTime(tz).date - val messageDate = instant.toLocalDateTime(tz).date - val dayDiff = todayDate.toEpochDays() - messageDate.toEpochDays() - - val time = instant.formatLocalized("h:mm a", is24Hour = is24Hour, if24Hour = "H:mm") - - return when { - dayDiff == 0L -> time - dayDiff == 1L -> stringResource(R.string.label_chatReceipt_yesterday) - dayDiff in 2L..6L -> instant.formatLocalized("EEEE") - messageDate.year == todayDate.year -> instant.formatLocalized("MMM d") - else -> instant.formatLocalized("MMM d, yyyy") - } -} - @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 6cec4f2fcb..e35b5219eb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -17,9 +17,9 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.shared.chat.ui.ChatListItem -import com.flipcash.shared.chat.ui.ReceiptStatus -import com.flipcash.shared.chat.ui.SeparatorConfig +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.messenger.R diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 34324673a4..bb8ad15f9e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -15,7 +15,7 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar -import com.flipcash.shared.chat.ui.ChatAction +import com.flipcash.shared.chat.models.ChatAction import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.hazeSource diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index c95919bbbb..1b14d8941b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 52e4fdb0b4..8c6f6772ab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.features.messenger.R +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @Composable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 0472ddbf42..217d3a740c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -37,13 +37,13 @@ import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.services.models.chat.MessagePointer import androidx.compose.runtime.CompositionLocalProvider -import com.flipcash.shared.chat.ui.ChatAction -import com.flipcash.shared.chat.ui.ChatActionHandler -import com.flipcash.shared.chat.ui.ChatListItem +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatActionHandler +import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.ui.ContentBubble -import com.flipcash.shared.chat.ui.LocalChatActionHandler -import com.flipcash.shared.chat.ui.ReceiptStatus -import com.flipcash.shared.chat.ui.SeparatorConfig +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme import com.getcode.ui.utils.rememberKeyboardController diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index 3d066fb24f..ac76dafc70 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.shared.chat.ui.ReceiptStatus +import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index ea87c4c644..e661d75072 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation(libs.bundles.compose.ui.testing) implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt index 53db5a82a7..3a9acfa0bb 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -1,7 +1,6 @@ package com.flipcash.app.myaccount.internal 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 @@ -42,11 +41,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight.Companion.W600 import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.flipcash.app.contacts.ui.ContactAvatar import com.flipcash.core.R import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow @@ -279,7 +278,7 @@ private fun ProfileHeader( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { ContactAvatar( - photoUri = profilePicture.displayUrl(), + photoUri = profilePicture?.url(preferred = MediaItemRendition.Role.DISPLAY), displayName = displayName.orEmpty(), modifier = Modifier .size(96.dp) @@ -307,15 +306,6 @@ private fun ProfileHeader( } } -// Prefer the DISPLAY rendition (falling back to THUMBNAIL / any) for the avatar image URL. -private fun MediaItem?.displayUrl(): String? { - val renditions = this?.renditions ?: return null - return (renditions.firstOrNull { it.role == MediaItemRendition.Role.DISPLAY } - ?: renditions.firstOrNull { it.role == MediaItemRendition.Role.THUMBNAIL } - ?: renditions.firstOrNull()) - ?.blob?.downloadUrl -} - /** Non-swipeable card wrapper — just visual styling. */ @Composable private fun CardRow( diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 441580f6eb..e8b0a698cd 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":libs:datetime")) implementation(libs.androidx.paging.runtime) implementation(libs.compose.paging) + api(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:theme")) testImplementation(libs.robolectric) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt similarity index 92% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt rename to apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 429f685860..cb5474625f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -1,4 +1,4 @@ -package com.flipcash.shared.chat.ui +package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf import com.getcode.solana.keys.Mint diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt similarity index 96% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt rename to apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index ccc8485ffe..5a8a80505e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -1,4 +1,4 @@ -package com.flipcash.shared.chat.ui +package com.flipcash.shared.chat.models import com.flipcash.services.models.chat.MessageContent import kotlin.time.Instant diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SeparatorConfig.kt similarity index 97% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt rename to apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SeparatorConfig.kt index d8a2a263e4..999a2ffcf9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/SeparatorConfig.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SeparatorConfig.kt @@ -1,4 +1,4 @@ -package com.flipcash.shared.chat.ui +package com.flipcash.shared.chat.models import com.getcode.util.toLocalDate import kotlin.time.Duration @@ -37,4 +37,4 @@ sealed interface SeparatorConfig { || (before - after).absoluteValue > gap override fun isGrouped(a: Instant, b: Instant): Boolean = true } -} +} \ No newline at end of file diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt new file mode 100644 index 0000000000..51ad50a2ce --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -0,0 +1,125 @@ +package com.flipcash.shared.chat.ui + +import android.text.format.DateFormat +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.util.formatLocalized +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +@Composable +fun ChatListRow( + avatar: @Composable () -> Unit, + title: @Composable RowScope.() -> Unit, + subtitle: @Composable () -> Unit, + modifier: Modifier = Modifier, + showDivider: Boolean = true, + endAction: @Composable () -> Unit = {}, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag("send_contact_row") + .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, + ) { + // Spacer used by the unread indicator that we moved + // left for spacing compatibility and easy add back later + Box(modifier = Modifier.requiredWidth(CodeTheme.dimens.inset)) + avatar() + } + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + title() + } + + subtitle() + } + + endAction() + } + 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 +fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(CodeTheme.dimens.grid.x2) + .background( + color = CodeTheme.colors.indicator, + shape = CircleShape, + ), + ) +} + +@Composable +fun formatLastActivity(instant: Instant): String { + val context = LocalContext.current + val is24Hour = DateFormat.is24HourFormat(context) + val tz = TimeZone.currentSystemDefault() + val todayDate = Clock.System.now().toLocalDateTime(tz).date + val messageDate = instant.toLocalDateTime(tz).date + val dayDiff = todayDate.toEpochDays() - messageDate.toEpochDays() + + val time = instant.formatLocalized("h:mm a", is24Hour = is24Hour, if24Hour = "H:mm") + + return when { + dayDiff == 0L -> time + dayDiff == 1L -> stringResource(R.string.label_chatReceipt_yesterday) + dayDiff in 2L..6L -> instant.formatLocalized("EEEE") + messageDate.year == todayDate.year -> instant.formatLocalized("MMM d") + else -> instant.formatLocalized("MMM d, yyyy") + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt new file mode 100644 index 0000000000..66c2ca02d1 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -0,0 +1,88 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import kotlin.time.Instant + +/** + * Trailing cluster of a chat row's title line — reusable by any conversation-style row + * (contacts, tip chats, …): the last-activity timestamp, then an unread badge, or an + * open-chevron when [canOpen] and there's nothing unread. + * + * Relies on [UnreadBadge] and [formatLastActivity] from `ChatListRow`. + */ +@Composable +fun RowScope.ChatRowTrailing( + lastActivity: Instant?, + unreadCount: Int, + canOpen: Boolean, +) { + if (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 = activityTextColor, + ) + } + + if (unreadCount > 0) { + UnreadBadge( + modifier = Modifier.padding( + start = CodeTheme.dimens.grid.x1, + end = CodeTheme.dimens.grid.x1, + ), + count = unreadCount, + ) + } else if (canOpen) { + Icon( + modifier = Modifier.scale(0.6f), + painter = painterResource(com.getcode.util.resources.R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} + +/** + * Subtitle line of a chat row: the typing indicator, else the message [preview], else the + * caller's [fallback] (e.g. a phone number or a "joined" line for a contact). + */ +@Composable +fun ChatRowSubtitle( + isTyping: Boolean, + preview: String?, + fallback: @Composable () -> Unit = {}, +) { + when { + isTyping -> SubtitleText(stringResource(R.string.label_isTyping)) + !preview.isNullOrEmpty() -> SubtitleText(preview) + else -> fallback() + } +} + +/** Standard single-line secondary text used for chat/contact row subtitles. */ +@Composable +fun SubtitleText(text: String) { + Text( + text = text, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPaymentsPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPaymentsPreview.kt index dd8382487c..3c62b16ca8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPaymentsPreview.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPaymentsPreview.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.core.RandomId diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt index 87cdb0ff70..45af18f3d0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationPreview.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.core.ui.ScreenFrame import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SeparatorConfig import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.core.RandomId diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index b91a57c963..864c08037d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -45,12 +45,15 @@ import androidx.paging.compose.LazyPagingItems import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.SeparatorConfig import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme import com.getcode.ui.components.PriceWithFlag -import com.getcode.ui.components.chat.messagecontents.MessageContentActionHandler import com.getcode.ui.core.addIf import com.getcode.util.resources.R diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts new file mode 100644 index 0000000000..87527e41bd --- /dev/null +++ b/apps/flipcash/shared/common-ui/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.flipcash.android.library.compose) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.common.ui" +} + +dependencies { + implementation(project(":apps:flipcash:core")) + implementation(project(":ui:core")) + implementation(project(":ui:components")) + implementation(project(":ui:theme")) + implementation(project(":ui:resources")) + implementation(project(":services:flipcash")) + implementation(project(":libs:datetime")) + implementation(project(":apps:flipcash:shared:theme")) + + api(libs.compose.material.icons.extended) + testImplementation(libs.robolectric) +} diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt similarity index 61% rename from apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ui/ContactAvatar.kt rename to apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 8b331d0491..808d04b3b0 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.contacts.ui +package com.flipcash.shared.common.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -7,11 +7,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +32,8 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MediaItemRendition import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -44,33 +44,7 @@ fun ContactAvatar( includeBorder: Boolean = true, ) { if (contact == null || contact.isUnknown) { - Box( - modifier = modifier - .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)) - .addIf(includeBorder) { - Modifier.border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CircleShape, - ) - }, - contentAlignment = Alignment.BottomCenter, - ) { - Image( - imageVector = Icons.Default.Person, - contentDescription = null, - colorFilter = ColorFilter.tint(CodeTheme.colors.textSecondary), - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - val scale = 1.2f - scaleX = scale - scaleY = scale - translationY = size.height * 0.18f - }, - contentScale = ContentScale.Fit, - ) - } + UnknownContactAvatar(modifier = modifier, includeBorder = includeBorder) } else { ContactAvatar( modifier = Modifier @@ -124,6 +98,78 @@ fun ContactAvatar( } } +@Composable +fun ContactAvatar( + userProfile: UserProfile, + imageRole: MediaItemRendition.Role = MediaItemRendition.Role.THUMBNAIL, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier.background( + Brush.linearGradient(CodeTheme.colors.contactAvatar.colors) + ) + ) { + val photoUri = userProfile.profilePicture?.url(imageRole) + 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) { + UnknownContactAvatar(includeBorder = true) + } + } else { + UnknownContactAvatar(includeBorder = true) + } + } +} + +@Composable +private fun UnknownContactAvatar( + includeBorder: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)) + .addIf(includeBorder) { + Modifier.border( + CodeTheme.dimens.border, + CodeTheme.colors.divider, + CircleShape, + ) + }, + contentAlignment = Alignment.BottomCenter, + ) { + Image( + imageVector = Icons.Default.Person, + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.textSecondary), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val scale = 1.2f + scaleX = scale + scaleY = scale + translationY = size.height * 0.18f + }, + contentScale = ContentScale.Fit, + ) + } +} + @Composable private fun BoxWithConstraintsScope.InitialsText(displayName: String) { val initials = remember(displayName) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt index 8194552e54..8c4c0d1efb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -5,4 +5,37 @@ import kotlinx.serialization.Serializable @Serializable data class MediaItem( val renditions: List, -) +) { + /** + * The [preferred] rendition if it's available, otherwise the next lower-quality rendition + * that is — degrading down the quality ladder ORIGINAL → DISPLAY → THUMBNAIL. Returns + * null when no rendition at or below [preferred] is available. + * + * "Available" means the rendition's [MediaItemRendition.blob] is populated (it has a + * download URL); renditions still uploading are skipped so the fallback keeps degrading. + * This never upgrades above [preferred] — e.g. `rendition(Role.THUMBNAIL)` will not + * return the ORIGINAL. + */ + fun rendition(preferred: MediaItemRendition.Role): MediaItemRendition? { + val start = QUALITY_LADDER.indexOf(preferred) + if (start < 0) return null // UNKNOWN / non-ladder roles have no fallback chain + for (i in start until QUALITY_LADDER.size) { + renditions.firstOrNull { it.role == QUALITY_LADDER[i] && it.blob != null } + ?.let { return it } + } + return null + } + + /** Download URL of the rendition resolved for [preferred], or null if none is available. */ + fun url(preferred: MediaItemRendition.Role): String? = + rendition(preferred)?.blob?.downloadUrl + + companion object { + /** Rendition quality, best first; fallback walks this downward from the requested role. */ + private val QUALITY_LADDER = listOf( + MediaItemRendition.Role.ORIGINAL, + MediaItemRendition.Role.DISPLAY, + MediaItemRendition.Role.THUMBNAIL, + ) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt new file mode 100644 index 0000000000..d7d7011277 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.services.models.chat + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class MediaItemTest { + + private fun rendition(role: MediaItemRendition.Role, available: Boolean = true) = + MediaItemRendition( + role = role, + blobId = BlobId(byteArrayOf(1)), + blob = if (available) { + BlobMetadata( + mimeType = "image/png", + sizeBytes = 1, + downloadUrl = "https://cdn/${role.name.lowercase()}", + image = null, + ) + } else null, + ) + + private fun media(vararg renditions: MediaItemRendition) = MediaItem(renditions.toList()) + + @Test + fun `returns the preferred rendition when available`() { + val display = rendition(MediaItemRendition.Role.DISPLAY) + val item = media( + rendition(MediaItemRendition.Role.ORIGINAL), + display, + rendition(MediaItemRendition.Role.THUMBNAIL), + ) + assertSame(display, item.rendition(MediaItemRendition.Role.DISPLAY)) + } + + @Test + fun `falls back down the ladder to the next highest available`() { + // Request ORIGINAL, only DISPLAY + THUMBNAIL exist -> DISPLAY. + val display = rendition(MediaItemRendition.Role.DISPLAY) + val item = media(display, rendition(MediaItemRendition.Role.THUMBNAIL)) + assertSame(display, item.rendition(MediaItemRendition.Role.ORIGINAL)) + } + + @Test + fun `falls back to thumbnail when it is the only one available`() { + val item = media(rendition(MediaItemRendition.Role.THUMBNAIL)) + assertEquals( + MediaItemRendition.Role.THUMBNAIL, + item.rendition(MediaItemRendition.Role.ORIGINAL)?.role, + ) + } + + @Test + fun `never upgrades above the requested rendition`() { + // Only ORIGINAL exists; requesting a lower tier must not return it. + val item = media(rendition(MediaItemRendition.Role.ORIGINAL)) + assertNull(item.rendition(MediaItemRendition.Role.DISPLAY)) + assertNull(item.rendition(MediaItemRendition.Role.THUMBNAIL)) + } + + @Test + fun `skips a rendition whose blob is not yet available`() { + // DISPLAY exists but is still uploading (blob null) -> falls through to THUMBNAIL. + val item = media( + rendition(MediaItemRendition.Role.DISPLAY, available = false), + rendition(MediaItemRendition.Role.THUMBNAIL), + ) + assertEquals( + MediaItemRendition.Role.THUMBNAIL, + item.rendition(MediaItemRendition.Role.DISPLAY)?.role, + ) + } + + @Test + fun `returns null when nothing at or below the request is available`() { + assertNull(media().rendition(MediaItemRendition.Role.ORIGINAL)) + val onlyUnready = media(rendition(MediaItemRendition.Role.THUMBNAIL, available = false)) + assertNull(onlyUnready.rendition(MediaItemRendition.Role.ORIGINAL)) + } + + @Test + fun `unknown role has no fallback chain`() { + val item = media(rendition(MediaItemRendition.Role.ORIGINAL)) + assertNull(item.rendition(MediaItemRendition.Role.UNKNOWN)) + } + + @Test + fun `url returns the resolved rendition download url`() { + val item = media(rendition(MediaItemRendition.Role.THUMBNAIL)) + assertEquals("https://cdn/thumbnail", item.url(MediaItemRendition.Role.ORIGINAL)) + assertNull(media().url(MediaItemRendition.Role.ORIGINAL)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f4c72f32e..4a993c2380 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -55,6 +55,7 @@ include( ":apps:flipcash:shared:bill-customization", ":apps:flipcash:shared:chat", ":apps:flipcash:shared:chat-ui", + ":apps:flipcash:shared:common-ui", ":apps:flipcash:shared:contacts", ":apps:flipcash:shared:currency-creator", ":apps:flipcash:shared:onramp:coinbase", diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/CircularIconButton.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/CircularIconButton.kt index 1d30a7420d..f80628d5a5 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/CircularIconButton.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/CircularIconButton.kt @@ -24,19 +24,21 @@ private val ButtonBackground = Color.White.copy(alpha = 0.1f) @Composable fun CircularIconButton( modifier: Modifier = Modifier, + imageSize: Dp = IconSize, + buttonSize: Dp = ButtonSize, onClick: () -> Unit, testTag: String? = null, content: @Composable (Dp) -> Unit, ) { Box( modifier = modifier - .size(ButtonSize) + .size(buttonSize) .background(ButtonBackground, CircleShape) .clip(CircleShape) .clickable { onClick() } .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), contentAlignment = Alignment.Center, ) { - content(IconSize) + content(imageSize) } } \ No newline at end of file