UI: implement multiple models deletion; update Models Management screen

This commit is contained in:
Han Yin 2025-04-14 22:01:23 -07:00
parent 025e3d2417
commit 2d6b8856f6
2 changed files with 183 additions and 45 deletions

View File

@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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
@ -35,6 +37,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@ -42,7 +45,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -50,6 +56,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.painterResource
import androidx.compose.ui.text.style.TextOverflow 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.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.R
import com.example.llama.revamp.data.model.ModelInfo import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.ui.components.StorageAppScaffold 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.ModelSortOrder
import com.example.llama.revamp.viewmodel.ModelsManagementViewModel import com.example.llama.revamp.viewmodel.ModelsManagementViewModel
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import com.example.llama.R
import com.example.llama.revamp.viewmodel.ModelImportState
/** /**
* Screen for managing LLM models (view, download, delete) * Screen for managing LLM models (view, download, delete)
@ -76,26 +86,25 @@ fun ModelsManagementScreen(
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
viewModel: ModelsManagementViewModel = hiltViewModel() viewModel: ModelsManagementViewModel = hiltViewModel()
) { ) {
val storageMetrics by viewModel.storageMetrics.collectAsState() val coroutineScope = rememberCoroutineScope()
val sortedModels by viewModel.sortedModels.collectAsState() val snackbarHostState = remember { SnackbarHostState() }
// Model sorting // ViewModel states
val storageMetrics by viewModel.storageMetrics.collectAsState()
val sortOrder by viewModel.sortOrder.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) } var showSortMenu by remember { mutableStateOf(false) }
// Model importing // UI state: importing
val importState by viewModel.importState.collectAsState()
var showImportModelMenu by remember { mutableStateOf(false) } var showImportModelMenu by remember { mutableStateOf(false) }
val fileLauncher = rememberLauncherForActivityResult( val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.importLocalModel(it) } } ) { uri -> uri?.let { viewModel.importLocalModel(it) } }
BackHandler(enabled = importState is ModelImportState.Importing) { // UI state: multi-selecting
/* Ignore back press while importing model */
}
// Multi-selection
var isMultiSelectionMode by remember { mutableStateOf(false) } var isMultiSelectionMode by remember { mutableStateOf(false) }
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() } val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
val exitSelectionMode = { val exitSelectionMode = {
@ -103,11 +112,19 @@ fun ModelsManagementScreen(
selectedModels.clear() selectedModels.clear()
} }
BackHandler(
enabled = managementState is Importation.Importing
|| managementState is Deletion.Deleting
) {
/* Ignore back press while processing model management requests */
}
StorageAppScaffold( StorageAppScaffold(
title = "Models Management", title = "Models Management",
storageUsed = storageMetrics?.usedGB ?: 0f, storageUsed = storageMetrics?.usedGB ?: 0f,
storageTotal = storageMetrics?.totalGB ?: 0f, storageTotal = storageMetrics?.totalGB ?: 0f,
onNavigateBack = onBackPressed, onNavigateBack = onBackPressed,
snackbarHostState = snackbarHostState,
bottomBar = { bottomBar = {
BottomAppBar( BottomAppBar(
actions = { actions = {
@ -135,11 +152,8 @@ fun ModelsManagementScreen(
IconButton( IconButton(
onClick = { onClick = {
// Delete selected
if (selectedModels.isNotEmpty()) { if (selectedModels.isNotEmpty()) {
// TODO-han.yin: pop up an AlertDialog asking user for confirmation viewModel.batchDeletionClicked(selectedModels.toMap())
viewModel.deleteModels(selectedModels)
exitSelectionMode()
} }
}, },
enabled = selectedModels.isNotEmpty() enabled = selectedModels.isNotEmpty()
@ -150,7 +164,7 @@ fun ModelsManagementScreen(
tint = if (selectedModels.isNotEmpty()) tint = if (selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
else else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
) )
} }
} else { } else {
@ -239,7 +253,9 @@ fun ModelsManagementScreen(
) )
} }
IconButton(onClick = { /* Filter action - stub for now */ }) { IconButton(
onClick = {/* TODO-han.yin: implement filtering */ }
) {
Icon( Icon(
imageVector = Icons.Default.FilterAlt, imageVector = Icons.Default.FilterAlt,
contentDescription = "Filter models" contentDescription = "Filter models"
@ -333,31 +349,69 @@ fun ModelsManagementScreen(
onModelInfoClick = { modelId -> onModelInfoClick = { modelId ->
viewModel.viewModelDetails(modelId) viewModel.viewModelDetails(modelId)
}, },
onModelDeleteClick = { modelId ->
viewModel.deleteModel(modelId)
},
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) )
// Model import progress overlay // Model import progress overlay
when (val state = importState) { when (val state = managementState) {
is ModelImportState.Importing -> { is Importation.Importing -> {
ImportProgressOverlay( ImportProgressOverlay(
progress = state.progress, progress = state.progress,
filename = state.filename, filename = state.filename,
onCancel = { /* Implement cancellation if needed */ } onCancel = { /* Implement cancellation if needed */ }
) )
} }
is ModelImportState.Error -> { is Importation.Error -> {
ErrorDialog( ErrorDialog(
title = "Import Failed",
message = state.message, message = state.message,
onDismiss = { viewModel.resetImportState() } onDismiss = { viewModel.resetManagementState() }
) )
} }
is ModelImportState.Success -> { is Importation.Success -> {
LaunchedEffect(state) { LaunchedEffect(state) {
// Show success snackbar or message coroutineScope.launch {
// This will auto-dismiss after the delay in viewModel 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 */ } else -> { /* Idle state, nothing to show */ }
@ -374,7 +428,6 @@ private fun ModelCardList(
selectedModels: Map<String, ModelInfo>, selectedModels: Map<String, ModelInfo>,
onModelClick: (String) -> Unit, onModelClick: (String) -> Unit,
onModelInfoClick: (String) -> Unit, onModelInfoClick: (String) -> Unit,
onModelDeleteClick: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumn( LazyColumn(
@ -392,10 +445,6 @@ private fun ModelCardList(
isSelected = selectedModels.contains(model.id), isSelected = selectedModels.contains(model.id),
onClick = { onModelClick(model.id) }, onClick = { onModelClick(model.id) },
onInfoClick = { onModelInfoClick(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)) Spacer(modifier = Modifier.height(8.dp))
} }
@ -409,9 +458,7 @@ private fun ModelCard(
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onDeleteClick: () -> Unit
) { ) {
// Model item implementation with selection support
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -464,19 +511,12 @@ private fun ModelCard(
contentDescription = "Model details" contentDescription = "Model details"
) )
} }
IconButton(onClick = onDeleteClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete model",
tint = MaterialTheme.colorScheme.error
)
}
} }
} }
} }
} }
// TODO-han.yin: Rewrite into
@Composable @Composable
fun ImportProgressOverlay( fun ImportProgressOverlay(
progress: Float, 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 @Composable
fun ErrorDialog( fun ErrorDialog(
title: String,
message: String, message: String,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Import Failed") }, title = { Text(title) },
text = { Text(message) }, text = { Text(message) },
confirmButton = { confirmButton = {
Button(onClick = onDismiss) { Button(onClick = onDismiss) {

View File

@ -108,6 +108,35 @@ class ModelsManagementViewModel @Inject constructor(
// TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs // TODO-han.yin: Stub for now. Would need to investigate HuggingFace APIs
} }
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
fun deleteModels(models: Map<String, ModelInfo>) = 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 { companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName private val TAG = ModelsManagementViewModel::class.java.simpleName