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.Context
import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
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.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import kotlinx.coroutines.delay
import androidx.compose.foundation.gestures.detectTapGestures
import com.swoosh.microblog.ui.animation.SwooshMotion
@ -1422,288 +1429,345 @@ fun PostCardContent(
onClickLabel = "View post details",
onLongClick = { showContextMenu = true }
)
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
// Top row: pin icon (if pinned) + timestamp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
// Padded content area
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)
) {
if (post.featured) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
// Top row: pin indicator (if pinned) + timestamp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
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 {
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 = "ALT",
modifier = Modifier
.padding(top = 4.dp)
.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
text = displayText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
if (showAltPopup) {
Text(
text = post.imageAlt,
modifier = Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 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) }
)
}
if (!expanded && post.textContent.length > 280) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Show more",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clickable { expanded = true }
.padding(vertical = 2.dp)
)
}
}
// Link preview
if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(10.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))
// Image grid
if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
PostImageGrid(
images = allImages,
onImageClick = { index ->
galleryStartIndex = index
showGallery = true
}
)
if (!post.imageAlt.isNullOrBlank()) {
var showAltPopup by remember { mutableStateOf(false) }
Text(
text = post.linkTitle,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
text = "ALT",
modifier = Modifier
.padding(top = 4.dp)
.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 = post.linkDescription,
text = post.imageAlt,
modifier = Modifier.padding(top = 4.dp),
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,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
// 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(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")
if (post.linkDescription != null) {
Text(
text = post.linkDescription,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}, 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(
Icons.Default.ContentCopy,
contentDescription = "Copy link",
modifier = Modifier.size(20.dp),
Icons.Default.Edit,
contentDescription = "Edit",
modifier = Modifier.size(24.dp),
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(
Icons.Default.Share,
contentDescription = "Share",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
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
)
}
}
IconButton(onClick = onTogglePin, modifier = Modifier.size(40.dp)) {
Icon(
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) -- only Delete, other actions are in the action bar
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false }
) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = { showContextMenu = false; onDelete() },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
)
}
}
// Context menu (long-press)
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false }
) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = { showContextMenu = false; onDelete() },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
)
}
// Full-width thick divider between posts (outside padded column)
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
// Thick separator between posts
HorizontalDivider(
modifier = Modifier.padding(horizontal = 0.dp),
thickness = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
)
// Gallery viewer
if (showGallery && allImages.isNotEmpty()) {
FullScreenGallery(