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,288 +1429,345 @@ 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
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
if (post.featured) { // Top row: pin indicator (if pinned) + timestamp
Icon( Row(
imageVector = Icons.Filled.PushPin, modifier = Modifier.fillMaxWidth(),
contentDescription = "Pinned", horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.size(14.dp), verticalAlignment = Alignment.CenterVertically
tint = MaterialTheme.colorScheme.primary ) {
if (post.featured) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(14.dp),
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 {
Spacer(modifier = Modifier.width(1.dp))
}
Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
// Content -- the star of the show
if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText(
text = displayText,
query = highlightQuery,
maxLines = if (expanded) Int.MAX_VALUE else 8
) )
} else { } else {
Spacer(modifier = Modifier.width(1.dp))
}
Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(6.dp))
// Content — the star of the show
if (highlightQuery != null && highlightQuery.isNotBlank()) {
HighlightedText(
text = displayText,
query = highlightQuery,
maxLines = if (expanded) Int.MAX_VALUE else 8
)
} else {
Text(
text = displayText,
style = MaterialTheme.typography.bodyLarge,
maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
}
if (!expanded && post.textContent.length > 280) {
Text(
text = "Show more",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clickable { expanded = true }
.padding(vertical = 4.dp)
)
}
// Image grid
if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))
PostImageGrid(
images = allImages,
onImageClick = { index ->
galleryStartIndex = index
showGallery = true
}
)
if (!post.imageAlt.isNullOrBlank()) {
var showAltPopup by remember { mutableStateOf(false) }
Text( Text(
text = "ALT", text = displayText,
modifier = Modifier style = MaterialTheme.typography.bodyLarge,
.padding(top = 4.dp) color = MaterialTheme.colorScheme.onSurface,
.clip(RoundedCornerShape(4.dp)) maxLines = if (expanded) Int.MAX_VALUE else 8,
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f)) overflow = TextOverflow.Ellipsis
.clickable { showAltPopup = !showAltPopup }
.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
) )
if (showAltPopup) {
Text(
text = post.imageAlt,
modifier = Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
}
// Hashtag chips if (!expanded && post.textContent.length > 280) {
if (post.tags.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp)) Text(
@OptIn(ExperimentalLayoutApi::class) text = "Show more",
FlowRow( style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
horizontalArrangement = Arrangement.spacedBy(6.dp), color = MaterialTheme.colorScheme.primary,
verticalArrangement = Arrangement.spacedBy(4.dp) modifier = Modifier
) { .clickable { expanded = true }
post.tags.forEach { tag -> .padding(vertical = 2.dp)
Text( )
text = "#$tag",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) }
)
}
} }
}
// Link preview // Image grid
if (post.linkUrl != null && post.linkTitle != null) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) { PostImageGrid(
Column(modifier = Modifier.padding(12.dp)) { images = allImages,
if (post.linkImageUrl != null) { onImageClick = { index ->
AsyncImage( galleryStartIndex = index
model = post.linkImageUrl, showGallery = true
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
} }
)
if (!post.imageAlt.isNullOrBlank()) {
var showAltPopup by remember { mutableStateOf(false) }
Text( Text(
text = post.linkTitle, text = "ALT",
style = MaterialTheme.typography.titleSmall, modifier = Modifier
maxLines = 2, .padding(top = 4.dp)
overflow = TextOverflow.Ellipsis .clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f))
.clickable { showAltPopup = !showAltPopup }
.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
) )
if (post.linkDescription != null) { if (showAltPopup) {
Text( Text(
text = post.linkDescription, text = post.imageAlt,
modifier = Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Link preview
if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
if (post.linkImageUrl != null) {
AsyncImage(
model = post.linkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = post.linkTitle,
style = MaterialTheme.typography.titleSmall,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} if (post.linkDescription != null) {
} Text(
} text = post.linkDescription,
} style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Queue status maxLines = 2,
if (post.queueStatus != QueueStatus.NONE) { overflow = TextOverflow.Ellipsis
Spacer(modifier = Modifier.height(8.dp)) )
val queueLabel = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
QueueStatus.UPLOADING -> "Uploading..."
QueueStatus.FAILED -> "Upload failed"
else -> ""
}
Row(verticalAlignment = Alignment.CenterVertically) {
AssistChip(
onClick = {},
label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) }
)
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall)
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Metadata line: status dot · reading time
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post)
}
val statusLabel = when {
post.queueStatus != QueueStatus.NONE -> "Pending"
else -> post.status.replaceFirstChar { it.uppercase() }
}
val statusColor = when {
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
post.status == "published" -> Color(0xFF2E7D32)
post.status == "scheduled" -> Color(0xFF1565C0)
else -> Color(0xFF7B1FA2)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(statusColor)
)
Text(
text = statusLabel,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = statusColor
)
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = PostStats.formatReadingTime(stats.readingTimeMinutes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Action bar separator
Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Action bar — evenly spaced icons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isPublished && hasShareableUrl) {
IconButton(onClick = {
onCopyLink()
snackbarHostState?.let { host ->
coroutineScope.launch {
host.showSnackbar("Link copied")
} }
} }
}, modifier = Modifier.size(40.dp)) { }
}
// 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
if (post.queueStatus != QueueStatus.NONE) {
Spacer(modifier = Modifier.height(8.dp))
val queueLabel = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
QueueStatus.UPLOADING -> "Uploading..."
QueueStatus.FAILED -> "Upload failed"
else -> ""
}
Row(verticalAlignment = Alignment.CenterVertically) {
AssistChip(
onClick = {},
label = { Text(queueLabel, style = MaterialTheme.typography.labelSmall) }
)
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Status line: colored dot + status text + reading time
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post)
}
val statusLabel = when {
post.queueStatus != QueueStatus.NONE -> "Pending"
else -> post.status.replaceFirstChar { it.uppercase() }
}
val statusColor = when {
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
post.status == "published" -> Color(0xFF2E7D32)
post.status == "scheduled" -> Color(0xFF1565C0)
else -> Color(0xFF7B1FA2)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColor)
)
Text(
text = statusLabel,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = statusColor
)
Text(
text = "·",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = PostStats.formatReadingTime(stats.readingTimeMinutes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
// Action bar separator
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// Action bar -- evenly spaced icons with labels
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// Edit action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onEdit)
) {
Icon( Icon(
Icons.Default.ContentCopy, Icons.Default.Edit,
contentDescription = "Copy link", 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
)
} }
IconButton(onClick = onShare, modifier = Modifier.size(40.dp)) {
// Copy link action (only for published posts with URL)
if (isPublished && hasShareableUrl) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
onCopyLink()
snackbarHostState?.let { host ->
coroutineScope.launch {
host.showSnackbar("Link copied")
}
}
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy link",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Share action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onShare)
) {
Icon(
Icons.Default.Share,
contentDescription = "Share",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Share",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Pin/Unpin action
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onTogglePin)
) {
Icon( Icon(
Icons.Default.Share, imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = "Share", contentDescription = if (post.featured) "Unpin" else "Pin",
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant 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
) )
} }
} }
IconButton(onClick = onTogglePin, modifier = Modifier.size(40.dp)) {
Icon( // Context menu (long-press) -- only Delete, other actions are in the action bar
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin, DropdownMenu(
contentDescription = if (post.featured) "Unpin" else "Pin", expanded = showContextMenu,
modifier = Modifier.size(20.dp), onDismissRequest = { showContextMenu = false }
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = { showContextMenu = false; onDelete() },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
) )
} }
} }
// Context menu (long-press) // Full-width thick divider between posts (outside padded column)
DropdownMenu( HorizontalDivider(
expanded = showContextMenu, thickness = 1.dp,
onDismissRequest = { showContextMenu = false } color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) { )
DropdownMenuItem(
text = { Text("Delete") },
onClick = { showContextMenu = false; onDelete() },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
)
}
} }
// Thick separator between posts
HorizontalDivider(
modifier = Modifier.padding(horizontal = 0.dp),
thickness = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
)
// Gallery viewer // Gallery viewer
if (showGallery && allImages.isNotEmpty()) { if (showGallery && allImages.isNotEmpty()) {
FullScreenGallery( FullScreenGallery(