feat: Redesign model selection sheet with OpenRouter/Vertex badging & offline-first caching

This commit is contained in:
Bruno Deanoz 2026-06-19 09:00:58 +02:00
parent ea5e625f0f
commit 7399c73e6a
4 changed files with 186 additions and 120 deletions

View file

@ -92,8 +92,8 @@ fun ChatScreen(
// Native sidebar drawer state // Native sidebar drawer state
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// Model picker: catalog (best-effort fetch) + sheet visibility // Model picker: catalog (loads instantly from local cache!) + sheet visibility
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) } var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
var showModelSheet by remember { mutableStateOf(false) } var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: the active server conversation + the sidebar list // Conversation persistence: the active server conversation + the sidebar list
@ -102,7 +102,12 @@ fun ChatScreen(
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect 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) 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 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) { if (showModelSheet) {
ModelSheet( ModelSheet(
models = models, models = models,
selectedModelId = session.config?.model ?: "", selectedModelId = session.config?.model ?: "",
favorites = session.favorites, favorites = session.favorites,
speed = session.config?.speed ?: ChatSpeed.Normal,
onSelectModel = { session.setModel(it); haptics.tick() }, onSelectModel = { session.setModel(it); haptics.tick() },
onToggleFavorite = { session.toggleFavorite(it) }, onToggleFavorite = { session.toggleFavorite(it) },
onSelectSpeed = { session.setSpeed(it); haptics.tick() },
onDismiss = { showModelSheet = false }, onDismiss = { showModelSheet = false },
) )
} }

View file

