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
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
// Model picker: catalog (best-effort fetch) + sheet visibility
var models by remember { mutableStateOf<List<KaizenModel>>(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 },
)
}

View file

@ -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<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 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<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 }
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<KaizenModel>,
selectedModelId: String,
favorites: Set<String>,
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"
}

View file

@ -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<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() {
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"
}
}

View file

@ -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