From 39a51e5d4bc310e2495ca9a97f38b1276b60a5bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:48:18 +0100 Subject: [PATCH] feat: add newsletter sending toggle in Composer publish dialog - Add newsletter fields to ComposerUiState (enabled, newsletters list, selected newsletter, sendAsNewsletter, emailSegment, subscriber count, confirmation dialog state) - Load newsletter data on init when newsletter features are enabled - Add newsletter options in publish dropdown: send-as-newsletter switch, newsletter picker (radio buttons), email segment picker (All/Free/Paid) - Show warning about irreversible email send with subscriber count - Change publish button to tertiaryContainer color and email icon when newsletter sending is active - Add NewsletterConfirmationDialog requiring "WYSLIJ" typed input to confirm, with summary of newsletter name, segment, count, and title - Pass newsletter slug and email segment through to PostRepository - Store newsletter slug in LocalPost for offline queue support --- .../microblog/ui/composer/ComposerScreen.kt | 296 +++++++++++++++++- .../ui/composer/ComposerViewModel.kt | 88 +++++- 2 files changed, 373 insertions(+), 11 deletions(-) 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 5c92c4b..a7dfa89 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 @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -49,6 +51,7 @@ import com.swoosh.microblog.data.AccountManager import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.GhostNewsletter import com.swoosh.microblog.data.model.GhostTagFull import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.ui.animation.SwooshMotion @@ -148,15 +151,23 @@ fun ComposerScreen( ) } } else { + val isNewsletterPublish = state.sendAsNewsletter && state.selectedNewsletter != null FilledIconButton( onClick = viewModel::publish, enabled = canSubmit, colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + containerColor = if (isNewsletterPublish) + MaterialTheme.colorScheme.tertiaryContainer + else MaterialTheme.colorScheme.primary, + contentColor = if (isNewsletterPublish) + MaterialTheme.colorScheme.onTertiaryContainer + else MaterialTheme.colorScheme.onPrimary ) ) { - Icon(Icons.Default.Send, "Publish") + Icon( + if (isNewsletterPublish) Icons.Default.Email else Icons.Default.Send, + if (isNewsletterPublish) "Publish & Send Email" else "Publish" + ) } } @@ -176,13 +187,24 @@ fun ComposerScreen( expanded = showSendMenu, onDismissRequest = { showSendMenu = false } ) { + val publishLabel = if (state.sendAsNewsletter && state.selectedNewsletter != null) { + if (state.isEditing) "Update & Send Email" else "Publish & Send Email" + } else { + if (state.isEditing) "Update & Publish" else "Publish Now" + } DropdownMenuItem( - text = { Text(if (state.isEditing) "Update & Publish" else "Publish Now") }, + text = { Text(publishLabel) }, onClick = { showSendMenu = false viewModel.publish() }, - leadingIcon = { Icon(Icons.Default.Send, null) }, + leadingIcon = { + Icon( + if (state.sendAsNewsletter && state.selectedNewsletter != null) + Icons.Default.Email else Icons.Default.Send, + null + ) + }, enabled = canSubmit ) DropdownMenuItem( @@ -203,6 +225,17 @@ fun ComposerScreen( leadingIcon = { Icon(Icons.Default.Schedule, null) }, enabled = canSubmit ) + + // Newsletter options + if (state.newsletterEnabled) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + NewsletterDropdownSection( + state = state, + onToggleSendAsNewsletter = viewModel::toggleSendAsNewsletter, + onSelectNewsletter = viewModel::selectNewsletter, + onSetEmailSegment = viewModel::setEmailSegment + ) + } } } } @@ -690,6 +723,259 @@ fun ComposerScreen( } } } + + // Newsletter confirmation dialog + val confirmNewsletter = state.selectedNewsletter + if (state.showNewsletterConfirmation && confirmNewsletter != null) { + NewsletterConfirmationDialog( + newsletterName = confirmNewsletter.name, + emailSegment = state.emailSegment, + subscriberCount = state.subscriberCount, + postTitle = state.text.take(60), + onConfirm = viewModel::confirmNewsletterSend, + onDismiss = viewModel::cancelNewsletterConfirmation + ) + } +} + +/** + * Newsletter options section in the publish dropdown menu. + */ +@Composable +fun NewsletterDropdownSection( + state: ComposerUiState, + onToggleSendAsNewsletter: () -> Unit, + onSelectNewsletter: (GhostNewsletter) -> Unit, + onSetEmailSegment: (String) -> Unit +) { + // Send as newsletter switch + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleSendAsNewsletter) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Send as newsletter", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch( + checked = state.sendAsNewsletter, + onCheckedChange = { onToggleSendAsNewsletter() }, + modifier = Modifier.padding(start = 8.dp) + ) + } + + // Newsletter picker and segment (only when sending) + AnimatedVisibility( + visible = state.sendAsNewsletter, + enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()), + exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy()) + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + // Newsletter picker + if (state.availableNewsletters.size > 1) { + Text( + text = "Newsletter:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Column(modifier = Modifier.selectableGroup()) { + state.availableNewsletters.forEach { newsletter -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = state.selectedNewsletter?.id == newsletter.id, + onClick = { onSelectNewsletter(newsletter) } + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = state.selectedNewsletter?.id == newsletter.id, + onClick = { onSelectNewsletter(newsletter) } + ) + Text( + text = newsletter.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + } else if (state.availableNewsletters.size == 1) { + Text( + text = "Newsletter: ${state.availableNewsletters.first().name}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + // Segment picker + Text( + text = "Send to:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + val segments = listOf("all" to "All subscribers", "status:free" to "Free members", "status:-free" to "Paid members") + Column(modifier = Modifier.selectableGroup()) { + segments.forEach { (value, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = state.emailSegment == value, + onClick = { onSetEmailSegment(value) } + ) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = state.emailSegment == value, + onClick = { onSetEmailSegment(value) } + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + + // Warning text + val countText = state.subscriberCount?.let { "~$it" } ?: "your" + Text( + text = "\u26A0 Email will be sent to $countText subscribers. This cannot be undone.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } +} + +/** + * Confirmation dialog for newsletter sending. + * Requires typing "WYSLIJ" to confirm. + */ +@Composable +fun NewsletterConfirmationDialog( + newsletterName: String, + emailSegment: String, + subscriberCount: Int?, + postTitle: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + var confirmInput by remember { mutableStateOf("") } + val isConfirmEnabled = confirmInput == "WYSLIJ" + val segmentLabel = when (emailSegment) { + "all" -> "All subscribers" + "status:free" -> "Free members" + "status:-free" -> "Paid members" + else -> emailSegment + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Confirm Newsletter Send", + style = MaterialTheme.typography.titleMedium + ) + }, + text = { + Column { + Text( + text = "You are about to send an email newsletter:", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Summary card + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Newsletter: $newsletterName", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Segment: $segmentLabel", + style = MaterialTheme.typography.bodySmall + ) + if (subscriberCount != null) { + Text( + text = "Recipients: ~$subscriberCount", + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = "Post: ${postTitle.take(40)}${if (postTitle.length > 40) "..." else ""}", + style = MaterialTheme.typography.bodySmall + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Warning + Text( + text = "\u26A0 IRREVERSIBLE: Once sent, this email cannot be recalled.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Confirmation input + Text( + text = "Type WYSLIJ to confirm:", + style = MaterialTheme.typography.labelMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = confirmInput, + onValueChange = { confirmInput = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("WYSLIJ") } + ) + } + }, + confirmButton = { + Button( + onClick = onConfirm, + enabled = isConfirmEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + Icons.Default.Email, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Send Email") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) } /** 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 be46fc8..0c5799e 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.MobiledocBuilder +import com.swoosh.microblog.data.NewsletterPreferences import com.swoosh.microblog.data.PreviewHtmlBuilder import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.model.* @@ -27,6 +28,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application private val repository = PostRepository(application) private val tagRepository = TagRepository(application) + private val newsletterPreferences = NewsletterPreferences(application) private val appContext = application private val _uiState = MutableStateFlow(ComposerUiState()) @@ -40,6 +42,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application init { loadAvailableTags() + loadNewsletterData() } private fun loadAvailableTags() { @@ -53,6 +56,55 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application } } + private fun loadNewsletterData() { + val enabled = newsletterPreferences.isNewsletterEnabled() + _uiState.update { it.copy(newsletterEnabled = enabled) } + if (enabled) { + viewModelScope.launch { + // Fetch available newsletters + repository.fetchNewsletters().fold( + onSuccess = { newsletters -> + _uiState.update { state -> + state.copy( + availableNewsletters = newsletters, + selectedNewsletter = newsletters.firstOrNull() + ) + } + }, + onFailure = { /* silently ignore */ } + ) + // Fetch subscriber count (lightweight: limit=1, read meta.pagination.total) + repository.fetchSubscriberCount().fold( + onSuccess = { count -> + _uiState.update { it.copy(subscriberCount = count) } + }, + onFailure = { /* silently ignore */ } + ) + } + } + } + + fun toggleSendAsNewsletter() { + _uiState.update { it.copy(sendAsNewsletter = !it.sendAsNewsletter) } + } + + fun selectNewsletter(newsletter: GhostNewsletter) { + _uiState.update { it.copy(selectedNewsletter = newsletter) } + } + + fun setEmailSegment(segment: String) { + _uiState.update { it.copy(emailSegment = segment) } + } + + fun confirmNewsletterSend() { + _uiState.update { it.copy(showNewsletterConfirmation = false) } + publish() + } + + fun cancelNewsletterConfirmation() { + _uiState.update { it.copy(showNewsletterConfirmation = false) } + } + fun updateTagInput(input: String) { val suggestions = if (input.isBlank()) { emptyList() @@ -223,7 +275,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application _uiState.update { it.copy(featured = !it.featured) } } - fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) + fun publish() { + val state = _uiState.value + if (state.sendAsNewsletter && state.selectedNewsletter != null && !state.showNewsletterConfirmation) { + // Show confirmation dialog before sending as newsletter + _uiState.update { it.copy(showNewsletterConfirmation = true) } + return + } + submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) + } fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) @@ -265,7 +325,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application linkImageUrl = state.linkPreview?.imageUrl, scheduledAt = state.scheduledAt, tags = tagsJson, - queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus + queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus, + newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null ) repository.saveLocalPost(localPost) @@ -311,11 +372,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application tags = ghostTags.ifEmpty { null } ) + // Determine newsletter params + val newsletterSlug = if (state.sendAsNewsletter && state.selectedNewsletter != null) { + state.selectedNewsletter.slug + } else null + val emailSeg = if (newsletterSlug != null) state.emailSegment else null + val result = if (editingGhostId != null) { val updatePost = ghostPost.copy(updated_at = editingUpdatedAt) - repository.updatePost(editingGhostId!!, updatePost) + repository.updatePost(editingGhostId!!, updatePost, newsletter = newsletterSlug, emailSegment = emailSeg) } else { - repository.createPost(ghostPost) + repository.createPost(ghostPost, newsletter = newsletterSlug, emailSegment = emailSeg) } result.fold( @@ -342,7 +409,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application linkImageUrl = state.linkPreview?.imageUrl, scheduledAt = state.scheduledAt, tags = tagsJson, - queueStatus = offlineQueueStatus + queueStatus = offlineQueueStatus, + newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null ) repository.saveLocalPost(localPost) PostUploadWorker.enqueue(appContext) @@ -382,7 +450,15 @@ data class ComposerUiState( val isEditing: Boolean = false, val error: String? = null, val isPreviewMode: Boolean = false, - val previewHtml: String = "" + val previewHtml: String = "", + // Newsletter fields + val newsletterEnabled: Boolean = false, + val availableNewsletters: List = emptyList(), + val selectedNewsletter: GhostNewsletter? = null, + val sendAsNewsletter: Boolean = false, + val emailSegment: String = "all", + val showNewsletterConfirmation: Boolean = false, + val subscriberCount: Int? = null ) { /** * Backwards compatibility: returns the first image URI or null.