diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt new file mode 100644 index 00000000..c2b9a66e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt @@ -0,0 +1,100 @@ +package com.m57.hermescontrol.ui.model + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ModelScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val mockViewModel = mockk(relaxed = true) + private val uiStateFlow = MutableStateFlow(ModelUiState()) + + @Before + fun setUp() { + every { mockViewModel.uiState } returns uiStateFlow.asStateFlow() + } + + @Test + fun modelScreen_pinnedSectionHidden_whenNoPinnedModels() { + uiStateFlow.value = + ModelUiState( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + is_current = true, + is_user_defined = false, + models = listOf("gpt-4"), + total_models = 1, + source = null, + authenticated = true, + auth_type = null, + warning = null, + ), + ), + pinnedModels = emptyList(), + ) + + composeTestRule.setContent { + ModelScreen( + onOpenDrawer = {}, + viewModel = mockViewModel, + ) + } + + composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist() + } + + @Test + fun modelScreen_rendersPinnedModels_whenPresent() { + uiStateFlow.value = + ModelUiState( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + is_current = true, + is_user_defined = false, + models = listOf("gpt-4"), + total_models = 1, + source = null, + authenticated = true, + auth_type = null, + warning = null, + ), + ), + pinnedModels = + listOf( + PinnedModel(providerSlug = "openai", modelName = "gpt-4"), + ), + ) + + composeTestRule.setContent { + ModelScreen( + onOpenDrawer = {}, + viewModel = mockViewModel, + ) + } + + composeTestRule.onNodeWithText("Pinned Models").assertIsDisplayed() + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index d6d2add1..5ccc273c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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 { + val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList() + return try { + val type = + object : com.google.gson.reflect.TypeToken< + List, + >() {}.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) { + 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( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt index fe766d66..ded62f08 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt @@ -16,3 +16,8 @@ data class ModelProvider( val auth_type: String?, val warning: String?, ) + +data class PinnedModel( + val providerSlug: String, + val modelName: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 52e80cdc..9fd146fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -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) + }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 066465b3..cb065fbd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -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 = emptyList(), ) -class ModelViewModel : ViewModel(), ToastHost { +class ModelViewModel : + ViewModel(), + ToastHost { private val _uiState = MutableStateFlow(ModelUiState()) val uiState: StateFlow = _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) } + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd29de45..10bcb79c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -268,6 +268,10 @@ Available Models: No models reported or provider needs authentication. Active Model + Pinned Models + Pin model + Unpin model + Toggle pinned section visibility No history diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index da43fa30..7bbe8d93 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -88,6 +88,8 @@ class E2eIntegrationTest { every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.saveConnectionProfiles(any()) } returns Unit + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.savePinnedModels(any()) } returns Unit // Application stubs every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt new file mode 100644 index 00000000..dcd8596d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt @@ -0,0 +1,188 @@ +package com.m57.hermescontrol.ui.model + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.PinnedModel +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ModelViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private var storedPinnedModels: MutableList = mutableListOf() + + private fun createViewModel(): ModelViewModel { + val vm = ModelViewModel() + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + mockkObject(AuthManager) + + storedPinnedModels = mutableListOf() + + every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() } + every { AuthManager.savePinnedModels(any()) } answers { + storedPinnedModels = firstArg>().toMutableList() + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `init loads pinned models from AuthManager`() { + storedPinnedModels.add(PinnedModel("openai", "gpt-4")) + storedPinnedModels.add(PinnedModel("anthropic", "claude-3")) + + val viewModel = createViewModel() + val state = viewModel.uiState.value + + assertEquals(2, state.pinnedModels.size) + assertEquals("gpt-4", state.pinnedModels[0].modelName) + assertEquals("claude-3", state.pinnedModels[1].modelName) + } + + @Test + fun `init with empty pinned models`() { + val viewModel = createViewModel() + assertTrue(viewModel.uiState.value.pinnedModels.isEmpty()) + } + + @Test + fun `pinModel adds model to pinned list`() { + val viewModel = createViewModel() + + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(1, state.pinnedModels.size) + assertEquals(PinnedModel("openai", "gpt-4"), state.pinnedModels[0]) + } + + @Test + fun `pinModel prevents duplicate pins`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pinnedModels.size) + + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.pinnedModels.size) + } + + @Test + fun `pinModel persists to AuthManager`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.savePinnedModels(listOf(PinnedModel("openai", "gpt-4"))) } + } + + @Test + fun `unpinModel removes model from pinned list`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + viewModel.pinModel("anthropic", "claude-3") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(2, viewModel.uiState.value.pinnedModels.size) + + viewModel.unpinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(1, state.pinnedModels.size) + assertEquals(PinnedModel("anthropic", "claude-3"), state.pinnedModels[0]) + } + + @Test + fun `unpinModel does nothing for non-pinned model`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.unpinModel("openai", "nonexistent-model") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.pinnedModels.size) + } + + @Test + fun `unpinModel persists removal to AuthManager`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + viewModel.pinModel("anthropic", "claude-3") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.unpinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + + verify { AuthManager.savePinnedModels(listOf(PinnedModel("anthropic", "claude-3"))) } + } + + @Test + fun `pinModel shows toast when max cap reached`() { + val viewModel = createViewModel() + + // Pin 15 models to reach cap + for (i in 1..15) { + viewModel.pinModel("provider-$i", "model-$i") + } + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(15, viewModel.uiState.value.pinnedModels.size) + + // Try pinning one more + viewModel.pinModel("overflow", "too-many") + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(15, state.pinnedModels.size) + assertNull(state.pinnedModels.find { it.providerSlug == "overflow" }) + + // Toast should be set + val toastMessage = state.toastMessage + assertTrue(toastMessage?.contains("15") == true) + } + + @Test + fun `pin and unpin cycle works`() { + val viewModel = createViewModel() + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pinnedModels.size) + + viewModel.unpinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue(viewModel.uiState.value.pinnedModels.isEmpty()) + + // Re-pin same model after unpin + viewModel.pinModel("openai", "gpt-4") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pinnedModels.size) + } +}