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:
parent
4bd1b5a6f3
commit
4ed567eb3a
@ -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 =
|
||||||
|
|||||||
@ -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,73 +449,77 @@ 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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user