ui: input bar reasoning chip tweaks (#673)
* ui: input bar reasoning chip tweaks - Remove emoji from reasoning level labels (dropdown + chip text) - Reduce reasoning chip horizontal padding (8dp -> 4dp) - Move reasoning chip to right side (after spacer, next to mic) - Increase model chip minimum width (120dp -> 140dp) - Remove unused width import * fix(ui): add missing widthIn import in ComposerToolbar Resolves 'Unresolved reference widthIn' compile error at ComposerToolbar.kt:95 that broke CI (unit-tests, android-lint, instrumented-tests all fail on compileDebugKotlin). * ui(chat): enhance input bar layout, reasoning menu header, and card shadow Give ChatInputBar a floating rounded container with shadow contrast. Make model chip take available space and add header to reasoning menu. * fix(ui): remove trailing blank line in ComposerToolbar for ktlint Fix standard:no-consecutive-blank-lines ktlint violation. * ui(model): enhance ModelPickerDialog with rounded cards and provider badges Apply card styling, rounded corners (24dp dialog, 12dp cards), provider badges, and shadow contrast to ModelPickerDialog. * feat(model): add in-dialog model pinning to ModelPickerDialog Add pin/unpin icon buttons to model cards in ModelPickerDialog across both ModelScreen and ChatScreen. * ui(model): remove provider badge chip and eliminate bottom whitespace in ModelPickerDialog Replace bottom Cancel button with top-right Close icon to eliminate bottom whitespace. Remove inline provider badge chip from model cards. * perf(model): optimize ModelPickerDialog load time with lazy items and O(1) set lookup Lazy-register individual model items in LazyColumn and memoize pinnedSet to prevent synchronous rendering lag when opening dialog. --------- Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
56de3323f0
commit
0bb418b4a7
7 files changed
+255
-86
No files matched your search
@@ -563,6 +563,7 @@ fun ChatScreen(
|
|||||||
title = "Switch model (this chat)",
|
title = "Switch model (this chat)",
|
||||||
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
|
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
|
||||||
pinnedModels = state.modelPickerPinned,
|
pinnedModels = state.modelPickerPinned,
|
||||||
|
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||||
onSelect = { provider, model ->
|
onSelect = { provider, model ->
|
||||||
viewModel.sendSlashModel(provider, model)
|
viewModel.sendSlashModel(provider, model)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1286,6 +1286,21 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun togglePinModel(
|
||||||
|
providerSlug: String,
|
||||||
|
modelName: String,
|
||||||
|
) {
|
||||||
|
val currentPinned = AuthManager.getPinnedModels().toMutableList()
|
||||||
|
val target = PinnedModel(providerSlug, modelName)
|
||||||
|
if (currentPinned.contains(target)) {
|
||||||
|
currentPinned.remove(target)
|
||||||
|
} else {
|
||||||
|
currentPinned.add(target)
|
||||||
|
}
|
||||||
|
AuthManager.savePinnedModels(currentPinned)
|
||||||
|
_uiState.update { it.copy(modelPickerPinned = currentPinned) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hot-swap the CURRENT session's model via the /model slash command.
|
* Hot-swap the CURRENT session's model via the /model slash command.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -105,10 +105,19 @@ fun ChatInputBar(
|
|||||||
exit = slideOutVertically(targetOffsetY = { it }),
|
exit = slideOutVertically(targetOffsetY = { it }),
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 3.dp,
|
border =
|
||||||
shadowElevation = 0.dp,
|
BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||||
|
),
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import androidx.compose.foundation.horizontalScroll
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
@@ -17,6 +16,7 @@ import androidx.compose.material.icons.filled.Stop
|
|||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
/**
|
/**
|
||||||
* Bottom toolbar row for the chat composer.
|
* Bottom toolbar row for the chat composer.
|
||||||
*
|
*
|
||||||
* Layout: [📎 attach] [model chip] [reasoning chip] [←spacer→] [🎙 mic]
|
* Layout: [📎 attach] [model chip] [←spacer→] [🧠 reasoning] [🎙 mic]
|
||||||
*
|
*
|
||||||
* The reasoning chip opens a dropdown menu to pick a level (instead of cycling).
|
* The reasoning chip opens a dropdown menu to pick a level (instead of cycling).
|
||||||
*/
|
*/
|
||||||
@@ -76,7 +76,7 @@ fun ComposerToolbar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model chip — fixed width, text scrolls if too long
|
// Model chip — takes available space, fixed height
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentSessionModel != null,
|
selected = currentSessionModel != null,
|
||||||
onClick = onModelTap,
|
onClick = onModelTap,
|
||||||
@@ -93,11 +93,12 @@ fun ComposerToolbar(
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.width(120.dp)
|
.weight(1f)
|
||||||
|
.height(28.dp)
|
||||||
.testTag("model_chip"),
|
.testTag("model_chip"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reasoning chip with dropdown menu
|
// Reasoning chip with dropdown menu (right side, next to mic)
|
||||||
Box {
|
Box {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = reasoningLevel != null,
|
selected = reasoningLevel != null,
|
||||||
@@ -110,23 +111,33 @@ fun ComposerToolbar(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.testTag("reasoning_chip"),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.height(28.dp)
|
||||||
|
.testTag("reasoning_chip"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showReasoningMenu,
|
expanded = showReasoningMenu,
|
||||||
onDismissRequest = { showReasoningMenu = false },
|
onDismissRequest = { showReasoningMenu = false },
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reasoning",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
val allLevels =
|
val allLevels =
|
||||||
listOf(
|
listOf(
|
||||||
"none" to "🧠 None",
|
"none" to "None",
|
||||||
"minimal" to "🧠 Minimal",
|
"minimal" to "Minimal",
|
||||||
"low" to "🧠 Low",
|
"low" to "Low",
|
||||||
"medium" to "🧠 Med",
|
"medium" to "Med",
|
||||||
"high" to "🧠 High",
|
"high" to "High",
|
||||||
"xhigh" to "🧠 XHigh",
|
"xhigh" to "XHigh",
|
||||||
"max" to "🧠 Max",
|
"max" to "Max",
|
||||||
"ultra" to "🧠 Ultra",
|
"ultra" to "Ultra",
|
||||||
)
|
)
|
||||||
allLevels.forEach { (level, label) ->
|
allLevels.forEach { (level, label) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -156,8 +167,6 @@ fun ComposerToolbar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Mic / Stop button
|
// Mic / Stop button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onMicTap,
|
onClick = onMicTap,
|
||||||
@@ -189,19 +198,19 @@ fun ComposerToolbar(
|
|||||||
*
|
*
|
||||||
* @param level One of: "none", "minimal", "low", "medium", "high",
|
* @param level One of: "none", "minimal", "low", "medium", "high",
|
||||||
* "xhigh", "max", "ultra", or null for model default.
|
* "xhigh", "max", "ultra", or null for model default.
|
||||||
* @return Display string such as "🧠 None", "🧠 Low", "🧠 XHigh", "🧠 Ultra", etc.
|
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
|
||||||
*/
|
*/
|
||||||
private fun buildReasoningLabel(level: String?): String {
|
private fun buildReasoningLabel(level: String?): String {
|
||||||
return when (level) {
|
return when (level) {
|
||||||
null -> "🧠 Med"
|
null -> "Med"
|
||||||
"none" -> "🧠 None"
|
"none" -> "None"
|
||||||
"minimal" -> "🧠 Minimal"
|
"minimal" -> "Minimal"
|
||||||
"low" -> "🧠 Low"
|
"low" -> "Low"
|
||||||
"medium" -> "🧠 Med"
|
"medium" -> "Med"
|
||||||
"high" -> "🧠 High"
|
"high" -> "High"
|
||||||
"xhigh" -> "🧠 XHigh"
|
"xhigh" -> "XHigh"
|
||||||
"max" -> "🧠 Max"
|
"max" -> "Max"
|
||||||
"ultra" -> "🧠 Ultra"
|
"ultra" -> "Ultra"
|
||||||
else -> "🧠 $level"
|
else -> level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +242,8 @@ fun ModelScreen(
|
|||||||
providers = state.providers,
|
providers = state.providers,
|
||||||
title = "Set Main Model",
|
title = "Set Main Model",
|
||||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||||
|
pinnedModels = state.pinnedModels,
|
||||||
|
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||||
onSelect = { provider, model ->
|
onSelect = { provider, model ->
|
||||||
viewModel.setMainModel(provider, model)
|
viewModel.setMainModel(provider, model)
|
||||||
},
|
},
|
||||||
@@ -266,6 +268,8 @@ fun ModelScreen(
|
|||||||
providers = state.providers,
|
providers = state.providers,
|
||||||
title = "Set Aux: ${state.auxPickerTask}",
|
title = "Set Aux: ${state.auxPickerTask}",
|
||||||
isLoading = state.isLoading && state.providers.isEmpty(),
|
isLoading = state.isLoading && state.providers.isEmpty(),
|
||||||
|
pinnedModels = state.pinnedModels,
|
||||||
|
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
|
||||||
onSelect = { provider, model ->
|
onSelect = { provider, model ->
|
||||||
viewModel.setAuxTask(state.auxPickerTask, provider, model)
|
viewModel.setAuxTask(state.auxPickerTask, provider, model)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -524,4 +524,16 @@ class ModelViewModel :
|
|||||||
_uiState.update { it.copy(pinnedModels = currentPinned) }
|
_uiState.update { it.copy(pinnedModels = currentPinned) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun togglePinModel(
|
||||||
|
providerSlug: String,
|
||||||
|
modelName: String,
|
||||||
|
) {
|
||||||
|
val target = PinnedModel(providerSlug, modelName)
|
||||||
|
if (_uiState.value.pinnedModels.contains(target)) {
|
||||||
|
unpinModel(providerSlug, modelName)
|
||||||
|
} else {
|
||||||
|
pinModel(providerSlug, modelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
package com.m57.hermescontrol.ui.model.components
|
package com.m57.hermescontrol.ui.model.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.outlined.PushPin
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -25,7 +31,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
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.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
@@ -40,6 +48,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
|
|||||||
*
|
*
|
||||||
* [pinnedModels] renders a pinned section at the top (mirrors the global model
|
* [pinnedModels] renders a pinned section at the top (mirrors the global model
|
||||||
* screen's pin section) so frequently-used models are one tap away.
|
* screen's pin section) so frequently-used models are one tap away.
|
||||||
|
* [onPinToggle] allows pinning and unpinning models directly inside the dialog.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ModelPickerDialog(
|
fun ModelPickerDialog(
|
||||||
@@ -47,6 +56,7 @@ fun ModelPickerDialog(
|
|||||||
title: String,
|
title: String,
|
||||||
isLoading: Boolean = false,
|
isLoading: Boolean = false,
|
||||||
pinnedModels: List<PinnedModel> = emptyList(),
|
pinnedModels: List<PinnedModel> = emptyList(),
|
||||||
|
onPinToggle: ((provider: String, model: String) -> Unit)? = null,
|
||||||
onSelect: (provider: String, model: String) -> Unit,
|
onSelect: (provider: String, model: String) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -54,7 +64,33 @@ fun ModelPickerDialog(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(title) },
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -81,21 +117,47 @@ fun ModelPickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pinnedSet =
|
||||||
|
remember(pinnedModels) {
|
||||||
|
pinnedModels.map { "${it.providerSlug}:${it.modelName}" }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredProvidersWithModels =
|
||||||
|
remember(pickerQuery, providers) {
|
||||||
|
providers.mapNotNull { provider ->
|
||||||
|
val matchingModels =
|
||||||
|
provider.models.orEmpty().filter { model ->
|
||||||
|
pickerQuery.isBlank() ||
|
||||||
|
provider.name.contains(pickerQuery, ignoreCase = true) ||
|
||||||
|
provider.slug.contains(pickerQuery, ignoreCase = true) ||
|
||||||
|
model.contains(pickerQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (matchingModels.isNotEmpty()) {
|
||||||
|
provider to matchingModels
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = pickerQuery,
|
query = pickerQuery,
|
||||||
onQueryChange = { pickerQuery = it },
|
onQueryChange = { pickerQuery = it },
|
||||||
placeholder = "Search models and providers...",
|
placeholder = "Search models and providers...",
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.height(400.dp)) {
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 420.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
// ── Pinned section ──
|
// ── Pinned section ──
|
||||||
if (filteredPinned.isNotEmpty()) {
|
if (filteredPinned.isNotEmpty()) {
|
||||||
item(key = "pinned-header") {
|
item(key = "pinned-header") {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp),
|
modifier = Modifier.padding(top = 4.dp, bottom = 6.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.PushPin,
|
imageVector = Icons.Filled.PushPin,
|
||||||
@@ -105,8 +167,9 @@ fun ModelPickerDialog(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Pinned",
|
text = "Pinned",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,66 +177,122 @@ fun ModelPickerDialog(
|
|||||||
filteredPinned,
|
filteredPinned,
|
||||||
key = { "pinned:${it.providerSlug}:${it.modelName}" },
|
key = { "pinned:${it.providerSlug}:${it.modelName}" },
|
||||||
) { pinned ->
|
) { pinned ->
|
||||||
OutlinedButton(
|
ModelItemCard(
|
||||||
|
modelName = pinned.modelName,
|
||||||
|
isPinned = true,
|
||||||
|
onPinToggle =
|
||||||
|
if (onPinToggle != null) {
|
||||||
|
{ onPinToggle(pinned.providerSlug, pinned.modelName) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
onClick = { onSelect(pinned.providerSlug, pinned.modelName) },
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
)
|
||||||
contentPadding =
|
|
||||||
androidx.compose.foundation.layout
|
|
||||||
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${pinned.modelName} · ${pinned.providerSlug}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── All providers / models ──
|
// ── All providers / models (lazy item per model) ──
|
||||||
items(
|
filteredProvidersWithModels.forEach { (provider, models) ->
|
||||||
providers.filter { provider ->
|
item(key = "header:${provider.slug}") {
|
||||||
pickerQuery.isBlank() ||
|
Row(
|
||||||
provider.name.contains(pickerQuery, ignoreCase = true) ||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
provider.slug.contains(pickerQuery, ignoreCase = true) ||
|
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
|
||||||
provider.models.orEmpty().any {
|
) {
|
||||||
it.contains(pickerQuery, ignoreCase = true)
|
Text(
|
||||||
}
|
text = provider.name,
|
||||||
},
|
style = MaterialTheme.typography.labelLarge,
|
||||||
key = { it.slug },
|
fontWeight = FontWeight.Bold,
|
||||||
) { provider ->
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
val models =
|
)
|
||||||
provider.models.orEmpty().filter {
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
pickerQuery.isBlank() ||
|
Text(
|
||||||
it.contains(pickerQuery, ignoreCase = true)
|
text = "${models.size} models",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (models.isNotEmpty()) {
|
items(
|
||||||
Text(
|
items = models,
|
||||||
text = provider.name,
|
key = { model -> "${provider.slug}:$model" },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) { model ->
|
||||||
fontWeight = FontWeight.Bold,
|
val isPinned = "${provider.slug}:$model" in pinnedSet
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
ModelItemCard(
|
||||||
|
modelName = model,
|
||||||
|
isPinned = isPinned,
|
||||||
|
onPinToggle =
|
||||||
|
if (onPinToggle != null) {
|
||||||
|
{ onPinToggle(provider.slug, model) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(provider.slug, model) },
|
||||||
)
|
)
|
||||||
|
|
||||||
models.forEach { model ->
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { onSelect(provider.slug, model) },
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
contentPadding =
|
|
||||||
androidx.compose.foundation.layout
|
|
||||||
.PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
|
||||||
) {
|
|
||||||
Text(text = model, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {},
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModelItemCard(
|
||||||
|
modelName: String,
|
||||||
|
isPinned: Boolean,
|
||||||
|
onPinToggle: (() -> Unit)?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
border =
|
||||||
|
BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = modelName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (onPinToggle != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onPinToggle,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin,
|
||||||
|
contentDescription = if (isPinned) "Unpin model" else "Pin model",
|
||||||
|
tint =
|
||||||
|
if (isPinned) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user