merge: integrate Phase 4a (Newsletter) with existing phases

This commit is contained in:
Paweł Orzech 2026-03-20 00:50:17 +01:00
commit 3b1061694d
8 changed files with 680 additions and 17 deletions

View file

@ -0,0 +1,33 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
class NewsletterPreferences private constructor(
private val prefs: SharedPreferences,
private val accountIdProvider: () -> String
) {
constructor(context: Context) : this(
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
)
/** Constructor for testing with plain SharedPreferences and a fixed account ID. */
constructor(prefs: SharedPreferences, accountId: String) : this(
prefs = prefs,
accountIdProvider = { accountId }
)
private fun activeAccountId(): String = accountIdProvider()
fun isNewsletterEnabled(): Boolean =
prefs.getBoolean("newsletter_enabled_${activeAccountId()}", false)
fun setNewsletterEnabled(enabled: Boolean) =
prefs.edit().putBoolean("newsletter_enabled_${activeAccountId()}", enabled).apply()
companion object {
const val PREFS_NAME = "newsletter_prefs"
}
}

View file

@ -3,6 +3,7 @@ package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.FileUploadResponse import com.swoosh.microblog.data.model.FileUploadResponse
import com.swoosh.microblog.data.model.GhostSite import com.swoosh.microblog.data.model.GhostSite
import com.swoosh.microblog.data.model.MembersResponse import com.swoosh.microblog.data.model.MembersResponse
import com.swoosh.microblog.data.model.NewslettersResponse
import com.swoosh.microblog.data.model.PageWrapper import com.swoosh.microblog.data.model.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostWrapper
@ -29,14 +30,18 @@ interface GhostApiService {
@POST("ghost/api/admin/posts/") @POST("ghost/api/admin/posts/")
@Headers("Content-Type: application/json") @Headers("Content-Type: application/json")
suspend fun createPost( suspend fun createPost(
@Body body: PostWrapper @Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse> ): Response<PostsResponse>
@PUT("ghost/api/admin/posts/{id}/") @PUT("ghost/api/admin/posts/{id}/")
@Headers("Content-Type: application/json") @Headers("Content-Type: application/json")
suspend fun updatePost( suspend fun updatePost(
@Path("id") id: String, @Path("id") id: String,
@Body body: PostWrapper @Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse> ): Response<PostsResponse>
@DELETE("ghost/api/admin/posts/{id}/") @DELETE("ghost/api/admin/posts/{id}/")
@ -62,6 +67,12 @@ interface GhostApiService {
@Query("include") include: String = "newsletters,labels" @Query("include") include: String = "newsletters,labels"
): Response<MembersResponse> ): Response<MembersResponse>
@GET("ghost/api/admin/newsletters/")
suspend fun getNewsletters(
@Query("filter") filter: String = "status:active",
@Query("limit") limit: String = "all"
): Response<NewslettersResponse>
@GET("ghost/api/admin/users/me/") @GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse> suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -0,0 +1,21 @@
package com.swoosh.microblog.data.model
data class NewslettersResponse(
val newsletters: List<GhostNewsletter>
)
data class GhostNewsletter(
val id: String,
val uuid: String?,
val name: String,
val slug: String,
val description: String?,
val status: String?,
val visibility: String?,
val subscribe_on_signup: Boolean?,
val sort_order: Int?,
val sender_name: String?,
val sender_email: String?,
val created_at: String?,
val updated_at: String?
)

View file

@ -8,6 +8,7 @@ import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.db.AppDatabase import com.swoosh.microblog.data.db.AppDatabase
import com.swoosh.microblog.data.db.LocalPostDao import com.swoosh.microblog.data.db.LocalPostDao
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.model.GhostNewsletter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -68,10 +69,18 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun createPost(post: GhostPost): Result<GhostPost> = suspend fun createPost(
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val response = getApi().createPost(PostWrapper(listOf(post))) val response = getApi().createPost(
PostWrapper(listOf(post)),
newsletter = newsletter,
emailSegment = emailSegment
)
if (response.isSuccessful) { if (response.isSuccessful) {
Result.success(response.body()!!.posts.first()) Result.success(response.body()!!.posts.first())
} else { } else {
@ -82,10 +91,20 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> = suspend fun updatePost(
id: String,
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val response = getApi().updatePost(id, PostWrapper(listOf(post))) val response = getApi().updatePost(
id,
PostWrapper(listOf(post)),
newsletter = newsletter,
emailSegment = emailSegment
)
if (response.isSuccessful) { if (response.isSuccessful) {
Result.success(response.body()!!.posts.first()) Result.success(response.body()!!.posts.first())
} else { } else {
@ -96,6 +115,35 @@ class PostRepository(private val context: Context) {
} }
} }
suspend fun fetchNewsletters(): Result<List<GhostNewsletter>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getNewsletters()
if (response.isSuccessful) {
Result.success(response.body()!!.newsletters)
} else {
Result.failure(Exception("Newsletters fetch failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchSubscriberCount(): Result<Int> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getMembers(limit = 1)
if (response.isSuccessful) {
val total = response.body()!!.meta?.pagination?.total ?: 0
Result.success(total)
} else {
Result.failure(Exception("Member count fetch failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deletePost(id: String): Result<Unit> = suspend fun deletePost(id: String): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {

View file

@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -50,6 +52,7 @@ import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost 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.GhostTagFull
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
@ -158,15 +161,23 @@ fun ComposerScreen(
) )
} }
} else { } else {
val isNewsletterPublish = state.sendAsNewsletter && state.selectedNewsletter != null
FilledIconButton( FilledIconButton(
onClick = viewModel::publish, onClick = viewModel::publish,
enabled = canSubmit, enabled = canSubmit,
colors = IconButtonDefaults.filledIconButtonColors( colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = if (isNewsletterPublish)
contentColor = MaterialTheme.colorScheme.onPrimary 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"
)
} }
} }
@ -186,13 +197,24 @@ fun ComposerScreen(
expanded = showSendMenu, expanded = showSendMenu,
onDismissRequest = { showSendMenu = false } 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( DropdownMenuItem(
text = { Text(if (state.isEditing) "Update & Publish" else "Publish Now") }, text = { Text(publishLabel) },
onClick = { onClick = {
showSendMenu = false showSendMenu = false
viewModel.publish() 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 enabled = canSubmit
) )
DropdownMenuItem( DropdownMenuItem(
@ -213,6 +235,17 @@ fun ComposerScreen(
leadingIcon = { Icon(Icons.Default.Schedule, null) }, leadingIcon = { Icon(Icons.Default.Schedule, null) },
enabled = canSubmit 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
)
}
} }
} }
} }
@ -723,6 +756,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")
}
}
)
} }
/** /**

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.PreviewHtmlBuilder import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
@ -27,6 +28,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private val repository = PostRepository(application) private val repository = PostRepository(application)
private val tagRepository = TagRepository(application) private val tagRepository = TagRepository(application)
private val newsletterPreferences = NewsletterPreferences(application)
private val appContext = application private val appContext = application
private val _uiState = MutableStateFlow(ComposerUiState()) private val _uiState = MutableStateFlow(ComposerUiState())
@ -40,6 +42,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
init { init {
loadAvailableTags() loadAvailableTags()
loadNewsletterData()
} }
private fun loadAvailableTags() { 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) { fun updateTagInput(input: String) {
val suggestions = if (input.isBlank()) { val suggestions = if (input.isBlank()) {
emptyList() emptyList()
@ -274,7 +326,15 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(featured = !it.featured) } _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) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -318,7 +378,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
tags = tagsJson, tags = tagsJson,
queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus, queueStatus = if (status == PostStatus.DRAFT) QueueStatus.NONE else offlineQueueStatus,
fileUri = state.fileUri?.toString(), fileUri = state.fileUri?.toString(),
fileName = state.fileName fileName = state.fileName,
newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null
) )
repository.saveLocalPost(localPost) repository.saveLocalPost(localPost)
@ -380,11 +441,17 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
tags = ghostTags.ifEmpty { null } 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 result = if (editingGhostId != null) {
val updatePost = ghostPost.copy(updated_at = editingUpdatedAt) val updatePost = ghostPost.copy(updated_at = editingUpdatedAt)
repository.updatePost(editingGhostId!!, updatePost) repository.updatePost(editingGhostId!!, updatePost, newsletter = newsletterSlug, emailSegment = emailSeg)
} else { } else {
repository.createPost(ghostPost) repository.createPost(ghostPost, newsletter = newsletterSlug, emailSegment = emailSeg)
} }
result.fold( result.fold(
@ -414,7 +481,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
queueStatus = offlineQueueStatus, queueStatus = offlineQueueStatus,
fileUri = state.fileUri?.toString(), fileUri = state.fileUri?.toString(),
uploadedFileUrl = uploadedFileUrl, uploadedFileUrl = uploadedFileUrl,
fileName = state.fileName fileName = state.fileName,
newsletterSlug = if (state.sendAsNewsletter) state.selectedNewsletter?.slug else null
) )
repository.saveLocalPost(localPost) repository.saveLocalPost(localPost)
PostUploadWorker.enqueue(appContext) PostUploadWorker.enqueue(appContext)
@ -460,7 +528,15 @@ data class ComposerUiState(
val fileName: String? = null, val fileName: String? = null,
val fileSize: Long? = null, val fileSize: Long? = null,
val fileMimeType: String? = null, val fileMimeType: String? = null,
val uploadedFileUrl: String? = null val uploadedFileUrl: String? = null,
// 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. * Backwards compatibility: returns the first image URI or null.

View file

@ -32,8 +32,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.launch
import com.swoosh.microblog.data.model.GhostAccount import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog import com.swoosh.microblog.ui.components.ConfirmationDialog
@ -293,6 +296,13 @@ fun SettingsScreen(
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// --- Newsletter section ---
NewsletterSettingsSection()
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
// --- Current Account section --- // --- Current Account section ---
Text("Current Account", style = MaterialTheme.typography.titleMedium) Text("Current Account", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -444,6 +454,86 @@ fun SettingsScreen(
} }
} }
@Composable
fun NewsletterSettingsSection() {
val context = LocalContext.current
val newsletterPreferences = remember { NewsletterPreferences(context) }
var newsletterEnabled by remember { mutableStateOf(newsletterPreferences.isNewsletterEnabled()) }
val coroutineScope = rememberCoroutineScope()
var validationStatus by remember { mutableStateOf<String?>(null) }
Text("Newsletter", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable newsletter features",
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = newsletterEnabled,
onCheckedChange = { enabled ->
newsletterEnabled = enabled
newsletterPreferences.setNewsletterEnabled(enabled)
if (enabled) {
// Best effort: validate by fetching newsletters
validationStatus = "Checking..."
coroutineScope.launch {
try {
val repository = PostRepository(context)
val result = repository.fetchNewsletters()
validationStatus = if (result.isSuccess) {
val count = result.getOrNull()?.size ?: 0
"$count newsletter(s) found"
} else {
null
}
} catch (_: Exception) {
validationStatus = null
}
}
} else {
validationStatus = null
}
}
)
}
Text(
text = "Show newsletter sending options when publishing posts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Validation status
AnimatedVisibility(
visible = validationStatus != null,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Text(
text = validationStatus ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
@Composable @Composable
fun ThemeModeSelector( fun ThemeModeSelector(
currentMode: ThemeMode, currentMode: ThemeMode,

View file

@ -0,0 +1,98 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = android.app.Application::class)
class NewsletterPreferencesTest {
private lateinit var prefs: SharedPreferences
private lateinit var newsletterPreferences: NewsletterPreferences
private val testAccountId = "test-account-123"
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
prefs = context.getSharedPreferences(NewsletterPreferences.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().commit()
newsletterPreferences = NewsletterPreferences(prefs, testAccountId)
}
// --- Default values ---
@Test
fun `default newsletter enabled is false`() {
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Setting and getting ---
@Test
fun `setting newsletter enabled to true persists`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `setting newsletter enabled to false persists`() {
newsletterPreferences.setNewsletterEnabled(true)
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `newsletter enabled persists across instances`() {
newsletterPreferences.setNewsletterEnabled(true)
val newInstance = NewsletterPreferences(prefs, testAccountId)
assertTrue(newInstance.isNewsletterEnabled())
}
@Test
fun `toggling on then off round-trips correctly`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Per-account isolation ---
@Test
fun `different accounts have independent newsletter settings`() {
val prefs1 = NewsletterPreferences(prefs, "account-1")
val prefs2 = NewsletterPreferences(prefs, "account-2")
prefs1.setNewsletterEnabled(true)
prefs2.setNewsletterEnabled(false)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `enabling for one account does not affect another`() {
val prefs1 = NewsletterPreferences(prefs, "account-a")
val prefs2 = NewsletterPreferences(prefs, "account-b")
prefs1.setNewsletterEnabled(true)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `empty account id still works`() {
val emptyPrefs = NewsletterPreferences(prefs, "")
emptyPrefs.setNewsletterEnabled(true)
assertTrue(emptyPrefs.isNewsletterEnabled())
}
}