From 6b48f7473ff80dbc9f81a8a07d8d2ea54fbe5f4e Mon Sep 17 00:00:00 2001 From: Han Yin Date: Mon, 14 Apr 2025 23:20:17 -0700 Subject: [PATCH] UI: extract a shared ModelCard component --- .../llama/revamp/ui/components/ModelCard.kt | 152 +++++++++++++++ .../revamp/ui/screens/ModelSelectionScreen.kt | 104 +---------- .../ui/screens/ModelsManagementScreen.kt | 173 +++++------------- 3 files changed, 206 insertions(+), 223 deletions(-) create mode 100644 examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/ModelCard.kt diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/ModelCard.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/ModelCard.kt new file mode 100644 index 0000000000..4361861478 --- /dev/null +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/ModelCard.kt @@ -0,0 +1,152 @@ +package com.example.llama.revamp.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.llama.revamp.data.model.ModelInfo +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Reusable card component for displaying model information. + * Can be configured for selection mode or normal display mode. + */ +@Composable +fun ModelCard( + model: ModelInfo, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean? = null, // `null`: not in selection mode, otherwise true/false + actionButton: @Composable (() -> Unit)? = null +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = when { + isSelected == true -> CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + isSelected == false -> CardDefaults.cardColors() + else -> CardDefaults.cardColors() // Not in selection mode + }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Show checkbox if in selection mode + if (isSelected != null) { + 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 + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Model details row (parameters, quantization, size) + Row { + if (model.parameters != null) { + Text( + text = model.parameters, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (model.quantization != null) { + Text( + text = " • ${model.quantization}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = " • ${model.formattedSize}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Context length + if (model.contextLength != null) { + Text( + text = "Context Length: ${model.contextLength}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Last used date + 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 + ) + } + } + + // Custom action button or built-in ones + actionButton?.invoke() ?: Spacer(modifier = Modifier.width(8.dp)) + } + } +} + +/** + * Predefined action buttons for ModelCard + */ +object ModelCardActions { + @Composable + fun PlayButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Select model", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + @Composable + fun InfoButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Model details" + ) + } + } +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt index 86107a8d00..d94c7eb880 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt @@ -1,23 +1,13 @@ 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 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -26,10 +16,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.llama.revamp.data.model.ModelInfo +import com.example.llama.revamp.ui.components.ModelCard +import com.example.llama.revamp.ui.components.ModelCardActions import com.example.llama.revamp.ui.components.PerformanceAppScaffold -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,7 +54,12 @@ fun ModelSelectionScreen( items(models) { model -> ModelCard( model = model, - onClick = { onModelSelected(model) } + onClick = { onModelSelected(model) }, + modifier = Modifier.padding(vertical = 4.dp), + isSelected = null, // Not in selection mode + actionButton = { + ModelCardActions.PlayButton(onClick = { onModelSelected(model) }) + } ) Spacer(modifier = Modifier.height(8.dp)) } @@ -73,85 +67,3 @@ fun ModelSelectionScreen( } } } - -@Composable -fun ModelCard( - model: ModelInfo, - onClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - elevation = CardDefaults.cardElevation( - defaultElevation = 2.dp - ) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = model.name, - style = MaterialTheme.typography.titleLarge - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Row { - Text( - text = model.parameters ?: " - ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = " • ${model.quantization ?: " - "}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = " • ${model.formattedSize}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "Context Length: ${model.contextLength ?: " - "}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - 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 - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.weight(1f)) - - IconButton( - onClick = onClick - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = "Select model", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - } -} 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 0c72b6f5bd..eee215e54e 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 @@ -3,7 +3,6 @@ package com.example.llama.revamp.ui.screens import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,14 +26,10 @@ 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.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button -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 @@ -68,6 +63,8 @@ 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.ModelCard +import com.example.llama.revamp.ui.components.ModelCardActions import com.example.llama.revamp.ui.components.StorageAppScaffold import com.example.llama.revamp.util.formatSize import com.example.llama.revamp.viewmodel.ModelManagementState @@ -76,9 +73,6 @@ 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 /** * Screen for managing LLM models (view, download, delete) @@ -336,28 +330,39 @@ fun ModelsManagementScreen( ) { paddingValues -> Box(modifier = Modifier.fillMaxSize()) { // Model cards - ModelCardList( - models = sortedModels, - isMultiSelectionMode = isMultiSelectionMode, - selectedModels = selectedModels, - onModelClick = { modelId -> - if (isMultiSelectionMode) { - // Toggle selection - if (selectedModels.contains(modelId)) { - selectedModels.remove(modelId) - } else { - selectedModels.put(modelId, sortedModels.first { it.id == modelId } ) - } - } else { - // View model details - viewModel.viewModelDetails(modelId) - } - }, - onModelInfoClick = { modelId -> - viewModel.viewModelDetails(modelId) - }, - modifier = Modifier.padding(paddingValues) - ) + LazyColumn( + modifier = Modifier.padding(paddingValues).fillMaxSize().padding(16.dp) + ) { + items(items = sortedModels, key = { it.id }) { model -> + ModelCard( + model = model, + onClick = { + if (isMultiSelectionMode) { + // Toggle selection + if (selectedModels.contains(model.id)) { + selectedModels.remove(model.id) + } else { + selectedModels.put(model.id, sortedModels.first { it.id == model.id } ) + } + } else { + // View model details + viewModel.viewModelDetails(model.id) + } + }, + modifier = Modifier.padding(bottom = 8.dp), + isSelected = + if (isMultiSelectionMode) selectedModels.contains(model.id) else null, + actionButton = + if (!isMultiSelectionMode) { + { + ModelCardActions.InfoButton( + onClick = { viewModel.viewModelDetails(model.id) } + ) + } + } else null + ) + } + } // Model import progress overlay when (val state = managementState) { @@ -368,13 +373,16 @@ fun ModelsManagementScreen( isImporting = false, progress = 0.0f, onConfirm = { - viewModel.importLocalModelFile(state.uri, state.fileName, state.fileSize) + viewModel.importLocalModelFile( + state.uri, state.fileName, state.fileSize + ) }, onCancel = { viewModel.resetManagementState() } ) } + is Importation.Importing -> { ImportProgressDialog( fileName = state.fileName, @@ -387,6 +395,7 @@ fun ModelsManagementScreen( }, ) } + is Importation.Error -> { ErrorDialog( title = "Import Failed", @@ -394,6 +403,7 @@ fun ModelsManagementScreen( onDismiss = { viewModel.resetManagementState() } ) } + is Importation.Success -> { LaunchedEffect(state) { coroutineScope.launch { @@ -405,6 +415,7 @@ fun ModelsManagementScreen( viewModel.resetManagementState() } } + is Deletion.Confirming -> { BatchDeleteConfirmationDialog( count = state.models.size, @@ -413,6 +424,7 @@ fun ModelsManagementScreen( isDeleting = false ) } + is Deletion.Deleting -> { BatchDeleteConfirmationDialog( count = state.models.size, @@ -421,6 +433,7 @@ fun ModelsManagementScreen( isDeleting = true ) } + is Deletion.Error -> { ErrorDialog( title = "Deletion Failed", @@ -428,6 +441,7 @@ fun ModelsManagementScreen( onDismiss = { viewModel.resetManagementState() } ) } + is Deletion.Success -> { LaunchedEffect(state) { exitSelectionMode() @@ -441,108 +455,13 @@ fun ModelsManagementScreen( } } + is ModelManagementState.Idle -> { /* Idle state, nothing to show */ } } } } } - -@Composable -private fun ModelCardList( - models: List, - isMultiSelectionMode: Boolean, - selectedModels: Map, - onModelClick: (String) -> Unit, - onModelInfoClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - ) { - items( - items = models, - key = { it.id } - ) { model -> - ModelCard( - model = model, - isMultiSelectionMode = isMultiSelectionMode, - isSelected = selectedModels.contains(model.id), - onClick = { onModelClick(model.id) }, - onInfoClick = { onModelInfoClick(model.id) }, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - } -} - -@Composable -private fun ModelCard( - model: ModelInfo, - isMultiSelectionMode: Boolean, - isSelected: Boolean, - onClick: () -> Unit, - onInfoClick: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = if (isSelected && isMultiSelectionMode) - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - else - CardDefaults.cardColors() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 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 = "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" - ) - } - } - } - } -} - @Composable fun ImportProgressDialog( fileName: String,