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:
341
specs/agent-a-gradle-setup.md
Normal file
341
specs/agent-a-gradle-setup.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Agent A: Gradle + Project Setup
|
||||
|
||||
## Phase: 0 (Scaffolding)
|
||||
## Priority: FIRST — blocks all other agents
|
||||
|
||||
## Context
|
||||
You are setting up an Android project for "Kecalek" — an end-to-end encrypted chat application.
|
||||
The app uses Signal Protocol (X3DH + Double Ratchet + Sender Keys) for encryption.
|
||||
This is a Kotlin-first project with Jetpack Compose UI.
|
||||
|
||||
## Task
|
||||
Create the complete Android project structure with Gradle build files, manifest, and empty package directories.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Root Level
|
||||
```
|
||||
android/
|
||||
├── build.gradle.kts (project-level)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
├── gradle/
|
||||
│ └── wrapper/
|
||||
│ ├── gradle-wrapper.jar
|
||||
│ └── gradle-wrapper.properties
|
||||
├── gradlew
|
||||
├── gradlew.bat
|
||||
└── app/
|
||||
├── build.gradle.kts (app-level)
|
||||
└── src/
|
||||
├── main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── java/com/kecalek/chat/ (package dirs)
|
||||
│ └── res/
|
||||
│ ├── values/
|
||||
│ │ ├── strings.xml
|
||||
│ │ ├── themes.xml
|
||||
│ │ └── colors.xml
|
||||
│ ├── mipmap-hdpi/
|
||||
│ ├── mipmap-mdpi/
|
||||
│ ├── mipmap-xhdpi/
|
||||
│ ├── mipmap-xxhdpi/
|
||||
│ └── mipmap-xxxhdpi/
|
||||
└── test/
|
||||
└── java/com/kecalek/chat/
|
||||
```
|
||||
|
||||
### Project-Level build.gradle.kts
|
||||
```kotlin
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.50" apply false
|
||||
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
|
||||
}
|
||||
```
|
||||
|
||||
### settings.gradle.kts
|
||||
```kotlin
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolution {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "Kecalek"
|
||||
include(":app")
|
||||
```
|
||||
|
||||
### App-Level build.gradle.kts
|
||||
```kotlin
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kecalek.chat"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kecalek.chat"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.8.5"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
// Hilt DI
|
||||
implementation("com.google.dagger:hilt-android:2.50")
|
||||
ksp("com.google.dagger:hilt-compiler:2.50")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Room + SQLCipher
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
||||
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
|
||||
|
||||
// Crypto: Tink + Bouncy Castle
|
||||
implementation("com.google.crypto.tink:tink-android:1.12.0")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
|
||||
implementation("org.bouncycastle:bcpkix-jdk18on:1.77")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// QR code
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
|
||||
// Camera (for QR scanning)
|
||||
implementation("androidx.camera:camera-camera2:1.3.1")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||
implementation("androidx.camera:camera-view:1.3.1")
|
||||
|
||||
// Biometric
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
// DataStore (encrypted preferences)
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// JSON
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
```
|
||||
|
||||
### AndroidManifest.xml
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<application
|
||||
android:name=".KecalekApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kecalek"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Kecalek">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
```
|
||||
|
||||
### gradle.properties
|
||||
```properties
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
```
|
||||
|
||||
### res/values/strings.xml
|
||||
```xml
|
||||
<resources>
|
||||
<string name="app_name">Kecalek</string>
|
||||
</resources>
|
||||
```
|
||||
|
||||
### res/xml/network_security_config.xml
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<!-- Debug only: allow cleartext for local dev -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
</network-security-config>
|
||||
```
|
||||
|
||||
## Package Directory Structure
|
||||
Create these empty directories under `app/src/main/java/com/kecalek/chat/`:
|
||||
```
|
||||
di/
|
||||
crypto/
|
||||
network/
|
||||
core/
|
||||
data/
|
||||
data/local/
|
||||
data/local/dao/
|
||||
data/local/entity/
|
||||
data/model/
|
||||
data/repository/
|
||||
ui/
|
||||
ui/theme/
|
||||
ui/navigation/
|
||||
ui/auth/
|
||||
ui/conversations/
|
||||
ui/chat/
|
||||
ui/groups/
|
||||
ui/profile/
|
||||
ui/verification/
|
||||
ui/devices/
|
||||
ui/components/
|
||||
util/
|
||||
```
|
||||
|
||||
## Placeholder Files
|
||||
Create these minimal placeholder files:
|
||||
|
||||
### KecalekApp.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class KecalekApp : Application()
|
||||
```
|
||||
|
||||
### MainActivity.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
// TODO: NavGraph entry point
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Min SDK 26 (Android 8.0)
|
||||
- Target SDK 34 (Android 14)
|
||||
- Kotlin 1.9.22
|
||||
- Compose BOM 2024.02.00
|
||||
- Java 17 compatibility
|
||||
- Do NOT add any business logic
|
||||
- Do NOT create any UI components beyond the placeholder MainActivity
|
||||
- All `TODO` comments should be brief and descriptive
|
||||
|
||||
## DO NOT
|
||||
- Implement any cryptographic operations
|
||||
- Add any UI screens or composables
|
||||
- Add business logic or ViewModels
|
||||
- Modify any files outside the android/ directory
|
||||
347
specs/agent-b-theme-navigation.md
Normal file
347
specs/agent-b-theme-navigation.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Agent B: Theme + Navigation
|
||||
|
||||
## Phase: 0 (Scaffolding)
|
||||
## Depends on: Agent A (project structure must exist)
|
||||
|
||||
## Context
|
||||
You are building the theme system and navigation graph for "Kecalek" — an encrypted chat Android app.
|
||||
The app uses a Catppuccin Mocha dark theme (Signal-like appearance).
|
||||
|
||||
## Task
|
||||
1. Implement Catppuccin Mocha dark theme in Jetpack Compose Material 3
|
||||
2. Set up Compose Navigation with all app routes
|
||||
3. Update MainActivity to use the theme and navigation
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/theme/Color.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Catppuccin Mocha palette
|
||||
object CatppuccinMocha {
|
||||
val Rosewater = Color(0xFFF5E0DC)
|
||||
val Flamingo = Color(0xFFF2CDCD)
|
||||
val Pink = Color(0xFFF5C2E7)
|
||||
val Mauve = Color(0xFFCBA6F7)
|
||||
val Red = Color(0xFFF38BA8)
|
||||
val Maroon = Color(0xFFEBA0AC)
|
||||
val Peach = Color(0xFFFAB387)
|
||||
val Yellow = Color(0xFFF9E2AF)
|
||||
val Green = Color(0xFFA6E3A1)
|
||||
val Teal = Color(0xFF94E2D5)
|
||||
val Sky = Color(0xFF89DCEB)
|
||||
val Sapphire = Color(0xFF74C7EC)
|
||||
val Blue = Color(0xFF89B4FA)
|
||||
val Lavender = Color(0xFFB4BEFE)
|
||||
|
||||
val Text = Color(0xFFCDD6F4)
|
||||
val Subtext1 = Color(0xFFBAC2DE)
|
||||
val Subtext0 = Color(0xFFA6ADC8)
|
||||
val Overlay2 = Color(0xFF9399B2)
|
||||
val Overlay1 = Color(0xFF7F849C)
|
||||
val Overlay0 = Color(0xFF6C7086)
|
||||
val Surface2 = Color(0xFF585B70)
|
||||
val Surface1 = Color(0xFF45475A)
|
||||
val Surface0 = Color(0xFF313244)
|
||||
val Base = Color(0xFF1E1E2E)
|
||||
val Mantle = Color(0xFF181825)
|
||||
val Crust = Color(0xFF11111B)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ui/theme/Type.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val KecalekTypography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### 3. ui/theme/Theme.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = CatppuccinMocha.Lavender,
|
||||
onPrimary = CatppuccinMocha.Base,
|
||||
primaryContainer = CatppuccinMocha.Lavender.copy(alpha = 0.3f),
|
||||
onPrimaryContainer = CatppuccinMocha.Text,
|
||||
secondary = CatppuccinMocha.Mauve,
|
||||
onSecondary = CatppuccinMocha.Base,
|
||||
secondaryContainer = CatppuccinMocha.Mauve.copy(alpha = 0.3f),
|
||||
onSecondaryContainer = CatppuccinMocha.Text,
|
||||
tertiary = CatppuccinMocha.Peach,
|
||||
onTertiary = CatppuccinMocha.Base,
|
||||
error = CatppuccinMocha.Red,
|
||||
onError = CatppuccinMocha.Base,
|
||||
errorContainer = CatppuccinMocha.Red.copy(alpha = 0.3f),
|
||||
background = CatppuccinMocha.Base,
|
||||
onBackground = CatppuccinMocha.Text,
|
||||
surface = CatppuccinMocha.Surface0,
|
||||
onSurface = CatppuccinMocha.Text,
|
||||
surfaceVariant = CatppuccinMocha.Surface1,
|
||||
onSurfaceVariant = CatppuccinMocha.Subtext1,
|
||||
outline = CatppuccinMocha.Overlay0,
|
||||
outlineVariant = CatppuccinMocha.Surface2,
|
||||
inverseSurface = CatppuccinMocha.Text,
|
||||
inverseOnSurface = CatppuccinMocha.Base,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KecalekTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DarkColorScheme,
|
||||
typography = KecalekTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ui/navigation/NavGraph.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
|
||||
/**
|
||||
* Navigation routes for the app.
|
||||
*/
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val REGISTER = "register"
|
||||
const val PAIRING = "pairing"
|
||||
const val CONVERSATION_LIST = "conversations"
|
||||
const val CHAT = "chat/{conversationId}"
|
||||
const val GROUP_INFO = "group_info/{conversationId}"
|
||||
const val PROFILE = "profile/{userId}"
|
||||
const val EDIT_PROFILE = "edit_profile"
|
||||
const val VERIFICATION = "verification/{userId}"
|
||||
const val DEVICE_LIST = "devices"
|
||||
const val SETTINGS = "settings"
|
||||
const val IMAGE_VIEWER = "image_viewer/{imageUrl}"
|
||||
|
||||
// Helper functions to build routes with arguments
|
||||
fun chat(conversationId: String) = "chat/$conversationId"
|
||||
fun groupInfo(conversationId: String) = "group_info/$conversationId"
|
||||
fun profile(userId: String) = "profile/$userId"
|
||||
fun verification(userId: String) = "verification/$userId"
|
||||
fun imageViewer(imageUrl: String) = "image_viewer/$imageUrl"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KecalekNavGraph(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
startDestination: String = Routes.LOGIN,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.LOGIN) {
|
||||
// TODO: LoginScreen(navController)
|
||||
}
|
||||
|
||||
composable(Routes.REGISTER) {
|
||||
// TODO: RegisterScreen(navController)
|
||||
}
|
||||
|
||||
composable(Routes.PAIRING) {
|
||||
// TODO: PairingScreen(navController)
|
||||
}
|
||||
|
||||
composable(Routes.CONVERSATION_LIST) {
|
||||
// TODO: ConversationListScreen(navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.CHAT,
|
||||
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
|
||||
// TODO: ChatScreen(conversationId, navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.GROUP_INFO,
|
||||
arguments = listOf(navArgument("conversationId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val conversationId = backStackEntry.arguments?.getString("conversationId") ?: return@composable
|
||||
// TODO: GroupInfoScreen(conversationId, navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.PROFILE,
|
||||
arguments = listOf(navArgument("userId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
|
||||
// TODO: ProfileScreen(userId, navController)
|
||||
}
|
||||
|
||||
composable(Routes.EDIT_PROFILE) {
|
||||
// TODO: EditProfileScreen(navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.VERIFICATION,
|
||||
arguments = listOf(navArgument("userId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
|
||||
// TODO: SafetyNumberScreen(userId, navController)
|
||||
}
|
||||
|
||||
composable(Routes.DEVICE_LIST) {
|
||||
// TODO: DeviceListScreen(navController)
|
||||
}
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
// TODO: SettingsScreen(navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.IMAGE_VIEWER,
|
||||
arguments = listOf(navArgument("imageUrl") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val imageUrl = backStackEntry.arguments?.getString("imageUrl") ?: return@composable
|
||||
// TODO: ImageViewer(imageUrl, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Update MainActivity.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.kecalek.chat.ui.navigation.KecalekNavGraph
|
||||
import com.kecalek.chat.ui.theme.KecalekTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
KecalekTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
KecalekNavGraph()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Color Usage Guide
|
||||
Use these mappings consistently across all screens:
|
||||
|
||||
| UI Element | Color |
|
||||
|-----------|-------|
|
||||
| Own message bubble background | `CatppuccinMocha.Lavender.copy(alpha = 0.15f)` |
|
||||
| Other's message bubble background | `CatppuccinMocha.Surface0` |
|
||||
| App background | `CatppuccinMocha.Base` |
|
||||
| Card/surface background | `CatppuccinMocha.Surface0` |
|
||||
| Primary text | `CatppuccinMocha.Text` |
|
||||
| Secondary text | `CatppuccinMocha.Subtext1` |
|
||||
| Muted text (timestamps) | `CatppuccinMocha.Overlay1` |
|
||||
| Online indicator | `CatppuccinMocha.Green` |
|
||||
| Error/delete | `CatppuccinMocha.Red` |
|
||||
| Unread badge | `CatppuccinMocha.Lavender` |
|
||||
| Links/@mentions | `CatppuccinMocha.Blue` |
|
||||
| Verified checkmark | `CatppuccinMocha.Green` |
|
||||
| Warning | `CatppuccinMocha.Peach` |
|
||||
| Send button | `CatppuccinMocha.Lavender` |
|
||||
| Input field background | `CatppuccinMocha.Surface1` |
|
||||
| Dividers | `CatppuccinMocha.Surface2` |
|
||||
|
||||
## Constraints
|
||||
- Dark theme only (no light theme for now)
|
||||
- Use Material 3 components exclusively
|
||||
- Edge-to-edge display (enableEdgeToEdge)
|
||||
- All navigation routes defined with TODO placeholders for actual screens
|
||||
|
||||
## DO NOT
|
||||
- Implement any actual screen UI (only TODO placeholders in NavGraph)
|
||||
- Add business logic
|
||||
- Implement any cryptographic operations
|
||||
- Modify build.gradle files
|
||||
657
specs/agent-c-data-models-room.md
Normal file
657
specs/agent-c-data-models-room.md
Normal file
@@ -0,0 +1,657 @@
|
||||
# Agent C: Data Models + Room Database
|
||||
|
||||
## Phase: 0 (Scaffolding)
|
||||
## Depends on: Agent A (project structure must exist)
|
||||
|
||||
## Context
|
||||
You are creating data models and Room database for "Kecalek" — an encrypted chat app.
|
||||
Models must match the server JSON format exactly for wire compatibility.
|
||||
The server uses snake_case JSON keys. Kotlin models use camelCase with JSON mapping.
|
||||
|
||||
## Task
|
||||
1. Create all domain model data classes
|
||||
2. Create Room entities and DAOs
|
||||
3. Create AppDatabase with SQLCipher encryption support
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. data/model/Message.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class Message(
|
||||
val id: String, // server: "message_id"
|
||||
val conversationId: String, // server: "conversation_id"
|
||||
val senderId: String, // server: "sender_id"
|
||||
var senderUsername: String, // server: "sender_username"
|
||||
val createdAt: Date, // server: "created_at" (ISO 8601)
|
||||
var text: String? = null,
|
||||
var replyTo: String? = null, // server: "reply_to" (message_id)
|
||||
var imageFileId: String? = null, // server: "image_file_id"
|
||||
var file: FileInfo? = null,
|
||||
var image: ImageInfo? = null,
|
||||
var isDeleted: Boolean = false, // server: "is_deleted"
|
||||
var readBy: Set<String> = emptySet(), // server: "read_by" (list of user_ids)
|
||||
var reactions: List<MessageReaction> = emptyList(),
|
||||
var forwardedFrom: ForwardedFrom? = null, // server: "forwarded_from"
|
||||
var pinnedAt: Date? = null, // server: "pinned_at"
|
||||
var pinnedBy: String? = null, // server: "pinned_by"
|
||||
) {
|
||||
fun isMine(currentUserId: String): Boolean = senderId == currentUserId
|
||||
}
|
||||
|
||||
data class MessageReaction(
|
||||
val userId: String, // server: "user_id"
|
||||
val reaction: String, // server: "reaction" (e.g. "thumbsup")
|
||||
val createdAt: Date, // server: "created_at"
|
||||
)
|
||||
|
||||
data class ForwardedFrom(
|
||||
val sender: String, // server: "sender" (username)
|
||||
val conversationId: String, // server: "conversation_id"
|
||||
val messageId: String, // server: "message_id"
|
||||
)
|
||||
|
||||
data class FileInfo(
|
||||
val fileId: String, // server: "file_id"
|
||||
val aesKey: String, // server: "aes_key" (base64)
|
||||
val iv: String, // server: "iv" (base64)
|
||||
val filename: String,
|
||||
val size: Int,
|
||||
val mimeType: String, // server: "mime_type"
|
||||
)
|
||||
|
||||
data class ImageInfo(
|
||||
val fileId: String, // server: "file_id"
|
||||
val aesKey: String, // server: "aes_key" (base64)
|
||||
val iv: String, // server: "iv" (base64)
|
||||
val thumbnail: String?, // server: "thumbnail" (base64 JPEG)
|
||||
val filename: String,
|
||||
val size: Int,
|
||||
)
|
||||
|
||||
object ReactionEmoji {
|
||||
val allowed = listOf("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown")
|
||||
val display = mapOf(
|
||||
"thumbsup" to "\uD83D\uDC4D",
|
||||
"heart" to "\u2764\uFE0F",
|
||||
"laugh" to "\uD83D\uDE02",
|
||||
"surprised" to "\uD83D\uDE2E",
|
||||
"sad" to "\uD83D\uDE22",
|
||||
"thumbsdown" to "\uD83D\uDC4E",
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. data/model/Conversation.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class Conversation(
|
||||
val id: String,
|
||||
var name: String? = null,
|
||||
var members: List<ConversationMember> = emptyList(),
|
||||
var createdBy: String? = null, // server: "created_by"
|
||||
var avatarFile: String? = null, // server: "avatar_file"
|
||||
var unreadCount: Int = 0,
|
||||
var isFavorite: Boolean = false,
|
||||
var lastMessageTime: Date? = null,
|
||||
) {
|
||||
val isGroup: Boolean
|
||||
get() = name != null || members.size > 2
|
||||
|
||||
fun displayName(currentUserId: String): String {
|
||||
if (!name.isNullOrEmpty()) return name!!
|
||||
return members.firstOrNull { it.userId != currentUserId }?.username ?: "Unknown"
|
||||
}
|
||||
|
||||
fun dmPartnerId(currentUserId: String): String? {
|
||||
if (isGroup) return null
|
||||
return members.firstOrNull { it.userId != currentUserId }?.userId
|
||||
}
|
||||
}
|
||||
|
||||
data class ConversationMember(
|
||||
val userId: String, // server: "user_id"
|
||||
var username: String,
|
||||
var email: String,
|
||||
)
|
||||
```
|
||||
|
||||
### 3. data/model/User.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
var username: String,
|
||||
var email: String,
|
||||
var identityKey: ByteArray? = null, // Ed25519 public key (32 bytes)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is User) return false
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
|
||||
data class UserProfile(
|
||||
val userId: String, // server: "user_id"
|
||||
var username: String? = null,
|
||||
var email: String? = null,
|
||||
var phone: String? = null,
|
||||
var phoneVisible: Boolean = true, // server: "phone_visible"
|
||||
var location: String? = null,
|
||||
var locationVisible: Boolean = true, // server: "location_visible"
|
||||
var avatarFile: String? = null, // server: "avatar_file"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. data/model/DeviceBundle.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
/**
|
||||
* Key bundle for one device, used in X3DH session initialization.
|
||||
* All keys are raw bytes (32 bytes for X25519/Ed25519, 64 bytes for signatures).
|
||||
*/
|
||||
data class DeviceBundle(
|
||||
val deviceId: String, // server: "device_id"
|
||||
val identityKey: ByteArray, // Ed25519 public key (32 bytes)
|
||||
val spk: ByteArray, // X25519 signed pre-key (32 bytes)
|
||||
val spkSignature: ByteArray, // Ed25519 signature (64 bytes)
|
||||
val spkId: String, // server: "signed_prekey_id" or "spk_id"
|
||||
val opk: ByteArray? = null, // X25519 one-time pre-key (32 bytes, optional)
|
||||
val opkId: String? = null, // server: "one_time_prekey_id" or "opk_id"
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Parse from server response dictionary.
|
||||
* Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64)
|
||||
*/
|
||||
fun fromMap(map: Map<String, Any?>, identityKeyOverride: ByteArray? = null): DeviceBundle {
|
||||
val deviceId = map["device_id"] as? String
|
||||
?: throw IllegalArgumentException("Missing device_id")
|
||||
|
||||
val ik = identityKeyOverride
|
||||
?: android.util.Base64.decode(
|
||||
map["identity_key"] as? String
|
||||
?: throw IllegalArgumentException("Missing identity_key"),
|
||||
android.util.Base64.DEFAULT
|
||||
)
|
||||
|
||||
// SPK - try both naming conventions
|
||||
val spkB64 = (map["signed_prekey"] as? String) ?: (map["spk"] as? String)
|
||||
?: throw IllegalArgumentException("Missing signed_prekey")
|
||||
val spk = android.util.Base64.decode(spkB64, android.util.Base64.DEFAULT)
|
||||
|
||||
val spkSigB64 = map["spk_signature"] as? String
|
||||
?: throw IllegalArgumentException("Missing spk_signature")
|
||||
val spkSig = android.util.Base64.decode(spkSigB64, android.util.Base64.DEFAULT)
|
||||
|
||||
val spkId = (map["signed_prekey_id"] as? String) ?: (map["spk_id"] as? String)
|
||||
?: throw IllegalArgumentException("Missing signed_prekey_id")
|
||||
|
||||
// OPK - optional
|
||||
val opkB64 = (map["one_time_prekey"] as? String) ?: (map["opk"] as? String)
|
||||
val opk = opkB64?.let { android.util.Base64.decode(it, android.util.Base64.DEFAULT) }
|
||||
val opkId = (map["one_time_prekey_id"] as? String) ?: (map["opk_id"] as? String)
|
||||
|
||||
return DeviceBundle(deviceId, ik, spk, spkSig, spkId, opk, opkId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is DeviceBundle) return false
|
||||
return deviceId == other.deviceId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = deviceId.hashCode()
|
||||
}
|
||||
```
|
||||
|
||||
### 5. data/model/Invitation.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.model
|
||||
|
||||
data class Invitation(
|
||||
val id: String,
|
||||
val conversationId: String, // server: "conversation_id"
|
||||
val conversationName: String, // server: "conversation_name"
|
||||
val invitedBy: String, // server: "invited_by"
|
||||
val invitedByUsername: String, // server: "invited_by_username"
|
||||
)
|
||||
```
|
||||
|
||||
### 6. util/Constants.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.util
|
||||
|
||||
/**
|
||||
* Application-wide constants matching Python server + iOS client.
|
||||
* CRITICAL: These values MUST match exactly for protocol compatibility.
|
||||
*/
|
||||
object Constants {
|
||||
const val VERSION = "0.8.5"
|
||||
const val MAX_MESSAGE_BYTES = 65536 // 64 KB
|
||||
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 // 5 MB
|
||||
const val MAX_FILE_BYTES = 50 * 1024 * 1024 // 50 MB
|
||||
const val IMAGE_CHUNK_SIZE = 32768 // 32 KB
|
||||
|
||||
const val SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
const val OPK_REPLENISH_THRESHOLD = 20
|
||||
const val OPK_BATCH_SIZE = 50
|
||||
const val SPK_ROTATION_DAYS = 7
|
||||
|
||||
const val MAX_SKIP = 256
|
||||
const val MAX_SENDER_KEY_SKIP = 256
|
||||
|
||||
const val DEVICE_BUNDLE_CACHE_TTL_MS = 300_000L // 5 minutes
|
||||
const val SEND_RECEIVE_TIMEOUT_MS = 30_000L // 30 seconds
|
||||
const val RECONNECT_BASE_DELAY_MS = 1_000L // 1 second
|
||||
const val RECONNECT_MAX_DELAY_MS = 30_000L // 30 seconds
|
||||
|
||||
const val PBKDF2_ITERATIONS = 600_000
|
||||
val ECP1_MAGIC = byteArrayOf(0x45, 0x43, 0x50, 0x31) // "ECP1"
|
||||
|
||||
// HKDF info/salt strings — MUST match Python/iOS exactly
|
||||
const val X3DH_INFO = "EncryptedChat_X3DH"
|
||||
const val ROOT_KEY_INFO = "EncryptedChat_RootKey"
|
||||
const val SELF_ENCRYPTION_SALT = "self_encryption"
|
||||
const val SELF_ENCRYPTION_INFO = "EncryptedChat_SelfKey"
|
||||
const val LOCAL_STORAGE_SALT = "local_storage"
|
||||
const val LOCAL_STORAGE_INFO = "EncryptedChat_LocalStorage"
|
||||
const val SENDER_KEY_CHAIN_INFO = "SenderKeyChain"
|
||||
|
||||
// Default server connection
|
||||
const val DEFAULT_HOST = "chat.ai-tech.news"
|
||||
const val DEFAULT_PORT = 9999
|
||||
}
|
||||
```
|
||||
|
||||
### 7. data/local/entity/MessageEntity.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "conversation_id", index = true)
|
||||
val conversationId: String,
|
||||
@ColumnInfo(name = "sender_id")
|
||||
val senderId: String,
|
||||
@ColumnInfo(name = "sender_username")
|
||||
val senderUsername: String,
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Long, // epoch millis
|
||||
val text: String? = null,
|
||||
@ColumnInfo(name = "reply_to")
|
||||
val replyTo: String? = null,
|
||||
@ColumnInfo(name = "image_file_id")
|
||||
val imageFileId: String? = null,
|
||||
@ColumnInfo(name = "file_json")
|
||||
val fileJson: String? = null, // JSON-serialized FileInfo
|
||||
@ColumnInfo(name = "image_json")
|
||||
val imageJson: String? = null, // JSON-serialized ImageInfo
|
||||
@ColumnInfo(name = "is_deleted")
|
||||
val isDeleted: Boolean = false,
|
||||
@ColumnInfo(name = "read_by_json")
|
||||
val readByJson: String? = null, // JSON array of user_ids
|
||||
@ColumnInfo(name = "reactions_json")
|
||||
val reactionsJson: String? = null, // JSON array of reactions
|
||||
@ColumnInfo(name = "forwarded_from_json")
|
||||
val forwardedFromJson: String? = null,
|
||||
@ColumnInfo(name = "pinned_at")
|
||||
val pinnedAt: Long? = null,
|
||||
@ColumnInfo(name = "pinned_by")
|
||||
val pinnedBy: String? = null,
|
||||
)
|
||||
```
|
||||
|
||||
### 8. data/local/entity/ConversationEntity.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
data class ConversationEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String? = null,
|
||||
@ColumnInfo(name = "created_by")
|
||||
val createdBy: String? = null,
|
||||
@ColumnInfo(name = "avatar_file")
|
||||
val avatarFile: String? = null,
|
||||
@ColumnInfo(name = "unread_count")
|
||||
val unreadCount: Int = 0,
|
||||
@ColumnInfo(name = "is_favorite")
|
||||
val isFavorite: Boolean = false,
|
||||
@ColumnInfo(name = "last_message_time")
|
||||
val lastMessageTime: Long? = null,
|
||||
@ColumnInfo(name = "members_json")
|
||||
val membersJson: String? = null, // JSON array of ConversationMember
|
||||
)
|
||||
```
|
||||
|
||||
### 9. data/local/entity/UserCacheEntity.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "user_cache")
|
||||
data class UserCacheEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
@ColumnInfo(name = "identity_key", typeAffinity = ColumnInfo.BLOB)
|
||||
val identityKey: ByteArray? = null,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is UserCacheEntity) return false
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
```
|
||||
|
||||
### 10. data/local/dao/MessageDao.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.kecalek.chat.data.local.entity.MessageEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY created_at ASC")
|
||||
fun getMessagesFlow(conversationId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId ORDER BY created_at ASC")
|
||||
suspend fun getMessages(conversationId: String): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :messageId")
|
||||
suspend fun getMessage(messageId: String): MessageEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(messages: List<MessageEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(message: MessageEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(message: MessageEntity)
|
||||
|
||||
@Query("UPDATE messages SET is_deleted = 1 WHERE id = :messageId")
|
||||
suspend fun markDeleted(messageId: String)
|
||||
|
||||
@Query("UPDATE messages SET reactions_json = :reactionsJson WHERE id = :messageId")
|
||||
suspend fun updateReactions(messageId: String, reactionsJson: String)
|
||||
|
||||
@Query("UPDATE messages SET pinned_at = :pinnedAt, pinned_by = :pinnedBy WHERE id = :messageId")
|
||||
suspend fun updatePinStatus(messageId: String, pinnedAt: Long?, pinnedBy: String?)
|
||||
|
||||
@Query("UPDATE messages SET read_by_json = :readByJson WHERE id = :messageId")
|
||||
suspend fun updateReadBy(messageId: String, readByJson: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversation_id = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
|
||||
@Query("SELECT MAX(created_at) FROM messages WHERE conversation_id = :conversationId")
|
||||
suspend fun getLatestTimestamp(conversationId: String): Long?
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND pinned_at IS NOT NULL ORDER BY pinned_at DESC")
|
||||
suspend fun getPinnedMessages(conversationId: String): List<MessageEntity>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversation_id = :conversationId AND text LIKE '%' || :query || '%' ORDER BY created_at ASC")
|
||||
suspend fun searchMessages(conversationId: String, query: String): List<MessageEntity>
|
||||
}
|
||||
```
|
||||
|
||||
### 11. data/local/dao/ConversationDao.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.kecalek.chat.data.local.entity.ConversationEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ConversationDao {
|
||||
@Query("SELECT * FROM conversations ORDER BY is_favorite DESC, last_message_time DESC")
|
||||
fun getAllFlow(): Flow<List<ConversationEntity>>
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY is_favorite DESC, last_message_time DESC")
|
||||
suspend fun getAll(): List<ConversationEntity>
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE id = :conversationId")
|
||||
suspend fun getById(conversationId: String): ConversationEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(conversations: List<ConversationEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(conversation: ConversationEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(conversation: ConversationEntity)
|
||||
|
||||
@Query("UPDATE conversations SET unread_count = :count WHERE id = :conversationId")
|
||||
suspend fun updateUnreadCount(conversationId: String, count: Int)
|
||||
|
||||
@Query("UPDATE conversations SET is_favorite = :isFavorite WHERE id = :conversationId")
|
||||
suspend fun updateFavorite(conversationId: String, isFavorite: Boolean)
|
||||
|
||||
@Query("UPDATE conversations SET name = :name WHERE id = :conversationId")
|
||||
suspend fun updateName(conversationId: String, name: String)
|
||||
|
||||
@Query("DELETE FROM conversations WHERE id = :conversationId")
|
||||
suspend fun delete(conversationId: String)
|
||||
}
|
||||
```
|
||||
|
||||
### 12. data/local/dao/UserCacheDao.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.kecalek.chat.data.local.entity.UserCacheEntity
|
||||
|
||||
@Dao
|
||||
interface UserCacheDao {
|
||||
@Query("SELECT * FROM user_cache WHERE id = :userId")
|
||||
suspend fun getById(userId: String): UserCacheEntity?
|
||||
|
||||
@Query("SELECT * FROM user_cache WHERE email = :email")
|
||||
suspend fun getByEmail(email: String): UserCacheEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(user: UserCacheEntity)
|
||||
|
||||
@Query("DELETE FROM user_cache")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
```
|
||||
|
||||
### 13. data/local/AppDatabase.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.kecalek.chat.data.local.dao.ConversationDao
|
||||
import com.kecalek.chat.data.local.dao.MessageDao
|
||||
import com.kecalek.chat.data.local.dao.UserCacheDao
|
||||
import com.kecalek.chat.data.local.entity.ConversationEntity
|
||||
import com.kecalek.chat.data.local.entity.MessageEntity
|
||||
import com.kecalek.chat.data.local.entity.UserCacheEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MessageEntity::class,
|
||||
ConversationEntity::class,
|
||||
UserCacheEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun conversationDao(): ConversationDao
|
||||
abstract fun userCacheDao(): UserCacheDao
|
||||
}
|
||||
```
|
||||
|
||||
### 14. util/Base64Utils.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.util
|
||||
|
||||
import android.util.Base64
|
||||
|
||||
/**
|
||||
* Base64 encoding/decoding matching Python protocol.py encode_binary/decode_binary.
|
||||
*/
|
||||
object Base64Utils {
|
||||
fun encode(data: ByteArray): String =
|
||||
Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
|
||||
fun decode(data: String): ByteArray =
|
||||
Base64.decode(data, Base64.DEFAULT)
|
||||
}
|
||||
```
|
||||
|
||||
### 15. util/DateFormatter.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Date parsing/formatting matching server ISO 8601 format.
|
||||
*/
|
||||
object DateFormatter {
|
||||
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private val isoFormatWithMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
fun parse(dateString: String): Date? {
|
||||
return try {
|
||||
if (dateString.contains(".")) {
|
||||
isoFormatWithMillis.parse(dateString)
|
||||
} else {
|
||||
isoFormat.parse(dateString)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun format(date: Date): String = isoFormat.format(date)
|
||||
|
||||
fun formatRelative(date: Date): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - date.time
|
||||
return when {
|
||||
diff < 60_000 -> "now"
|
||||
diff < 3_600_000 -> "${diff / 60_000}m"
|
||||
diff < 86_400_000 -> "${diff / 3_600_000}h"
|
||||
diff < 604_800_000 -> "${diff / 86_400_000}d"
|
||||
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatTime(date: Date): String =
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
|
||||
}
|
||||
```
|
||||
|
||||
## Server JSON Field Mapping (CRITICAL)
|
||||
These are the exact field names the server uses. Models must parse these correctly:
|
||||
|
||||
```json
|
||||
// Message from get_messages
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"conversation_id": "uuid",
|
||||
"sender_id": "uuid",
|
||||
"sender_username": "name",
|
||||
"created_at": "2024-01-01T12:00:00",
|
||||
"text": "hello",
|
||||
"reply_to": "uuid or null",
|
||||
"image_file_id": "uuid or null",
|
||||
"is_deleted": false,
|
||||
"read_by": ["uuid1", "uuid2"],
|
||||
"reactions": [{"user_id": "uuid", "reaction": "heart", "created_at": "..."}],
|
||||
"forwarded_from": {"sender": "username", "conversation_id": "uuid", "message_id": "uuid"},
|
||||
"pinned_at": "2024-01-01T12:00:00 or null",
|
||||
"pinned_by": "uuid or null",
|
||||
"file": {"file_id": "uuid", "aes_key": "b64", "iv": "b64", "filename": "doc.pdf", "size": 1234, "mime_type": "application/pdf"},
|
||||
"image": {"file_id": "uuid", "aes_key": "b64", "iv": "b64", "thumbnail": "b64_jpeg", "filename": "photo.jpg", "size": 5678}
|
||||
}
|
||||
|
||||
// Conversation from list_conversations
|
||||
{
|
||||
"conversation_id": "uuid",
|
||||
"name": "Group Name or null",
|
||||
"created_by": "uuid",
|
||||
"avatar_file": "filename or null",
|
||||
"unread_count": 3,
|
||||
"members": [{"user_id": "uuid", "username": "name", "email": "email"}]
|
||||
}
|
||||
|
||||
// Key bundle from get_key_bundle
|
||||
{
|
||||
"device_id": "uuid",
|
||||
"identity_key": "base64",
|
||||
"signed_prekey": "base64", // also accepts "spk"
|
||||
"spk_signature": "base64",
|
||||
"signed_prekey_id": "string", // also accepts "spk_id"
|
||||
"one_time_prekey": "base64", // also accepts "opk", optional
|
||||
"one_time_prekey_id": "string" // also accepts "opk_id", optional
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Use `Long` (epoch millis) for dates in Room entities, `Date` in domain models
|
||||
- JSON serialization for complex fields in Room (reactions, members, file info)
|
||||
- Room entity field names use snake_case (matching SQL conventions)
|
||||
- Domain model field names use camelCase (Kotlin conventions)
|
||||
- All DAO query methods must be `suspend fun` or return `Flow`
|
||||
- Index `conversation_id` on messages table for query performance
|
||||
|
||||
## DO NOT
|
||||
- Implement database encryption setup (that's in DI module)
|
||||
- Create repository implementations (that's Agent H)
|
||||
- Implement any cryptographic operations
|
||||
- Add any UI code
|
||||
160
specs/agent-d-auth-screens.md
Normal file
160
specs/agent-d-auth-screens.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Agent D: Auth Screens
|
||||
|
||||
## Phase: 2 (UI Shells)
|
||||
## Depends on: Agent A (project), Agent B (theme + navigation)
|
||||
|
||||
## Context
|
||||
You are building authentication screens for "Kecalek" encrypted chat app.
|
||||
The login uses RSA challenge-response (no password sent to server).
|
||||
Registration requires email verification code.
|
||||
Device pairing allows linking a new device to an existing account.
|
||||
|
||||
## Task
|
||||
Create Login, Register, Pairing screens and AuthViewModel skeleton.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/auth/LoginScreen.kt
|
||||
Jetpack Compose screen with:
|
||||
- **App title** "Kecalek" at the top (headlineLarge)
|
||||
- **Subtitle** "Encrypted Messaging" (bodyMedium, Subtext1 color)
|
||||
- **Email/Username** text field (OutlinedTextField, Surface1 background)
|
||||
- **Password** text field (password visibility toggle icon)
|
||||
- **Login button** (filled, Lavender primary, full width)
|
||||
- **"Create Account"** text button below (navigates to Register)
|
||||
- **"Link Device"** text button below (navigates to Pairing)
|
||||
- **Server config section** (expandable/collapsible):
|
||||
- Host text field (default: "chat.ai-tech.news")
|
||||
- Port text field (default: "9999")
|
||||
- TLS toggle switch (default: off)
|
||||
- **Loading state**: CircularProgressIndicator replacing login button
|
||||
- **Error state**: Red error text below password field
|
||||
- **Biometric login button** (fingerprint icon, shown only if biometric available)
|
||||
|
||||
**Layout**: Centered vertically, max width 400dp, padding 24dp.
|
||||
|
||||
### 2. ui/auth/RegisterScreen.kt
|
||||
Jetpack Compose screen with:
|
||||
- **Back arrow** in top bar (navigates back)
|
||||
- **Title** "Create Account"
|
||||
- **Username** text field
|
||||
- **Email** text field
|
||||
- **Password** text field (with visibility toggle)
|
||||
- **Confirm Password** text field
|
||||
- **Register button** (filled, Lavender, full width)
|
||||
- **Confirmation code section** (shown after successful registration):
|
||||
- Info text "Check your email for a verification code"
|
||||
- 6-digit code input field
|
||||
- Confirm button
|
||||
- **Loading state** + **Error state** (same pattern as Login)
|
||||
|
||||
### 3. ui/auth/PairingScreen.kt
|
||||
Jetpack Compose screen with:
|
||||
- **Back arrow** in top bar
|
||||
- **Title** "Link New Device"
|
||||
- **Info text** explaining pairing process
|
||||
- **8-digit pairing code** displayed prominently (headlineLarge, monospace, letter-spacing)
|
||||
- **Countdown timer** or progress indicator showing poll status
|
||||
- **"Waiting for authorization..."** text with animated dots
|
||||
- **Cancel button** (outlined)
|
||||
- **Status messages**: "Device authorized", "Pairing failed", etc.
|
||||
|
||||
### 4. ui/auth/AuthViewModel.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.auth
|
||||
|
||||
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 javax.inject.Inject
|
||||
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isLoggedIn: Boolean = false,
|
||||
val isRegistered: Boolean = false,
|
||||
val needsConfirmation: Boolean = false,
|
||||
val pairingCode: String? = null,
|
||||
val isPairingWaiting: Boolean = false,
|
||||
val serverHost: String = "chat.ai-tech.news",
|
||||
val serverPort: Int = 9999,
|
||||
val useTls: Boolean = false,
|
||||
val biometricAvailable: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
// TODO: Inject ChatClient, SessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun login(emailOrUsername: String, password: String) {
|
||||
// TODO: Implement RSA challenge-response login
|
||||
// 1. ChatClient.login(email, password)
|
||||
// 2. On success: navigate to ConversationList
|
||||
// 3. On failure: show error
|
||||
}
|
||||
|
||||
fun register(username: String, email: String, password: String) {
|
||||
// TODO: Implement registration
|
||||
// 1. ChatClient.register(username, email, password)
|
||||
// 2. On success: show confirmation code input
|
||||
// 3. On failure: show error
|
||||
}
|
||||
|
||||
fun confirmRegistration(email: String, code: String) {
|
||||
// TODO: Confirm with 6-digit code
|
||||
// 1. ChatClient.confirm_registration(email, code)
|
||||
// 2. On success: auto-login
|
||||
}
|
||||
|
||||
fun startPairing() {
|
||||
// TODO: Start device pairing
|
||||
// 1. ChatClient.pairing_start() -> get 8-digit code
|
||||
// 2. Show code to user
|
||||
// 3. Start polling for authorization
|
||||
}
|
||||
|
||||
fun loginWithBiometric() {
|
||||
// TODO: Biometric authentication
|
||||
}
|
||||
|
||||
fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
serverHost = host,
|
||||
serverPort = port,
|
||||
useTls = useTls,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference: iOS LoginView behavior
|
||||
- Login/Register are modes of the same view (iOS uses toggle tabs)
|
||||
- Server configuration is expandable (collapsed by default)
|
||||
- Biometric login uses Face ID/Touch ID
|
||||
- Error messages appear below the form
|
||||
- Loading spinner replaces the action button
|
||||
|
||||
## Constraints
|
||||
- Use Material 3 components (OutlinedTextField, FilledTonalButton, etc.)
|
||||
- Use `CatppuccinMocha` colors from theme
|
||||
- Use `hiltViewModel()` for ViewModel injection
|
||||
- All screens receive `navController: NavHostController` parameter
|
||||
- Password fields must have visibility toggle
|
||||
- Support keyboard "Done" action to submit form
|
||||
- Use `rememberSaveable` for form state to survive config changes
|
||||
|
||||
## DO NOT
|
||||
- Implement actual login/register/pairing logic (just skeleton functions)
|
||||
- Handle cryptographic operations
|
||||
- Store credentials or keys
|
||||
- Modify navigation graph (screen placeholders already exist)
|
||||
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)
|
||||
230
specs/agent-f-chat-screen.md
Normal file
230
specs/agent-f-chat-screen.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Agent F: Chat Screen
|
||||
|
||||
## Phase: 2 (UI Shells)
|
||||
## Depends on: Agent A, Agent B (theme + navigation), Agent C (models)
|
||||
|
||||
## Context
|
||||
The chat screen is the core messaging interface. It shows encrypted messages in bubbles,
|
||||
supports replies, file attachments, reactions, pins, and real-time updates.
|
||||
|
||||
## Task
|
||||
Create ChatScreen, MessageBubble, MessageInput, ImageViewer, and ChatViewModel skeleton.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/chat/ChatScreen.kt
|
||||
Jetpack Compose screen with:
|
||||
- **Top bar (custom)**:
|
||||
- Back arrow (left)
|
||||
- Circular avatar (32dp)
|
||||
- Conversation name + "Encrypted" / "Verified" label below (small, Green or Overlay1)
|
||||
- Action buttons (right): Search icon, Info/Group icon
|
||||
- **Message list** (LazyColumn, reverseLayout = true):
|
||||
- Messages grouped by date (date separator headers)
|
||||
- Each message is a MessageBubble
|
||||
- Scroll to bottom FAB (shown when not at bottom)
|
||||
- **Reply preview bar** (shown when replying to a message):
|
||||
- Vertical Lavender bar + replied message text preview + close button
|
||||
- **Search overlay** (shown when search active):
|
||||
- Search text field with prev/next buttons + match count
|
||||
- Highlighted matches in messages
|
||||
- **Message input** at bottom (MessageInput composable)
|
||||
|
||||
### 2. ui/chat/MessageBubble.kt
|
||||
Composable for a single message:
|
||||
- **Own messages**: Right-aligned, Lavender background (alpha 0.15)
|
||||
- **Other's messages**: Left-aligned, Surface0 background
|
||||
- **Content layout** (vertical):
|
||||
- Sender name (only in groups, for other's messages, bold, Lavender color)
|
||||
- Forwarded header (if forwarded): "Forwarded from Username" with blue left border
|
||||
- Reply reference (if reply): small gray box with replied message text (1 line)
|
||||
- Text content with:
|
||||
- Link detection (Blue, underlined)
|
||||
- @mention highlighting (Blue, bold)
|
||||
- Image thumbnail (if image): clickable, shows full-size on tap
|
||||
- File card (if file): icon + filename + size, clickable to download
|
||||
- **Bottom row** (horizontal):
|
||||
- Timestamp (Overlay1, small)
|
||||
- Pin icon (if pinned)
|
||||
- Read receipt indicators:
|
||||
- 1 checkmark = sent
|
||||
- 2 checkmarks = delivered
|
||||
- 2 blue checkmarks = read
|
||||
- Reaction badges (if reactions): row of emoji+count chips below message
|
||||
- **Deleted message**: "This message was deleted" in italic, Overlay1 color
|
||||
- **Long-press context menu**:
|
||||
- Reply
|
||||
- React (submenu with 6 emoji)
|
||||
- Copy text
|
||||
- Forward
|
||||
- Pin / Unpin
|
||||
- Delete (own messages only, Red color)
|
||||
- **Message shape**: Rounded rectangle (12dp), with tail on sender side
|
||||
|
||||
### 3. ui/chat/MessageInput.kt
|
||||
Composable for message input area:
|
||||
- **Layout** (horizontal):
|
||||
- Attachment button (left, paperclip icon)
|
||||
- On click: shows bottom sheet with "Image" and "File" options
|
||||
- Text field (weight 1f, pill-shaped, Surface1 background)
|
||||
- Placeholder: "Message..."
|
||||
- Auto-grow up to 4 lines
|
||||
- @mention detection triggers autocomplete popup
|
||||
- Send button (right, Lavender, circular, arrow icon)
|
||||
- Shown only when text is non-empty or attachment selected
|
||||
- **@mention autocomplete**: Popup above input showing matching member names
|
||||
- **Attachment preview**: Shows selected image/file name before sending
|
||||
|
||||
### 4. ui/chat/ImageViewer.kt
|
||||
Full-screen image viewer:
|
||||
- **Black background** with translucent system bars
|
||||
- **Zoomable image** (pinch-to-zoom, double-tap zoom, pan)
|
||||
- **Top bar**: Back button + filename (transparent background)
|
||||
- **Bottom bar**: Share button + Save button
|
||||
- **Gesture**: Swipe down to dismiss
|
||||
|
||||
### 5. ui/chat/ChatViewModel.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.chat
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
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.ConversationMember
|
||||
import com.kecalek.chat.data.model.Message
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val conversation: Conversation? = null,
|
||||
val members: List<ConversationMember> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val replyingTo: Message? = null,
|
||||
val isSearchActive: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val searchResults: List<Int> = emptyList(), // indices into messages
|
||||
val currentSearchIndex: Int = -1,
|
||||
val currentUserId: String = "",
|
||||
val verificationStatus: String = "encrypted", // "encrypted" or "verified"
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
// TODO: Inject repositories
|
||||
) : ViewModel() {
|
||||
|
||||
val conversationId: String = savedStateHandle["conversationId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadMessages() {
|
||||
// TODO: Load from cache + incremental sync from server
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
// TODO: Encrypt and send message
|
||||
}
|
||||
|
||||
fun sendImage(uri: String) {
|
||||
// TODO: Encrypt and upload image
|
||||
}
|
||||
|
||||
fun sendFile(uri: String) {
|
||||
// TODO: Encrypt and upload file
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: String) {
|
||||
// TODO: Soft-delete message
|
||||
}
|
||||
|
||||
fun reactToMessage(messageId: String, reaction: String) {
|
||||
// TODO: Add/remove reaction
|
||||
}
|
||||
|
||||
fun pinMessage(messageId: String) {
|
||||
// TODO: Pin/unpin message
|
||||
}
|
||||
|
||||
fun forwardMessage(messageId: String, targetConversationId: String) {
|
||||
// TODO: Forward message to another conversation
|
||||
}
|
||||
|
||||
fun setReplyTo(message: Message?) {
|
||||
_uiState.value = _uiState.value.copy(replyingTo = message)
|
||||
}
|
||||
|
||||
fun toggleSearch() {
|
||||
val current = _uiState.value
|
||||
_uiState.value = current.copy(
|
||||
isSearchActive = !current.isSearchActive,
|
||||
searchQuery = "",
|
||||
searchResults = emptyList(),
|
||||
currentSearchIndex = -1,
|
||||
)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
// TODO: Search through local message cache
|
||||
}
|
||||
|
||||
fun nextSearchResult() {
|
||||
// TODO: Navigate to next search result
|
||||
}
|
||||
|
||||
fun prevSearchResult() {
|
||||
// TODO: Navigate to previous search result
|
||||
}
|
||||
|
||||
fun markAsRead() {
|
||||
// TODO: Mark visible messages as read
|
||||
}
|
||||
|
||||
fun downloadFile(fileId: String) {
|
||||
// TODO: Download and decrypt file
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Message Bubble Visual Spec
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Forwarded from Alice (fwd) │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Replying to: original message... │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ Bob (sender name, groups only) │
|
||||
│ │
|
||||
│ Message text content here with │
|
||||
│ @mentions highlighted in blue │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ [Image Thumbnail] │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 12:34 📌 ✓✓ │
|
||||
│ 👍2 ❤️1 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Use Material 3 components
|
||||
- LazyColumn with reverseLayout for chat (newest at bottom)
|
||||
- Message bubbles must be efficient for long conversations
|
||||
- Use `remember` for expensive computations in bubbles
|
||||
- Maximum bubble width: 80% of screen width
|
||||
- Image thumbnails: max 200dp width, maintain aspect ratio
|
||||
- Context menu via `DropdownMenu` on long-press
|
||||
|
||||
## DO NOT
|
||||
- Implement actual encryption/decryption
|
||||
- Implement actual file upload/download
|
||||
- Handle server communication
|
||||
- Implement QR code scanning (that's Agent G)
|
||||
268
specs/agent-g-profile-group-verification.md
Normal file
268
specs/agent-g-profile-group-verification.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# Agent G: Profile + Group + Verification Screens
|
||||
|
||||
## Phase: 2 (UI Shells)
|
||||
## Depends on: Agent A, Agent B (theme + navigation), Agent C (models)
|
||||
|
||||
## Context
|
||||
These screens handle user profiles, group management, and contact verification.
|
||||
Verification uses Signal-style safety numbers, fingerprints, and QR codes.
|
||||
|
||||
## Task
|
||||
Create Profile, EditProfile, GroupInfo, SafetyNumber, QRScanner, DeviceList screens and ViewModels.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/profile/ProfileScreen.kt
|
||||
Jetpack Compose screen showing user profile:
|
||||
- **Top bar**: Back arrow + "Profile" title
|
||||
- **Own profile** (editable mode):
|
||||
- Large circular avatar (80dp) with camera overlay icon
|
||||
- Username (editable text field)
|
||||
- Email (read-only, Subtext1 color)
|
||||
- Phone text field (with visibility toggle switch)
|
||||
- Location text field (with visibility toggle switch)
|
||||
- "Save" button (Lavender, full width)
|
||||
- Divider
|
||||
- "Key Rotation" button (Peach/warning color)
|
||||
- "Authorize Device" button
|
||||
- "Logout" button (Red color)
|
||||
- **Other user profile** (read-only mode):
|
||||
- Large circular avatar (80dp)
|
||||
- Username (headlineMedium)
|
||||
- Email (bodyMedium, Subtext1)
|
||||
- Phone (if visible)
|
||||
- Location (if visible)
|
||||
- Divider
|
||||
- **Security section**:
|
||||
- Verification status badge ("Verified" green or "Not Verified" muted)
|
||||
- "View Safety Number" button (navigates to Verification screen)
|
||||
- Fingerprint display (monospace, small)
|
||||
|
||||
### 2. ui/profile/EditProfileScreen.kt
|
||||
(Can be merged into ProfileScreen as a mode, or separate — up to implementation)
|
||||
|
||||
### 3. ui/profile/ProfileViewModel.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.profile
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
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.UserProfile
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ProfileUiState(
|
||||
val profile: UserProfile? = null,
|
||||
val isOwnProfile: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val verificationStatus: String = "unverified", // "verified", "trusted", "unverified"
|
||||
val fingerprint: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
// TODO: Inject repositories
|
||||
) : ViewModel() {
|
||||
|
||||
val userId: String = savedStateHandle["userId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ProfileUiState())
|
||||
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadProfile() { /* TODO */ }
|
||||
fun updateProfile(phone: String?, location: String?, phoneVisible: Boolean, locationVisible: Boolean) { /* TODO */ }
|
||||
fun updateAvatar(imageBytes: ByteArray) { /* TODO */ }
|
||||
fun rotateKeys() { /* TODO */ }
|
||||
fun logout() { /* TODO */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ui/groups/GroupInfoScreen.kt
|
||||
Jetpack Compose screen for group management:
|
||||
- **Top bar**: Back arrow + "Group Info" title
|
||||
- **Group avatar** (80dp, circular) with camera overlay (creator only)
|
||||
- **Group name** (editable by creator, with edit icon)
|
||||
- **Member count** ("X members")
|
||||
- Divider
|
||||
- **Members list**:
|
||||
- Each member: avatar (32dp) + username + email
|
||||
- Creator badge (small crown or "Admin" label)
|
||||
- Verified checkmark for verified members (not self)
|
||||
- Creator can tap member → "Remove" option
|
||||
- **"Add Member"** button (creator or all members, depending on server):
|
||||
- Opens dialog with email input
|
||||
- Divider
|
||||
- **"Leave Group"** button (Red, outlined)
|
||||
- **"Delete Group"** button (Red, filled — creator only)
|
||||
- Confirmation dialogs for destructive actions
|
||||
|
||||
### 5. ui/groups/CreateGroupSheet.kt
|
||||
Bottom sheet for group creation (reuse from Agent E or create here):
|
||||
- Group name field
|
||||
- Email field + "Add" button
|
||||
- Member chip list
|
||||
- "Create" button
|
||||
|
||||
### 6. ui/groups/InvitationBanner.kt
|
||||
Composable for invitation display in conversation list:
|
||||
- Peach/Amber border card
|
||||
- Group name + "invited by Username"
|
||||
- Accept (Green icon button) + Decline (Red icon button)
|
||||
|
||||
### 7. ui/verification/SafetyNumberScreen.kt
|
||||
Jetpack Compose screen for contact verification:
|
||||
- **Top bar**: Back arrow + "Verify Contact" title
|
||||
- **Verification status badge**:
|
||||
- "Verified" (Green background)
|
||||
- "Trusted" (Lavender background)
|
||||
- "Not Verified" (Surface1 background)
|
||||
- **Safety number display**:
|
||||
- 60 digits displayed as 12 groups of 5
|
||||
- 3 lines of 4 groups each
|
||||
- Monospace font, large text
|
||||
- Info text: "Compare this number with your contact's device"
|
||||
- **QR code**:
|
||||
- Generated QR code image (200dp)
|
||||
- "Show my QR code" section
|
||||
- **Fingerprints section**:
|
||||
- "My fingerprint": 30 digits (6 groups of 5, 2 lines)
|
||||
- "Their fingerprint": 30 digits
|
||||
- Monospace font
|
||||
- **Action buttons**:
|
||||
- "Mark as Verified" (Green, filled) — shown when not verified
|
||||
- "Remove Verification" (Red, outlined) — shown when verified
|
||||
- "Scan QR Code" button (opens camera)
|
||||
|
||||
### 8. ui/verification/QRScannerScreen.kt
|
||||
Camera-based QR code scanner:
|
||||
- Full-screen camera preview
|
||||
- QR code detection overlay (frame guide)
|
||||
- On successful scan: verify and show result
|
||||
- "Cancel" button overlay
|
||||
- Uses CameraX + ZXing for detection
|
||||
|
||||
### 9. ui/verification/VerificationVM.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.verification
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
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 javax.inject.Inject
|
||||
|
||||
data class VerificationUiState(
|
||||
val peerUsername: String = "",
|
||||
val verificationStatus: String = "unverified",
|
||||
val safetyNumber: String = "", // 60 digits formatted
|
||||
val myFingerprint: String = "", // 30 digits formatted
|
||||
val peerFingerprint: String = "", // 30 digits formatted
|
||||
val qrCodeData: ByteArray? = null, // QR payload for generation
|
||||
val isLoading: Boolean = false,
|
||||
val scanResult: String? = null, // Success/failure message
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class VerificationVM @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
// TODO: Inject ChatClient for verification operations
|
||||
) : ViewModel() {
|
||||
|
||||
val userId: String = savedStateHandle["userId"] ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(VerificationUiState())
|
||||
val uiState: StateFlow<VerificationUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadVerificationData() { /* TODO: get safety number, fingerprints, QR data */ }
|
||||
fun markAsVerified() { /* TODO: verify_contact() */ }
|
||||
fun removeVerification() { /* TODO: unverify_contact() */ }
|
||||
fun processQrScanResult(data: String) { /* TODO: verify_qr_code() */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 10. ui/devices/DeviceListScreen.kt
|
||||
Jetpack Compose screen for device management:
|
||||
- **Top bar**: Back arrow + "My Devices" title
|
||||
- **Device list** (LazyColumn):
|
||||
- Each device: device name/ID + last seen timestamp
|
||||
- Current device highlighted with "(This device)" label
|
||||
- "Remove" button for other devices (Red icon)
|
||||
- **Info text**: "Removing a device will end its session"
|
||||
|
||||
### 11. ui/devices/DeviceViewModel.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.devices
|
||||
|
||||
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 javax.inject.Inject
|
||||
|
||||
data class Device(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val lastSeen: String,
|
||||
val isCurrentDevice: Boolean,
|
||||
)
|
||||
|
||||
data class DeviceListState(
|
||||
val devices: List<Device> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceViewModel @Inject constructor(
|
||||
// TODO: Inject ChatClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DeviceListState())
|
||||
val uiState: StateFlow<DeviceListState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadDevices() { /* TODO */ }
|
||||
fun removeDevice(deviceId: String) { /* TODO */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Safety Number Display Format
|
||||
```
|
||||
12345 67890 12345 67890
|
||||
12345 67890 12345 67890
|
||||
12345 67890 12345 67890
|
||||
```
|
||||
- 12 groups of 5 digits
|
||||
- 3 lines of 4 groups
|
||||
- Monospace font
|
||||
- Large text (20sp)
|
||||
|
||||
## Fingerprint Display Format
|
||||
```
|
||||
12345 67890 12345
|
||||
67890 12345 67890
|
||||
```
|
||||
- 6 groups of 5 digits
|
||||
- 2 lines of 3 groups
|
||||
- Monospace font
|
||||
|
||||
## Constraints
|
||||
- Use Material 3 components
|
||||
- CameraX for QR scanner (not deprecated Camera1)
|
||||
- QR generation via ZXing BarcodeEncoder
|
||||
- Confirmation dialogs for destructive actions (leave group, delete group, remove device)
|
||||
- Creator-only actions clearly gated in UI
|
||||
|
||||
## DO NOT
|
||||
- Implement actual crypto verification logic
|
||||
- Generate real safety numbers or fingerprints
|
||||
- Handle actual server communication
|
||||
- Implement actual QR code encoding/decoding logic
|
||||
280
specs/agent-h-repositories.md
Normal file
280
specs/agent-h-repositories.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Agent H: Repository Implementations
|
||||
|
||||
## Phase: 3 (Core Logic)
|
||||
## Depends on: Agent A, Agent C (models + Room database), Agent I (DI modules)
|
||||
|
||||
## Context
|
||||
Repositories provide the data layer between ViewModels and data sources (Room DB + server).
|
||||
They handle caching, data transformation between entities and domain models.
|
||||
Server communication is delegated to ChatClient (not implemented here).
|
||||
|
||||
## Task
|
||||
Create repository implementations for messages, conversations, and users.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. data/repository/MessageRepository.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.repository
|
||||
|
||||
import com.kecalek.chat.data.local.dao.MessageDao
|
||||
import com.kecalek.chat.data.local.entity.MessageEntity
|
||||
import com.kecalek.chat.data.model.*
|
||||
import com.kecalek.chat.util.DateFormatter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MessageRepository @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getMessagesFlow(conversationId: String): Flow<List<Message>> =
|
||||
messageDao.getMessagesFlow(conversationId).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
suspend fun getMessages(conversationId: String): List<Message> =
|
||||
messageDao.getMessages(conversationId).map { it.toDomain() }
|
||||
|
||||
suspend fun getMessage(messageId: String): Message? =
|
||||
messageDao.getMessage(messageId)?.toDomain()
|
||||
|
||||
suspend fun saveMessages(messages: List<Message>) {
|
||||
messageDao.insertAll(messages.map { it.toEntity() })
|
||||
}
|
||||
|
||||
suspend fun saveMessage(message: Message) {
|
||||
messageDao.insert(message.toEntity())
|
||||
}
|
||||
|
||||
suspend fun markDeleted(messageId: String) {
|
||||
messageDao.markDeleted(messageId)
|
||||
}
|
||||
|
||||
suspend fun updateReactions(messageId: String, reactions: List<MessageReaction>) {
|
||||
val reactionsJson = reactions.joinToString(",", "[", "]") { r ->
|
||||
"""{"user_id":"${r.userId}","reaction":"${r.reaction}","created_at":"${DateFormatter.format(r.createdAt)}"}"""
|
||||
}
|
||||
messageDao.updateReactions(messageId, reactionsJson)
|
||||
}
|
||||
|
||||
suspend fun updatePinStatus(messageId: String, pinnedAt: Date?, pinnedBy: String?) {
|
||||
messageDao.updatePinStatus(messageId, pinnedAt?.time, pinnedBy)
|
||||
}
|
||||
|
||||
suspend fun updateReadBy(messageId: String, readBy: Set<String>) {
|
||||
val readByJson = readBy.joinToString(",", "[", "]") { "\"$it\"" }
|
||||
messageDao.updateReadBy(messageId, readByJson)
|
||||
}
|
||||
|
||||
suspend fun deleteByConversation(conversationId: String) {
|
||||
messageDao.deleteByConversation(conversationId)
|
||||
}
|
||||
|
||||
suspend fun getLatestTimestamp(conversationId: String): Long? =
|
||||
messageDao.getLatestTimestamp(conversationId)
|
||||
|
||||
suspend fun getPinnedMessages(conversationId: String): List<Message> =
|
||||
messageDao.getPinnedMessages(conversationId).map { it.toDomain() }
|
||||
|
||||
suspend fun searchMessages(conversationId: String, query: String): List<Message> =
|
||||
messageDao.searchMessages(conversationId, query).map { it.toDomain() }
|
||||
|
||||
// --- Entity <-> Domain mapping ---
|
||||
|
||||
private fun MessageEntity.toDomain(): Message = Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
senderId = senderId,
|
||||
senderUsername = senderUsername,
|
||||
createdAt = Date(createdAt),
|
||||
text = text,
|
||||
replyTo = replyTo,
|
||||
imageFileId = imageFileId,
|
||||
file = fileJson?.let { parseFileInfo(it) },
|
||||
image = imageJson?.let { parseImageInfo(it) },
|
||||
isDeleted = isDeleted,
|
||||
readBy = readByJson?.let { parseStringSet(it) } ?: emptySet(),
|
||||
reactions = reactionsJson?.let { parseReactions(it) } ?: emptyList(),
|
||||
forwardedFrom = forwardedFromJson?.let { parseForwardedFrom(it) },
|
||||
pinnedAt = pinnedAt?.let { Date(it) },
|
||||
pinnedBy = pinnedBy,
|
||||
)
|
||||
|
||||
private fun Message.toEntity(): MessageEntity = MessageEntity(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
senderId = senderId,
|
||||
senderUsername = senderUsername,
|
||||
createdAt = createdAt.time,
|
||||
text = text,
|
||||
replyTo = replyTo,
|
||||
imageFileId = imageFileId,
|
||||
fileJson = file?.let { serializeFileInfo(it) },
|
||||
imageJson = image?.let { serializeImageInfo(it) },
|
||||
isDeleted = isDeleted,
|
||||
readByJson = if (readBy.isNotEmpty()) readBy.joinToString(",", "[", "]") { "\"$it\"" } else null,
|
||||
reactionsJson = if (reactions.isNotEmpty()) serializeReactions(reactions) else null,
|
||||
forwardedFromJson = forwardedFrom?.let { serializeForwardedFrom(it) },
|
||||
pinnedAt = pinnedAt?.time,
|
||||
pinnedBy = pinnedBy,
|
||||
)
|
||||
|
||||
// TODO: Implement JSON serialization helpers
|
||||
private fun parseFileInfo(json: String): FileInfo? = null // TODO
|
||||
private fun parseImageInfo(json: String): ImageInfo? = null // TODO
|
||||
private fun parseStringSet(json: String): Set<String> = emptySet() // TODO
|
||||
private fun parseReactions(json: String): List<MessageReaction> = emptyList() // TODO
|
||||
private fun parseForwardedFrom(json: String): ForwardedFrom? = null // TODO
|
||||
private fun serializeFileInfo(info: FileInfo): String = "" // TODO
|
||||
private fun serializeImageInfo(info: ImageInfo): String = "" // TODO
|
||||
private fun serializeReactions(reactions: List<MessageReaction>): String = "" // TODO
|
||||
private fun serializeForwardedFrom(fwd: ForwardedFrom): String = "" // TODO
|
||||
}
|
||||
```
|
||||
|
||||
### 2. data/repository/ConversationRepository.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.repository
|
||||
|
||||
import com.kecalek.chat.data.local.dao.ConversationDao
|
||||
import com.kecalek.chat.data.local.entity.ConversationEntity
|
||||
import com.kecalek.chat.data.model.Conversation
|
||||
import com.kecalek.chat.data.model.ConversationMember
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConversationRepository @Inject constructor(
|
||||
private val conversationDao: ConversationDao,
|
||||
) {
|
||||
fun getAllFlow(): Flow<List<Conversation>> =
|
||||
conversationDao.getAllFlow().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
suspend fun getAll(): List<Conversation> =
|
||||
conversationDao.getAll().map { it.toDomain() }
|
||||
|
||||
suspend fun getById(conversationId: String): Conversation? =
|
||||
conversationDao.getById(conversationId)?.toDomain()
|
||||
|
||||
suspend fun saveAll(conversations: List<Conversation>) {
|
||||
conversationDao.insertAll(conversations.map { it.toEntity() })
|
||||
}
|
||||
|
||||
suspend fun save(conversation: Conversation) {
|
||||
conversationDao.insert(conversation.toEntity())
|
||||
}
|
||||
|
||||
suspend fun updateUnreadCount(conversationId: String, count: Int) {
|
||||
conversationDao.updateUnreadCount(conversationId, count)
|
||||
}
|
||||
|
||||
suspend fun toggleFavorite(conversationId: String, isFavorite: Boolean) {
|
||||
conversationDao.updateFavorite(conversationId, isFavorite)
|
||||
}
|
||||
|
||||
suspend fun updateName(conversationId: String, name: String) {
|
||||
conversationDao.updateName(conversationId, name)
|
||||
}
|
||||
|
||||
suspend fun delete(conversationId: String) {
|
||||
conversationDao.delete(conversationId)
|
||||
}
|
||||
|
||||
// --- Entity <-> Domain mapping ---
|
||||
|
||||
private fun ConversationEntity.toDomain(): Conversation = Conversation(
|
||||
id = id,
|
||||
name = name,
|
||||
members = membersJson?.let { parseMembers(it) } ?: emptyList(),
|
||||
createdBy = createdBy,
|
||||
avatarFile = avatarFile,
|
||||
unreadCount = unreadCount,
|
||||
isFavorite = isFavorite,
|
||||
lastMessageTime = lastMessageTime?.let { Date(it) },
|
||||
)
|
||||
|
||||
private fun Conversation.toEntity(): ConversationEntity = ConversationEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
createdBy = createdBy,
|
||||
avatarFile = avatarFile,
|
||||
unreadCount = unreadCount,
|
||||
isFavorite = isFavorite,
|
||||
lastMessageTime = lastMessageTime?.time,
|
||||
membersJson = serializeMembers(members),
|
||||
)
|
||||
|
||||
// TODO: Implement JSON serialization helpers
|
||||
private fun parseMembers(json: String): List<ConversationMember> = emptyList() // TODO
|
||||
private fun serializeMembers(members: List<ConversationMember>): String = "" // TODO
|
||||
}
|
||||
```
|
||||
|
||||
### 3. data/repository/UserRepository.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.data.repository
|
||||
|
||||
import com.kecalek.chat.data.local.dao.UserCacheDao
|
||||
import com.kecalek.chat.data.local.entity.UserCacheEntity
|
||||
import com.kecalek.chat.data.model.User
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserRepository @Inject constructor(
|
||||
private val userCacheDao: UserCacheDao,
|
||||
) {
|
||||
suspend fun getUser(userId: String): User? =
|
||||
userCacheDao.getById(userId)?.toDomain()
|
||||
|
||||
suspend fun getUserByEmail(email: String): User? =
|
||||
userCacheDao.getByEmail(email)?.toDomain()
|
||||
|
||||
suspend fun cacheUser(user: User) {
|
||||
userCacheDao.insert(user.toEntity())
|
||||
}
|
||||
|
||||
suspend fun clearCache() {
|
||||
userCacheDao.deleteAll()
|
||||
}
|
||||
|
||||
private fun UserCacheEntity.toDomain(): User = User(
|
||||
id = id,
|
||||
username = username,
|
||||
email = email,
|
||||
identityKey = identityKey,
|
||||
)
|
||||
|
||||
private fun User.toEntity(): UserCacheEntity = UserCacheEntity(
|
||||
id = id,
|
||||
username = username,
|
||||
email = email,
|
||||
identityKey = identityKey,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- All repositories are `@Singleton` (Hilt scope)
|
||||
- All data operations are `suspend` functions
|
||||
- Flow-based observers for real-time UI updates
|
||||
- JSON serialization for complex fields (reactions, members, file info)
|
||||
- Entity <-> Domain model mapping is bidirectional
|
||||
|
||||
## DO NOT
|
||||
- Implement actual server API calls (that's in ChatClient)
|
||||
- Implement any crypto operations
|
||||
- Create new Room entities or DAOs (use existing from Agent C)
|
||||
- Add UI code
|
||||
142
specs/agent-i-hilt-di.md
Normal file
142
specs/agent-i-hilt-di.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Agent I: Hilt DI Modules
|
||||
|
||||
## Phase: 3 (Core Logic)
|
||||
## Depends on: Agent A (Gradle), Agent C (Room database)
|
||||
|
||||
## Context
|
||||
Set up Hilt dependency injection for the entire app.
|
||||
Provides singletons for database, network, crypto components.
|
||||
|
||||
## Task
|
||||
Create all Hilt DI modules for the application.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. di/AppModule.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.kecalek.chat.data.local.AppDatabase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.sqlcipher.database.SupportFactory
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): AppDatabase {
|
||||
// TODO: Get database passphrase from secure storage
|
||||
// For now, use a placeholder. In production, derive from identity key.
|
||||
val passphrase = "TODO_REPLACE_WITH_DERIVED_KEY".toByteArray()
|
||||
val factory = SupportFactory(passphrase)
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"kecalek_chat.db"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. di/DatabaseModule.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.di
|
||||
|
||||
import com.kecalek.chat.data.local.AppDatabase
|
||||
import com.kecalek.chat.data.local.dao.ConversationDao
|
||||
import com.kecalek.chat.data.local.dao.MessageDao
|
||||
import com.kecalek.chat.data.local.dao.UserCacheDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
fun provideMessageDao(db: AppDatabase): MessageDao = db.messageDao()
|
||||
|
||||
@Provides
|
||||
fun provideConversationDao(db: AppDatabase): ConversationDao = db.conversationDao()
|
||||
|
||||
@Provides
|
||||
fun provideUserCacheDao(db: AppDatabase): UserCacheDao = db.userCacheDao()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. di/NetworkModule.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
// TODO: Provide ConnectionManager singleton
|
||||
// TODO: Provide ProtocolHandler singleton
|
||||
// TODO: Provide ServerApi singleton
|
||||
|
||||
// Placeholder — will be wired when network layer is implemented by Claude Code
|
||||
}
|
||||
```
|
||||
|
||||
### 4. di/CryptoModule.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CryptoModule {
|
||||
|
||||
// TODO: Provide crypto components (implemented by Claude Code)
|
||||
// - AesGcmCrypto
|
||||
// - HkdfUtils
|
||||
// - KeyEncryption (ECP1)
|
||||
// - Ed25519Crypto
|
||||
// - X25519Crypto
|
||||
// - RSACrypto
|
||||
// - MessagePadding
|
||||
// - ContactVerification
|
||||
|
||||
// Placeholder — will be wired when crypto layer is implemented by Claude Code
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- All modules use `@InstallIn(SingletonComponent::class)` unless scoped differently
|
||||
- Database uses SQLCipher encryption via SupportFactory
|
||||
- DAO providers are simple pass-through from AppDatabase
|
||||
- Network and Crypto modules are placeholders (Claude Code implements the actual classes)
|
||||
- Use `@Singleton` for expensive objects (database, network manager)
|
||||
|
||||
## DO NOT
|
||||
- Implement actual crypto or network classes
|
||||
- Add any UI-related bindings
|
||||
- Create ViewModel bindings (Hilt handles those automatically via @HiltViewModel)
|
||||
132
specs/agent-j-file-sharing-ui.md
Normal file
132
specs/agent-j-file-sharing-ui.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Agent J: File Sharing UI
|
||||
|
||||
## Phase: 4 (Feature Completion)
|
||||
## Depends on: Agent F (Chat Screen), Agent C (models with FileInfo/ImageInfo)
|
||||
|
||||
## Context
|
||||
File sharing uses chunked encrypted upload/download (AES-256-GCM).
|
||||
The UI handles image/file picking, thumbnail display, download progress, and file type icons.
|
||||
Encryption logic is handled by ChatClient (Claude Code) — this agent only handles UI.
|
||||
|
||||
## Task
|
||||
Create file/image picker integration, download UI, file type icons, and thumbnail display.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/chat/AttachmentSheet.kt
|
||||
Bottom sheet shown when attachment button is tapped:
|
||||
- **"Image"** option with gallery icon
|
||||
- Opens system image picker (ActivityResultContracts.PickVisualMedia)
|
||||
- Shows selected image preview before sending
|
||||
- **"File"** option with document icon
|
||||
- Opens system file picker (ActivityResultContracts.OpenDocument)
|
||||
- Shows selected file name + size before sending
|
||||
- **"Camera"** option with camera icon
|
||||
- Opens camera to take photo (ActivityResultContracts.TakePicture)
|
||||
|
||||
### 2. ui/chat/ImageThumbnail.kt
|
||||
Composable for image message display in chat:
|
||||
- Shows base64 JPEG thumbnail from ImageInfo.thumbnail
|
||||
- Max width 200dp, maintain aspect ratio
|
||||
- Rounded corners (8dp)
|
||||
- Loading shimmer while full image loads
|
||||
- On tap: navigate to ImageViewer with full image URL
|
||||
- Download progress overlay (if downloading full resolution)
|
||||
|
||||
### 3. ui/chat/FileCard.kt
|
||||
Composable for file attachment display in chat:
|
||||
- Horizontal layout:
|
||||
- File type icon (40dp, left):
|
||||
- PDF: Red document icon
|
||||
- Image: Blue image icon
|
||||
- Video: Purple play icon
|
||||
- Audio: Green music icon
|
||||
- Archive: Yellow archive icon
|
||||
- Default: Gray document icon
|
||||
- Center (weight 1f):
|
||||
- Filename (bold, 1 line, ellipsize end)
|
||||
- File size (formatted: KB, MB) + mime type
|
||||
- Download button/progress (right):
|
||||
- Download icon (if not downloaded)
|
||||
- CircularProgressIndicator (if downloading)
|
||||
- Checkmark (if downloaded)
|
||||
- Background: Surface1 with 1dp Surface2 border, rounded 8dp
|
||||
- On tap: download if not downloaded, open if downloaded
|
||||
|
||||
### 4. ui/chat/DownloadProgress.kt
|
||||
Reusable download progress composable:
|
||||
- Circular progress indicator with percentage text
|
||||
- Cancel button
|
||||
- File size downloaded / total
|
||||
|
||||
### 5. util/FileUtils.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
|
||||
object FileUtils {
|
||||
fun getFileName(context: Context, uri: Uri): String {
|
||||
// Query ContentResolver for display name
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0) return it.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment ?: "unknown"
|
||||
}
|
||||
|
||||
fun getFileSize(context: Context, uri: Uri): Long {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val sizeIndex = it.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
if (sizeIndex >= 0) return it.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun getMimeType(context: Context, uri: Uri): String {
|
||||
return context.contentResolver.getType(uri)
|
||||
?: MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(uri.toString().substringAfterLast('.'))
|
||||
?: "application/octet-stream"
|
||||
}
|
||||
|
||||
fun formatFileSize(bytes: Long): String = when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB"
|
||||
else -> "${"%.1f".format(bytes / (1024.0 * 1024.0 * 1024.0))} GB"
|
||||
}
|
||||
|
||||
fun getFileTypeIcon(mimeType: String): FileTypeIcon = when {
|
||||
mimeType.startsWith("image/") -> FileTypeIcon.IMAGE
|
||||
mimeType.startsWith("video/") -> FileTypeIcon.VIDEO
|
||||
mimeType.startsWith("audio/") -> FileTypeIcon.AUDIO
|
||||
mimeType == "application/pdf" -> FileTypeIcon.PDF
|
||||
mimeType.contains("zip") || mimeType.contains("tar") || mimeType.contains("rar") -> FileTypeIcon.ARCHIVE
|
||||
else -> FileTypeIcon.DOCUMENT
|
||||
}
|
||||
|
||||
enum class FileTypeIcon { PDF, IMAGE, VIDEO, AUDIO, ARCHIVE, DOCUMENT }
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Use ActivityResultContracts for file/image picking (no deprecated startActivityForResult)
|
||||
- Thumbnail display from base64 data (not URL loading)
|
||||
- Max image display width: 200dp in chat
|
||||
- File cards: consistent height, ellipsize long filenames
|
||||
- Download progress: 0-100% with cancel option
|
||||
|
||||
## DO NOT
|
||||
- Implement actual file encryption/decryption
|
||||
- Implement actual chunked upload/download
|
||||
- Handle server communication
|
||||
- Store files on disk (that's ChatClient's job)
|
||||
101
specs/agent-k-reactions-pins-search.md
Normal file
101
specs/agent-k-reactions-pins-search.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Agent K: Reactions, Pins, Search, Forward
|
||||
|
||||
## Phase: 4 (Feature Completion)
|
||||
## Depends on: Agent F (Chat Screen with MessageBubble)
|
||||
|
||||
## Context
|
||||
These features enhance the chat experience. Reactions use 6 predefined emoji.
|
||||
Pins allow highlighting important messages. Search is client-side through cached messages.
|
||||
Forward sends messages to other conversations with attribution.
|
||||
|
||||
## Task
|
||||
Create reaction picker, pin UI, search overlay, forward dialog, and @mention autocomplete.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/chat/ReactionPicker.kt
|
||||
Emoji reaction picker composable:
|
||||
- **Layout**: Horizontal row of 6 emoji buttons in a rounded Surface1 card
|
||||
- **Emoji**: thumbsup, heart, laugh, surprised, sad, thumbsdown
|
||||
- **Display**: Use emoji characters (not images)
|
||||
- **Behavior**:
|
||||
- Shown as popup/overlay near the long-pressed message
|
||||
- Tap emoji: add reaction (or remove if already reacted)
|
||||
- Dismiss on outside tap
|
||||
- **Animation**: Scale-in animation when appearing
|
||||
|
||||
### 2. ui/chat/ReactionBadge.kt
|
||||
Reaction display below messages:
|
||||
- **Layout**: FlowRow of reaction chips
|
||||
- **Each chip**: emoji + count in a small pill shape
|
||||
- Background: Surface1 (or Lavender alpha if user reacted)
|
||||
- Border: Surface2 (or Lavender if user reacted)
|
||||
- Text: emoji (14sp) + count (12sp)
|
||||
- **Behavior**: Tap to toggle own reaction
|
||||
|
||||
### 3. ui/chat/PinnedMessagesSheet.kt
|
||||
Bottom sheet showing pinned messages:
|
||||
- **Header**: "Pinned Messages" title + close button
|
||||
- **List**: LazyColumn of pinned messages
|
||||
- Each item: sender name + message text preview + pin date
|
||||
- Tap: scroll to message in chat (dismiss sheet)
|
||||
- **Empty state**: "No pinned messages"
|
||||
|
||||
### 4. ui/chat/SearchOverlay.kt
|
||||
Search bar overlay at top of chat screen:
|
||||
- **Layout**: Row with:
|
||||
- Search TextField (weight 1f, with search icon)
|
||||
- Match count text ("3 of 12")
|
||||
- Up arrow button (previous result)
|
||||
- Down arrow button (next result)
|
||||
- Close button (X)
|
||||
- **Behavior**:
|
||||
- Activated by search icon in top bar
|
||||
- Results update as user types (debounced 300ms)
|
||||
- Current match highlighted differently from other matches
|
||||
- Up/Down cycle through results
|
||||
- Escape or X closes search
|
||||
- **Match highlighting in messages**: Yellow background on matching text
|
||||
|
||||
### 5. ui/chat/ForwardPickerDialog.kt
|
||||
Dialog for selecting forwarding target:
|
||||
- **Title**: "Forward to..."
|
||||
- **Conversation list**: LazyColumn of all user's conversations
|
||||
- Each item: avatar + conversation name
|
||||
- Tap: forward to that conversation and dismiss
|
||||
- **Search field**: Filter conversations by name
|
||||
- **Cancel button**
|
||||
|
||||
### 6. ui/chat/MentionAutocomplete.kt
|
||||
@mention autocomplete popup:
|
||||
- **Trigger**: Typing "@" in message input
|
||||
- **Layout**: Popup above the text input
|
||||
- LazyColumn of matching member names
|
||||
- Each item: avatar (24dp) + username
|
||||
- **Behavior**:
|
||||
- Filters as user types after "@"
|
||||
- Tap: inserts "@username " into text field
|
||||
- Dismiss on escape, backspace past "@", or clicking outside
|
||||
- **Styling**: Surface1 background, elevated (shadow)
|
||||
|
||||
## Reaction Emoji Mapping
|
||||
```
|
||||
"thumbsup" -> 👍
|
||||
"heart" -> ❤️
|
||||
"laugh" -> 😂
|
||||
"surprised" -> 😮
|
||||
"sad" -> 😢
|
||||
"thumbsdown" -> 👎
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Reaction picker uses emoji characters (Unicode), not custom images
|
||||
- Search is debounced (300ms) to avoid excessive computation
|
||||
- Forward dialog loads conversation list from local cache
|
||||
- @mention autocomplete only shows members of current conversation
|
||||
- All animations should be subtle and fast (200-300ms)
|
||||
|
||||
## DO NOT
|
||||
- Implement actual server calls for reactions/pins
|
||||
- Handle message encryption for forwarding
|
||||
- Implement full-text search indexing (just LIKE query on cached text)
|
||||
299
specs/agent-l-notifications-service.md
Normal file
299
specs/agent-l-notifications-service.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Agent L: Notifications + Background Service
|
||||
|
||||
## Phase: 4 (Feature Completion)
|
||||
## Depends on: Agent A (Manifest), Agent I (DI)
|
||||
|
||||
## Context
|
||||
The app needs a persistent TCP connection to receive real-time push notifications from the server.
|
||||
On Android, this requires a Foreground Service to keep the connection alive when the app is backgrounded.
|
||||
Additionally, local notifications are shown when messages arrive while the app is not in focus.
|
||||
|
||||
## Task
|
||||
Create foreground service for TCP connection, notification channels, and app lifecycle handling.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. core/ChatService.kt
|
||||
Android Foreground Service:
|
||||
```kotlin
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.kecalek.chat.MainActivity
|
||||
import com.kecalek.chat.R
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatService : Service() {
|
||||
|
||||
// TODO: @Inject ChatClient or ConnectionManager
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID_SERVICE = "kecalek_service"
|
||||
const val CHANNEL_ID_MESSAGES = "kecalek_messages"
|
||||
const val CHANNEL_ID_GROUPS = "kecalek_groups"
|
||||
const val CHANNEL_ID_SYSTEM = "kecalek_system"
|
||||
const val NOTIFICATION_ID_SERVICE = 1
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID_SERVICE, buildServiceNotification())
|
||||
// TODO: Start TCP connection listener
|
||||
// TODO: Handle incoming notifications and show local notifications
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
// TODO: Disconnect from server
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
|
||||
// Service channel (silent, low priority)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_SERVICE,
|
||||
"Connection Service",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Keeps the encrypted connection alive"
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
|
||||
// Message notifications (high priority, sound)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_MESSAGES,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "New message notifications"
|
||||
enableVibration(true)
|
||||
}
|
||||
)
|
||||
|
||||
// Group notifications
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_GROUPS,
|
||||
"Groups",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Group activity notifications"
|
||||
}
|
||||
)
|
||||
|
||||
// System notifications (key changes, connection)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID_SYSTEM,
|
||||
"System",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Connection and security notifications"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildServiceNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID_SERVICE)
|
||||
.setContentTitle("Kecalek")
|
||||
.setContentText("Connected securely")
|
||||
.setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. core/NotificationHelper.kt
|
||||
Helper for showing local notifications:
|
||||
```kotlin
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.kecalek.chat.MainActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationHelper @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val notificationManager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private var nextNotificationId = 100
|
||||
|
||||
fun showMessageNotification(
|
||||
senderName: String,
|
||||
conversationId: String,
|
||||
messagePreview: String?,
|
||||
) {
|
||||
// E2EE: Never show plaintext in notification if app is locked
|
||||
val displayText = messagePreview ?: "New encrypted message"
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
putExtra("conversationId", conversationId)
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, conversationId.hashCode(), intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_MESSAGES)
|
||||
.setContentTitle(senderName)
|
||||
.setContentText(displayText)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email) // TODO: Custom icon
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setGroup("messages")
|
||||
.build()
|
||||
|
||||
notificationManager.notify(nextNotificationId++, notification)
|
||||
}
|
||||
|
||||
fun showGroupNotification(
|
||||
groupName: String,
|
||||
action: String, // "invited you", "member joined", etc.
|
||||
) {
|
||||
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_GROUPS)
|
||||
.setContentTitle(groupName)
|
||||
.setContentText(action)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Custom icon
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(nextNotificationId++, notification)
|
||||
}
|
||||
|
||||
fun showSystemNotification(title: String, text: String) {
|
||||
val notification = NotificationCompat.Builder(context, ChatService.CHANNEL_ID_SYSTEM)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_lock) // TODO: Custom icon
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(nextNotificationId++, notification)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. core/AppLifecycleObserver.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.core
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Observes app lifecycle to manage TCP connection state.
|
||||
* - Foreground: ensure connection is active
|
||||
* - Background: keep connection via foreground service
|
||||
*/
|
||||
@Singleton
|
||||
class AppLifecycleObserver @Inject constructor(
|
||||
// TODO: Inject ChatClient or ConnectionManager
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
var isInForeground: Boolean = false
|
||||
private set
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
isInForeground = true
|
||||
// TODO: Reconnect if disconnected, health check
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
isInForeground = false
|
||||
// TODO: Connection stays alive via foreground service
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update AndroidManifest.xml
|
||||
Add service declaration:
|
||||
```xml
|
||||
<service
|
||||
android:name=".core.ChatService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
```
|
||||
|
||||
## Notification Types to Handle
|
||||
These 17 server push notification types must trigger appropriate local notifications:
|
||||
|
||||
| Server Type | Notification Channel | Display |
|
||||
|-------------|---------------------|---------|
|
||||
| `new_message` | Messages | "Sender: message preview" |
|
||||
| `messages_read` | (none) | Silent — update UI only |
|
||||
| `message_deleted` | (none) | Silent — update UI only |
|
||||
| `message_reacted` | (none) | Silent — update UI only |
|
||||
| `message_pinned` | (none) | Silent — update UI only |
|
||||
| `message_unpinned` | (none) | Silent — update UI only |
|
||||
| `conversation_created` | Groups | "New conversation created" |
|
||||
| `conversation_renamed` | Groups | "Group renamed to X" |
|
||||
| `member_added` | Groups | "X joined the group" |
|
||||
| `member_removed` | Groups | "X was removed" |
|
||||
| `group_invitation` | Groups | "X invited you to Y" |
|
||||
| `user_online` | (none) | Silent — update UI only |
|
||||
| `user_offline` | (none) | Silent — update UI only |
|
||||
| `online_users` | (none) | Silent — update UI only |
|
||||
| `session_reset` | System | "Session reset by X" |
|
||||
| `keys_updated` | System | "Security keys updated" |
|
||||
|
||||
## Constraints
|
||||
- Foreground service with `FOREGROUND_SERVICE_DATA_SYNC` type
|
||||
- `START_STICKY` to restart if killed
|
||||
- Notification channels required for Android O+
|
||||
- E2EE consideration: don't show plaintext in notifications when app is locked
|
||||
- Group notifications by conversation for notification stacking
|
||||
- Use PendingIntent.FLAG_IMMUTABLE (Android 12+ requirement)
|
||||
|
||||
## DO NOT
|
||||
- Implement actual TCP connection management
|
||||
- Handle message decryption
|
||||
- Implement Firebase Cloud Messaging (future enhancement)
|
||||
- Store messages or update database directly (delegate to ChatClient)
|
||||
261
specs/agent-m-settings-polish.md
Normal file
261
specs/agent-m-settings-polish.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Agent M: Settings + Polish
|
||||
|
||||
## Phase: 4 (Feature Completion)
|
||||
## Depends on: Agent B (Theme + Navigation), Agent D (Auth for server config)
|
||||
|
||||
## Context
|
||||
Final polish: settings screen, connection indicator, privacy lock screen, error handling UI.
|
||||
|
||||
## Task
|
||||
Create settings screen, reusable UI components for connection status, error handling, and privacy lock.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### 1. ui/settings/SettingsScreen.kt
|
||||
Jetpack Compose settings screen:
|
||||
- **Top bar**: Back arrow + "Settings" title
|
||||
- **Server Configuration section**:
|
||||
- Host text field (current value displayed)
|
||||
- Port text field
|
||||
- TLS toggle switch
|
||||
- "Save" button
|
||||
- **Account section**:
|
||||
- "Change Username" button
|
||||
- "Change Password" button
|
||||
- "Key Rotation" button (Peach/warning color with info text)
|
||||
- "My Devices" button (navigates to DeviceList)
|
||||
- **Privacy section**:
|
||||
- "Privacy Lock" toggle (enables biometric/PIN lock)
|
||||
- "Lock Timeout" selector (30s, 1min, 5min)
|
||||
- **About section**:
|
||||
- App version ("Kecalek v0.8.5")
|
||||
- "End-to-end encrypted" info text
|
||||
- "Signal Protocol (X3DH + Double Ratchet)"
|
||||
- **Danger zone**:
|
||||
- "Delete Account" button (Red, with confirmation dialog)
|
||||
- "Logout" button (Red outlined)
|
||||
|
||||
### 2. ui/components/ConnectionIndicator.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
enum class ConnectionStatus {
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
RECONNECTING,
|
||||
}
|
||||
|
||||
/**
|
||||
* Small dot indicator showing connection status.
|
||||
* Green = connected, Red = disconnected, Orange = reconnecting.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionIndicator(
|
||||
status: ConnectionStatus,
|
||||
showLabel: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = when (status) {
|
||||
ConnectionStatus.CONNECTED -> CatppuccinMocha.Green
|
||||
ConnectionStatus.DISCONNECTED -> CatppuccinMocha.Red
|
||||
ConnectionStatus.RECONNECTING -> CatppuccinMocha.Peach
|
||||
}
|
||||
val label = when (status) {
|
||||
ConnectionStatus.CONNECTED -> "Connected"
|
||||
ConnectionStatus.DISCONNECTED -> "Disconnected"
|
||||
ConnectionStatus.RECONNECTING -> "Reconnecting..."
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
if (showLabel) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = color,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ui/components/SearchBar.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
placeholder: String = "Search...",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(placeholder, color = CatppuccinMocha.Overlay1) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CatppuccinMocha.Lavender,
|
||||
unfocusedBorderColor = CatppuccinMocha.Surface2,
|
||||
focusedContainerColor = CatppuccinMocha.Surface1,
|
||||
unfocusedContainerColor = CatppuccinMocha.Surface1,
|
||||
),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ui/auth/PrivacyLockScreen.kt
|
||||
Privacy lock overlay (anti-forensic feature):
|
||||
- **Full-screen overlay** covering all content
|
||||
- **Dark background** (Base color, 98% opacity)
|
||||
- **Lock icon** centered (large, 64dp)
|
||||
- **Password field** (or biometric prompt)
|
||||
- **"Unlock" button** (Lavender)
|
||||
- **Behavior**:
|
||||
- Shown when app loses focus for > 30 seconds (configurable)
|
||||
- Immediate dark overlay on app background (hides content)
|
||||
- After timeout: requires password/biometric to unlock
|
||||
- Password verified by decrypting identity key (ECP1 format)
|
||||
|
||||
### 5. ui/components/ErrorSnackbar.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.components
|
||||
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@Composable
|
||||
fun ErrorSnackbar(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = modifier,
|
||||
) { data ->
|
||||
Snackbar(
|
||||
containerColor = CatppuccinMocha.Red.copy(alpha = 0.9f),
|
||||
contentColor = CatppuccinMocha.Text,
|
||||
actionColor = CatppuccinMocha.Rosewater,
|
||||
snackbarData = data,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. ui/components/ConfirmationDialog.kt
|
||||
```kotlin
|
||||
package com.kecalek.chat.ui.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.kecalek.chat.ui.theme.CatppuccinMocha
|
||||
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String = "Confirm",
|
||||
dismissText: String = "Cancel",
|
||||
isDestructive: Boolean = false,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = CatppuccinMocha.Surface0,
|
||||
titleContentColor = CatppuccinMocha.Text,
|
||||
textContentColor = CatppuccinMocha.Subtext1,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isDestructive) CatppuccinMocha.Red else CatppuccinMocha.Lavender,
|
||||
contentColor = if (isDestructive) CatppuccinMocha.Text else CatppuccinMocha.Base,
|
||||
),
|
||||
) {
|
||||
Text(confirmText)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(dismissText, color = CatppuccinMocha.Subtext1)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- Settings persist via DataStore or SharedPreferences
|
||||
- Server config changes require reconnection
|
||||
- Privacy lock uses BiometricPrompt API
|
||||
- Connection indicator should be lightweight (placed in top bars)
|
||||
- Confirmation dialogs for all destructive actions
|
||||
- Error snackbar theming matches Catppuccin Mocha
|
||||
|
||||
## DO NOT
|
||||
- Implement actual server reconnection logic
|
||||
- Handle biometric authentication implementation (just the UI shell)
|
||||
- Implement password verification against ECP1 keys
|
||||
- Implement actual account deletion (server endpoint doesn't exist yet)
|
||||
Reference in New Issue
Block a user