feat: handle email-only posts in PostUploadWorker, Feed, and Detail screens

Phase 4b.3: Sent status in Feed + PostUploadWorker handling.
- PostUploadWorker: handle QUEUED_EMAIL_ONLY with email_only=true on GhostPost,
  pass newsletter slug to repository.createPost()
- FeedViewModel: map GhostPost email_only/sent status to FeedPost.emailOnly
- FeedScreen FilterChipsBar: add "Sent" chip (magenta, only when newsletter enabled)
- FeedScreen PostCardContent: show envelope icon + "Sent" in magenta for sent posts,
  replace "Share" with "Copy content" for email-only posts
- FeedScreen StatusBadge: handle sent/emailOnly status
- DetailScreen: show email-only info card with errorContainer color when post is
  sent via email only, noting it's not visible on the blog
This commit is contained in:
Paweł Orzech 2026-03-20 00:58:50 +01:00
parent f93a21e743
commit 5c931b138c
4 changed files with 124 additions and 19 deletions

View file

@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
@ -375,6 +376,43 @@ fun DetailScreen(
PostStatsSection(post) PostStatsSection(post)
} }
} }
// Email-only info card
if (post.status == "sent" || post.emailOnly) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Email,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "\u2709 SENT VIA EMAIL ONLY",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "This post is not visible on your blog.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
)
}
}
}
}
} }
} }

View file

@ -767,6 +767,12 @@ fun FilterChipsBar(
activeFilter: PostFilter, activeFilter: PostFilter,
onFilterSelected: (PostFilter) -> Unit onFilterSelected: (PostFilter) -> Unit
) { ) {
val context = LocalContext.current
val newsletterEnabled = remember {
com.swoosh.microblog.data.NewsletterPreferences(context).isNewsletterEnabled()
}
val sentColor = Color(0xFF6A1B9A) // magenta
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -775,19 +781,29 @@ fun FilterChipsBar(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
PostFilter.values().forEach { filter -> PostFilter.values().forEach { filter ->
// Only show SENT chip when newsletter is enabled
if (filter == PostFilter.SENT && !newsletterEnabled) return@forEach
val selected = filter == activeFilter val selected = filter == activeFilter
val containerColor by animateColorAsState( val containerColor by animateColorAsState(
targetValue = if (selected) targetValue = when {
MaterialTheme.colorScheme.primaryContainer selected && filter == PostFilter.SENT -> sentColor.copy(alpha = 0.2f)
else selected -> MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.surface, else -> MaterialTheme.colorScheme.surface
},
animationSpec = SwooshMotion.quick(), animationSpec = SwooshMotion.quick(),
label = "chipColor" label = "chipColor"
) )
FilterChip( FilterChip(
selected = selected, selected = selected,
onClick = { onFilterSelected(filter) }, onClick = { onFilterSelected(filter) },
label = { Text(filter.displayName) }, label = {
Text(
filter.displayName,
color = if (selected && filter == PostFilter.SENT) sentColor
else Color.Unspecified
)
},
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = containerColor selectedContainerColor = containerColor
) )
@ -1647,12 +1663,15 @@ fun PostCardContent(
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) { val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post) PostStats.fromFeedPost(post)
} }
val isSent = post.status == "sent" || post.emailOnly
val statusLabel = when { val statusLabel = when {
post.queueStatus != QueueStatus.NONE -> "Pending" post.queueStatus != QueueStatus.NONE -> "Pending"
isSent -> "Sent"
else -> post.status.replaceFirstChar { it.uppercase() } else -> post.status.replaceFirstChar { it.uppercase() }
} }
val statusColor = when { val statusColor = when {
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100) post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
isSent -> Color(0xFF6A1B9A)
post.status == "published" -> Color(0xFF2E7D32) post.status == "published" -> Color(0xFF2E7D32)
post.status == "scheduled" -> Color(0xFF1565C0) post.status == "scheduled" -> Color(0xFF1565C0)
else -> Color(0xFF7B1FA2) else -> Color(0xFF7B1FA2)
@ -1661,19 +1680,28 @@ fun PostCardContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Box( if (isSent) {
modifier = Modifier Icon(
.size(8.dp) Icons.Default.Email,
.clip(CircleShape) contentDescription = "Sent",
.background(statusColor) modifier = Modifier.size(12.dp),
) tint = statusColor
)
} else {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColor)
)
}
Text( Text(
text = statusLabel, text = statusLabel,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = statusColor color = statusColor
) )
Text( Text(
text = "·", text = "\u00B7",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -1714,8 +1742,35 @@ fun PostCardContent(
) )
} }
// Share action (copies link to clipboard) // Share / Copy content action
if (isPublished && hasShareableUrl) { if (isSent) {
// For sent (email-only) posts, show "Copy content" instead of "Share"
val copyContext = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
val clipboard = copyContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post content", post.textContent))
snackbarHostState?.let { host ->
coroutineScope.launch {
host.showSnackbar("Content copied to clipboard")
}
}
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy content",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else if (isPublished && hasShareableUrl) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { modifier = Modifier.clickable {
@ -2168,8 +2223,10 @@ fun buildHighlightedString(
@Composable @Composable
fun StatusBadge(post: FeedPost) { fun StatusBadge(post: FeedPost) {
val isSent = post.status == "sent" || post.emailOnly
val (label, containerColor, labelColor) = when { val (label, containerColor, labelColor) = when {
post.queueStatus != QueueStatus.NONE -> Triple("Pending", Color(0xFFFFF3E0), Color(0xFFE65100)) post.queueStatus != QueueStatus.NONE -> Triple("Pending", Color(0xFFFFF3E0), Color(0xFFE65100))
isSent -> Triple("Sent", Color(0xFFF3E5F5), Color(0xFF6A1B9A))
post.status == "published" -> Triple("Published", Color(0xFFE8F5E9), Color(0xFF2E7D32)) post.status == "published" -> Triple("Published", Color(0xFFE8F5E9), Color(0xFF2E7D32))
post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0)) post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2)) else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))

View file

@ -536,6 +536,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
val fileData = extractFileCardFromMobiledoc(mobiledoc) val fileData = extractFileCardFromMobiledoc(mobiledoc)
val isEmailOnly = status == "sent" || email_only == true
return FeedPost( return FeedPost(
ghostId = id, ghostId = id,
slug = slug, slug = slug,
@ -558,7 +559,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
updatedAt = updated_at, updatedAt = updated_at,
isLocal = false, isLocal = false,
fileUrl = fileData?.first, fileUrl = fileData?.first,
fileName = fileData?.second fileName = fileData?.second,
emailOnly = isEmailOnly
) )
} }
@ -645,7 +647,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
isLocal = true, isLocal = true,
queueStatus = queueStatus, queueStatus = queueStatus,
fileUrl = uploadedFileUrl ?: fileUri, fileUrl = uploadedFileUrl ?: fileUri,
fileName = fileName fileName = fileName,
emailOnly = emailOnly
) )
} }

