From 7399c73e6ab02fa0d08b7da87949d4c0a9e7bace Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 09:00:58 +0200 Subject: [PATCH] feat: Redesign model selection sheet with OpenRouter/Vertex badging & offline-first caching --- .../java/dev/kaizen/app/chat/ChatScreen.kt | 15 +- .../java/dev/kaizen/app/chat/ModelSheet.kt | 266 ++++++++++-------- .../java/dev/kaizen/app/net/SecureStore.kt | 23 ++ .../dev/kaizen/app/net/SessionViewModel.kt | 2 +- 4 files changed, 186 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index a4cd84b..6033749 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -92,8 +92,8 @@ fun ChatScreen( // Native sidebar drawer state val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - // Model picker: catalog (best-effort fetch) + sheet visibility - var models by remember { mutableStateOf>(emptyList()) } + // 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: the active server conversation + the sidebar list @@ -102,7 +102,12 @@ fun ChatScreen( LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect - models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) + // Fetch fresh models from server and cache them locally if successful + val fetchedModels = KaizenApi.fetchModels(cfg.baseUrl, cfg.token) + if (fetchedModels.isNotEmpty()) { + models = fetchedModels + session.store.saveModelsCache(fetchedModels) + } conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) } @@ -354,16 +359,14 @@ fun ChatScreen( Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens } - // Model + speed picker (bottom sheet), opened by the top pill + // Model picker (bottom sheet), opened by the top pill if (showModelSheet) { ModelSheet( models = models, selectedModelId = session.config?.model ?: "", favorites = session.favorites, - speed = session.config?.speed ?: ChatSpeed.Normal, onSelectModel = { session.setModel(it); haptics.tick() }, onToggleFavorite = { session.toggleFavorite(it) }, - onSelectSpeed = { session.setSpeed(it); haptics.tick() }, onDismiss = { showModelSheet = false }, ) } diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt index e75bd42..7b44a74 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -3,49 +3,27 @@ package dev.kaizen.app.chat import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.* +import androidx.compose.runtime.* 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.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.ui.theme.Amber +import dev.kaizen.app.haptics.rememberHaptics /** Human label + composite-id resolver for the top pill — falls back to a de-prefixed id. */ fun modelDisplayName(modelId: String, models: List): String = @@ -80,13 +58,12 @@ fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) } } -/** A logical group of models in the picker (Favorites, Vertex AI, then by publisher). */ +/** A logical group of models in the picker (Favorites, then by publisher/provider). */ private data class ModelGroup(val label: String, val models: List) private fun groupFor(m: KaizenModel): String = when { - m.provider == "vertex" -> "Vertex AI" - else -> m.id.substringBefore("/", "").ifEmpty { "Andere" } - .replaceFirstChar { it.uppercase() } + m.provider == "vertex" || m.id.startsWith("vertex:") -> "Vertex AI" + else -> "OpenRouter" } private fun buildGroups(models: List, favorites: Set, query: String): List { @@ -98,23 +75,37 @@ private fun buildGroups(models: List, favorites: Set, query val favs = filtered.filter { it.id in favorites } if (favs.isNotEmpty()) groups += ModelGroup("★ Favoriten", favs) - // Vertex first, then publishers alphabetically — favorites still appear in their home group too. + // Group models, sorting group labels (Vertex first, then OpenRouter) filtered.groupBy { groupFor(it) } .toSortedMap(compareBy({ if (it == "Vertex AI") "" else it.lowercase() })) .forEach { (label, list) -> groups += ModelGroup(label, list.sortedBy { it.name }) } return groups } +data class ProviderInfo( + val name: String, + val color: Color, +) + +fun getProviderInfo(modelId: String, provider: String?): ProviderInfo { + return when { + provider == "vertex" || modelId.startsWith("vertex:") -> { + ProviderInfo("Vertex AI", Color(0xFF6366F1)) // Indigo for Vertex AI + } + else -> { + ProviderInfo("OpenRouter", Color(0xFF10B981)) // Emerald Green for OpenRouter + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelSheet( models: List, selectedModelId: String, favorites: Set, - speed: ChatSpeed, onSelectModel: (String) -> Unit, onToggleFavorite: (String) -> Unit, - onSelectSpeed: (ChatSpeed) -> Unit, onDismiss: () -> Unit, ) { val cs = MaterialTheme.colorScheme @@ -124,50 +115,71 @@ fun ModelSheet( ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { - // --- Response speed segment --- + // --- Title --- Text( - "Geschwindigkeit", - color = cs.onSurfaceVariant, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp), + "Modell auswählen", + color = cs.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 14.dp, start = 4.dp), ) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - val options = ChatSpeed.entries - options.forEachIndexed { i, opt -> - SegmentedButton( - selected = speed == opt, - onClick = { onSelectSpeed(opt) }, - shape = SegmentedButtonDefaults.itemShape(i, options.size), - ) { Text(speedLabel(opt)) } + + // --- Custom Premium Glassmorphic Search --- + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(cs.surfaceVariant.copy(alpha = 0.4f)) + .border(1.dp, cs.onSurface.copy(alpha = 0.06f), RoundedCornerShape(16.dp)) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Rounded.Search, + contentDescription = null, + tint = cs.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + "Modell suchen…", + color = cs.onSurfaceVariant.copy(alpha = 0.5f), + fontSize = 14.sp + ) + } + BasicTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + cursorBrush = SolidColor(Amber), + textStyle = androidx.compose.ui.text.TextStyle( + color = cs.onBackground, + fontSize = 14.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } + if (query.isNotEmpty()) { + Icon( + Icons.Rounded.Close, + contentDescription = "Löschen", + tint = cs.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier + .size(18.dp) + .clickable { query = "" } + ) } } - Spacer(Modifier.size(16.dp)) - - // --- Search --- - TextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text("Modell suchen…") }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - ) - Spacer(Modifier.size(12.dp)) - LazyColumn(Modifier.fillMaxWidth().heightIn(max = 460.dp)) { + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 480.dp)) { if (groups.isEmpty()) { item { Text( - if (models.isEmpty()) "Modelle werden geladen…" else "Keine Treffer", + if (models.isEmpty()) "Verbindung wird hergestellt..." else "Keine Treffer", color = cs.onSurfaceVariant, fontSize = 14.sp, modifier = Modifier.padding(vertical = 24.dp), @@ -177,11 +189,12 @@ fun ModelSheet( groups.forEach { group -> item(key = "h_${group.label}") { Text( - group.label, - color = cs.onSurfaceVariant.copy(alpha = 0.6f), + group.label.uppercase(), + color = cs.onSurfaceVariant.copy(alpha = 0.5f), fontSize = 11.sp, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 14.dp, bottom = 6.dp, start = 4.dp), + letterSpacing = 0.8.sp, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp, start = 4.dp), ) } items(group.models, key = { "${group.label}_${it.id}" }) { model -> @@ -209,61 +222,88 @@ private fun ModelRow( onToggleFavorite: () -> Unit, ) { val cs = MaterialTheme.colorScheme + val haptics = rememberHaptics() + val providerInfo = remember(model.id, model.provider) { getProviderInfo(model.id, model.provider) } + Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) Amber.copy(alpha = 0.12f) else Color.Transparent) - .clickable { onClick() } - .padding(horizontal = 8.dp, vertical = 12.dp), + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + if (selected) { + Amber.copy(alpha = 0.08f) + } else { + cs.surfaceVariant.copy(alpha = 0.15f) + } + ) + .border( + width = 1.dp, + color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent, + shape = RoundedCornerShape(16.dp) + ) + .clickable { haptics.tick(); onClick() } + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Dynamic Colored Badge for Provider (Vertex AI or OpenRouter) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(providerInfo.color.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = providerInfo.name, + color = providerInfo.color, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ) + } + + Spacer(Modifier.size(12.dp)) + + // Model Name & Clean Technical ID + Column(modifier = Modifier.weight(1f)) { + Text( + model.name, + color = cs.onBackground, + fontSize = 15.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ) + Text( + text = model.id.substringAfter(":"), + color = cs.onSurfaceVariant.copy(alpha = 0.4f), + fontSize = 11.sp, + maxLines = 1, + ) + } + + // Sleek Star Toggle Button Box( Modifier .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .clickable { onToggleFavorite() }, + .clip(CircleShape) + .clickable { haptics.tick(); onToggleFavorite() }, contentAlignment = Alignment.Center, ) { Icon( - Icons.Rounded.Star, + imageVector = if (favorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, contentDescription = "Favorit", tint = if (favorite) Amber else cs.onSurfaceVariant.copy(alpha = 0.35f), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(20.dp), ) } - Spacer(Modifier.size(4.dp)) - Text( - model.name, - color = cs.onBackground, - fontSize = 15.sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - if (model.provider == "vertex") { - VertexBadge() - Spacer(Modifier.size(8.dp)) - } + if (selected) { - Icon(Icons.Rounded.Check, contentDescription = "Ausgewählt", tint = Amber, modifier = Modifier.size(20.dp)) + Spacer(Modifier.size(4.dp)) + Icon( + Icons.Rounded.Check, + contentDescription = "Ausgewählt", + tint = Amber, + modifier = Modifier.size(20.dp) + ) } } } - -@Composable -private fun VertexBadge() { - Box( - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(Amber.copy(alpha = 0.16f)) - .padding(horizontal = 7.dp, vertical = 2.dp), - ) { - Text("Vertex", color = Amber, fontSize = 10.sp, fontWeight = FontWeight.Bold) - } -} - -private fun speedLabel(s: ChatSpeed): String = when (s) { - ChatSpeed.Instant -> "Instant" - ChatSpeed.Normal -> "Normal" - ChatSpeed.Max -> "Max" -} diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt index 12b8831..66aa942 100644 --- a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt +++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString /** * Hardware-backed encrypted persistence for the bearer token + server config. @@ -55,6 +58,25 @@ class SecureStore(context: Context) { prefs.edit().putStringSet(KEY_FAVORITES, ids).apply() } + /** Caches the last successfully fetched model list locally for offline-first support. */ + fun loadModelsCache(): List? { + val jsonStr = prefs.getString(KEY_MODELS_CACHE, null) ?: return null + return try { + Json.decodeFromString>(jsonStr) + } catch (e: Exception) { + null + } + } + + fun saveModelsCache(models: List) { + try { + val jsonStr = Json.encodeToString(models) + prefs.edit().putString(KEY_MODELS_CACHE, jsonStr).apply() + } catch (e: Exception) { + // best-effort + } + } + fun clear() { prefs.edit().clear().apply() } @@ -66,5 +88,6 @@ class SecureStore(context: Context) { const val KEY_EMAIL = "email" const val KEY_SPEED = "speed" const val KEY_FAVORITES = "favorite_models" + const val KEY_MODELS_CACHE = "models_cache" } } diff --git a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt index 3a461b0..3dda110 100644 --- a/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/net/SessionViewModel.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.setValue * * Held at Activity scope (instantiated directly like SettingsViewModel) — no DI yet. */ -class SessionViewModel(private val store: SecureStore) { +class SessionViewModel(val store: SecureStore) { var config by mutableStateOf(store.load()) private set