merge: integrate Phase 4b (Email-only Posts) - all 8 features complete

This commit is contained in:
Paweł Orzech 2026-03-20 00:59:58 +01:00
commit e71d15805c
11 changed files with 461 additions and 33 deletions

View file

@ -26,13 +26,13 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC") @Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPosts( suspend fun getQueuedPosts(
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): List<LocalPost> ): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC") @Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPostsByAccount( suspend fun getQueuedPostsByAccount(
accountId: String, accountId: String,
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): List<LocalPost> ): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE localId = :localId") @Query("SELECT * FROM local_posts WHERE localId = :localId")

View file

@ -48,7 +48,8 @@ data class GhostPost(
val visibility: String? = "public", val visibility: String? = "public",
val authors: List<Author>? = null, val authors: List<Author>? = null,
val reading_time: Int? = null, val reading_time: Int? = null,
val tags: List<GhostTag>? = null val tags: List<GhostTag>? = null,
val email_only: Boolean? = null
) )
data class GhostTag( data class GhostTag(
@ -107,13 +108,15 @@ data class LocalPost(
enum class PostStatus { enum class PostStatus {
DRAFT, DRAFT,
PUBLISHED, PUBLISHED,
SCHEDULED SCHEDULED,
SENT
} }
enum class QueueStatus { enum class QueueStatus {
NONE, NONE,
QUEUED_PUBLISH, QUEUED_PUBLISH,
QUEUED_SCHEDULED, QUEUED_SCHEDULED,
QUEUED_EMAIL_ONLY,
UPLOADING, UPLOADING,
FAILED FAILED
} }
@ -147,7 +150,8 @@ data class FeedPost(
val isLocal: Boolean = false, val isLocal: Boolean = false,
val queueStatus: QueueStatus = QueueStatus.NONE, val queueStatus: QueueStatus = QueueStatus.NONE,
val fileUrl: String? = null, val fileUrl: String? = null,
val fileName: String? = null val fileName: String? = null,
val emailOnly: Boolean = false
) )
@Stable @Stable
@ -164,7 +168,8 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
ALL("All", null), ALL("All", null),
PUBLISHED("Published", "status:published"), PUBLISHED("Published", "status:published"),
DRAFT("Drafts", "status:draft"), DRAFT("Drafts", "status:draft"),
SCHEDULED("Scheduled", "status:scheduled"); SCHEDULED("Scheduled", "status:scheduled"),
SENT("Sent", "status:sent");
/** Returns the matching [PostStatus] for local filtering, or null for ALL. */ /** Returns the matching [PostStatus] for local filtering, or null for ALL. */
fun toPostStatus(): PostStatus? = when (this) { fun toPostStatus(): PostStatus? = when (this) {
@ -172,6 +177,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
PUBLISHED -> PostStatus.PUBLISHED PUBLISHED -> PostStatus.PUBLISHED
DRAFT -> PostStatus.DRAFT DRAFT -> PostStatus.DRAFT
SCHEDULED -> PostStatus.SCHEDULED SCHEDULED -> PostStatus.SCHEDULED
SENT -> PostStatus.SENT
} }
/** Empty-state message shown when filter yields no results. */ /** Empty-state message shown when filter yields no results. */
@ -180,6 +186,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
PUBLISHED -> "No published posts yet" PUBLISHED -> "No published posts yet"
DRAFT -> "No drafts yet" DRAFT -> "No drafts yet"
SCHEDULED -> "No scheduled posts yet" SCHEDULED -> "No scheduled posts yet"
SENT -> "No sent newsletters yet"
} }
} }

View file

@ -38,6 +38,7 @@ data class OverallStats(
PostStatus.PUBLISHED -> publishedCount++ PostStatus.PUBLISHED -> publishedCount++
PostStatus.DRAFT -> draftCount++ PostStatus.DRAFT -> draftCount++
PostStatus.SCHEDULED -> scheduledCount++ PostStatus.SCHEDULED -> scheduledCount++
PostStatus.SENT -> publishedCount++ // sent counts as published
} }
} }
@ -47,6 +48,7 @@ data class OverallStats(
"published" -> publishedCount++ "published" -> publishedCount++
"draft" -> draftCount++ "draft" -> draftCount++
"scheduled" -> scheduledCount++ "scheduled" -> scheduledCount++
"sent" -> publishedCount++
} }
} }

