diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt index 4e74d46b18..01d9167a59 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt @@ -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, diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt index 390ef370ca..bbef95f47d 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/scaffold/BottomAppBars.kt @@ -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, + 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) + } ) } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt index 90918343ce..90ecd71abd 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt @@ -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( diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelSelectionViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelSelectionViewModel.kt index 28438d1d76..d0950d2a3b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelSelectionViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelSelectionViewModel.kt @@ -63,7 +63,7 @@ class ModelSelectionViewModel @Inject constructor( _showSortMenu.value = visible } - // UI state: filters + // UI state: filter menu private val _activeFilters = MutableStateFlow>( ModelFilter.ALL_FILTERS.associateWith { false } ) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt index 5d3da860d0..783864d59c 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt @@ -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> = modelRepository.getModels() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), - initialValue = emptyList() - ) - - private val _sortedModels = MutableStateFlow>(emptyList()) - val sortedModels: StateFlow> = _sortedModels.asStateFlow() + // Data: models + private val _filteredModels = MutableStateFlow>(emptyList()) + val filteredModels: StateFlow> = _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>( + ModelFilter.ALL_FILTERS.associateWith { false } + ) + val activeFilters: StateFlow> = _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 = _showFilterMenu.asStateFlow() + + fun toggleFilterMenu(visible: Boolean) { + _showFilterMenu.value = visible + } + // UI state: import menu private val _showImportModelMenu = MutableStateFlow(false) val showImportModelMenu: StateFlow = _showImportModelMenu.asStateFlow() @@ -106,20 +129,18 @@ 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, 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 private val _managementState = MutableStateFlow(ModelManagementState.Idle) val managementState: StateFlow = _managementState.asStateFlow()