UI: extract a shared ModelCard component

This commit is contained in:
Han Yin 2025-04-14 23:20:17 -07:00
parent 0d41e75ca5
commit 6b48f7473f
3 changed files with 206 additions and 223 deletions

View File

@ -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"
)
}
}
}

View File

@ -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
)
}
}
}
}
}

View File

@ -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<ModelInfo>,
isMultiSelectionMode: Boolean,
selectedModels: Map<String, ModelInfo>,
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,