From 2d6b8856f618cfd481c1d01fa1a032d04eb3d646 Mon Sep 17 00:00:00 2001 From: Han Yin Date: Mon, 14 Apr 2025 22:01:23 -0700 Subject: [PATCH] UI: implement multiple models deletion; update Models Management screen --- .../ui/screens/ModelsManagementScreen.kt | 199 ++++++++++++++---- .../viewmodel/ModelsManagementViewModel.kt | 29 +++ 2 files changed, 183 insertions(+), 45 deletions(-) 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 d2d5ac9d92..69889603af 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 @@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +15,7 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -35,6 +37,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton @@ -42,7 +45,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -50,6 +56,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,16 +64,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel +import com.example.llama.R import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.ui.components.StorageAppScaffold +import com.example.llama.revamp.viewmodel.ModelManagementState.Deletion +import com.example.llama.revamp.viewmodel.ModelManagementState.Importation import com.example.llama.revamp.viewmodel.ModelSortOrder import com.example.llama.revamp.viewmodel.ModelsManagementViewModel +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import com.example.llama.R -import com.example.llama.revamp.viewmodel.ModelImportState /** * Screen for managing LLM models (view, download, delete) @@ -76,26 +86,25 @@ fun ModelsManagementScreen( onBackPressed: () -> Unit, viewModel: ModelsManagementViewModel = hiltViewModel() ) { - val storageMetrics by viewModel.storageMetrics.collectAsState() - val sortedModels by viewModel.sortedModels.collectAsState() + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } - // Model sorting + // ViewModel states + val storageMetrics by viewModel.storageMetrics.collectAsState() val sortOrder by viewModel.sortOrder.collectAsState() + val sortedModels by viewModel.sortedModels.collectAsState() + val managementState by viewModel.managementState.collectAsState() + + // UI state: sorting var showSortMenu by remember { mutableStateOf(false) } - // Model importing - val importState by viewModel.importState.collectAsState() - + // UI state: importing var showImportModelMenu by remember { mutableStateOf(false) } val fileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { viewModel.importLocalModel(it) } } - BackHandler(enabled = importState is ModelImportState.Importing) { - /* Ignore back press while importing model */ - } - - // Multi-selection + // UI state: multi-selecting var isMultiSelectionMode by remember { mutableStateOf(false) } val selectedModels = remember { mutableStateMapOf() } val exitSelectionMode = { @@ -103,11 +112,19 @@ fun ModelsManagementScreen( selectedModels.clear() } + BackHandler( + enabled = managementState is Importation.Importing + || managementState is Deletion.Deleting + ) { + /* Ignore back press while processing model management requests */ + } + StorageAppScaffold( title = "Models Management", storageUsed = storageMetrics?.usedGB ?: 0f, storageTotal = storageMetrics?.totalGB ?: 0f, onNavigateBack = onBackPressed, + snackbarHostState = snackbarHostState, bottomBar = { BottomAppBar( actions = { @@ -135,11 +152,8 @@ fun ModelsManagementScreen( IconButton( onClick = { - // Delete selected if (selectedModels.isNotEmpty()) { - // TODO-han.yin: pop up an AlertDialog asking user for confirmation - viewModel.deleteModels(selectedModels) - exitSelectionMode() + viewModel.batchDeletionClicked(selectedModels.toMap()) } }, enabled = selectedModels.isNotEmpty() @@ -150,7 +164,7 @@ fun ModelsManagementScreen( tint = if (selectedModels.isNotEmpty()) MaterialTheme.colorScheme.error else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) } } else { @@ -239,7 +253,9 @@ fun ModelsManagementScreen( ) } - IconButton(onClick = { /* Filter action - stub for now */ }) { + IconButton( + onClick = {/* TODO-han.yin: implement filtering */ } + ) { Icon( imageVector = Icons.Default.FilterAlt, contentDescription = "Filter models" @@ -333,31 +349,69 @@ fun ModelsManagementScreen( onModelInfoClick = { modelId -> viewModel.viewModelDetails(modelId) }, - onModelDeleteClick = { modelId -> - viewModel.deleteModel(modelId) - }, modifier = Modifier.padding(paddingValues) ) // Model import progress overlay - when (val state = importState) { - is ModelImportState.Importing -> { + when (val state = managementState) { + is Importation.Importing -> { ImportProgressOverlay( progress = state.progress, filename = state.filename, onCancel = { /* Implement cancellation if needed */ } ) } - is ModelImportState.Error -> { + is Importation.Error -> { ErrorDialog( + title = "Import Failed", message = state.message, - onDismiss = { viewModel.resetImportState() } + onDismiss = { viewModel.resetManagementState() } ) } - is ModelImportState.Success -> { + is Importation.Success -> { LaunchedEffect(state) { - // Show success snackbar or message - // This will auto-dismiss after the delay in viewModel + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = "Imported model: ${state.model.name}", + duration = SnackbarDuration.Short + ) + } + } + } + is Deletion.Confirming -> { + BatchDeleteConfirmationDialog( + count = state.models.size, + onConfirm = { viewModel.deleteModels(state.models) }, + onDismiss = { viewModel.resetManagementState() }, + isDeleting = false + ) + } + is Deletion.Deleting -> { + BatchDeleteConfirmationDialog( + count = state.models.size, + onConfirm = { /* No-op during processing */ }, + onDismiss = { /* No-op during processing */ }, + isDeleting = true + ) + } + is Deletion.Error -> { + ErrorDialog( + title = "Deletion Failed", + message = state.message, + onDismiss = { viewModel.resetManagementState() } + ) + } + is Deletion.Success -> { + LaunchedEffect(state) { + exitSelectionMode() + coroutineScope.launch { + val count = state.models.size + snackbarHostState.showSnackbar( + message = "Deleted $count ${if (count > 1) "models" else "model"}.", + duration = SnackbarDuration.Long + ) + } + } } else -> { /* Idle state, nothing to show */ } @@ -374,7 +428,6 @@ private fun ModelCardList( selectedModels: Map, onModelClick: (String) -> Unit, onModelInfoClick: (String) -> Unit, - onModelDeleteClick: (String) -> Unit, modifier: Modifier = Modifier ) { LazyColumn( @@ -392,10 +445,6 @@ private fun ModelCardList( isSelected = selectedModels.contains(model.id), onClick = { onModelClick(model.id) }, onInfoClick = { onModelInfoClick(model.id) }, - onDeleteClick = { - // TODO-han.yin: pop up an AlertDialog asking user for confirmation - onModelDeleteClick(model.id) - } ) Spacer(modifier = Modifier.height(8.dp)) } @@ -409,9 +458,7 @@ private fun ModelCard( isSelected: Boolean, onClick: () -> Unit, onInfoClick: () -> Unit, - onDeleteClick: () -> Unit ) { - // Model item implementation with selection support Card( modifier = Modifier .fillMaxWidth() @@ -464,19 +511,12 @@ private fun ModelCard( contentDescription = "Model details" ) } - - IconButton(onClick = onDeleteClick) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete model", - tint = MaterialTheme.colorScheme.error - ) - } } } } } +// TODO-han.yin: Rewrite into @Composable fun ImportProgressOverlay( progress: Float, @@ -552,14 +592,83 @@ fun ImportProgressOverlay( } } +@Composable +fun BatchDeleteConfirmationDialog( + count: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + isDeleting: Boolean = false +) { + AlertDialog( + // Prevent dismissal when deletion is in progress + onDismissRequest = { + if (!isDeleting) onDismiss() + }, + // Prevent dismissal via back button during deletion + properties = DialogProperties( + dismissOnBackPress = !isDeleting, + dismissOnClickOutside = !isDeleting + ), + title = { + Text("Confirm Deletion") + }, + text = { + Column { + Text( + "Are you sure you want to delete " + + "$count selected ${if (count == 1) "model" else "models"}? " + + "This operation cannot be undone." + ) + + if (isDeleting) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Deleting models...") + } + } + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = !isDeleting + ) { + Text( + text = "Delete", + color = if (!isDeleting) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isDeleting + ) { + Text("Cancel") + } + } + ) +} + @Composable fun ErrorDialog( + title: String, message: String, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Import Failed") }, + title = { Text(title) }, text = { Text(message) }, confirmButton = { Button(onClick = onDismiss) { 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 715daab3c1..65fd2b6145 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 @@ -108,6 +108,35 @@ class ModelsManagementViewModel @Inject constructor( // TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs } + fun batchDeletionClicked(models: Map) { + _managementState.value = Deletion.Confirming(models) + } + + fun deleteModels(models: Map) = viewModelScope.launch { + val total = models.size + if (total == 0) return@launch + + try { + // Delete models one by one + _managementState.value = Deletion.Deleting(0f, models) + var deleted = 0 + models.keys.toList().forEach { + modelRepository.deleteModel(it) + deleted++ + _managementState.value = Deletion.Deleting(deleted.toFloat() / total, models) + } + _managementState.value = Deletion.Success(models.values.toList()) + + // Reset state after a delay + delay(SUCCESS_RESET_TIMEOUT_MS) + _managementState.value = ModelManagementState.Idle + } catch (e: Exception) { + _managementState.value = Deletion.Error( + message = e.message ?: "Error deleting $total models" + ) + } + } + companion object { private val TAG = ModelsManagementViewModel::class.java.simpleName