mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
merge: integrate Phase 4a (Newsletter) with existing phases
This commit is contained in:
commit
3b1061694d
8 changed files with 680 additions and 17 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
)
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue