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 ->
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 {

View File

@ -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 {

View File

@ -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
)
}

View File

@ -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,

View File

@ -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
)
}

View File

@ -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
)
}

View File

@ -20,13 +20,15 @@ import com.example.llama.viewmodel.PreselectedModelToRun
@Composable
fun ModelsBrowsingScreen(
filteredModels: List<ModelInfo>,
filteredModels: List<ModelInfo>?,
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 {

View File

@ -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<ModelInfo>,
filteredModels: List<ModelInfo>?,
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",

View File

@ -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,

View File

@ -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()
}
}
}

View File

@ -61,11 +61,11 @@ class ModelsManagementViewModel @Inject constructor(
}
}
fun selectAllFilteredModelsToDelete(filteredModels: List<ModelInfo>) {
_selectedModelsToDelete.value = filteredModels.associateBy { it.id }
fun selectModelsToDelete(models: List<ModelInfo>) {
_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)

View File

@ -39,6 +39,9 @@ class ModelsViewModel @Inject constructor(
) : ViewModel() {
// UI state: model management mode
private val _allModels = MutableStateFlow<List<ModelInfo>?>(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<List<ModelInfo>>(emptyList())
private val _filteredModels = MutableStateFlow<List<ModelInfo>?>(null)
val filteredModels = _filteredModels.asStateFlow()
// Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
private val _queryResults = MutableStateFlow<List<ModelInfo>?>(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
}
}
}