UI: add model loading in progress view; polish the empty model info view
This commit is contained in:
parent
a4881cb87b
commit
f23b74c730
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -32,23 +32,43 @@ fun InfoView(
|
|||
message: String? = null,
|
||||
action: InfoAction? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
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()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
message?.let {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ fun ModelsSearchingScreen(
|
|||
expanded = true,
|
||||
onExpandedChange = handleExpanded
|
||||
) {
|
||||
if (queryResults.isEmpty()) {
|
||||
queryResults?.let { results ->
|
||||
if (results.isEmpty()) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
// If no results under current query, show "no results" message
|
||||
EmptySearchResultsView(
|
||||
|
|
@ -117,7 +118,7 @@ fun ModelsSearchingScreen(
|
|||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
|
||||
) {
|
||||
items(items = queryResults, key = { it.id }) { model ->
|
||||
items(items = results, key = { it.id }) { model ->
|
||||
ModelCardFullExpandable(
|
||||
model = model,
|
||||
isSelected = if (model == preselection?.modelInfo) true else null,
|
||||
|
|
@ -138,6 +139,7 @@ fun ModelsSearchingScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
} ?: ModelsLoadingInProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,32 +164,42 @@ class ModelsViewModel @Inject constructor(
|
|||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
modelRepository.getModels().collectLatest {
|
||||
_allModels.value = it
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
combine(
|
||||
modelRepository.getModels(),
|
||||
_allModels,
|
||||
_activeFilters,
|
||||
_sortOrder,
|
||||
) { models, filters, sortOrder ->
|
||||
models.filterBy(filters).sortByOrder(sortOrder)
|
||||
models?.filterBy(filters)?.sortByOrder(sortOrder)
|
||||
}.collectLatest {
|
||||
_filteredModels.value = it
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
launch {
|
||||
combine(
|
||||
modelRepository.getModels(),
|
||||
_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 }
|
||||
models?.queryBy(query.toString())?.sortedBy {
|
||||
it.dateLastUsed ?: it.dateAdded
|
||||
}
|
||||
}
|
||||
}.collectLatest {
|
||||
_queryResults.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-select a model to expand its details and show Run FAB
|
||||
|
|
|
|||
Loading…
Reference in New Issue