mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
merge: integrate Phase 4b (Email-only Posts) - all 8 features complete
This commit is contained in:
commit
e71d15805c
11 changed files with 461 additions and 33 deletions
|
|
@ -26,13 +26,13 @@ interface LocalPostDao {
|
|||
|
||||
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||
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>
|
||||
|
||||
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||
suspend fun getQueuedPostsByAccount(
|
||||
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>
|
||||
|
||||
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ data class GhostPost(
|
|||
val visibility: String? = "public",
|
||||
val authors: List<Author>? = null,
|
||||
val reading_time: Int? = null,
|
||||
val tags: List<GhostTag>? = null
|
||||
val tags: List<GhostTag>? = null,
|
||||
val email_only: Boolean? = null
|
||||
)
|
||||
|
||||
data class GhostTag(
|
||||
|
|
@ -107,13 +108,15 @@ data class LocalPost(
|
|||
enum class PostStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
SCHEDULED
|
||||
SCHEDULED,
|
||||
SENT
|
||||
}
|
||||
|
||||
enum class QueueStatus {
|
||||
NONE,
|
||||
QUEUED_PUBLISH,
|
||||
QUEUED_SCHEDULED,
|
||||
QUEUED_EMAIL_ONLY,
|
||||
UPLOADING,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -147,7 +150,8 @@ data class FeedPost(
|
|||
val isLocal: Boolean = false,
|
||||
val queueStatus: QueueStatus = QueueStatus.NONE,
|
||||
val fileUrl: String? = null,
|
||||
val fileName: String? = null
|
||||
val fileName: String? = null,
|
||||
val emailOnly: Boolean = false
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
|
@ -164,7 +168,8 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
|||
ALL("All", null),
|
||||
PUBLISHED("Published", "status:published"),
|
||||
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. */
|
||||
fun toPostStatus(): PostStatus? = when (this) {
|
||||
|
|
@ -172,6 +177,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
|
|||
PUBLISHED -> PostStatus.PUBLISHED
|
||||
DRAFT -> PostStatus.DRAFT
|
||||
SCHEDULED -> PostStatus.SCHEDULED
|
||||
SENT -> PostStatus.SENT
|
||||
}
|
||||
|
||||
/** 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"
|
||||
DRAFT -> "No drafts yet"
|
||||
SCHEDULED -> "No scheduled posts yet"
|
||||
SENT -> "No sent newsletters yet"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ data class OverallStats(
|
|||
PostStatus.PUBLISHED -> publishedCount++
|
||||
PostStatus.DRAFT -> draftCount++
|
||||
PostStatus.SCHEDULED -> scheduledCount++
|
||||
PostStatus.SENT -> publishedCount++ // sent counts as published
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ data class OverallStats(
|
|||
"published" -> publishedCount++
|
||||
"draft" -> draftCount++
|
||||
"scheduled" -> scheduledCount++
|
||||
"sent" -> publishedCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,20 @@ fun ComposerScreen(
|
|||
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
|
||||
if (state.newsletterEnabled) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
|
@ -823,6 +837,18 @@ fun ComposerScreen(
|
|||
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.
|
||||
* Includes an "Add more" button at the end.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,62 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
_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) {
|
||||
val suggestions = if (input.isBlank()) {
|
||||
emptyList()
|
||||
|
|
@ -601,7 +657,9 @@ data class ComposerUiState(
|
|||
val sendAsNewsletter: Boolean = false,
|
||||
val emailSegment: String = "all",
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.Delete
|
||||
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.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
|
|
@ -399,6 +400,43 @@ fun DetailScreen(
|
|||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -769,6 +769,12 @@ fun FilterChipsBar(
|
|||
activeFilter: PostFilter,
|
||||
onFilterSelected: (PostFilter) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val newsletterEnabled = remember {
|
||||
com.swoosh.microblog.data.NewsletterPreferences(context).isNewsletterEnabled()
|
||||
}
|
||||
val sentColor = Color(0xFF6A1B9A) // magenta
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -777,19 +783,29 @@ fun FilterChipsBar(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PostFilter.values().forEach { filter ->
|
||||
// Only show SENT chip when newsletter is enabled
|
||||
if (filter == PostFilter.SENT && !newsletterEnabled) return@forEach
|
||||
|
||||
val selected = filter == activeFilter
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface,
|
||||
targetValue = when {
|
||||
selected && filter == PostFilter.SENT -> sentColor.copy(alpha = 0.2f)
|
||||
selected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
},
|
||||
animationSpec = SwooshMotion.quick(),
|
||||
label = "chipColor"
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onFilterSelected(filter) },
|
||||
label = { Text(filter.displayName) },
|
||||
label = {
|
||||
Text(
|
||||
filter.displayName,
|
||||
color = if (selected && filter == PostFilter.SENT) sentColor
|
||||
else Color.Unspecified
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = containerColor
|
||||
)
|
||||
|
|
@ -1635,6 +1651,7 @@ fun PostCardContent(
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val queueLabel = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
|
||||
QueueStatus.QUEUED_EMAIL_ONLY -> "Pending email send"
|
||||
QueueStatus.UPLOADING -> "Uploading..."
|
||||
QueueStatus.FAILED -> "Upload failed"
|
||||
else -> ""
|
||||
|
|
@ -1645,7 +1662,7 @@ fun PostCardContent(
|
|||
label = queueLabel,
|
||||
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))
|
||||
TextButton(onClick = onCancelQueue) {
|
||||
Text("Cancel", style = MaterialTheme.typography.labelSmall)
|
||||
|
|
@ -1660,12 +1677,15 @@ fun PostCardContent(
|
|||
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
||||
PostStats.fromFeedPost(post)
|
||||
}
|
||||
val isSent = post.status == "sent" || post.emailOnly
|
||||
val statusLabel = when {
|
||||
post.queueStatus != QueueStatus.NONE -> "Pending"
|
||||
isSent -> "Sent"
|
||||
else -> post.status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
val statusColor = when {
|
||||
post.queueStatus != QueueStatus.NONE -> Color(0xFFE65100)
|
||||
isSent -> Color(0xFF6A1B9A)
|
||||
post.status == "published" -> Color(0xFF2E7D32)
|
||||
post.status == "scheduled" -> Color(0xFF1565C0)
|
||||
else -> Color(0xFF7B1FA2)
|
||||
|
|
@ -1674,19 +1694,28 @@ fun PostCardContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isSent) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = "Sent",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = statusColor
|
||||
)
|
||||
} else {
|
||||
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 = "·",
|
||||
text = "\u00B7",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -1727,8 +1756,35 @@ fun PostCardContent(
|
|||
)
|
||||
}
|
||||
|
||||
// Share action (copies link to clipboard)
|
||||
if (isPublished && hasShareableUrl) {
|
||||
// Share / Copy content action
|
||||
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(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable {
|
||||
|
|
@ -2181,8 +2237,10 @@ fun buildHighlightedString(
|
|||
|
||||
@Composable
|
||||
fun StatusBadge(post: FeedPost) {
|
||||
val isSent = post.status == "sent" || post.emailOnly
|
||||
val (label, containerColor, labelColor) = when {
|
||||
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 == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
|
||||
else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
val fileData = extractFileCardFromMobiledoc(mobiledoc)
|
||||
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
|
||||
val isEmailOnly = status == "sent" || email_only == true
|
||||
return FeedPost(
|
||||
ghostId = id,
|
||||
slug = slug,
|
||||
|
|
@ -561,7 +562,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
updatedAt = updated_at,
|
||||
isLocal = false,
|
||||
fileUrl = fileData?.first,
|
||||
fileName = fileData?.second
|
||||
fileName = fileData?.second,
|
||||
emailOnly = isEmailOnly
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -686,7 +688,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
isLocal = true,
|
||||
queueStatus = queueStatus,
|
||||
fileUrl = uploadedFileUrl ?: fileUri,
|
||||
fileName = fileName
|
||||
fileName = fileName,
|
||||
emailOnly = emailOnly
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,12 +124,15 @@ class PostUploadWorker(
|
|||
}
|
||||
val ghostTags = tagNames.map { GhostTag(name = it) }
|
||||
|
||||
val isEmailOnly = post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = post.title,
|
||||
mobiledoc = mobiledoc,
|
||||
status = when (post.queueStatus) {
|
||||
QueueStatus.QUEUED_PUBLISH -> "published"
|
||||
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
|
||||
QueueStatus.QUEUED_EMAIL_ONLY -> "published"
|
||||
else -> "draft"
|
||||
},
|
||||
featured = post.featured,
|
||||
|
|
@ -137,13 +140,17 @@ class PostUploadWorker(
|
|||
feature_image_alt = post.imageAlt,
|
||||
published_at = post.scheduledAt,
|
||||
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) {
|
||||
repository.updatePost(post.ghostId, ghostPost)
|
||||
repository.updatePost(post.ghostId, ghostPost, newsletter = newsletterSlug)
|
||||
} else {
|
||||
repository.createPost(ghostPost)
|
||||
repository.createPost(ghostPost, newsletter = newsletterSlug)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
|
|
|
|||
|
|
@ -265,13 +265,13 @@ class GhostModelsTest {
|
|||
// --- Enum values ---
|
||||
|
||||
@Test
|
||||
fun `PostStatus has exactly 3 values`() {
|
||||
assertEquals(3, PostStatus.values().size)
|
||||
fun `PostStatus has exactly 4 values`() {
|
||||
assertEquals(4, PostStatus.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QueueStatus has exactly 5 values`() {
|
||||
assertEquals(5, QueueStatus.values().size)
|
||||
fun `QueueStatus has exactly 6 values`() {
|
||||
assertEquals(6, QueueStatus.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -279,6 +279,7 @@ class GhostModelsTest {
|
|||
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
|
||||
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
|
||||
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
|
||||
assertEquals(PostStatus.SENT, PostStatus.valueOf("SENT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -286,6 +287,7 @@ class GhostModelsTest {
|
|||
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
|
||||
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
|
||||
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.FAILED, QueueStatus.valueOf("FAILED"))
|
||||
}
|
||||
|
|
@ -422,4 +424,95 @@ class GhostModelsTest {
|
|||
val json = gson.toJson(wrapper)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ class PostFilterTest {
|
|||
// --- Enum values ---
|
||||
|
||||
@Test
|
||||
fun `PostFilter has exactly 4 values`() {
|
||||
assertEquals(4, PostFilter.values().size)
|
||||
fun `PostFilter has exactly 5 values`() {
|
||||
assertEquals(5, PostFilter.values().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -18,6 +18,7 @@ class PostFilterTest {
|
|||
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
|
||||
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
|
||||
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
|
||||
assertEquals(PostFilter.SENT, PostFilter.valueOf("SENT"))
|
||||
}
|
||||
|
||||
// --- Display names ---
|
||||
|
|
@ -107,4 +108,26 @@ class PostFilterTest {
|
|||
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue