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:
filip
2026-03-11 01:19:17 +01:00
commit fe861cfafa
134 changed files with 19078 additions and 0 deletions

View 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)