From eebc05b5595e0e49320a4b76e2121e90e0670c6b Mon Sep 17 00:00:00 2001 From: Han Yin Date: Mon, 14 Apr 2025 12:55:26 -0700 Subject: [PATCH] UI: polish UI for ModelsManagementScreen; inject ModelsManagementVieModel --- .../revamp/ui/components/AppScaffolds.kt | 2 + .../ui/screens/ModelsManagementScreen.kt | 418 +++++++++++++----- .../viewmodel/ModelsManagementViewModel.kt | 4 + .../main/res/drawable/logo_huggingface.xml | 34 ++ 4 files changed, 336 insertions(+), 122 deletions(-) create mode 100644 examples/llama.android/app/src/main/res/drawable/logo_huggingface.xml diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt index 10472038c4..7ffd6f7a5b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt @@ -73,6 +73,7 @@ fun StorageAppScaffold( storageTotal: Float, onNavigateBack: (() -> Unit)? = null, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + bottomBar: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { Scaffold( @@ -87,6 +88,7 @@ fun StorageAppScaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + bottomBar = bottomBar, content = content ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt index 74f912c0d7..fc32f2ca18 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt @@ -1,5 +1,6 @@ package com.example.llama.revamp.ui.screens +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -7,35 +8,52 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.Sort +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ClearAll +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.ui.components.StorageAppScaffold +import com.example.llama.revamp.viewmodel.ModelSortOrder +import com.example.llama.revamp.viewmodel.ModelsManagementViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import com.example.llama.R /** * Screen for managing LLM models (view, download, delete) @@ -43,132 +61,317 @@ import java.util.Locale @Composable fun ModelsManagementScreen( onBackPressed: () -> Unit, + viewModel: ModelsManagementViewModel = hiltViewModel() ) { // For demo purposes, we'll use sample models - val installedModels = remember { ModelInfo.getSampleModels() } + val models by viewModel.availableModels.collectAsState() + val storageMetrics by viewModel.storageMetrics.collectAsState() - // Edit mode for models' batch deletion - var isEditMode by remember { mutableStateOf(false) } + // UI states + var isMultiSelectionMode by remember { mutableStateOf(false) } + val selectedModels = remember { mutableStateMapOf() } + var showSortMenu by remember { mutableStateOf(false) } + var showAddModelMenu by remember { mutableStateOf(false) } - // Calculate storage info - val storageUsed = 14.6f // This would be calculated from actual models - val storageTotal = 32.0f // This would be from device storage info + val exitSelectionMode = { + isMultiSelectionMode = false + selectedModels.clear() + } StorageAppScaffold( title = "Models Management", - storageUsed = storageUsed, - storageTotal = storageTotal, + storageUsed = storageMetrics.usedGB, + storageTotal = storageMetrics.totalGB, onNavigateBack = onBackPressed, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp) - ) { - // Summary card - Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "Models Storage", - style = MaterialTheme.typography.titleMedium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Storage Used: 14.6GB / 32GB", - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(4.dp)) - - LinearProgressIndicator( - progress = { 0.45f }, - modifier = Modifier.fillMaxWidth() + bottomBar = { + BottomAppBar( + actions = { + if (isMultiSelectionMode) { + // Multi-selection mode actions + IconButton(onClick = { + // Select all + selectedModels.putAll(models.map { it.id to it }) + }) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = "Select all" ) } - OutlinedButton( - onClick = { /* Download new model */ }, - modifier = Modifier.padding(start = 16.dp) + IconButton(onClick = { + // Deselect all + selectedModels.clear() + }) { + Icon( + imageVector = Icons.Default.ClearAll, + contentDescription = "Deselect all" + ) + } + + IconButton( + onClick = { + // Delete selected + if (selectedModels.isNotEmpty()) { + viewModel.deleteModels(selectedModels) + exitSelectionMode() + } + }, + enabled = selectedModels.isNotEmpty() ) { - Text("Add Model") + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete selected", + tint = if (selectedModels.isNotEmpty()) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + } + } else { + // Default mode actions + IconButton(onClick = { showSortMenu = true }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = "Sort models" + ) + } + + // Sort dropdown menu + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { Text("Name (A-Z)") }, + onClick = { + viewModel.setSortOrder(ModelSortOrder.NAME_ASC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Name (Z-A)") }, + onClick = { + viewModel.setSortOrder(ModelSortOrder.NAME_DESC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Size (Largest first)") }, + onClick = { + viewModel.setSortOrder(ModelSortOrder.SIZE_DESC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Size (Smallest first)") }, + onClick = { + viewModel.setSortOrder(ModelSortOrder.SIZE_ASC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Last used") }, + onClick = { + viewModel.setSortOrder(ModelSortOrder.LAST_USED) + showSortMenu = false + } + ) + } + + IconButton(onClick = { /* Filter action - stub for now */ }) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = "Filter models" + ) + } + + IconButton(onClick = { + isMultiSelectionMode = true + }) { + Icon( + imageVector = Icons.Default.DeleteSweep, + contentDescription = "Delete models" + ) } } - } - } + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + if (isMultiSelectionMode) { + exitSelectionMode() + } else { + showAddModelMenu = true + } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer + ) { + Icon( + imageVector = if (isMultiSelectionMode) Icons.Default.Close else Icons.Default.Add, + contentDescription = if (isMultiSelectionMode) "Exit selection mode" else "Add model" + ) + } - // Installed models list - Text( - text = "Installed Models", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) + // Add model dropdown menu + DropdownMenu( + expanded = showAddModelMenu, + onDismissRequest = { showAddModelMenu = false } + ) { + DropdownMenuItem( + text = { Text("Import local model") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = "Import a local model on the device" + ) + }, + onClick = { + viewModel.importLocalModel() + showAddModelMenu = false + } + ) + DropdownMenuItem( + text = { Text("Download from HuggingFace") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.logo_huggingface), + contentDescription = "Browse and download a model from HuggingFace", + modifier = Modifier.size(24.dp), + tint = Color.Unspecified, + ) + }, + onClick = { + viewModel.importFromHuggingFace() + showAddModelMenu = false + } + ) + } + } ) - - LazyColumn { - items(installedModels) { model -> - ModelManagementItem( - model = model, - onInfoClick = { /* Show model details */ }, - onDeleteClick = { /* Delete model */ } - ) - Spacer(modifier = Modifier.height(8.dp)) + }, + ) { paddingValues -> + // Main content + ModelList( + models = models, + isMultiSelectionMode = isMultiSelectionMode, + selectedModels = selectedModels, + onModelClick = { modelId -> + if (isMultiSelectionMode) { + // Toggle selection + if (selectedModels.contains(modelId)) { + selectedModels.remove(modelId) + } else { + selectedModels.put(modelId, models.first { it.id == modelId } ) + } + } else { + // View model details + viewModel.viewModelDetails(modelId) } - } + }, + onModelInfoClick = { modelId -> + viewModel.viewModelDetails(modelId) + }, + onModelDeleteClick = { modelId -> + viewModel.deleteModel(modelId) + }, + modifier = Modifier.padding(paddingValues) + ) + } +} + + +@Composable +private fun ModelList( + models: List, + isMultiSelectionMode: Boolean, + selectedModels: Map, + onModelClick: (String) -> Unit, + onModelInfoClick: (String) -> Unit, + onModelDeleteClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + ) { + items( + items = models, + key = { it.id } + ) { model -> + ModelItem( + model = model, + isMultiSelectionMode = isMultiSelectionMode, + isSelected = selectedModels.contains(model.id), + onClick = { onModelClick(model.id) }, + onInfoClick = { onModelInfoClick(model.id) }, + onDeleteClick = { onModelDeleteClick(model.id) } + ) + Spacer(modifier = Modifier.height(8.dp)) } } } @Composable -fun ModelManagementItem( +private fun ModelItem( model: ModelInfo, + isMultiSelectionMode: Boolean, + isSelected: Boolean, + onClick: () -> Unit, onInfoClick: () -> Unit, onDeleteClick: () -> Unit ) { + // Model item implementation with selection support Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = if (isSelected && isMultiSelectionMode) + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + else + CardDefaults.cardColors() ) { - Column( - modifier = Modifier.padding(16.dp) + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = model.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) + // Show checkbox in selection mode + if (isMultiSelectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() }, + modifier = Modifier.padding(end = 8.dp) + ) + } + // Model info + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.name, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "${model.parameters} • ${model.quantization} • ${model.formattedSize}", + style = MaterialTheme.typography.bodySmall + ) + + model.lastUsed?.let { lastUsed -> + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) Text( - text = "${model.parameters} • ${model.quantization} • ${model.formattedSize}", - style = MaterialTheme.typography.bodyMedium, + text = "Last used: ${dateFormat.format(Date(lastUsed))}", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } + } + // Only show action buttons in non-selection mode + if (!isMultiSelectionMode) { IconButton(onClick = onInfoClick) { Icon( imageVector = Icons.Default.Info, - contentDescription = "Model details", - tint = MaterialTheme.colorScheme.primary + contentDescription = "Model details" ) } @@ -180,35 +383,6 @@ fun ModelManagementItem( ) } } - - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Location: ${model.path}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - model.lastUsed?.let { lastUsed -> - val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) - Text( - text = "Last used: ${dateFormat.format(Date(lastUsed))}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt index 34f7330275..1e5b0ea0be 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt @@ -97,6 +97,10 @@ class ModelsManagementViewModel @Inject constructor( // TODO-han.yin: Stub for now. Would open file picker and import model } + fun importFromHuggingFace() { + // TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs + } + private fun updateStorageMetrics() { // Recalculate storage metrics after model changes // TODO-han.yin: Stub for now. Would query actual storage diff --git a/examples/llama.android/app/src/main/res/drawable/logo_huggingface.xml b/examples/llama.android/app/src/main/res/drawable/logo_huggingface.xml new file mode 100644 index 0000000000..a666ce3a2d --- /dev/null +++ b/examples/llama.android/app/src/main/res/drawable/logo_huggingface.xml @@ -0,0 +1,34 @@ + + + + + + + + + + +