From f9d060ed7db7a9db4f64df1bc0507ab1d80de66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:54:34 +0100 Subject: [PATCH 1/3] feat: add SENT status, QUEUED_EMAIL_ONLY queue status, and email_only field for email-only posts Phase 4b.1: Add data model support for email-only posts. - PostStatus: add SENT enum value - QueueStatus: add QUEUED_EMAIL_ONLY enum value - PostFilter: add SENT filter with "status:sent" ghost filter - GhostPost: add email_only Boolean field - FeedPost: add emailOnly Boolean field - LocalPostDao: include QUEUED_EMAIL_ONLY in queued posts query - OverallStats: handle SENT status in stats calculation - FeedScreen: show "Pending email send" for QUEUED_EMAIL_ONLY queue status - Update tests for new enum values and fields --- .../swoosh/microblog/data/db/LocalPostDao.kt | 4 +- .../microblog/data/model/GhostModels.kt | 15 ++- .../microblog/data/model/OverallStats.kt | 2 + .../swoosh/microblog/ui/feed/FeedScreen.kt | 3 +- .../microblog/data/model/GhostModelsTest.kt | 101 +++++++++++++++++- .../microblog/data/model/PostFilterTest.kt | 27 ++++- 6 files changed, 139 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 572bcee..5238897 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -26,13 +26,13 @@ interface LocalPostDao { @Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC") suspend fun getQueuedPosts( - statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) + statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY) ): List @Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC") suspend fun getQueuedPostsByAccount( accountId: String, - statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) + statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY) ): List @Query("SELECT * FROM local_posts WHERE localId = :localId") diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 45dca66..b923166 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -48,7 +48,8 @@ data class GhostPost( val visibility: String? = "public", val authors: List? = null, val reading_time: Int? = null, - val tags: List? = null + val tags: List? = 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 } @@ -145,7 +148,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 @@ -162,7 +166,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) { @@ -170,6 +175,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. */ @@ -178,6 +184,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" } } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt index f5c11da..13b0399 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt @@ -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++ } } 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 8395ce4..6de4430 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 @@ -1621,6 +1621,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 -> "" @@ -1631,7 +1632,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) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 1915767..27119f5 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt index 1cf1887..50a4d89 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt @@ -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()) + } } From f93a21e74386325bf7e86200da3b30f9b1549254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:56:04 +0100 Subject: [PATCH 2/3] feat: add email-only post option in Composer with confirmation dialog Phase 4b.2: Email-only option in Composer. - Add "Send via Email Only" dropdown menu item (visible when newsletter enabled) - Add showEmailOnlyConfirmation state to ComposerUiState - Add sendEmailOnly(), confirmEmailOnly(), cancelEmailOnly() to ViewModel - submitEmailOnlyPost() saves with emailOnly=true, QUEUED_EMAIL_ONLY status - Add EmailOnlyConfirmationDialog with warning icon, post preview, newsletter picker (if multiple), bold warning about irreversibility, and error-colored confirm button --- .../microblog/ui/composer/ComposerScreen.kt | 139 ++++++++++++++++++ .../ui/composer/ComposerViewModel.kt | 60 +++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index bdaeac9..c35a3df 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -236,6 +236,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)) @@ -769,6 +783,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 + ) + } } /** @@ -1011,6 +1037,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, + 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. diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 802b316..9e85adb 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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() @@ -536,7 +592,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. From 5c931b138c808723ebe26adb05476122405fd1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:58:50 +0100 Subject: [PATCH 3/3] 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 --- .../microblog/ui/detail/DetailScreen.kt | 38 +++++++++ .../swoosh/microblog/ui/feed/FeedScreen.kt | 85 ++++++++++++++++--- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 7 +- .../microblog/worker/PostUploadWorker.kt | 13 ++- 4 files changed, 124 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index dcfa1f1..67fa9e7 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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 @@ -375,6 +376,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) + ) + } + } + } + } } } 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 6de4430..8c94b7c 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 @@ -767,6 +767,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() @@ -775,19 +781,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 ) @@ -1647,12 +1663,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) @@ -1661,19 +1680,28 @@ fun PostCardContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(statusColor) - ) + 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 ) @@ -1714,8 +1742,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 { @@ -2168,8 +2223,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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 0173054..f726103 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -536,6 +536,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } } val fileData = extractFileCardFromMobiledoc(mobiledoc) + val isEmailOnly = status == "sent" || email_only == true return FeedPost( ghostId = id, slug = slug, @@ -558,7 +559,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { updatedAt = updated_at, isLocal = false, fileUrl = fileData?.first, - fileName = fileData?.second + fileName = fileData?.second, + emailOnly = isEmailOnly ) } @@ -645,7 +647,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { isLocal = true, queueStatus = queueStatus, fileUrl = uploadedFileUrl ?: fileUri, - fileName = fileName + fileName = fileName, + emailOnly = emailOnly ) } diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 94c9c59..ca8ae99 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -94,12 +94,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, @@ -107,13 +110,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(