diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index abdaa02..f54b4c4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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(