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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -1437,18 +1440,19 @@ fun FeedScreen(
else -> { else -> {
when (viewStyle) { when (viewStyle) {
ViewStyle.GRID -> { ViewStyle.GRID -> {
LazyVerticalGrid( LazyVerticalStaggeredGrid(
columns = GridCells.Fixed(2), columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalItemSpacing = 12.dp
) { ) {
items(count = pagingItems.itemCount) { index -> items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index] val link = pagingItems[index]
if (link != null) { if (link != null) {
GridViewItem( GridViewItem(
link = link, link = link,
onTagClick = viewModel::onTagClicked,
onItemClick = { onItemClick = {
if (selectionMode) { if (selectionMode) {
selectedIds = 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 @Composable
fun GridViewItem( fun GridViewItem(
link: ShaarliLink, link: ShaarliLink,
onTagClick: (String) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onItemClick: (() -> Unit)? = null, onItemClick: (() -> Unit)? = null,
onItemLongClick: (() -> Unit)? = null, onItemLongClick: (() -> Unit)? = null,
@ -317,18 +318,28 @@ fun GridViewItem(
GlassCard( GlassCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.heightIn(min = 220.dp),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick, onLongClick = onItemLongClick,
glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth()
verticalArrangement = Arrangement.SpaceBetween
) { ) {
Column { // Selection checkbox
// Thumbnail (Grid View) if (selectionMode) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
}
// Thumbnail
if (!link.thumbnailUrl.isNullOrBlank()) { if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
@ -341,21 +352,22 @@ fun GridViewItem(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(140.dp) .height(120.dp)
.padding(bottom = 12.dp) .padding(bottom = 8.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
} }
// Title with pin indicator // Title
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
HealthStatusIcon( HealthStatusIcon(
healthStatus = link.healthStatus, 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( Text(
@ -377,64 +389,39 @@ fun GridViewItem(
imageVector = Icons.Default.PushPin, imageVector = Icons.Default.PushPin,
contentDescription = "Épinglé", contentDescription = "Épinglé",
tint = MaterialTheme.colorScheme.primary, 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
// Description (plain text for scroll performance)
if (link.description.isNotBlank()) { if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = link.description, text = link.description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 6,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
}
Column { // Tags - clickable like ListView
// Tags (show only first 2)
if (link.tags.isNotEmpty()) { if (link.tags.isNotEmpty()) {
Row( Spacer(modifier = Modifier.height(8.dp))
horizontalArrangement = Arrangement.spacedBy(4.dp), LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
modifier = Modifier.padding(bottom = 8.dp) items(link.tags) { tag ->
) { TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
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
)
} }
} }
} }
// Actions row Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(), // Metadata row
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) 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) { if (link.isAudioPlayable() && onPlayClick != null) {
IconButton( IconButton(
onClick = { onPlayClick(link) }, onClick = { onPlayClick(link) },
modifier = Modifier.size(24.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.PlayArrow, imageVector = Icons.Default.PlayArrow,
contentDescription = "Lire l'audio", contentDescription = "Lire l'audio",
tint = MaterialTheme.colorScheme.tertiary, tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(16.dp) modifier = Modifier.size(18.dp)
) )
} }
} }
// Pin button
IconButton( IconButton(
onClick = { onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id) onTogglePin(link.id)
}, },
modifier = Modifier.size(24.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.PushPin, imageVector = Icons.Default.PushPin,
contentDescription = if (link.isPinned) "Désépingler" else "Épingler", contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, tint = if (link.isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
modifier = Modifier.size(14.dp) modifier = Modifier.size(18.dp)
) )
} }
IconButton( IconButton(
onClick = onViewClick, onClick = onViewClick,
modifier = Modifier.size(24.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Visibility, imageVector = Icons.Default.Visibility,
contentDescription = "View Details", contentDescription = "View Details",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(18.dp)
) )
} }
IconButton( IconButton(
onClick = { onEditClick(link.id) }, onClick = { onEditClick(link.id) },
modifier = Modifier.size(24.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Edit, imageVector = Icons.Default.Edit,
contentDescription = "Edit", contentDescription = "Edit",
tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f), tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(18.dp)
) )
} }
IconButton( IconButton(
onClick = { showDeleteDialog = true }, onClick = { showDeleteDialog = true },
modifier = Modifier.size(24.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Delete", contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), 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 * Compact view item - minimal info for dense lists