@ -3,49 +3,27 @@ package dev.kaizen.app.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.*
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material3.*
import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.*
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.KaizenModel import dev.kaizen.app.net.KaizenModel
import dev.kaizen.app.ui.theme.Amber 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. */ /** Human label + composite-id resolver for the top pill — falls back to a de-prefixed id. */
fun modelDisplayName(modelId: String, models: List<KaizenModel>): String = fun modelDisplayName(modelId: String, models: List<KaizenModel>): 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<KaizenModel>) private data class ModelGroup(val label: String, val models: List<KaizenModel>)
private fun groupFor(m: KaizenModel): String = when { private fun groupFor(m: KaizenModel): String = when {
m.provider == "vertex" -> "Vertex AI" m.provider == "vertex" || m.id.startsWith("vertex:") -> "Vertex AI"
else -> m.id.substringBefore("/", "").ifEmpty { "Andere" } else -> "OpenRouter"
.replaceFirstChar { it.uppercase() }
} }
private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query: String): List<ModelGroup> { private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query: String): List<ModelGroup> {
@ -98,23 +75,37 @@ private fun buildGroups(models: List<KaizenModel>, favorites: Set<String>, query
val favs = filtered.filter { it.id in favorites } val favs = filtered.filter { it.id in favorites }
if (favs.isNotEmpty()) groups += ModelGroup("★ Favoriten", favs) 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) } filtered.groupBy { groupFor(it) }
.toSortedMap(compareBy({ if (it == "Vertex AI") "" else it.lowercase() })) .toSortedMap(compareBy({ if (it == "Vertex AI") "" else it.lowercase() }))
.forEach { (label, list) -> groups += ModelGroup(label, list.sortedBy { it.name }) } .forEach { (label, list) -> groups += ModelGroup(label, list.sortedBy { it.name }) }
return groups 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ModelSheet( fun ModelSheet(
models: List<KaizenModel>, models: List<KaizenModel>,
selectedModelId: String, selectedModelId: String,
favorites: Set<String>, favorites: Set<String>,
speed: ChatSpeed,
onSelectModel: (String) -> Unit, onSelectModel: (String) -> Unit,
onToggleFavorite: (String) -> Unit, onToggleFavorite: (String) -> Unit,
onSelectSpeed: (ChatSpeed) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
@ -124,50 +115,71 @@ fun ModelSheet(
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
// --- Response speed segment --- // --- Title ---
Text( Text(
"Geschwindigkeit", "Modell auswählen",
color = cs.onSurfaceVariant, color = cs.onBackground,
fontSize = 12.sp, fontSize = 18.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 14.dp, start = 4.dp),
) )
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
val options = ChatSpeed.entries // --- Custom Premium Glassmorphic Search ---
options.forEachIndexed { i, opt -> Row(
SegmentedButton( modifier = Modifier
selected = speed == opt, .fillMaxWidth()
onClick = { onSelectSpeed(opt) }, .clip(RoundedCornerShape(16.dp))
shape = SegmentedButtonDefaults.itemShape(i, options.size), .background(cs.surfaceVariant.copy(alpha = 0.4f))
) { Text(speedLabel(opt)) } .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)) Spacer(Modifier.size(12.dp))
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 460.dp)) { LazyColumn(Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
if (groups.isEmpty()) { if (groups.isEmpty()) {
item { item {
Text( Text(
if (models.isEmpty()) "Modelle werden geladen…" else "Keine Treffer", if (models.isEmpty()) "Verbindung wird hergestellt..." else "Keine Treffer",
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(vertical = 24.dp), modifier = Modifier.padding(vertical = 24.dp),
@ -177,11 +189,12 @@ fun ModelSheet(
groups.forEach { group -> groups.forEach { group ->
item(key = "h_${group.label}") { item(key = "h_${group.label}") {
Text( Text(
group.label, group.label.uppercase(),
color = cs.onSurfaceVariant.copy(alpha = 0.6f), color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, 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 -> items(group.models, key = { "${group.label}_${it.id}" }) { model ->
@ -209,61 +222,88 @@ private fun ModelRow(
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val haptics = rememberHaptics()
val providerInfo = remember(model.id, model.provider) { getProviderInfo(model.id, model.provider) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .padding(vertical = 4.dp)
.background(if (selected) Amber.copy(alpha = 0.12f) else Color.Transparent) .clip(RoundedCornerShape(16.dp))
.clickable { onClick() } .background(
.padding(horizontal = 8.dp, vertical = 12.dp), 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, 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( Box(
Modifier Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(10.dp)) .clip(CircleShape)
.clickable { onToggleFavorite() }, .clickable { haptics.tick(); onToggleFavorite() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
Icons.Rounded.Star, imageVector = if (favorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = "Favorit", contentDescription = "Favorit",
tint = if (favorite) Amber else cs.onSurfaceVariant.copy(alpha = 0.35f), 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) { 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"
}

View file

@ -4,6 +4,9 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey 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. * 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() prefs.edit().putStringSet(KEY_FAVORITES, ids).apply()
} }
/** Caches the last successfully fetched model list locally for offline-first support. */
fun loadModelsCache(): List<KaizenModel>? {
val jsonStr = prefs.getString(KEY_MODELS_CACHE, null) ?: return null
return try {
Json.decodeFromString<List<KaizenModel>>(jsonStr)
} catch (e: Exception) {
null
}
}
fun saveModelsCache(models: List<KaizenModel>) {
try {
val jsonStr = Json.encodeToString(models)
prefs.edit().putString(KEY_MODELS_CACHE, jsonStr).apply()
} catch (e: Exception) {
// best-effort
}
}
fun clear() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
@ -66,5 +88,6 @@ class SecureStore(context: Context) {
const val KEY_EMAIL = "email" const val KEY_EMAIL = "email"
const val KEY_SPEED = "speed" const val KEY_SPEED = "speed"
const val KEY_FAVORITES = "favorite_models" const val KEY_FAVORITES = "favorite_models"
const val KEY_MODELS_CACHE = "models_cache"
} }
} }

View file

@ -12,7 +12,7 @@ import androidx.compose.runtime.setValue
* *
* Held at Activity scope (instantiated directly like SettingsViewModel) no DI yet. * 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()) var config by mutableStateOf(store.load())
private set private set