kaizen-app/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
Bruno Deanoz 9759d478a7 feat: offline-first cache layer with Room
Room 2.8.4 as local SQLite read-cache for conversations and messages.
Sidebar and chat history load instantly from cache, background refresh
syncs with the server. Streaming stays in-memory, flushed to Room after
completion. Includes ChatViewModel, repositories, mappers, TypeConverters,
and unit tests.
2026-06-21 12:50:46 +02:00

531 lines
25 KiB
Kotlin

package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.net.SaveMessage
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings }
@Composable
fun ChatScreen(
settingsViewModel: SettingsViewModel,
session: SessionViewModel,
chat: ChatViewModel,
modifier: Modifier = Modifier
) {
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
val messages = remember { mutableStateListOf<Message>() }
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) }
var selectedMode by remember { mutableStateOf("Standard") }
// Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
// Collect the user name reactively from the ViewModel
val userName by settingsViewModel.userName.collectAsState()
// Tablet & Landscape optimization: Detect large screens and cap the content width
val configuration = LocalConfiguration.current
val isTablet = configuration.screenWidthDp > 600
// Native sidebar drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// Model picker: catalog (loads instantly from local cache!) + sheet visibility
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) }
val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) }
// Pending file attachments — uploaded immediately on pick, cleared on send.
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val cfg = session.config ?: return@rememberLauncherForActivityResult
val cr = context.contentResolver
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
val pf = PendingFile(name = fileName, mimeType = mimeType, bytes = bytes)
pendingFiles.add(pf)
scope.launch {
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, fileName, mimeType, bytes)) {
is FetchResult.Ok -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
}
is FetchResult.Fail -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
}
}
}
}
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
loadError = null
val errors = mutableListOf<String>()
when (val r = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)) {
is FetchResult.Ok -> {
models = r.data
if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
}
is FetchResult.Fail -> errors.add(r.reason)
}
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ")
}
}
// Warm the HTTP/2 connection pool on every app resume (covers background → foreground
// after OkHttp's 5-minute keep-alive has expired). On first composition, the
// LaunchedEffect above already warms the pool via fetchModels/fetchConversations.
LifecycleResumeEffect(session.config?.baseUrl) {
session.config?.let { cfg ->
scope.launch { KaizenApi.prewarm(cfg.baseUrl) }
}
onPauseOrDispose { }
}
fun refreshConversations() {
val cfg = session.config ?: return
scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
}
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
fun newChat() {
messages.clear()
conversationId = null
}
fun openConversation(summary: ConversationSummary) {
if (summary.locked) return
val cfg = session.config ?: return
scope.launch {
// Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear()
val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
),
)
}
conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
),
)
}
}
}
}
}
fun send(text: String) {
val trimmed = text.trim()
val uploadedAtts = pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
val cfg = session.config ?: return
haptics.click()
val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
messages.add(userMsg)
input = ""
pendingFiles.clear()
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true
val wasNewConversation = conversationId == null
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
scope.launch {
haptics.thinkingStart()
var sawContent = false
try {
val convIdDeferred = if (conversationId == null) {
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
).collect { visible ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) {
sawContent = true
haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
content = visible,
)
}
val idx = messages.indexOfLast { it.id == assistantId }
val finalContent = if (idx >= 0) messages[idx].content else ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages(
cfg.baseUrl, cfg.token, convId,
listOf(
SaveMessage(
id = userMsg.wireId, parentId = userParentId,
role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
),
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
),
)
if (saved) {
// Cache new messages in Room for offline-first
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
id = userMsg.wireId, conversationId = convId,
role = "user", content = trimmed,
attachments = uploadedAtts, sortOrder = msgCount - 2,
),
MessageEntity(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
),
))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
refreshConversations()
}
}
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) {
val existing = messages[idx].content
val errorSuffix = "\n\n${chatErrorText(e)}"
messages[idx] = messages[idx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e),
)
}
} finally {
isStreaming = false
haptics.responseEnd()
}
}
}
// Auto-scroll: isolated from the main composition via derivedStateOf + snapshotFlow.
// Only the snapshot read triggers work — the root composable does NOT recompose on
// every streamed character, eliminating the recomposition storm that previously
// re-drew the entire screen (top bar, input, background) at 60+ fps.
val autoScrollKey by remember {
derivedStateOf { messages.size to (messages.lastOrNull()?.content?.length ?: 0) }
}
LaunchedEffect(Unit) {
snapshotFlow { autoScrollKey }.collect {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
}
}
// Single source of truth Screen routing
when (currentScreen) {
AppScreen.Settings -> {
SettingsScreen(
viewModel = settingsViewModel,
onBack = { currentScreen = AppScreen.Chat },
modifier = modifier
)
}
AppScreen.Chat -> {
// ModalNavigationDrawer provides a native drawer that slides from left-to-right
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
KaizenSidebar(
userName = userName,
conversations = conversations,
currentId = conversationId,
onClose = { scope.launch { drawerState.close() } },
onNewChat = {
haptics.tick()
newChat()
scope.launch { drawerState.close() }
},
onSelectConversation = { summary ->
haptics.tick()
openConversation(summary)
scope.launch { drawerState.close() }
},
onOpenSettings = {
haptics.tick()
currentScreen = AppScreen.Settings
scope.launch { drawerState.close() }
},
onLogout = {
haptics.tick()
newChat()
scope.launch { drawerState.close() }
session.logout()
}
)
},
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
) {
MeshBackground {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 1. Scrollable Chat Content
// Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock.
// Centered layout with responsive width capping on tablets.
if (messages.isEmpty()) {
EmptyHero(
userName = userName,
onPick = { haptics.tick(); send(it) },
modifier = Modifier
.fillMaxSize()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
)
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
contentPadding = PaddingValues(
top = 76.dp, // Leaves breathing room for the floating top menu button
bottom = 152.dp, // Leaves luxurious room for the floating ModePills + ChatInput + bottom margins
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { message -> MessageRow(message) }
}
}
// 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar)
val isDark = remember { isStreaming || messages.isNotEmpty() } // slight adaptive context tint
val menuGlassBg = if (isDark.not() && isSystemInDarkTheme().not()) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f)))
} else {
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f)))
}
val menuGlassBorder = if (isDark.not() && isSystemInDarkTheme().not()) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)))
}
Row(
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(start = 16.dp, top = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds
.clip(CircleShape)
.background(menuGlassBg)
.border(1.2.dp, menuGlassBorder, CircleShape)
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
.size(44.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Menü öffnen",
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(20.dp)
)
}
Spacer(Modifier.width(8.dp))
ModelPill(
label = modelDisplayName(session.config?.model ?: "", models),
onClick = { haptics.tick(); showModelSheet = true },
modifier = Modifier.weight(1f, fill = false),
)
}
// Error banner when initial data load fails
if (loadError != null) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFDC2626).copy(alpha = 0.15f))
.border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
loadError!!,
color = Color(0xFFDC2626),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
// 3. Floating Bottom Control Dock (responsive centering and clear bottom margins)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
) {
ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() })
Spacer(Modifier.height(10.dp))
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
pendingFiles = pendingFiles,
onAddClick = { filePicker.launch("*/*") },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
)
Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens
}
// Model picker (bottom sheet), opened by the top pill
if (showModelSheet) {
ModelSheet(
models = models,
selectedModelId = session.config?.model ?: "",
favorites = session.favorites,
onSelectModel = { session.setModel(it); haptics.tick() },
onToggleFavorite = { session.toggleFavorite(it) },
onDismiss = { showModelSheet = false },
)
}
}
}
}
}
}
}
/** Maps a streaming failure to a short, human German message shown in the bubble. */
private fun chatErrorText(e: Throwable): String = when {
e is ChatHttpException && e.code == 401 -> "Sitzung abgelaufen. Bitte erneut anmelden."
e is ChatHttpException && e.code == 402 -> "Guthaben aufgebraucht."
e is ChatHttpException && e.code == 429 -> "Zu viele Anfragen. Kurz warten."
e is ChatHttpException -> "Fehler vom Server (${e.code})."
else -> "Keine Verbindung zum Server."
}