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 isMultiSelectionMode by modelsManagementViewModel.isMultiSelectionMode.collectAsState()
val selectedModels by modelsManagementViewModel.selectedModels.collectAsState() val selectedModels by modelsManagementViewModel.selectedModels.collectAsState()
val showSortMenu by modelsManagementViewModel.showSortMenu.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() val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models // Create file launcher for importing local models
@ -246,7 +248,12 @@ fun AppContent(
} }
), ),
filtering = BottomBarConfig.ModelsManagement.FilteringConfig( 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( selection = BottomBarConfig.ModelsManagement.SelectionConfig(
isActive = isMultiSelectionMode, 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.ClearAll
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete 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.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@ -101,7 +101,12 @@ sealed class BottomBarConfig {
) )
data class FilteringConfig( 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( data class SelectionConfig(
@ -271,20 +276,6 @@ fun ModelsManagementBottomBar(
actions = { actions = {
if (selection.isActive) { if (selection.isActive) {
/* Multi-selection mode actions */ /* 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( IconButton(
onClick = selection.deleteSelected, onClick = selection.deleteSelected,
enabled = selection.selectedModels.isNotEmpty() 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 { } else {
/* Default mode actions */ /* Default mode actions */
// Multi-selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models"
)
}
// Sorting action // Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) { IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon( Icon(
@ -339,20 +351,47 @@ fun ModelsManagementBottomBar(
} }
// Filtering action // Filtering action
IconButton( IconButton(onClick = { filtering.toggleMenu(true) }) {
onClick = filtering.onClick
) {
Icon( Icon(
imageVector = Icons.Default.FilterAlt, imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models" contentDescription = "Filter models"
) )
} }
// Selection action // Filter dropdown menu
IconButton(onClick = { selection.toggleMode(true) }) { DropdownMenu(
Icon( expanded = filtering.isMenuVisible,
imageVector = Icons.Default.DeleteSweep, onDismissRequest = { filtering.toggleMenu(false) }
contentDescription = "Delete models" ) {
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: ModelsManagementViewModel,
) { ) {
// ViewModel states // ViewModel states
val sortedModels by viewModel.sortedModels.collectAsState() val filteredModels by viewModel.filteredModels.collectAsState()
val managementState by viewModel.managementState.collectAsState() val managementState by viewModel.managementState.collectAsState()
// Selection state from ViewModel // Selection state from ViewModel
@ -81,7 +81,7 @@ fun ModelsManagementScreen(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.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 val isSelected = if (isMultiSelectionMode) selectedModels.contains(model.id) else null
ModelCardFullExpandable( ModelCardFullExpandable(

View File

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

View File

@ -5,8 +5,11 @@ import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.ModelInfo
import com.example.llama.revamp.data.model.ModelSortOrder 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.InsufficientStorageException
import com.example.llama.revamp.data.repository.ModelRepository import com.example.llama.revamp.data.repository.ModelRepository
import com.example.llama.revamp.util.getFileNameFromUri import com.example.llama.revamp.util.getFileNameFromUri
@ -17,15 +20,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.FileNotFoundException import java.io.FileNotFoundException
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.set
@HiltViewModel @HiltViewModel
class ModelsManagementViewModel @Inject constructor( class ModelsManagementViewModel @Inject constructor(
@ -33,16 +36,9 @@ class ModelsManagementViewModel @Inject constructor(
private val modelRepository: ModelRepository private val modelRepository: ModelRepository
) : ViewModel() { ) : ViewModel() {
// Data: available models // Data: models
private val _availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels() private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
.stateIn( val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = emptyList()
)
private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
// UI state: multi-selection mode // UI state: multi-selection mode
private val _isMultiSelectionMode = MutableStateFlow(false) private val _isMultiSelectionMode = MutableStateFlow(false)
@ -61,7 +57,7 @@ class ModelsManagementViewModel @Inject constructor(
fun toggleModelSelectionById(modelId: String) { fun toggleModelSelectionById(modelId: String) {
val current = _selectedModels.value.toMutableMap() 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 (model != null) {
if (current.containsKey(modelId)) { if (current.containsKey(modelId)) {
@ -75,7 +71,7 @@ class ModelsManagementViewModel @Inject constructor(
fun toggleAllSelection(selectAll: Boolean) { fun toggleAllSelection(selectAll: Boolean) {
if (selectAll) { if (selectAll) {
_selectedModels.value = _sortedModels.value.associateBy { it.id } _selectedModels.value = _filteredModels.value.associateBy { it.id }
} else { } else {
_selectedModels.value = emptyMap() _selectedModels.value = emptyMap()
} }
@ -96,6 +92,33 @@ class ModelsManagementViewModel @Inject constructor(
_showSortMenu.value = show _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 // UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false) private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow() val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
@ -106,18 +129,16 @@ class ModelsManagementViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
combine(_availableModels, _sortOrder, ::sortModels) combine(
.collect { _sortedModels.value = it } 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 // Internal state