Initial commit: Kecalek Android client
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>
This commit is contained in:
274
specs/agent-e-conversation-list.md
Normal file
274
specs/agent-e-conversation-list.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user