feat(#365): add pin support for models with pinned section at top (#414)

* feat(#365): add pin support for models with pinned section at top

* fix(#365): stub Pinned Models methods in E2eIntegrationTest

* fix(#365): use state flow for pin/unpin, add max cap, add ViewModel tests

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-28 02:23:25 +03:00
1 parent 42a67bffa9
commit 4745728920
8 files changed
+630 -10

No files matched your search

@@ -40,6 +40,7 @@ object AuthManager {
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
private const val KEY_THEME_PRESET = "theme_preset"
private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode"
private const val KEY_PINNED_MODELS = "pinned_models"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -180,6 +181,29 @@ object AuthManager {
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
}
// ── Pinned Models ────────────────────────────────────────────────────
fun getPinnedModels(): List<com.m57.hermescontrol.data.model.PinnedModel> {
val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList()
return try {
val type =
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.PinnedModel>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse pinned models", e)
}
emptyList()
}
}
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) {
val json = gson.toJson(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
}
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
fun setProfileToken(
@@ -16,3 +16,8 @@ data class ModelProvider(
val auth_type: String?,
val warning: String?,
)
data class PinnedModel(
val providerSlug: String,
val modelName: String,
)
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.model
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -11,14 +12,20 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -26,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -116,6 +124,205 @@ fun ModelScreen(
placeholder = "Search models...",
)
}
if (state.pinnedModels.isNotEmpty()) {
item {
var isPinnedSectionExpanded by rememberSaveable { mutableStateOf(true) }
val filteredPinned =
remember(query, state.pinnedModels) {
if (query.isBlank()) {
state.pinnedModels
} else {
state.pinnedModels.filter {
it.modelName.contains(query, ignoreCase = true) ||
it.providerSlug.contains(query, ignoreCase = true)
}
}
}
if (filteredPinned.isNotEmpty()) {
Card(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize()
.padding(bottom = 8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f,
),
),
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.model_section_pinned),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
IconButton(
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Icon(
imageVector =
if (isPinnedSectionExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
R.string.content_desc_pinned_section_toggle,
),
)
}
}
AnimatedVisibility(visible = isPinnedSectionExpanded) {
Column(modifier = Modifier.padding(top = 8.dp)) {
filteredPinned.forEach { pinned ->
val isActive =
state.activeProfile?.provider == pinned.providerSlug &&
state.activeProfile?.model == pinned.modelName
Card(
onClick = {
viewModel.selectModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme
.primaryContainer
.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = pinned.modelName,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme
.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = pinned.providerSlug,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = {
viewModel.unpinModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription =
stringResource(
R.string.content_desc_unpin_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
}
}
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
@@ -268,15 +475,61 @@ fun ModelScreen(
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
val isPinned =
state.pinnedModels.any {
it.providerSlug == provider.slug &&
it.modelName == model
}
IconButton(
onClick = {
if (isPinned) {
viewModel.unpinModel(provider.slug, model)
} else {
viewModel.pinModel(provider.slug, model)
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector =
if (isPinned) {
Icons.Filled.PushPin
} else {
Icons.Outlined.PushPin
},
contentDescription =
stringResource(
if (isPinned) {
R.string.content_desc_unpin_model
} else {
R.string.content_desc_pin_model
},
),
tint =
if (isPinned) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme
.onSurfaceVariant
.copy(alpha = 0.6f)
},
)
}
}
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -24,12 +26,19 @@ data class ModelUiState(
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
)
class ModelViewModel : ViewModel(), ToastHost {
class ModelViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
}
fun loadModelOptions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
@@ -70,6 +79,7 @@ class ModelViewModel : ViewModel(), ToastHost {
isLoading = false,
providers = responseResult.data.providers.orEmpty(),
activeProfile = activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
@@ -139,4 +149,38 @@ class ModelViewModel : ViewModel(), ToastHost {
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
companion object {
private const val MAX_PINNED_MODELS = 15
}
fun pinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val newPin = PinnedModel(providerSlug, modelName)
if (currentPinned.contains(newPin)) return
if (currentPinned.size >= MAX_PINNED_MODELS) {
_uiState.update {
it.copy(toastMessage = "Maximum of $MAX_PINNED_MODELS pinned models reached")
}
return
}
currentPinned.add(newPin)
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
fun unpinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val pinToRemove = PinnedModel(providerSlug, modelName)
if (currentPinned.remove(pinToRemove)) {
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
}
}