feature: support filtering in Model Management screen

This commit is contained in:
Han Yin 2025-04-21 15:07:33 -07:00
parent d97e28a6d8
commit dd0367b970
5 changed files with 121 additions and 54 deletions

View File

@ -228,6 +228,8 @@ fun AppContent(
val isMultiSelectionMode by modelsManagementViewModel.isMultiSelectionMode.collectAsState()
val selectedModels by modelsManagementViewModel.selectedModels.collectAsState()
val showSortMenu by modelsManagementViewModel.showSortMenu.collectAsState()
val activeFilters by modelsManagementViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsManagementViewModel.showFilterMenu.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models
@ -246,7 +248,12 @@ fun AppContent(
}
),
filtering = BottomBarConfig.ModelsManagement.FilteringConfig(
onClick = { /* TODO: implement filtering */ },
isActive = activeFilters.any { it.value },
filters = activeFilters,
onToggleFilter = modelsManagementViewModel::toggleFilter,
onClearFilters = modelsManagementViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsManagementViewModel::toggleFilterMenu
),
selection = BottomBarConfig.ModelsManagement.SelectionConfig(
isActive = isMultiSelectionMode,

View File

@ -17,13 +17,13 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
@ -101,7 +101,12 @@ sealed class BottomBarConfig {
)
data class FilteringConfig(
val onClick: () -> Unit
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class SelectionConfig(
@ -271,20 +276,6 @@ fun ModelsManagementBottomBar(
actions = {
if (selection.isActive) {
/* Multi-selection mode actions */
IconButton(onClick = { selection.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(
onClick = selection.deleteSelected,
enabled = selection.selectedModels.isNotEmpty()
@ -299,9 +290,30 @@ fun ModelsManagementBottomBar(
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { selection.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
} else {
/* Default mode actions */
// Multi-selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models"
)
}
// Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon(
@ -339,20 +351,47 @@ fun ModelsManagementBottomBar(
}
// Filtering action
IconButton(
onClick = filtering.onClick
) {
IconButton(onClick = { filtering.toggleMenu(true) }) {
Icon(
imageVector = Icons.Default.FilterAlt,
imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Delete models"
// Filter dropdown menu
DropdownMenu(
expanded = filtering.isMenuVisible,
onDismissRequest = { filtering.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filtering.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}

View File

@ -53,7 +53,7 @@ fun ModelsManagementScreen(
viewModel: ModelsManagementViewModel,
) {
// ViewModel states
val sortedModels by viewModel.sortedModels.collectAsState()
val filteredModels by viewModel.filteredModels.collectAsState()
val managementState by viewModel.managementState.collectAsState()
// Selection state from ViewModel
@ -81,7 +81,7 @@ fun ModelsManagementScreen(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = sortedModels, key = { it.id }) { model ->
items(items = filteredModels, key = { it.id }) { model ->
val isSelected = if (isMultiSelectionMode) selectedModels.contains(model.id) else null
ModelCardFullExpandable(

View File

@ -63,7 +63,7 @@ class ModelSelectionViewModel @Inject constructor(
_showSortMenu.value = visible
}
// UI state: filters
// UI state: filter menu
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false }
)

View File

@ -5,8 +5,11 @@ import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.llama.revamp.data.model.ModelFilter
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.data.model.ModelSortOrder
import com.example.llama.revamp.data.model.filterBy
import com.example.llama.revamp.data.model.sortByOrder
import com.example.llama.revamp.data.repository.InsufficientStorageException
import com.example.llama.revamp.data.repository.ModelRepository
import com.example.llama.revamp.util.getFileNameFromUri
@ -17,15 +20,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import javax.inject.Inject
import kotlin.collections.set
@HiltViewModel
class ModelsManagementViewModel @Inject constructor(
@ -33,16 +36,9 @@ class ModelsManagementViewModel @Inject constructor(
private val modelRepository: ModelRepository
) : ViewModel() {
// Data: available models
private val _availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = emptyList()
)
private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
// Data: models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
// UI state: multi-selection mode
private val _isMultiSelectionMode = MutableStateFlow(false)
@ -61,7 +57,7 @@ class ModelsManagementViewModel @Inject constructor(
fun toggleModelSelectionById(modelId: String) {
val current = _selectedModels.value.toMutableMap()
val model = _sortedModels.value.find { it.id == modelId }
val model = _filteredModels.value.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
@ -75,7 +71,7 @@ class ModelsManagementViewModel @Inject constructor(
fun toggleAllSelection(selectAll: Boolean) {
if (selectAll) {
_selectedModels.value = _sortedModels.value.associateBy { it.id }
_selectedModels.value = _filteredModels.value.associateBy { it.id }
} else {
_selectedModels.value = emptyMap()
}
@ -96,6 +92,33 @@ class ModelsManagementViewModel @Inject constructor(
_showSortMenu.value = show
}
// UI state: filters
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false }
)
val activeFilters: StateFlow<Map<ModelFilter, Boolean>> = _activeFilters.asStateFlow()
fun toggleFilter(filter: ModelFilter, enabled: Boolean) {
_activeFilters.update { current ->
current.toMutableMap().apply {
this[filter] = enabled
}
}
}
fun clearFilters() {
_activeFilters.update { current ->
current.mapValues { false }
}
}
private val _showFilterMenu = MutableStateFlow(false)
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
@ -106,18 +129,16 @@ class ModelsManagementViewModel @Inject constructor(
init {
viewModelScope.launch {
combine(_availableModels, _sortOrder, ::sortModels)
.collect { _sortedModels.value = it }
combine(
modelRepository.getModels(),
_activeFilters,
_sortOrder,
) { models, filters, sortOrder ->
models.filterBy(filters).sortByOrder(sortOrder)
}.collectLatest {
_filteredModels.value = it
}
}
private fun sortModels(models: List<ModelInfo>, order: ModelSortOrder) =
when (order) {
ModelSortOrder.NAME_ASC -> models.sortedBy { it.name }
ModelSortOrder.NAME_DESC -> models.sortedByDescending { it.name }
ModelSortOrder.SIZE_ASC -> models.sortedBy { it.sizeInBytes }
ModelSortOrder.SIZE_DESC -> models.sortedByDescending { it.sizeInBytes }
ModelSortOrder.LAST_USED -> models.sortedByDescending { it.dateLastUsed ?: 0 }
}
// Internal state