# Agent E: Conversation List Screen ## Phase: 2 (UI Shells) ## Depends on: Agent A (project), Agent B (theme + navigation), Agent C (models) ## Context The conversation list is the main screen after login. It shows all DMs and groups with online status, unread counts, last message preview, and group invitations. ## Task Create ConversationListScreen, ConversationRow, NewConversationSheet, and ViewModel skeleton. ## Files to Create ### 1. ui/conversations/ConversationListScreen.kt Jetpack Compose screen with: - **Top bar**: "Chats" title + profile icon button (right) + settings gear (right) - **Invitations section** (shown only when invitations exist): - Amber/Peach colored card for each invitation - Shows: "Group Name — invited by Username" - Accept (Green) and Decline (Red) buttons - **Conversation list** (LazyColumn): - Each item is a ConversationRow - Pull-to-refresh (SwipeRefresh) - Empty state: "No conversations yet" with illustration - **FAB** (bottom-right): "+" button to create new conversation - On click: show NewConversationSheet - **Long-press** on conversation: context menu with: - "Mark as read" - "Add to favorites" / "Remove from favorites" - Sorting: favorites first, then by last message time descending ### 2. ui/conversations/ConversationRow.kt Composable for a single conversation list item: - **Left**: Circular avatar (40dp) - DM: User avatar with online green dot overlay (bottom-right) - Group: Group avatar or default letter circle (deterministic color from name) - **Center** (weight 1f, vertical arrangement): - **Top row**: Conversation name (bold if unread) + timestamp (right-aligned, Overlay1 color) - **Bottom row**: Last message preview (1 line, Subtext1 color) + unread badge (right-aligned) - **Unread badge**: Lavender circle with white count text (shown when count > 0) - **Favorite star**: Small star icon if favorited (Yellow color) - **Verified checkmark**: Small green checkmark for verified DM contacts ### 3. ui/conversations/NewConversationSheet.kt Bottom sheet (ModalBottomSheet) with: - **Title**: "New Conversation" - **Tabs**: "Direct Message" | "Create Group" - **DM tab**: - Email text field - "Start Chat" button - **Group tab**: - Group name text field - Email text field with "Add" button (adds to member list) - Member list (removable chips) - "Create Group" button - **Loading** + **Error** states ### 4. ui/conversations/ConversationListVM.kt ```kotlin package com.kecalek.chat.ui.conversations import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import com.kecalek.chat.data.model.Conversation import com.kecalek.chat.data.model.Invitation import javax.inject.Inject data class ConversationListState( val conversations: List = emptyList(), val invitations: List = emptyList(), val onlineUsers: Set = emptySet(), val isLoading: Boolean = false, val error: String? = null, val currentUserId: String = "", ) @HiltViewModel class ConversationListVM @Inject constructor( // TODO: Inject repositories ) : ViewModel() { private val _uiState = MutableStateFlow(ConversationListState()) val uiState: StateFlow = _uiState.asStateFlow() fun loadConversations() { // TODO: Fetch from server + local cache } fun loadInvitations() { // TODO: Fetch pending invitations } fun acceptInvitation(conversationId: String) { // TODO: Accept group invitation } fun declineInvitation(conversationId: String) { // TODO: Decline group invitation } fun createDmConversation(email: String) { // TODO: Find or create DM conversation } fun createGroupConversation(name: String, memberEmails: List) { // TODO: Create group conversation } fun toggleFavorite(conversationId: String) { // TODO: Toggle favorite status } fun markAsRead(conversationId: String) { // TODO: Mark all messages in conversation as read } fun refresh() { // TODO: Pull-to-refresh } } ``` ### 5. ui/components/CircularAvatar.kt ```kotlin package com.kecalek.chat.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape 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.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import kotlin.math.absoluteValue /** * Circular avatar with fallback to colored letter circle. * Color is deterministic based on the name string. */ @Composable fun CircularAvatar( imageUrl: String?, name: String, size: Dp = 40.dp, modifier: Modifier = Modifier, ) { if (imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = name, modifier = modifier .size(size) .clip(CircleShape), contentScale = ContentScale.Crop, ) } else { val colors = listOf( Color(0xFFF38BA8), Color(0xFFFAB387), Color(0xFFF9E2AF), Color(0xFFA6E3A1), Color(0xFF89DCEB), Color(0xFF89B4FA), Color(0xFFCBA6F7), Color(0xFFF5C2E7), ) val color = colors[name.hashCode().absoluteValue % colors.size] val initial = name.firstOrNull()?.uppercase() ?: "?" Box( modifier = modifier .size(size) .clip(CircleShape) .background(color), contentAlignment = Alignment.Center, ) { Text( text = initial, color = Color(0xFF1E1E2E), fontSize = (size.value * 0.4).sp, ) } } } ``` ### 6. ui/components/OnlineDot.kt ```kotlin package com.kecalek.chat.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.kecalek.chat.ui.theme.CatppuccinMocha /** * Small green dot overlay for online status indication. * Place this in a Box with the avatar, aligned to BottomEnd. */ @Composable fun OnlineDot(modifier: Modifier = Modifier) { Box( modifier = modifier .size(12.dp) .background(CatppuccinMocha.Green, CircleShape) .border(2.dp, CatppuccinMocha.Base, CircleShape) ) } ``` ### 7. ui/components/UnreadBadge.kt ```kotlin package com.kecalek.chat.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kecalek.chat.ui.theme.CatppuccinMocha @Composable fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { if (count > 0) { Box( modifier = modifier .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) .background(CatppuccinMocha.Lavender, CircleShape) .padding(horizontal = 6.dp, vertical = 2.dp), contentAlignment = Alignment.Center, ) { Text( text = if (count > 99) "99+" else count.toString(), color = CatppuccinMocha.Base, fontSize = 11.sp, fontWeight = FontWeight.Bold, ) } } } ``` ## Constraints - Use Material 3 components - ConversationRow must be efficient (used in LazyColumn) - Avatar colors must be deterministic (same name = same color) - Unread badge shows "99+" for counts > 99 - Online dot: 12dp green circle with 2dp Base-colored border - Favorites sorted first, then by lastMessageTime descending ## DO NOT - Implement actual server communication - Handle encryption/decryption - Implement real avatar loading from server (use placeholder URLs for now)