feat: Migrate grid view to staggered layout with Google Keep-style cards and enhanced tag interaction

- Replace LazyVerticalGrid with LazyVerticalStaggeredGrid for dynamic height cards
- Remove fixed height constraints (heightIn) to allow wrap-content behavior
- Add onTagClick callback to GridViewItem for tag filtering support
- Increase description maxLines from 3 to 6 for better content preview
- Replace tag preview badges with full clickable TagChip components in LazyRow
- Standardize action
This commit is contained in:
Bruno Charest 2026-02-11 19:23:01 -05:00
parent 4bd1b5a6f3
commit 4ed567eb3a
2 changed files with 189 additions and 194 deletions

View File

@ -10,6 +10,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -1437,18 +1440,19 @@ fun FeedScreen(
else -> {
when (viewStyle) {
ViewStyle.GRID -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalItemSpacing = 12.dp
) {
items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index]
if (link != null) {
GridViewItem(
link = link,
onTagClick = viewModel::onTagClicked,
onItemClick = {
if (selectionMode) {
selectedIds =

View File

@ -283,11 +283,12 @@ fun ListViewItem(
}
/**
* Grid view item - compact cards in a 2-column grid
* Grid view item - Google Keep style staggered cards with wrap-content height
*/
@Composable
fun GridViewItem(
link: ShaarliLink,
onTagClick: (String) -> Unit,
onLinkClick: (String) -> Unit,
onItemClick: (() -> Unit)? = null,
onItemLongClick: (() -> Unit)? = null,
@ -317,18 +318,28 @@ fun GridViewItem(
GlassCard(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 220.dp),
.fillMaxWidth(),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick,
glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
modifier = Modifier.fillMaxWidth()
) {
Column {
// Thumbnail (Grid View)
// Selection checkbox
if (selectionMode) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
}
// Thumbnail
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
@ -341,21 +352,22 @@ fun GridViewItem(
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.padding(bottom = 12.dp)
.height(120.dp)
.padding(bottom = 8.dp)
.clip(RoundedCornerShape(12.dp))
)
}
// Title with pin indicator
// Title
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
HealthStatusIcon(
healthStatus = link.healthStatus,
modifier = Modifier.size(16.dp).padding(end = 4.dp).align(Alignment.CenterVertically)
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp, top = 2.dp)
)
Text(
@ -377,64 +389,39 @@ fun GridViewItem(
imageVector = Icons.Default.PushPin,
contentDescription = "Épinglé",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
modifier = Modifier
.size(16.dp)
.padding(start = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Description (plain text for scroll performance)
// Description
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = link.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
Column {
// Tags (show only first 2)
// Tags - clickable like ListView
if (link.tags.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
link.tags.take(2).forEach { tag ->
Text(
text = "#$tag",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (link.tags.size > 2) {
Text(
text = "+${link.tags.size - 2}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(link.tags) { tag ->
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
}
}
}
// Actions row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
Spacer(modifier = Modifier.height(8.dp))
// Metadata row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@ -462,74 +449,78 @@ fun GridViewItem(
}
}
Row {
Spacer(modifier = Modifier.height(4.dp))
// Action buttons row - all buttons from other views, with proper touch targets
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
if (link.isAudioPlayable() && onPlayClick != null) {
IconButton(
onClick = { onPlayClick(link) },
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Lire l'audio",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(16.dp)
modifier = Modifier.size(18.dp)
)
}
}
// Pin button
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.size(14.dp)
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = onViewClick,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = "View Details",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = { onEditClick(link.id) },
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = { showDeleteDialog = true },
modifier = Modifier.size(24.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
}
/**
* Compact view item - minimal info for dense lists