mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
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
This commit is contained in:
parent
bbe991b027
commit
39a51e5d4b
2 changed files with 373 additions and 11 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<GhostNewsletter> = 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue