feat: Bluesky-inspired feed redesign - opaque surface for swipe, large icons with labels, clean layout

This commit is contained in:
Paweł Orzech 2026-03-19 14:20:03 +01:00
parent 71d58008c6
commit 5ab2cbafdc
No known key found for this signature in database

View file

@ -4,16 +4,23 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
@ -1422,34 +1429,45 @@ fun PostCardContent(
onClickLabel = "View post details", onClickLabel = "View post details",
onLongClick = { showContextMenu = true } onLongClick = { showContextMenu = true }
) )
.padding(horizontal = 20.dp, vertical = 14.dp)
) { ) {
// Top row: pin icon (if pinned) + timestamp // Padded content area
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)
) {
// Top row: pin indicator (if pinned) + timestamp
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (post.featured) { if (post.featured) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.Filled.PushPin, imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned", contentDescription = "Pinned",
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Pinned",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.primary
)
}
} else { } else {
Spacer(modifier = Modifier.width(1.dp)) Spacer(modifier = Modifier.width(1.dp))
} }
Text( Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt), text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(8.dp))
// Content — the star of the show // Content -- the star of the show
if (highlightQuery != null && highlightQuery.isNotBlank()) { if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText( HighlightedText(
text = displayText, text = displayText,
@ -1460,25 +1478,27 @@ fun PostCardContent(
Text( Text(
text = displayText, text = displayText,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 8, maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
if (!expanded && post.textContent.length > 280) { if (!expanded && post.textContent.length > 280) {
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Show more", text = "Show more",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
.clickable { expanded = true } .clickable { expanded = true }
.padding(vertical = 4.dp) .padding(vertical = 2.dp)
) )
} }
// Image grid // Image grid
if (allImages.isNotEmpty()) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
PostImageGrid( PostImageGrid(
images = allImages, images = allImages,
onImageClick = { index -> onImageClick = { index ->
@ -1511,28 +1531,9 @@ fun PostCardContent(
} }
} }
// Hashtag chips
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
Text(
text = "#$tag",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) }
)
}
}
}
// Link preview // Link preview
if (post.linkUrl != null && post.linkTitle != null) { if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
if (post.linkImageUrl != null) { if (post.linkImageUrl != null) {
@ -1566,6 +1567,25 @@ fun PostCardContent(
} }
} }
// Hashtag tags (bold colored text, not chips)
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
Text(
text = "#$tag",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) }
)
}
}
}
// Queue status // Queue status
if (post.queueStatus != QueueStatus.NONE) { if (post.queueStatus != QueueStatus.NONE) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -1589,9 +1609,9 @@ fun PostCardContent(
} }
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
// Metadata line: status dot · reading time // Status line: colored dot + status text + reading time
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) { val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post) PostStats.fromFeedPost(post)
} }
@ -1612,7 +1632,7 @@ fun PostCardContent(
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.clip(RoundedCornerShape(4.dp)) .clip(CircleShape)
.background(statusColor) .background(statusColor)
) )
Text( Text(
@ -1620,7 +1640,11 @@ fun PostCardContent(
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = statusColor color = statusColor
) )
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
text = "·",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text( Text(
text = PostStats.formatReadingTime(stats.readingTimeMinutes), text = PostStats.formatReadingTime(stats.readingTimeMinutes),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@ -1628,61 +1652,101 @@ fun PostCardContent(
) )
} }
Spacer(modifier = Modifier.height(12.dp))
// Action bar separator // Action bar separator
Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Action bar — evenly spaced icons // Action bar -- evenly spaced icons with labels
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 6.dp), .padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) { // Edit action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onEdit)
) {
Icon( Icon(
Icons.Default.Edit, Icons.Default.Edit,
contentDescription = "Edit", contentDescription = "Edit",
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text(
text = "Edit",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
// Copy link action (only for published posts with URL)
if (isPublished && hasShareableUrl) { if (isPublished && hasShareableUrl) {
IconButton(onClick = { Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
onCopyLink() onCopyLink()
snackbarHostState?.let { host -> snackbarHostState?.let { host ->
coroutineScope.launch { coroutineScope.launch {
host.showSnackbar("Link copied") host.showSnackbar("Link copied")
} }
} }
}, modifier = Modifier.size(40.dp)) { }
) {
Icon( Icon(
Icons.Default.ContentCopy, Icons.Default.ContentCopy,
contentDescription = "Copy link", contentDescription = "Copy link",
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text(
text = "Copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
IconButton(onClick = onShare, modifier = Modifier.size(40.dp)) {
// Share action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onShare)
) {
Icon( Icon(
Icons.Default.Share, Icons.Default.Share,
contentDescription = "Share", contentDescription = "Share",
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} Text(
} text = "Share",
IconButton(onClick = onTogglePin, modifier = Modifier.size(40.dp)) { style = MaterialTheme.typography.labelSmall,
Icon( color = MaterialTheme.colorScheme.onSurfaceVariant
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = if (post.featured) "Unpin" else "Pin",
modifier = Modifier.size(20.dp),
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
// Context menu (long-press) // Pin/Unpin action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onTogglePin)
) {
Icon(
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = if (post.featured) "Unpin" else "Pin",
modifier = Modifier.size(24.dp),
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (post.featured) "Unpin" else "Pin",
style = MaterialTheme.typography.labelSmall,
color = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Context menu (long-press) -- only Delete, other actions are in the action bar
DropdownMenu( DropdownMenu(
expanded = showContextMenu, expanded = showContextMenu,
onDismissRequest = { showContextMenu = false } onDismissRequest = { showContextMenu = false }
@ -1697,12 +1761,12 @@ fun PostCardContent(
} }
} }
// Thick separator between posts // Full-width thick divider between posts (outside padded column)
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 0.dp), thickness = 1.dp,
thickness = 2.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
) )
}
// Gallery viewer // Gallery viewer
if (showGallery && allImages.isNotEmpty()) { if (showGallery && allImages.isNotEmpty()) {