UI: add model loading in progress view; polish the empty model info view

This commit is contained in:
Han Yin 2025-08-30 19:08:51 -07:00
parent a4881cb87b
commit f23b74c730
12 changed files with 184 additions and 97 deletions

View File

@ -293,6 +293,10 @@ fun AppContent(
ModelScreenUiMode.MANAGING -> ModelScreenUiMode.MANAGING ->
BottomBarConfig.Models.Management( BottomBarConfig.Models.Management(
isDeletionEnabled = filteredModels?.isNotEmpty() == true,
onToggleDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
},
sorting = BottomBarConfig.Models.Management.SortingConfig( sorting = BottomBarConfig.Models.Management.SortingConfig(
currentOrder = sortOrder, currentOrder = sortOrder,
isMenuVisible = showSortMenu, isMenuVisible = showSortMenu,
@ -322,9 +326,6 @@ fun AppContent(
modelsManagementViewModel.toggleImportMenu(false) modelsManagementViewModel.toggleImportMenu(false)
} }
), ),
onToggleDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
}
) )
ModelScreenUiMode.DELETING -> ModelScreenUiMode.DELETING ->
@ -334,10 +335,12 @@ fun AppContent(
}, },
selectedModels = selectedModelsToDelete, selectedModels = selectedModelsToDelete,
selectAllFilteredModels = { selectAllFilteredModels = {
modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels) filteredModels?.let {
modelsManagementViewModel.selectModelsToDelete(it)
}
}, },
clearAllSelectedModels = { clearAllSelectedModels = {
modelsManagementViewModel.clearAllSelectedModelsToDelete() modelsManagementViewModel.clearSelectedModelsToDelete()
}, },
deleteSelected = { deleteSelected = {
selectedModelsToDelete.let { selectedModelsToDelete.let {

View File

@ -32,23 +32,43 @@ fun InfoView(
message: String? = null, message: String? = null,
action: InfoAction? = null action: InfoAction? = null
) { ) {
Column( InfoView(
modifier = modifier.padding(16.dp), modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, title = title,
verticalArrangement = Arrangement.Center icon = {
) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) 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()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
) )
message?.let { message?.let {

View File

@ -125,10 +125,11 @@ fun AppScaffold(
is BottomBarConfig.Models.Management -> { is BottomBarConfig.Models.Management -> {
ModelsManagementBottomBar( ModelsManagementBottomBar(
isDeletionEnabled = config.isDeletionEnabled,
onToggleDeleting = config.onToggleDeleting,
sortingConfig = config.sorting, sortingConfig = config.sorting,
filteringConfig = config.filtering, filteringConfig = config.filtering,
importingConfig = config.importing, importingConfig = config.importing
onToggleDeleting = config.onToggleDeleting
) )
} }

View File

@ -46,10 +46,11 @@ sealed class BottomBarConfig {
) : BottomBarConfig() ) : BottomBarConfig()
data class Management( data class Management(
val isDeletionEnabled: Boolean,
val onToggleDeleting: () -> Unit,
val sorting: SortingConfig, val sorting: SortingConfig,
val filtering: FilteringConfig, val filtering: FilteringConfig,
val importing: ImportConfig, val importing: ImportConfig,
val onToggleDeleting: () -> Unit,
) : BottomBarConfig() { ) : BottomBarConfig() {
data class SortingConfig( data class SortingConfig(
val currentOrder: ModelSortOrder, val currentOrder: ModelSortOrder,

View File

@ -104,7 +104,10 @@ fun ModelsBrowsingBottomBar(
imageVector = imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt if (filteringConfig.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt, else Icons.Outlined.FilterAlt,
contentDescription = "Filter models" contentDescription = "Filter models",
tint =
if (filteringConfig.isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }

View File

@ -31,19 +31,19 @@ import com.example.llama.data.model.ModelSortOrder
@Composable @Composable
fun ModelsManagementBottomBar( fun ModelsManagementBottomBar(
isDeletionEnabled: Boolean,
onToggleDeleting: () -> Unit,
sortingConfig: BottomBarConfig.Models.Management.SortingConfig, sortingConfig: BottomBarConfig.Models.Management.SortingConfig,
filteringConfig: BottomBarConfig.Models.Management.FilteringConfig, filteringConfig: BottomBarConfig.Models.Management.FilteringConfig,
importingConfig: BottomBarConfig.Models.Management.ImportConfig, importingConfig: BottomBarConfig.Models.Management.ImportConfig,
onToggleDeleting: () -> Unit,
) { ) {
BottomAppBar( BottomAppBar(
actions = { actions = {
// Batch-deletion action // Batch-deletion action
IconButton(onClick = onToggleDeleting) { IconButton(enabled = isDeletionEnabled, onClick = onToggleDeleting) {
Icon( Icon(
imageVector = Icons.Outlined.DeleteSweep, imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models" contentDescription = "Delete models",)
)
} }
// Sorting action // Sorting action
@ -104,7 +104,10 @@ fun ModelsManagementBottomBar(
imageVector = imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt if (filteringConfig.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt, else Icons.Outlined.FilterAlt,
contentDescription = "Filter models" contentDescription = "Filter models",
tint =
if (filteringConfig.isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }

View File

@ -20,13 +20,15 @@ import com.example.llama.viewmodel.PreselectedModelToRun
@Composable @Composable
fun ModelsBrowsingScreen( fun ModelsBrowsingScreen(
filteredModels: List<ModelInfo>, filteredModels: List<ModelInfo>?,
activeFiltersCount: Int, activeFiltersCount: Int,
preselection: PreselectedModelToRun?, preselection: PreselectedModelToRun?,
onManageModelsClicked: () -> Unit, onManageModelsClicked: () -> Unit,
viewModel: ModelsViewModel, viewModel: ModelsViewModel,
) { ) {
if (filteredModels.isEmpty()) { if (filteredModels == null) {
ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) {
// Empty model prompt // Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked) EmptyModelsView(activeFiltersCount, onManageModelsClicked)
} else { } else {

View File

@ -1,5 +1,6 @@
package com.example.llama.ui.screens package com.example.llama.ui.screens
import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.basicMarquee 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.automirrored.outlined.ContactSupport
import androidx.compose.material.icons.filled.Attribution import androidx.compose.material.icons.filled.Attribution
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
@ -78,13 +80,15 @@ import java.util.Locale
*/ */
@Composable @Composable
fun ModelsManagementAndDeletingScreen( fun ModelsManagementAndDeletingScreen(
filteredModels: List<ModelInfo>, filteredModels: List<ModelInfo>?,
activeFiltersCount: Int, activeFiltersCount: Int,
isDeleting: Boolean, isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel, modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel, managementViewModel: ModelsManagementViewModel,
) { ) {
val context = LocalContext.current
// Selection state // Selection state
val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState() val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
@ -102,18 +106,29 @@ fun ModelsManagementAndDeletingScreen(
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (filteredModels.isEmpty()) { if (filteredModels == null) {
ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) {
// Import model prompt // Import model prompt
val message = when (activeFiltersCount) { val message = when (activeFiltersCount) {
0 -> "Tap the \"+\" button to import a model!" 0 -> "Tap the \"+\" button\n to import a model"
1 -> "No models match the selected filter" 1 -> "No models match\n the selected filter"
else -> "No models match the selected filters" else -> "No models match\n the selected filters"
} }
InfoView( InfoView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(0.8f).align(Alignment.Center),
title = "No Models Available", title = message,
icon = Icons.Default.FolderOpen, 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 { } else {
// Model cards // Model cards
@ -178,6 +193,7 @@ fun ModelsManagementAndDeletingScreen(
is Importation.Error -> { is Importation.Error -> {
ErrorDialog( ErrorDialog(
context = context,
title = "Import Failed", title = "Import Failed",
message = state.message, message = state.message,
learnMoreUrl = state.learnMoreUrl, learnMoreUrl = state.learnMoreUrl,
@ -241,6 +257,7 @@ fun ModelsManagementAndDeletingScreen(
is Download.Error -> { is Download.Error -> {
ErrorDialog( ErrorDialog(
context = context,
title = "Download Failed", title = "Download Failed",
message = state.message, message = state.message,
onDismiss = { managementViewModel.resetManagementState() } onDismiss = { managementViewModel.resetManagementState() }
@ -267,6 +284,7 @@ fun ModelsManagementAndDeletingScreen(
is Deletion.Error -> { is Deletion.Error -> {
ErrorDialog( ErrorDialog(
context = context,
title = "Deletion Failed", title = "Deletion Failed",
message = state.message, message = state.message,
onDismiss = { managementViewModel.resetManagementState() } onDismiss = { managementViewModel.resetManagementState() }
@ -660,13 +678,12 @@ private fun BatchDeleteConfirmationDialog(
@Composable @Composable
private fun ErrorDialog( private fun ErrorDialog(
context: Context,
title: String, title: String,
message: String, message: String,
learnMoreUrl: String? = null, learnMoreUrl: String? = null,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val context = LocalContext.current
val action = learnMoreUrl?.let { url -> val action = learnMoreUrl?.let { url ->
InfoAction( InfoAction(
label = "Learn More", label = "Learn More",

View File

@ -4,11 +4,14 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -17,6 +20,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoView import com.example.llama.ui.components.InfoView
import com.example.llama.ui.scaffold.ScaffoldEvent import com.example.llama.ui.scaffold.ScaffoldEvent
@ -63,7 +67,7 @@ fun ModelsScreen(
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING) modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
} }
ModelScreenUiMode.DELETING -> { ModelScreenUiMode.DELETING -> {
managementViewModel.clearAllSelectedModelsToDelete() managementViewModel.clearSelectedModelsToDelete()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
} }
} }
@ -77,7 +81,10 @@ fun ModelsScreen(
ModelsBrowsingScreen( ModelsBrowsingScreen(
filteredModels = filteredModels, filteredModels = filteredModels,
preselection = preselection, preselection = preselection,
onManageModelsClicked = { /* TODO-han.yin */ }, onManageModelsClicked = {
managementViewModel.toggleImportMenu(true)
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
activeFiltersCount = activeFiltersCount, activeFiltersCount = activeFiltersCount,
viewModel = modelsViewModel, 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 @Composable
private fun RamErrorDialog( private fun RamErrorDialog(
ramError: RamWarning, ramError: RamWarning,

View File

@ -101,7 +101,8 @@ fun ModelsSearchingScreen(
expanded = true, expanded = true,
onExpandedChange = handleExpanded onExpandedChange = handleExpanded
) { ) {
if (queryResults.isEmpty()) { queryResults?.let { results ->
if (results.isEmpty()) {
if (searchQuery.isNotBlank()) { if (searchQuery.isNotBlank()) {
// If no results under current query, show "no results" message // If no results under current query, show "no results" message
EmptySearchResultsView( EmptySearchResultsView(
@ -117,7 +118,7 @@ fun ModelsSearchingScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp), contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) { ) {
items(items = queryResults, key = { it.id }) { model -> items(items = results, key = { it.id }) { model ->
ModelCardFullExpandable( ModelCardFullExpandable(
model = model, model = model,
isSelected = if (model == preselection?.modelInfo) true else null, isSelected = if (model == preselection?.modelInfo) true else null,
@ -138,6 +139,7 @@ fun ModelsSearchingScreen(
} }
} }
} }
} ?: ModelsLoadingInProgressView()
} }
} }
} }

View File

@ -61,11 +61,11 @@ class ModelsManagementViewModel @Inject constructor(
} }
} }
fun selectAllFilteredModelsToDelete(filteredModels: List<ModelInfo>) { fun selectModelsToDelete(models: List<ModelInfo>) {
_selectedModelsToDelete.value = filteredModels.associateBy { it.id } _selectedModelsToDelete.value = models.associateBy { it.id }
} }
fun clearAllSelectedModelsToDelete() { fun clearSelectedModelsToDelete() {
_selectedModelsToDelete.value = emptyMap() _selectedModelsToDelete.value = emptyMap()
} }
@ -100,7 +100,7 @@ class ModelsManagementViewModel @Inject constructor(
huggingFaceQueryJob?.let { huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() } if (it.isActive) { it.cancel() }
} }
clearAllSelectedModelsToDelete() clearSelectedModelsToDelete()
_managementState.value = ModelManagementState.Idle _managementState.value = ModelManagementState.Idle
} }
@ -288,7 +288,7 @@ class ModelsManagementViewModel @Inject constructor(
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete) _managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete)
} }
_managementState.value = Deletion.Success(modelsToDelete.values.toList()) _managementState.value = Deletion.Success(modelsToDelete.values.toList())
clearAllSelectedModelsToDelete() clearSelectedModelsToDelete()
// Reset state after a delay // Reset state after a delay
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS) delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)

View File

@ -39,6 +39,9 @@ class ModelsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
// UI state: model management mode // UI state: model management mode
private val _allModels = MutableStateFlow<List<ModelInfo>?>(null)
val allModels = _allModels.asStateFlow()
private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING) private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING)
val modelScreenUiMode = _modelScreenUiMode.asStateFlow() val modelScreenUiMode = _modelScreenUiMode.asStateFlow()
@ -128,11 +131,11 @@ class ModelsViewModel @Inject constructor(
} }
// Data: filtered & sorted models // Data: filtered & sorted models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList()) private val _filteredModels = MutableStateFlow<List<ModelInfo>?>(null)
val filteredModels = _filteredModels.asStateFlow() val filteredModels = _filteredModels.asStateFlow()
// Data: queried models // Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList()) private val _queryResults = MutableStateFlow<List<ModelInfo>?>(null)
val queryResults = _queryResults.asStateFlow() val queryResults = _queryResults.asStateFlow()
// Data: pre-selected model in expansion mode // Data: pre-selected model in expansion mode
@ -161,32 +164,42 @@ class ModelsViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
launch {
modelRepository.getModels().collectLatest {
_allModels.value = it
}
}
launch {
combine( combine(
modelRepository.getModels(), _allModels,
_activeFilters, _activeFilters,
_sortOrder, _sortOrder,
) { models, filters, sortOrder -> ) { models, filters, sortOrder ->
models.filterBy(filters).sortByOrder(sortOrder) models?.filterBy(filters)?.sortByOrder(sortOrder)
}.collectLatest { }.collectLatest {
_filteredModels.value = it _filteredModels.value = it
} }
} }
viewModelScope.launch { launch {
combine( combine(
modelRepository.getModels(), _allModels,
snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS) snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS)
) { models, query -> ) { models, query ->
if (query.isBlank()) { if (query.isBlank()) {
emptyList() emptyList()
} else { } else {
models.queryBy(query.toString()).sortedBy { it.dateLastUsed ?: it.dateAdded } models?.queryBy(query.toString())?.sortedBy {
it.dateLastUsed ?: it.dateAdded
}
} }
}.collectLatest { }.collectLatest {
_queryResults.value = it _queryResults.value = it
} }
} }
} }
}
/** /**
* Pre-select a model to expand its details and show Run FAB * Pre-select a model to expand its details and show Run FAB