View file

@ -94,12 +94,15 @@ class PostUploadWorker(
} }
val ghostTags = tagNames.map { GhostTag(name = it) } val ghostTags = tagNames.map { GhostTag(name = it) }
val isEmailOnly = post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY
val ghostPost = GhostPost( val ghostPost = GhostPost(
title = post.title, title = post.title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,
status = when (post.queueStatus) { status = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH -> "published" QueueStatus.QUEUED_PUBLISH -> "published"
QueueStatus.QUEUED_SCHEDULED -> "scheduled" QueueStatus.QUEUED_SCHEDULED -> "scheduled"
QueueStatus.QUEUED_EMAIL_ONLY -> "published"
else -> "draft" else -> "draft"
}, },
featured = post.featured, featured = post.featured,
@ -107,13 +110,17 @@ class PostUploadWorker(
feature_image_alt = post.imageAlt, feature_image_alt = post.imageAlt,
published_at = post.scheduledAt, published_at = post.scheduledAt,
visibility = "public", visibility = "public",
tags = ghostTags.ifEmpty { null } tags = ghostTags.ifEmpty { null },
email_only = if (isEmailOnly) true else null
) )
// Determine newsletter slug for email-only or newsletter posts
val newsletterSlug = post.newsletterSlug
val result = if (post.ghostId != null) { val result = if (post.ghostId != null) {
repository.updatePost(post.ghostId, ghostPost) repository.updatePost(post.ghostId, ghostPost, newsletter = newsletterSlug)
} else { } else {
repository.createPost(ghostPost) repository.createPost(ghostPost, newsletter = newsletterSlug)
} }
result.fold( result.fold(