* 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:
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user