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.
531 lines
25 KiB
Kotlin
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."
|
|
}
|