From f23b74c730f3904e2a633ce93dd6d36e35a45beb Mon Sep 17 00:00:00 2001 From: Han Yin Date: Sat, 30 Aug 2025 19:08:51 -0700 Subject: [PATCH] UI: add model loading in progress view; polish the empty model info view --- .../java/com/example/llama/MainActivity.kt | 13 ++-- .../example/llama/ui/components/InfoView.kt | 34 ++++++++-- .../example/llama/ui/scaffold/AppScaffold.kt | 5 +- .../ui/scaffold/bottombar/BottomBarConfig.kt | 3 +- .../bottombar/ModelsBrowsingBottomBar.kt | 5 +- .../bottombar/ModelsManagementBottomBar.kt | 13 ++-- .../llama/ui/screens/ModelsBrowsingScreen.kt | 6 +- .../ModelsManagementAndDeletingScreen.kt | 37 +++++++--- .../example/llama/ui/screens/ModelsScreen.kt | 26 ++++++- .../llama/ui/screens/ModelsSearchingScreen.kt | 68 ++++++++++--------- .../viewmodel/ModelsManagementViewModel.kt | 10 +-- .../llama/viewmodel/ModelsViewModel.kt | 61 ++++++++++------- 12 files changed, 184 insertions(+), 97 deletions(-) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt b/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt index 647c49c911..6a2dbb33cc 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt @@ -293,6 +293,10 @@ fun AppContent( ModelScreenUiMode.MANAGING -> BottomBarConfig.Models.Management( + isDeletionEnabled = filteredModels?.isNotEmpty() == true, + onToggleDeleting = { + modelsViewModel.toggleMode(ModelScreenUiMode.DELETING) + }, sorting = BottomBarConfig.Models.Management.SortingConfig( currentOrder = sortOrder, isMenuVisible = showSortMenu, @@ -322,9 +326,6 @@ fun AppContent( modelsManagementViewModel.toggleImportMenu(false) } ), - onToggleDeleting = { - modelsViewModel.toggleMode(ModelScreenUiMode.DELETING) - } ) ModelScreenUiMode.DELETING -> @@ -334,10 +335,12 @@ fun AppContent( }, selectedModels = selectedModelsToDelete, selectAllFilteredModels = { - modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels) + filteredModels?.let { + modelsManagementViewModel.selectModelsToDelete(it) + } }, clearAllSelectedModels = { - modelsManagementViewModel.clearAllSelectedModelsToDelete() + modelsManagementViewModel.clearSelectedModelsToDelete() }, deleteSelected = { selectedModelsToDelete.let { diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/components/InfoView.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/components/InfoView.kt index d048781434..0a1eb166b0 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/components/InfoView.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/components/InfoView.kt @@ -31,24 +31,44 @@ fun InfoView( icon: ImageVector, message: String? = null, action: InfoAction? = null +) { + InfoView( + modifier = modifier, + title = title, + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + }, + message = message, + action = action + ) +} + +@Composable +fun InfoView( + modifier: Modifier = Modifier, + title: String, + icon: @Composable () -> Unit, + message: String? = null, + action: InfoAction? = null ) { Column( modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) - ) + icon() Spacer(modifier = Modifier.height(16.dp)) Text( text = title, - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center ) message?.let { diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt index 04268b75c0..888b57de24 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt @@ -125,10 +125,11 @@ fun AppScaffold( is BottomBarConfig.Models.Management -> { ModelsManagementBottomBar( + isDeletionEnabled = config.isDeletionEnabled, + onToggleDeleting = config.onToggleDeleting, sortingConfig = config.sorting, filteringConfig = config.filtering, - importingConfig = config.importing, - onToggleDeleting = config.onToggleDeleting + importingConfig = config.importing ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt index 6adb67fb85..d34f5411c9 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt @@ -46,10 +46,11 @@ sealed class BottomBarConfig { ) : BottomBarConfig() data class Management( + val isDeletionEnabled: Boolean, + val onToggleDeleting: () -> Unit, val sorting: SortingConfig, val filtering: FilteringConfig, val importing: ImportConfig, - val onToggleDeleting: () -> Unit, ) : BottomBarConfig() { data class SortingConfig( val currentOrder: ModelSortOrder, diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsBrowsingBottomBar.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsBrowsingBottomBar.kt index 55d82efc2d..daed872042 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsBrowsingBottomBar.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsBrowsingBottomBar.kt @@ -104,7 +104,10 @@ fun ModelsBrowsingBottomBar( imageVector = if (filteringConfig.isActive) Icons.Default.FilterAlt else Icons.Outlined.FilterAlt, - contentDescription = "Filter models" + contentDescription = "Filter models", + tint = + if (filteringConfig.isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsManagementBottomBar.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsManagementBottomBar.kt index 79003e0eca..cb97d3e92a 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsManagementBottomBar.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsManagementBottomBar.kt @@ -31,19 +31,19 @@ import com.example.llama.data.model.ModelSortOrder @Composable fun ModelsManagementBottomBar( + isDeletionEnabled: Boolean, + onToggleDeleting: () -> Unit, sortingConfig: BottomBarConfig.Models.Management.SortingConfig, filteringConfig: BottomBarConfig.Models.Management.FilteringConfig, importingConfig: BottomBarConfig.Models.Management.ImportConfig, - onToggleDeleting: () -> Unit, ) { BottomAppBar( actions = { // Batch-deletion action - IconButton(onClick = onToggleDeleting) { + IconButton(enabled = isDeletionEnabled, onClick = onToggleDeleting) { Icon( imageVector = Icons.Outlined.DeleteSweep, - contentDescription = "Delete models" - ) + contentDescription = "Delete models",) } // Sorting action @@ -104,7 +104,10 @@ fun ModelsManagementBottomBar( imageVector = if (filteringConfig.isActive) Icons.Default.FilterAlt else Icons.Outlined.FilterAlt, - contentDescription = "Filter models" + contentDescription = "Filter models", + tint = + if (filteringConfig.isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt index 1cb6ff1738..ee484a2cec 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt @@ -20,13 +20,15 @@ import com.example.llama.viewmodel.PreselectedModelToRun @Composable fun ModelsBrowsingScreen( - filteredModels: List, + filteredModels: List?, activeFiltersCount: Int, preselection: PreselectedModelToRun?, onManageModelsClicked: () -> Unit, viewModel: ModelsViewModel, ) { - if (filteredModels.isEmpty()) { + if (filteredModels == null) { + ModelsLoadingInProgressView() + } else if (filteredModels.isEmpty()) { // Empty model prompt EmptyModelsView(activeFiltersCount, onManageModelsClicked) } else { diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt index 66d77564bf..0c6a5bb316 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt @@ -1,5 +1,6 @@ package com.example.llama.ui.screens +import android.content.Context import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.foundation.basicMarquee @@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.automirrored.outlined.ContactSupport import androidx.compose.material.icons.filled.Attribution import androidx.compose.material.icons.filled.Download @@ -78,13 +80,15 @@ import java.util.Locale */ @Composable fun ModelsManagementAndDeletingScreen( - filteredModels: List, + filteredModels: List?, activeFiltersCount: Int, isDeleting: Boolean, onScaffoldEvent: (ScaffoldEvent) -> Unit, modelsViewModel: ModelsViewModel, managementViewModel: ModelsManagementViewModel, ) { + val context = LocalContext.current + // Selection state val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState() @@ -102,18 +106,29 @@ fun ModelsManagementAndDeletingScreen( } Box(modifier = Modifier.fillMaxSize()) { - if (filteredModels.isEmpty()) { + if (filteredModels == null) { + ModelsLoadingInProgressView() + } else if (filteredModels.isEmpty()) { // Import model prompt val message = when (activeFiltersCount) { - 0 -> "Tap the \"+\" button to import a model!" - 1 -> "No models match the selected filter" - else -> "No models match the selected filters" + 0 -> "Tap the \"+\" button\n to import a model" + 1 -> "No models match\n the selected filter" + else -> "No models match\n the selected filters" } InfoView( - modifier = Modifier.fillMaxSize(), - title = "No Models Available", + modifier = Modifier.fillMaxSize(0.8f).align(Alignment.Center), + title = message, icon = Icons.Default.FolderOpen, - message = message, + message = "Import a local GGUF model file, or download directly from HuggingFace!", + action = InfoAction( + label = "Learn more", + icon = Icons.AutoMirrored.Default.Help, + onAction = { + val url = "https://huggingface.co/docs/hub/en/gguf" + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } + ) ) } else { // Model cards @@ -178,6 +193,7 @@ fun ModelsManagementAndDeletingScreen( is Importation.Error -> { ErrorDialog( + context = context, title = "Import Failed", message = state.message, learnMoreUrl = state.learnMoreUrl, @@ -241,6 +257,7 @@ fun ModelsManagementAndDeletingScreen( is Download.Error -> { ErrorDialog( + context = context, title = "Download Failed", message = state.message, onDismiss = { managementViewModel.resetManagementState() } @@ -267,6 +284,7 @@ fun ModelsManagementAndDeletingScreen( is Deletion.Error -> { ErrorDialog( + context = context, title = "Deletion Failed", message = state.message, onDismiss = { managementViewModel.resetManagementState() } @@ -660,13 +678,12 @@ private fun BatchDeleteConfirmationDialog( @Composable private fun ErrorDialog( + context: Context, title: String, message: String, learnMoreUrl: String? = null, onDismiss: () -> Unit ) { - val context = LocalContext.current - val action = learnMoreUrl?.let { url -> InfoAction( label = "Learn More", diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt index f40df3de0b..abdce9fbe2 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt @@ -4,11 +4,14 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -17,6 +20,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.example.llama.data.model.ModelInfo import com.example.llama.ui.components.InfoView import com.example.llama.ui.scaffold.ScaffoldEvent @@ -63,7 +67,7 @@ fun ModelsScreen( modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING) } ModelScreenUiMode.DELETING -> { - managementViewModel.clearAllSelectedModelsToDelete() + managementViewModel.clearSelectedModelsToDelete() modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) } } @@ -77,7 +81,10 @@ fun ModelsScreen( ModelsBrowsingScreen( filteredModels = filteredModels, preselection = preselection, - onManageModelsClicked = { /* TODO-han.yin */ }, + onManageModelsClicked = { + managementViewModel.toggleImportMenu(true) + modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) + }, activeFiltersCount = activeFiltersCount, viewModel = modelsViewModel, ) @@ -112,6 +119,21 @@ fun ModelsScreen( } } +@Composable +fun ModelsLoadingInProgressView() { + InfoView( + modifier = Modifier.fillMaxSize(), + title = "Loading...", + icon = { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + strokeWidth = ProgressIndicatorDefaults.CircularStrokeWidth * 1.5f + ) + }, + message = "Searching for installed models on your device...", + ) +} + @Composable private fun RamErrorDialog( ramError: RamWarning, diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt index 792d300cc6..ab1311795b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt @@ -101,43 +101,45 @@ fun ModelsSearchingScreen( expanded = true, onExpandedChange = handleExpanded ) { - if (queryResults.isEmpty()) { - if (searchQuery.isNotBlank()) { - // If no results under current query, show "no results" message - EmptySearchResultsView( - onClearSearch = { - textFieldState.clearText() - toggleSearchFocusAndIme(true) - } - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp), - ) { - items(items = queryResults, key = { it.id }) { model -> - ModelCardFullExpandable( - model = model, - isSelected = if (model == preselection?.modelInfo) true else null, - onSelected = { selected -> - if (selected) { - toggleSearchFocusAndIme(false) - } else { - viewModel.resetPreselection() - toggleSearchFocusAndIme(true) - } - }, - isExpanded = model == preselection?.modelInfo, - onExpanded = { expanded -> - viewModel.preselectModel(model, expanded) - toggleSearchFocusAndIme(!expanded) + queryResults?.let { results -> + if (results.isEmpty()) { + if (searchQuery.isNotBlank()) { + // If no results under current query, show "no results" message + EmptySearchResultsView( + onClearSearch = { + textFieldState.clearText() + toggleSearchFocusAndIme(true) } ) } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp), + ) { + items(items = results, key = { it.id }) { model -> + ModelCardFullExpandable( + model = model, + isSelected = if (model == preselection?.modelInfo) true else null, + onSelected = { selected -> + if (selected) { + toggleSearchFocusAndIme(false) + } else { + viewModel.resetPreselection() + toggleSearchFocusAndIme(true) + } + }, + isExpanded = model == preselection?.modelInfo, + onExpanded = { expanded -> + viewModel.preselectModel(model, expanded) + toggleSearchFocusAndIme(!expanded) + } + ) + } + } } - } + } ?: ModelsLoadingInProgressView() } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt index 01de4990d1..ac6435a8fd 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt @@ -61,11 +61,11 @@ class ModelsManagementViewModel @Inject constructor( } } - fun selectAllFilteredModelsToDelete(filteredModels: List) { - _selectedModelsToDelete.value = filteredModels.associateBy { it.id } + fun selectModelsToDelete(models: List) { + _selectedModelsToDelete.value = models.associateBy { it.id } } - fun clearAllSelectedModelsToDelete() { + fun clearSelectedModelsToDelete() { _selectedModelsToDelete.value = emptyMap() } @@ -100,7 +100,7 @@ class ModelsManagementViewModel @Inject constructor( huggingFaceQueryJob?.let { if (it.isActive) { it.cancel() } } - clearAllSelectedModelsToDelete() + clearSelectedModelsToDelete() _managementState.value = ModelManagementState.Idle } @@ -288,7 +288,7 @@ class ModelsManagementViewModel @Inject constructor( _managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete) } _managementState.value = Deletion.Success(modelsToDelete.values.toList()) - clearAllSelectedModelsToDelete() + clearSelectedModelsToDelete() // Reset state after a delay delay(DELETE_SUCCESS_RESET_TIMEOUT_MS) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt index 4f3c7e0729..122e6e4a27 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt @@ -39,6 +39,9 @@ class ModelsViewModel @Inject constructor( ) : ViewModel() { // UI state: model management mode + private val _allModels = MutableStateFlow?>(null) + val allModels = _allModels.asStateFlow() + private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING) val modelScreenUiMode = _modelScreenUiMode.asStateFlow() @@ -128,11 +131,11 @@ class ModelsViewModel @Inject constructor( } // Data: filtered & sorted models - private val _filteredModels = MutableStateFlow>(emptyList()) + private val _filteredModels = MutableStateFlow?>(null) val filteredModels = _filteredModels.asStateFlow() // Data: queried models - private val _queryResults = MutableStateFlow>(emptyList()) + private val _queryResults = MutableStateFlow?>(null) val queryResults = _queryResults.asStateFlow() // Data: pre-selected model in expansion mode @@ -161,29 +164,39 @@ class ModelsViewModel @Inject constructor( init { viewModelScope.launch { - combine( - modelRepository.getModels(), - _activeFilters, - _sortOrder, - ) { models, filters, sortOrder -> - models.filterBy(filters).sortByOrder(sortOrder) - }.collectLatest { - _filteredModels.value = it - } - } - - viewModelScope.launch { - combine( - modelRepository.getModels(), - snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS) - ) { models, query -> - if (query.isBlank()) { - emptyList() - } else { - models.queryBy(query.toString()).sortedBy { it.dateLastUsed ?: it.dateAdded } + launch { + modelRepository.getModels().collectLatest { + _allModels.value = it + } + } + + launch { + combine( + _allModels, + _activeFilters, + _sortOrder, + ) { models, filters, sortOrder -> + models?.filterBy(filters)?.sortByOrder(sortOrder) + }.collectLatest { + _filteredModels.value = it + } + } + + launch { + combine( + _allModels, + snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS) + ) { models, query -> + if (query.isBlank()) { + emptyList() + } else { + models?.queryBy(query.toString())?.sortedBy { + it.dateLastUsed ?: it.dateAdded + } + } + }.collectLatest { + _queryResults.value = it } - }.collectLatest { - _queryResults.value = it } } }