Complete Android client for encrypted chat platform. 78+ Kotlin files: crypto (X3DH, Double Ratchet, AES-GCM, Ed25519, X25519, RSA-PSS), network (TCP/TLS, 50 endpoints), Hilt DI, Room+SQLCipher DB, Jetpack Compose UI with Catppuccin Mocha theme. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
8.8 KiB
Markdown
275 lines
8.8 KiB
Markdown
# 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<Conversation> = emptyList(),
|
|
val invitations: List<Invitation> = emptyList(),
|
|
val onlineUsers: Set<String> = 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<ConversationListState> = _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<String>) {
|
|
// 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)
|