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
This commit is contained in:
Paweł Orzech 2026-03-20 00:56:04 +01:00
parent f9d060ed7d
commit f93a21e743
2 changed files with 198 additions and 1 deletions

View file

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

View file

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