View file

@ -250,6 +250,20 @@ fun ComposerScreen(
enabled = canSubmit enabled = canSubmit
) )
// Email-only option
if (state.newsletterEnabled) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(
text = { Text("Send via Email Only") },
onClick = {
showSendMenu = false
viewModel.sendEmailOnly()
},
leadingIcon = { Icon(Icons.Default.Email, null) },
enabled = canSubmit
)
}
// Newsletter options // Newsletter options
if (state.newsletterEnabled) { if (state.newsletterEnabled) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
@ -823,6 +837,18 @@ fun ComposerScreen(
onDismiss = viewModel::cancelNewsletterConfirmation onDismiss = viewModel::cancelNewsletterConfirmation
) )
} }
// Email-only confirmation dialog
if (state.showEmailOnlyConfirmation) {
EmailOnlyConfirmationDialog(
postPreview = state.text.take(80),
availableNewsletters = state.availableNewsletters,
selectedNewsletter = state.selectedNewsletter,
onSelectNewsletter = viewModel::selectNewsletter,
onConfirm = viewModel::confirmEmailOnly,
onDismiss = viewModel::cancelEmailOnly
)
}
} }
/** /**
@ -1065,6 +1091,119 @@ fun NewsletterConfirmationDialog(
) )
} }
/**
* Confirmation dialog for email-only sending.
* Shows a warning that the post will NOT appear on the blog.
*/
@Composable
fun EmailOnlyConfirmationDialog(
postPreview: String,
availableNewsletters: List<GhostNewsletter>,
selectedNewsletter: GhostNewsletter?,
onSelectNewsletter: (GhostNewsletter) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = "Send via email only?",
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column {
// Post content preview
if (postPreview.isNotBlank()) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = postPreview + if (postPreview.length >= 80) "..." else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(12.dp))
}
// Newsletter picker (only if multiple)
if (availableNewsletters.size > 1) {
Text(
text = "Newsletter:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(modifier = Modifier.selectableGroup()) {
availableNewsletters.forEach { newsletter ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedNewsletter?.id == newsletter.id,
onClick = { onSelectNewsletter(newsletter) }
)
Text(
text = newsletter.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
} else if (selectedNewsletter != null) {
Text(
text = "Newsletter: ${selectedNewsletter.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
}
// Bold warning
Text(
text = "This cannot be undone. Post will NOT appear on blog.",
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text("\u2709 SEND EMAIL")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
/** /**
* Displays a 2-column grid of image thumbnails with remove buttons. * Displays a 2-column grid of image thumbnails with remove buttons.
* Includes an "Add more" button at the end. * Includes an "Add more" button at the end.

View file

@ -105,6 +105,62 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(showNewsletterConfirmation = false) } _uiState.update { it.copy(showNewsletterConfirmation = false) }
} }
fun sendEmailOnly() {
_uiState.update { it.copy(showEmailOnlyConfirmation = true) }
}
fun confirmEmailOnly() {
_uiState.update { it.copy(showEmailOnlyConfirmation = false) }
submitEmailOnlyPost()
}
fun cancelEmailOnly() {
_uiState.update { it.copy(showEmailOnlyConfirmation = false) }
}
private fun submitEmailOnlyPost() {
val state = _uiState.value
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, error = null) }
val title = state.text.take(60)
val hashtagTags = HashtagParser.parse(state.text)
val allTags = (state.extractedTags + hashtagTags).distinctBy { it.lowercase() }
val tagsJson = Gson().toJson(allTags)
val altText = state.imageAlt.ifBlank { null }
val newsletterSlug = state.selectedNewsletter?.slug
// Save locally and queue for upload
val localPost = LocalPost(
localId = editingLocalId ?: 0,
ghostId = editingGhostId,
title = title,
content = state.text,
status = PostStatus.PUBLISHED,
featured = false,
imageUri = state.imageUris.firstOrNull()?.toString(),
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
imageAlt = altText,
linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description,
linkImageUrl = state.linkPreview?.imageUrl,
tags = tagsJson,
queueStatus = QueueStatus.QUEUED_EMAIL_ONLY,
emailOnly = true,
newsletterSlug = newsletterSlug,
fileUri = state.fileUri?.toString(),
fileName = state.fileName
)
repository.saveLocalPost(localPost)
PostUploadWorker.enqueue(appContext)
_uiState.update { it.copy(isSubmitting = false, isSuccess = true) }
}
}
fun updateTagInput(input: String) { fun updateTagInput(input: String) {
val suggestions = if (input.isBlank()) { val suggestions = if (input.isBlank()) {
emptyList() emptyList()
@ -601,7 +657,9 @@ data class ComposerUiState(
val sendAsNewsletter: Boolean = false, val sendAsNewsletter: Boolean = false,
val emailSegment: String = "all", val emailSegment: String = "all",
val showNewsletterConfirmation: Boolean = false, val showNewsletterConfirmation: Boolean = false,
val subscriberCount: Int? = null val subscriberCount: Int? = null,
// Email-only
val showEmailOnlyConfirmation: Boolean = false
) { ) {
/** /**
* Backwards compatibility: returns the first image URI or null. * Backwards compatibility: returns the first image URI or null.

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
@ -399,6 +400,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

@ -769,6 +769,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()
@ -777,19 +783,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
) )
@ -1635,6 +1651,7 @@ fun PostCardContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val queueLabel = when (post.queueStatus) { val queueLabel = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload" QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
QueueStatus.QUEUED_EMAIL_ONLY -> "Pending email send"
QueueStatus.UPLOADING -> "Uploading..." QueueStatus.UPLOADING -> "Uploading..."
QueueStatus.FAILED -> "Upload failed" QueueStatus.FAILED -> "Upload failed"
else -> "" else -> ""
@ -1645,7 +1662,7 @@ fun PostCardContent(
label = queueLabel, label = queueLabel,
isUploading = isUploading isUploading = isUploading
) )
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) { if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED || post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancelQueue) { TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall) Text("Cancel", style = MaterialTheme.typography.labelSmall)
@ -1660,12 +1677,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)
@ -1674,19 +1694,28 @@ fun PostCardContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (isSent) {
Icon(
Icons.Default.Email,
contentDescription = "Sent",
modifier = Modifier.size(12.dp),
tint = statusColor
)
} else {
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.clip(CircleShape) .clip(CircleShape)
.background(statusColor) .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
) )
@ -1727,8 +1756,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 {
@ -2181,8 +2237,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

@ -537,6 +537,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
val fileData = extractFileCardFromMobiledoc(mobiledoc) val fileData = extractFileCardFromMobiledoc(mobiledoc)
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc) val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
val isEmailOnly = status == "sent" || email_only == true
return FeedPost( return FeedPost(
ghostId = id, ghostId = id,
slug = slug, slug = slug,
@ -561,7 +562,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
) )
} }
@ -686,7 +688,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

@ -124,12 +124,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,
@ -137,13 +140,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(

View file

@ -265,13 +265,13 @@ class GhostModelsTest {
// --- Enum values --- // --- Enum values ---
@Test @Test
fun `PostStatus has exactly 3 values`() { fun `PostStatus has exactly 4 values`() {
assertEquals(3, PostStatus.values().size) assertEquals(4, PostStatus.values().size)
} }
@Test @Test
fun `QueueStatus has exactly 5 values`() { fun `QueueStatus has exactly 6 values`() {
assertEquals(5, QueueStatus.values().size) assertEquals(6, QueueStatus.values().size)
} }
@Test @Test
@ -279,6 +279,7 @@ class GhostModelsTest {
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT")) assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED")) assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED")) assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
assertEquals(PostStatus.SENT, PostStatus.valueOf("SENT"))
} }
@Test @Test
@ -286,6 +287,7 @@ class GhostModelsTest {
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE")) assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH")) assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED")) assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED"))
assertEquals(QueueStatus.QUEUED_EMAIL_ONLY, QueueStatus.valueOf("QUEUED_EMAIL_ONLY"))
assertEquals(QueueStatus.UPLOADING, QueueStatus.valueOf("UPLOADING")) assertEquals(QueueStatus.UPLOADING, QueueStatus.valueOf("UPLOADING"))
assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED")) assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED"))
} }
@ -422,4 +424,95 @@ class GhostModelsTest {
val json = gson.toJson(wrapper) val json = gson.toJson(wrapper)
assertTrue(json.contains("\"feature_image_alt\":\"Photo description\"")) assertTrue(json.contains("\"feature_image_alt\":\"Photo description\""))
} }
// --- email_only field ---
@Test
fun `GhostPost default email_only is null`() {
val post = GhostPost()
assertNull(post.email_only)
}
@Test
fun `GhostPost stores email_only true`() {
val post = GhostPost(email_only = true)
assertEquals(true, post.email_only)
}
@Test
fun `GhostPost serializes email_only to JSON`() {
val post = GhostPost(title = "Email post", email_only = true)
val json = gson.toJson(post)
assertTrue(json.contains("\"email_only\":true"))
}
@Test
fun `GhostPost deserializes email_only from JSON`() {
val json = """{"id":"1","email_only":true}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertEquals(true, post.email_only)
}
@Test
fun `GhostPost deserializes with missing email_only`() {
val json = """{"id":"1","title":"Test"}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNull(post.email_only)
}
// --- FeedPost emailOnly ---
@Test
fun `FeedPost default emailOnly is false`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertFalse(post.emailOnly)
}
@Test
fun `FeedPost stores emailOnly true`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "sent",
publishedAt = null,
createdAt = null,
updatedAt = null,
emailOnly = true
)
assertTrue(post.emailOnly)
}
// --- LocalPost emailOnly ---
@Test
fun `LocalPost default emailOnly is false`() {
val post = LocalPost()
assertFalse(post.emailOnly)
}
@Test
fun `LocalPost stores emailOnly true`() {
val post = LocalPost(emailOnly = true, newsletterSlug = "default-newsletter")
assertTrue(post.emailOnly)
assertEquals("default-newsletter", post.newsletterSlug)
}
} }

View file

@ -8,8 +8,8 @@ class PostFilterTest {
// --- Enum values --- // --- Enum values ---
@Test @Test
fun `PostFilter has exactly 4 values`() { fun `PostFilter has exactly 5 values`() {
assertEquals(4, PostFilter.values().size) assertEquals(5, PostFilter.values().size)
} }
@Test @Test
@ -18,6 +18,7 @@ class PostFilterTest {
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED")) assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT")) assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED")) assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
assertEquals(PostFilter.SENT, PostFilter.valueOf("SENT"))
} }
// --- Display names --- // --- Display names ---
@ -107,4 +108,26 @@ class PostFilterTest {
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() { fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage()) assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage())
} }
// --- SENT filter ---
@Test
fun `SENT displayName is Sent`() {
assertEquals("Sent", PostFilter.SENT.displayName)
}
@Test
fun `SENT ghostFilter is status_sent`() {
assertEquals("status:sent", PostFilter.SENT.ghostFilter)
}
@Test
fun `SENT toPostStatus returns PostStatus SENT`() {
assertEquals(PostStatus.SENT, PostFilter.SENT.toPostStatus())
}
@Test
fun `SENT emptyMessage returns No sent newsletters yet`() {
assertEquals("No sent newsletters yet", PostFilter.SENT.emptyMessage())
}
} }