feat: Redesign model selection sheet with OpenRouter/Vertex badging & offline-first caching
This commit is contained in:
parent
ea5e625f0f
commit
7399c73e6a
4 changed files with 186 additions and 120 deletions
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
options.forEachIndexed { i, opt ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = speed == opt,
|
|
||||||
onClick = { onSelectSpeed(opt) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(i, options.size),
|
|
||||||
) { Text(speedLabel(opt)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.size(16.dp))
|
// --- Custom Premium Glassmorphic Search ---
|
||||||
|
Row(
|
||||||
// --- Search ---
|
modifier = Modifier
|
||||||
TextField(
|
.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,
|
value = query,
|
||||||
onValueChange = { query = it },
|
onValueChange = { query = it },
|
||||||
placeholder = { Text("Modell suchen…") },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
cursorBrush = SolidColor(Amber),
|
||||||
shape = RoundedCornerShape(14.dp),
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
colors = TextFieldDefaults.colors(
|
color = cs.onBackground,
|
||||||
focusedIndicatorColor = Color.Transparent,
|
fontSize = 14.sp
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
disabledIndicatorColor = Color.Transparent,
|
|
||||||
),
|
),
|
||||||
|
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(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(
|
Box(
|
||||||
Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.background(providerInfo.color.copy(alpha = 0.15f))
|
||||||
.clickable { onToggleFavorite() },
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Text(
|
||||||
Icons.Rounded.Star,
|
text = providerInfo.name,
|
||||||
contentDescription = "Favorit",
|
color = providerInfo.color,
|
||||||
tint = if (favorite) Amber else cs.onSurfaceVariant.copy(alpha = 0.35f),
|
fontSize = 10.sp,
|
||||||
modifier = Modifier.size(18.dp),
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.size(4.dp))
|
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
|
||||||
|
// Model Name & Clean Technical ID
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
model.name,
|
model.name,
|
||||||
color = cs.onBackground,
|
color = cs.onBackground,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
|
Text(
|
||||||
|
text = model.id.substringAfter(":"),
|
||||||
|
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
// Sleek Star Toggle Button
|
||||||
private fun VertexBadge() {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.size(36.dp)
|
||||||
.background(Amber.copy(alpha = 0.16f))
|
.clip(CircleShape)
|
||||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
.clickable { haptics.tick(); onToggleFavorite() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text("Vertex", color = Amber, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
Icon(
|
||||||
}
|
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(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun speedLabel(s: ChatSpeed): String = when (s) {
|
if (selected) {
|
||||||
ChatSpeed.Instant -> "Instant"
|
Spacer(Modifier.size(4.dp))
|
||||||
ChatSpeed.Normal -> "Normal"
|
Icon(
|
||||||
ChatSpeed.Max -> "Max"
|
Icons.Rounded.Check,
|
||||||
|
contentDescription = "Ausgewählt",
|
||||||
|
tint = Amber,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue