UI: polish the bottom bars and info view when no models found; show loading in progress while fetching models

This commit is contained in:
Han Yin 2025-08-30 20:00:44 -07:00
parent f23b74c730
commit cf306db855
9 changed files with 93 additions and 62 deletions

View File

@ -192,6 +192,7 @@ fun AppContent(
// Model selection screen // Model selection screen
currentRoute == AppDestinations.MODELS_ROUTE -> { currentRoute == AppDestinations.MODELS_ROUTE -> {
// Collect states for bottom bar // Collect states for bottom bar
val allModels by modelsViewModel.allModels.collectAsState()
val filteredModels by modelsViewModel.filteredModels.collectAsState() val filteredModels by modelsViewModel.filteredModels.collectAsState()
val sortOrder by modelsViewModel.sortOrder.collectAsState() val sortOrder by modelsViewModel.sortOrder.collectAsState()
val showSortMenu by modelsViewModel.showSortMenu.collectAsState() val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
@ -202,6 +203,8 @@ fun AppContent(
val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState() val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState() val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
val hasModelsInstalled = allModels?.isNotEmpty() == true
// Create file launcher for importing local models // Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult( val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
@ -242,10 +245,12 @@ fun AppContent(
when (modelScreenUiMode) { when (modelScreenUiMode) {
ModelScreenUiMode.BROWSING -> ModelScreenUiMode.BROWSING ->
BottomBarConfig.Models.Browsing( BottomBarConfig.Models.Browsing(
isSearchingEnabled = hasModelsInstalled,
onToggleSearching = { onToggleSearching = {
modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING) modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING)
}, },
sorting = BottomBarConfig.Models.Browsing.SortingConfig( sorting = BottomBarConfig.Models.Browsing.SortingConfig(
isEnabled = hasModelsInstalled,
currentOrder = sortOrder, currentOrder = sortOrder,
isMenuVisible = showSortMenu, isMenuVisible = showSortMenu,
toggleMenu = modelsViewModel::toggleSortMenu, toggleMenu = modelsViewModel::toggleSortMenu,
@ -255,7 +260,7 @@ fun AppContent(
} }
), ),
filtering = BottomBarConfig.Models.Browsing.FilteringConfig( filtering = BottomBarConfig.Models.Browsing.FilteringConfig(
isActive = activeFilters.any { it.value }, isEnabled = hasModelsInstalled,
filters = activeFilters, filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter, onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters, onClearFilters = modelsViewModel::clearFilters,
@ -292,12 +297,13 @@ fun AppContent(
) )
ModelScreenUiMode.MANAGING -> ModelScreenUiMode.MANAGING ->
BottomBarConfig.Models.Management( BottomBarConfig.Models.Managing(
isDeletionEnabled = filteredModels?.isNotEmpty() == true, isDeletionEnabled = filteredModels?.isNotEmpty() == true,
onToggleDeleting = { onToggleDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING) modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
}, },
sorting = BottomBarConfig.Models.Management.SortingConfig( sorting = BottomBarConfig.Models.Managing.SortingConfig(
isEnabled = hasModelsInstalled,
currentOrder = sortOrder, currentOrder = sortOrder,
isMenuVisible = showSortMenu, isMenuVisible = showSortMenu,
toggleMenu = { modelsViewModel.toggleSortMenu(it) }, toggleMenu = { modelsViewModel.toggleSortMenu(it) },
@ -306,15 +312,15 @@ fun AppContent(
modelsViewModel.toggleSortMenu(false) modelsViewModel.toggleSortMenu(false)
} }
), ),
filtering = BottomBarConfig.Models.Management.FilteringConfig( filtering = BottomBarConfig.Models.Managing.FilteringConfig(
isActive = activeFilters.any { it.value }, isEnabled = hasModelsInstalled,
filters = activeFilters, filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter, onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters, onClearFilters = modelsViewModel::clearFilters,
isMenuVisible = showFilterMenu, isMenuVisible = showFilterMenu,
toggleMenu = modelsViewModel::toggleFilterMenu toggleMenu = modelsViewModel::toggleFilterMenu
), ),
importing = BottomBarConfig.Models.Management.ImportConfig( importing = BottomBarConfig.Models.Managing.ImportConfig(
isMenuVisible = showImportModelMenu, isMenuVisible = showImportModelMenu,
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) }, toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
importFromLocal = { importFromLocal = {
@ -466,9 +472,6 @@ fun AppContent(
// Model Selection Screen // Model Selection Screen
composable(AppDestinations.MODELS_ROUTE) { composable(AppDestinations.MODELS_ROUTE) {
ModelsScreen( ModelsScreen(
onManageModelsClicked = {
// TODO-han.yin: remove this after implementing onboarding flow
},
onConfirmSelection = { modelInfo, ramWarning -> onConfirmSelection = { modelInfo, ramWarning ->
if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) { if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading() navigationActions.navigateToModelLoading()

View File

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -68,7 +69,8 @@ fun InfoView(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
) )
message?.let { message?.let {

View File

@ -107,6 +107,7 @@ fun AppScaffold(
is BottomBarConfig.Models.Browsing -> { is BottomBarConfig.Models.Browsing -> {
ModelsBrowsingBottomBar( ModelsBrowsingBottomBar(
isSearchingEnabled = config.isSearchingEnabled,
onToggleSearching = config.onToggleSearching, onToggleSearching = config.onToggleSearching,
sortingConfig = config.sorting, sortingConfig = config.sorting,
filteringConfig = config.filtering, filteringConfig = config.filtering,
@ -123,7 +124,7 @@ fun AppScaffold(
) )
} }
is BottomBarConfig.Models.Management -> { is BottomBarConfig.Models.Managing -> {
ModelsManagementBottomBar( ModelsManagementBottomBar(
isDeletionEnabled = config.isDeletionEnabled, isDeletionEnabled = config.isDeletionEnabled,
onToggleDeleting = config.onToggleDeleting, onToggleDeleting = config.onToggleDeleting,

View File

@ -16,12 +16,14 @@ sealed class BottomBarConfig {
sealed class Models : BottomBarConfig() { sealed class Models : BottomBarConfig() {
data class Browsing( data class Browsing(
val isSearchingEnabled: Boolean,
val onToggleSearching: () -> Unit, val onToggleSearching: () -> Unit,
val sorting: SortingConfig, val sorting: SortingConfig,
val filtering: FilteringConfig, val filtering: FilteringConfig,
val runAction: RunActionConfig, val runAction: RunActionConfig,
) : BottomBarConfig() { ) : BottomBarConfig() {
data class SortingConfig( data class SortingConfig(
val isEnabled: Boolean,
val currentOrder: ModelSortOrder, val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean, val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit, val toggleMenu: (Boolean) -> Unit,
@ -29,7 +31,7 @@ sealed class BottomBarConfig {
) )
data class FilteringConfig( data class FilteringConfig(
val isActive: Boolean, val isEnabled: Boolean,
val filters: Map<ModelFilter, Boolean>, val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit, val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit, val onClearFilters: () -> Unit,
@ -45,7 +47,7 @@ sealed class BottomBarConfig {
val runAction: RunActionConfig, val runAction: RunActionConfig,
) : BottomBarConfig() ) : BottomBarConfig()
data class Management( data class Managing(
val isDeletionEnabled: Boolean, val isDeletionEnabled: Boolean,
val onToggleDeleting: () -> Unit, val onToggleDeleting: () -> Unit,
val sorting: SortingConfig, val sorting: SortingConfig,
@ -53,6 +55,7 @@ sealed class BottomBarConfig {
val importing: ImportConfig, val importing: ImportConfig,
) : BottomBarConfig() { ) : BottomBarConfig() {
data class SortingConfig( data class SortingConfig(
val isEnabled: Boolean,
val currentOrder: ModelSortOrder, val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean, val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit, val toggleMenu: (Boolean) -> Unit,
@ -60,7 +63,7 @@ sealed class BottomBarConfig {
) )
data class FilteringConfig( data class FilteringConfig(
val isActive: Boolean, val isEnabled: Boolean,
val filters: Map<ModelFilter, Boolean>, val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit, val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit, val onClearFilters: () -> Unit,

View File

@ -22,15 +22,18 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelSortOrder import com.example.llama.data.model.ModelSortOrder
@Composable @Composable
fun ModelsBrowsingBottomBar( fun ModelsBrowsingBottomBar(
isSearchingEnabled: Boolean,
onToggleSearching: () -> Unit, onToggleSearching: () -> Unit,
sortingConfig: BottomBarConfig.Models.Browsing.SortingConfig, sortingConfig: BottomBarConfig.Models.Browsing.SortingConfig,
filteringConfig: BottomBarConfig.Models.Browsing.FilteringConfig, filteringConfig: BottomBarConfig.Models.Browsing.FilteringConfig,
@ -39,7 +42,10 @@ fun ModelsBrowsingBottomBar(
BottomAppBar( BottomAppBar(
actions = { actions = {
// Enter search action // Enter search action
IconButton(onClick = onToggleSearching) { IconButton(
enabled = isSearchingEnabled,
onClick = onToggleSearching
) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = "Search models" contentDescription = "Search models"
@ -47,7 +53,10 @@ fun ModelsBrowsingBottomBar(
} }
// Sorting action // Sorting action
IconButton(onClick = { sortingConfig.toggleMenu(true) }) { IconButton(
enabled = sortingConfig.isEnabled,
onClick = { sortingConfig.toggleMenu(true) }
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Sort, imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models" contentDescription = "Sort models"
@ -99,15 +108,20 @@ fun ModelsBrowsingBottomBar(
} }
// Filter action // Filter action
IconButton(onClick = { filteringConfig.toggleMenu(true) }) { val hasFilters = filteringConfig.filters.any { it.value }
IconButton(
enabled = filteringConfig.isEnabled,
colors = IconButtonDefaults.iconButtonColors().copy(
contentColor = if (hasFilters) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
onClick = { filteringConfig.toggleMenu(true) }
) {
Icon( Icon(
imageVector = imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt if (hasFilters) 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

@ -19,6 +19,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -33,9 +34,9 @@ import com.example.llama.data.model.ModelSortOrder
fun ModelsManagementBottomBar( fun ModelsManagementBottomBar(
isDeletionEnabled: Boolean, isDeletionEnabled: Boolean,
onToggleDeleting: () -> Unit, onToggleDeleting: () -> Unit,
sortingConfig: BottomBarConfig.Models.Management.SortingConfig, sortingConfig: BottomBarConfig.Models.Managing.SortingConfig,
filteringConfig: BottomBarConfig.Models.Management.FilteringConfig, filteringConfig: BottomBarConfig.Models.Managing.FilteringConfig,
importingConfig: BottomBarConfig.Models.Management.ImportConfig, importingConfig: BottomBarConfig.Models.Managing.ImportConfig,
) { ) {
BottomAppBar( BottomAppBar(
actions = { actions = {
@ -47,7 +48,10 @@ fun ModelsManagementBottomBar(
} }
// Sorting action // Sorting action
IconButton(onClick = { sortingConfig.toggleMenu(true) }) { IconButton(
enabled = sortingConfig.isEnabled,
onClick = { sortingConfig.toggleMenu(true) }
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Sort, imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models" contentDescription = "Sort models"
@ -99,15 +103,20 @@ fun ModelsManagementBottomBar(
} }
// Filtering action // Filtering action
IconButton(onClick = { filteringConfig.toggleMenu(true) }) { val hasFilters = filteringConfig.filters.any { it.value }
IconButton(
enabled = filteringConfig.isEnabled,
onClick = { filteringConfig.toggleMenu(true) },
colors = IconButtonDefaults.iconButtonColors().copy(
contentColor = if (hasFilters) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Icon( Icon(
imageVector = imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt if (hasFilters) 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

@ -1,14 +1,16 @@
package com.example.llama.ui.screens package com.example.llama.ui.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.filled.Add import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
@ -30,7 +32,29 @@ fun ModelsBrowsingScreen(
ModelsLoadingInProgressView() ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) { } else if (filteredModels.isEmpty()) {
// Empty model prompt // Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked) val title = when (activeFiltersCount) {
0 -> "No models installed yet"
1 -> "No models match your filter"
else -> "No models match your filters"
}
val message = when (activeFiltersCount) {
0 -> "Tap the button below to install your first Large Language Model!"
1 -> "Try removing your filter to see more results"
else -> "Try removing some filters to see more results"
}
Box(modifier = Modifier.fillMaxSize()) {
InfoView(
modifier = Modifier.fillMaxSize(0.9f).align(Alignment.Center),
title = title,
icon = Icons.Default.FolderOpen,
message = message,
action = InfoAction(
label = "Get Started",
icon = Icons.AutoMirrored.Default.ArrowForward,
onAction = onManageModelsClicked
)
)
}
} else { } else {
// Model cards // Model cards
LazyColumn( LazyColumn(
@ -54,26 +78,3 @@ fun ModelsBrowsingScreen(
} }
} }
} }
@Composable
private fun EmptyModelsView(
activeFiltersCount: Int,
onManageModelsClicked: () -> Unit
) {
val message = when (activeFiltersCount) {
0 -> "Import some models to get started!"
1 -> "No models match the selected filter"
else -> "No models match the selected filters"
}
InfoView(
modifier = Modifier.fillMaxSize(),
title = "No Models Available",
icon = Icons.Default.FolderOpen,
message = message,
action = InfoAction(
label = "Add Models",
icon = Icons.Default.Add,
onAction = onManageModelsClicked
)
)
}

View File

@ -56,7 +56,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri import androidx.core.net.toUri
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.data.source.remote.HuggingFaceModel import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.ui.components.InfoAction import com.example.llama.ui.components.InfoAction
@ -110,18 +109,18 @@ fun ModelsManagementAndDeletingScreen(
ModelsLoadingInProgressView() ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) { } else if (filteredModels.isEmpty()) {
// Import model prompt // Import model prompt
val message = when (activeFiltersCount) { val title = when (activeFiltersCount) {
0 -> "Tap the \"+\" button\n to import a model" 0 -> "Tap the \"+\" button\n to install a model"
1 -> "No models match\n the selected filter" 1 -> "No models match\n the selected filter"
else -> "No models match\n the selected filters" else -> "No models match\n the selected filters"
} }
InfoView( InfoView(
modifier = Modifier.fillMaxSize(0.8f).align(Alignment.Center), modifier = Modifier.fillMaxSize(0.9f).align(Alignment.Center),
title = message, title = title,
icon = Icons.Default.FolderOpen, icon = Icons.Default.FolderOpen,
message = "Import a local GGUF model file, or download directly from HuggingFace!", message = "Import a local GGUF model file, or download directly from HuggingFace!",
action = InfoAction( action = InfoAction(
label = "Learn more", label = "Learn More",
icon = Icons.AutoMirrored.Default.Help, icon = Icons.AutoMirrored.Default.Help,
onAction = { onAction = {
val url = "https://huggingface.co/docs/hub/en/gguf" val url = "https://huggingface.co/docs/hub/en/gguf"

View File

@ -33,7 +33,6 @@ import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ModelsScreen( fun ModelsScreen(
onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit, onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onScaffoldEvent: (ScaffoldEvent) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel, modelsViewModel: ModelsViewModel,