merge: integrate pinned/featured posts feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:52:52 +01:00
commit 34feca3461
No known key found for this signature in database
13 changed files with 582 additions and 7 deletions

View file

@ -22,6 +22,7 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_1_2 = object : Migration(1, 2) { val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL") db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
} }
} }

View file

@ -49,4 +49,10 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC") @Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
suspend fun getAllPostsList(): List<LocalPost> suspend fun getAllPostsList(): List<LocalPost>
@Query("UPDATE local_posts SET featured = :featured WHERE localId = :localId")
suspend fun updateFeatured(localId: Long, featured: Boolean)
@Query("UPDATE local_posts SET featured = :featured WHERE ghostId = :ghostId")
suspend fun updateFeaturedByGhostId(ghostId: String, featured: Boolean)
} }

View file

@ -37,6 +37,7 @@ data class GhostPost(
val plaintext: String? = null, val plaintext: String? = null,
val mobiledoc: String? = null, val mobiledoc: String? = null,
val status: String? = null, val status: String? = null,
val featured: Boolean? = false,
val feature_image: String? = null, val feature_image: String? = null,
val feature_image_alt: String? = null, val feature_image_alt: String? = null,
val created_at: String? = null, val created_at: String? = null,
@ -64,6 +65,7 @@ data class LocalPost(
val content: String = "", val content: String = "",
val htmlContent: String? = null, val htmlContent: String? = null,
val status: PostStatus = PostStatus.DRAFT, val status: PostStatus = PostStatus.DRAFT,
val featured: Boolean = false,
val imageUri: String? = null, val imageUri: String? = null,
val uploadedImageUrl: String? = null, val uploadedImageUrl: String? = null,
val linkUrl: String? = null, val linkUrl: String? = null,
@ -108,6 +110,7 @@ data class FeedPost(
val linkDescription: String?, val linkDescription: String?,
val linkImageUrl: String?, val linkImageUrl: String?,
val status: String, val status: String,
val featured: Boolean = false,
val publishedAt: String?, val publishedAt: String?,
val createdAt: String?, val createdAt: String?,
val updatedAt: String?, val updatedAt: String?,

View file

@ -150,6 +150,32 @@ class PostRepository(private val context: Context) {
suspend fun getAllLocalPostsList(): List<LocalPost> = dao.getAllPostsList() suspend fun getAllLocalPostsList(): List<LocalPost> = dao.getAllPostsList()
// --- Featured/Pinned operations ---
suspend fun updateLocalFeatured(localId: Long, featured: Boolean) =
dao.updateFeatured(localId, featured)
suspend fun updateLocalFeaturedByGhostId(ghostId: String, featured: Boolean) =
dao.updateFeaturedByGhostId(ghostId, featured)
suspend fun toggleFeatured(ghostId: String, featured: Boolean, updatedAt: String): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val post = GhostPost(
featured = featured,
updated_at = updatedAt
)
val response = getApi().updatePost(ghostId, PostWrapper(listOf(post)))
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
Result.failure(Exception("Toggle featured failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// --- Connectivity check --- // --- Connectivity check ---
fun isNetworkAvailable(): Boolean { fun isNetworkAvailable(): Boolean {

View file

@ -330,6 +330,33 @@ fun ComposerScreen(
) )
} }
// Feature toggle
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PushPin,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (state.featured) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Feature this post",
style = MaterialTheme.typography.bodyMedium
)
}
Switch(
checked = state.featured,
onCheckedChange = { viewModel.toggleFeatured() }
)
}
if (state.error != null) { if (state.error != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(

View file

@ -47,6 +47,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
description = post.linkDescription, description = post.linkDescription,
imageUrl = post.linkImageUrl imageUrl = post.linkImageUrl
) else null, ) else null,
featured = post.featured,
isEditing = true isEditing = true
) )
} }
@ -139,6 +140,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(previewHtml = html) } _uiState.update { it.copy(previewHtml = html) }
} }
fun toggleFeatured() {
_uiState.update { it.copy(featured = !it.featured) }
}
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -167,6 +172,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title, title = title,
content = state.text, content = state.text,
status = status, status = status,
featured = if (status != PostStatus.DRAFT) state.featured else false,
imageUri = state.imageUri?.toString(), imageUri = state.imageUri?.toString(),
imageAlt = altText, imageAlt = altText,
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
@ -211,6 +217,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title, title = title,
mobiledoc = mobiledoc, mobiledoc = mobiledoc,
status = status.name.lowercase(), status = status.name.lowercase(),
featured = if (status != PostStatus.DRAFT) state.featured else false,
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = altText, feature_image_alt = altText,
published_at = state.scheduledAt, published_at = state.scheduledAt,
@ -236,6 +243,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title, title = title,
content = state.text, content = state.text,
status = status, status = status,
featured = if (status != PostStatus.DRAFT) state.featured else false,
imageUri = state.imageUri?.toString(), imageUri = state.imageUri?.toString(),
uploadedImageUrl = featureImage, uploadedImageUrl = featureImage,
imageAlt = altText, imageAlt = altText,
@ -274,6 +282,7 @@ data class ComposerUiState(
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,
val featured: Boolean = false,
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isEditing: Boolean = false, val isEditing: Boolean = false,

View file

@ -22,9 +22,11 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -55,7 +57,8 @@ fun DetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEdit: (FeedPost) -> Unit, onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit, onDelete: (FeedPost) -> Unit,
onPreview: ((String) -> Unit)? = null onPreview: ((String) -> Unit)? = null,
onTogglePin: (FeedPost) -> Unit = {}
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showOverflowMenu by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) }
@ -78,6 +81,14 @@ fun DetailScreen(
} }
}, },
actions = { actions = {
// Pin/Unpin button
IconButton(onClick = { onTogglePin(post) }) {
Icon(
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = if (post.featured) "Unpin" else "Pin",
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Preview button - show rendered HTML // Preview button - show rendered HTML
IconButton(onClick = { IconButton(onClick = {
val html = if (!post.htmlContent.isNullOrBlank()) { val html = if (!post.htmlContent.isNullOrBlank()) {
@ -365,6 +376,9 @@ private fun PostStatsSection(post: FeedPost) {
if (post.publishedAt != null) { if (post.publishedAt != null) {
MetadataRow("Published", post.publishedAt) MetadataRow("Published", post.publishedAt)
} }
if (post.featured) {
MetadataRow("Featured", "Pinned")
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View file

@ -25,10 +25,13 @@ import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -81,6 +84,10 @@ fun FeedScreen(
// Track which post is pending delete confirmation // Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) } var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
// Split posts into pinned and regular
val pinnedPosts = state.posts.filter { it.featured }
val regularPosts = state.posts.filter { !it.featured }
// Pull-to-refresh // Pull-to-refresh
val pullRefreshState = rememberPullRefreshState( val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
@ -214,7 +221,12 @@ fun FeedScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> // Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
SwipeablePostCard( SwipeablePostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
@ -239,6 +251,47 @@ fun FeedScreen(
}, },
onEdit = { onEditPost(post) }, onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post }, onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -261,6 +314,22 @@ fun FeedScreen(
modifier = Modifier.align(Alignment.TopCenter) modifier = Modifier.align(Alignment.TopCenter)
) )
// Show snackbar for pin/unpin confirmation
if (state.snackbarMessage != null) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
dismissAction = {
TextButton(onClick = viewModel::clearSnackbar) { Text("OK") }
}
) {
Text(state.snackbarMessage!!)
}
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
}
}
// Show non-connection errors as snackbar (when posts are visible) // Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar( Snackbar(
@ -303,6 +372,30 @@ fun FeedScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PinnedSectionHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Pinned",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.primary
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SwipeablePostCard( fun SwipeablePostCard(
@ -313,6 +406,7 @@ fun SwipeablePostCard(
onCopyLink: () -> Unit = {}, onCopyLink: () -> Unit = {},
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onTogglePin: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null
) { ) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
@ -362,6 +456,7 @@ fun SwipeablePostCard(
onCopyLink = onCopyLink, onCopyLink = onCopyLink,
onEdit = onEdit, onEdit = onEdit,
onDelete = onDelete, onDelete = onDelete,
onTogglePin = onTogglePin,
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
@ -449,6 +544,7 @@ fun PostCardContent(
onCopyLink: () -> Unit = {}, onCopyLink: () -> Unit = {},
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onTogglePin: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@ -475,7 +571,10 @@ fun PostCardContent(
} }
), ),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = if (post.featured)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
else
MaterialTheme.colorScheme.surface
) )
) { ) {
Box { Box {
@ -486,7 +585,18 @@ fun PostCardContent(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
StatusBadge(post) StatusBadge(post)
if (post.featured) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Text( Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt), text = formatRelativeTime(post.publishedAt ?: post.createdAt),
@ -664,6 +774,20 @@ fun PostCardContent(
}, },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
) )
DropdownMenuItem(
text = { Text(if (post.featured) "Unpin post" else "Pin post") },
onClick = {
showContextMenu = false
onTogglePin()
},
leadingIcon = {
Icon(
imageVector = if (post.featured) Icons.Outlined.PushPin else Icons.Filled.PushPin,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
if (isPublished && hasShareableUrl) { if (isPublished && hasShareableUrl) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Copy link") }, text = { Text("Copy link") },

View file

@ -184,6 +184,54 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun toggleFeatured(post: FeedPost) {
viewModelScope.launch {
val newFeatured = !post.featured
val ghostId = post.ghostId
val updatedAt = post.updatedAt
if (ghostId != null && updatedAt != null) {
// Optimistically update local state
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(featured = newFeatured) else it
}
mergePosts()
repository.toggleFeatured(ghostId, newFeatured, updatedAt).fold(
onSuccess = { updatedGhostPost ->
// Update with server response
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(
featured = updatedGhostPost.featured ?: false,
updatedAt = updatedGhostPost.updated_at
) else it
}
mergePosts()
val message = if (newFeatured) "Post pinned" else "Post unpinned"
_uiState.update { it.copy(snackbarMessage = message) }
},
onFailure = { e ->
// Revert optimistic update
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(featured = !newFeatured) else it
}
mergePosts()
_uiState.update { it.copy(error = "Failed to ${if (newFeatured) "pin" else "unpin"} post: ${e.message}") }
}
)
} else if (post.isLocal && post.localId != null) {
// Local-only post: just update the local DB
repository.updateLocalFeatured(post.localId, newFeatured)
val message = if (newFeatured) "Post pinned" else "Post unpinned"
_uiState.update { it.copy(snackbarMessage = message) }
}
}
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
fun clearError() { fun clearError() {
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(error = null) }
} }
@ -191,7 +239,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private fun mergePosts(queuedPosts: List<FeedPost>? = null) { private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal } val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
val allPosts = queued + remotePosts val allPosts = queued + remotePosts
_uiState.update { it.copy(posts = allPosts) } // Sort: featured/pinned posts first, then chronological
val sorted = allPosts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.isLocal } // local queued posts after pinned but before regular
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
_uiState.update { it.copy(posts = sorted) }
} }
private fun GhostPost.toFeedPost(): FeedPost = FeedPost( private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
@ -208,6 +262,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkDescription = null, linkDescription = null,
linkImageUrl = null, linkImageUrl = null,
status = status ?: "draft", status = status ?: "draft",
featured = featured ?: false,
publishedAt = published_at, publishedAt = published_at,
createdAt = created_at, createdAt = created_at,
updatedAt = updated_at, updatedAt = updated_at,
@ -227,6 +282,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkDescription = linkDescription, linkDescription = linkDescription,
linkImageUrl = linkImageUrl, linkImageUrl = linkImageUrl,
status = status.name.lowercase(), status = status.name.lowercase(),
featured = featured,
publishedAt = null, publishedAt = null,
createdAt = null, createdAt = null,
updatedAt = null, updatedAt = null,
@ -240,7 +296,8 @@ data class FeedUiState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val error: String? = null, val error: String? = null,
val isConnectionError: Boolean = false val isConnectionError: Boolean = false,
val snackbarMessage: String? = null
) )
fun formatRelativeTime(isoString: String?): String { fun formatRelativeTime(isoString: String?): String {

View file

@ -105,6 +105,11 @@ fun SwooshNavGraph(
onPreview = { html -> onPreview = { html ->
previewHtml = html previewHtml = html
navController.navigate(Routes.PREVIEW) navController.navigate(Routes.PREVIEW)
},
onTogglePin = { p ->
feedViewModel.toggleFeatured(p)
// Update selected post so UI reflects the change
selectedPost = p.copy(featured = !p.featured)
} }
) )
} }

View file

@ -50,6 +50,7 @@ class PostUploadWorker(
QueueStatus.QUEUED_SCHEDULED -> "scheduled" QueueStatus.QUEUED_SCHEDULED -> "scheduled"
else -> "draft" else -> "draft"
}, },
featured = post.featured,
feature_image = featureImage, feature_image = featureImage,
feature_image_alt = post.imageAlt, feature_image_alt = post.imageAlt,
published_at = post.scheduledAt, published_at = post.scheduledAt,

View file

@ -0,0 +1,301 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class FeaturedPostsTest {
private val gson = Gson()
// --- GhostPost featured field ---
@Test
fun `GhostPost default featured is false`() {
val post = GhostPost()
assertEquals(false, post.featured)
}
@Test
fun `GhostPost featured can be set to true`() {
val post = GhostPost(featured = true)
assertTrue(post.featured!!)
}
@Test
fun `GhostPost featured can be set to null`() {
val post = GhostPost(featured = null)
assertNull(post.featured)
}
@Test
fun `GhostPost featured serializes to JSON correctly`() {
val post = GhostPost(id = "abc", featured = true)
val json = gson.toJson(post)
assertTrue(json.contains("\"featured\":true"))
}
@Test
fun `GhostPost featured false serializes correctly`() {
val post = GhostPost(id = "abc", featured = false)
val json = gson.toJson(post)
assertTrue(json.contains("\"featured\":false"))
}
@Test
fun `GhostPost deserializes featured true from JSON`() {
val json = """{"id":"xyz","featured":true}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertTrue(post.featured!!)
}
@Test
fun `GhostPost deserializes featured false from JSON`() {
val json = """{"id":"xyz","featured":false}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertFalse(post.featured!!)
}
@Test
fun `GhostPost deserializes missing featured field`() {
val json = """{"id":"xyz"}"""
val post = gson.fromJson(json, GhostPost::class.java)
// Gson may set missing Boolean? to null or false depending on initialization
// The important thing is it doesn't crash and treats it as not featured
assertTrue(post.featured == null || post.featured == false)
}
@Test
fun `GhostPost copy preserves featured`() {
val post = GhostPost(featured = true)
val copy = post.copy(id = "new")
assertTrue(copy.featured!!)
}
// --- LocalPost featured field ---
@Test
fun `LocalPost default featured is false`() {
val post = LocalPost()
assertFalse(post.featured)
}
@Test
fun `LocalPost featured can be set to true`() {
val post = LocalPost(featured = true)
assertTrue(post.featured)
}
@Test
fun `LocalPost copy preserves featured`() {
val post = LocalPost(featured = true)
val copy = post.copy(title = "updated")
assertTrue(copy.featured)
}
@Test
fun `LocalPost copy can change featured`() {
val post = LocalPost(featured = true)
val copy = post.copy(featured = false)
assertFalse(copy.featured)
}
// --- FeedPost featured field ---
@Test
fun `FeedPost default featured is false`() {
val post = createFeedPost()
assertFalse(post.featured)
}
@Test
fun `FeedPost featured can be set to true`() {
val post = createFeedPost(featured = true)
assertTrue(post.featured)
}
@Test
fun `FeedPost copy preserves featured`() {
val post = createFeedPost(featured = true)
val copy = post.copy(title = "updated")
assertTrue(copy.featured)
}
@Test
fun `FeedPost copy can toggle featured`() {
val post = createFeedPost(featured = true)
val toggled = post.copy(featured = !post.featured)
assertFalse(toggled.featured)
}
// --- Feed ordering (pinned first) ---
@Test
fun `featured posts sort before non-featured posts`() {
val regular = createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-02T00:00:00Z")
val pinned = createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-01T00:00:00Z")
val sorted = listOf(regular, pinned).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertTrue(sorted[0].featured)
assertEquals("2", sorted[0].ghostId)
assertFalse(sorted[1].featured)
assertEquals("1", sorted[1].ghostId)
}
@Test
fun `multiple featured posts sorted chronologically among themselves`() {
val pinned1 = createFeedPost(ghostId = "1", featured = true, publishedAt = "2025-01-01T00:00:00Z")
val pinned2 = createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-03T00:00:00Z")
val pinned3 = createFeedPost(ghostId = "3", featured = true, publishedAt = "2025-01-02T00:00:00Z")
val sorted = listOf(pinned1, pinned2, pinned3).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals("2", sorted[0].ghostId)
assertEquals("3", sorted[1].ghostId)
assertEquals("1", sorted[2].ghostId)
}
@Test
fun `non-featured posts sorted chronologically among themselves`() {
val post1 = createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-01T00:00:00Z")
val post2 = createFeedPost(ghostId = "2", featured = false, publishedAt = "2025-01-03T00:00:00Z")
val post3 = createFeedPost(ghostId = "3", featured = false, publishedAt = "2025-01-02T00:00:00Z")
val sorted = listOf(post1, post2, post3).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals("2", sorted[0].ghostId)
assertEquals("3", sorted[1].ghostId)
assertEquals("1", sorted[2].ghostId)
}
@Test
fun `mixed featured and non-featured posts sort correctly`() {
val posts = listOf(
createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-05T00:00:00Z"),
createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-01T00:00:00Z"),
createFeedPost(ghostId = "3", featured = false, publishedAt = "2025-01-04T00:00:00Z"),
createFeedPost(ghostId = "4", featured = true, publishedAt = "2025-01-03T00:00:00Z")
)
val sorted = posts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
// Featured first: 4 (Jan 3), 2 (Jan 1)
assertEquals("4", sorted[0].ghostId)
assertTrue(sorted[0].featured)
assertEquals("2", sorted[1].ghostId)
assertTrue(sorted[1].featured)
// Then regular: 1 (Jan 5), 3 (Jan 4)
assertEquals("1", sorted[2].ghostId)
assertFalse(sorted[2].featured)
assertEquals("3", sorted[3].ghostId)
assertFalse(sorted[3].featured)
}
@Test
fun `empty list sorts without error`() {
val sorted = emptyList<FeedPost>().sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertTrue(sorted.isEmpty())
}
@Test
fun `single featured post list sorts correctly`() {
val posts = listOf(createFeedPost(ghostId = "1", featured = true))
val sorted = posts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals(1, sorted.size)
assertTrue(sorted[0].featured)
}
@Test
fun `partitioning posts into pinned and regular sections works`() {
val posts = listOf(
createFeedPost(ghostId = "1", featured = true),
createFeedPost(ghostId = "2", featured = false),
createFeedPost(ghostId = "3", featured = true),
createFeedPost(ghostId = "4", featured = false)
)
val pinned = posts.filter { it.featured }
val regular = posts.filter { !it.featured }
assertEquals(2, pinned.size)
assertEquals(2, regular.size)
assertTrue(pinned.all { it.featured })
assertTrue(regular.none { it.featured })
}
// --- Model mapping with featured ---
@Test
fun `PostWrapper with featured post serializes correctly`() {
val wrapper = PostWrapper(listOf(GhostPost(title = "Test", featured = true, updated_at = "2025-01-01T00:00:00Z")))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"featured\":true"))
assertTrue(json.contains("\"updated_at\":\"2025-01-01T00:00:00Z\""))
}
@Test
fun `PostsResponse with featured posts deserializes correctly`() {
val json = """{
"posts": [
{"id": "1", "title": "Pinned", "featured": true},
{"id": "2", "title": "Regular", "featured": false}
],
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
}"""
val response = gson.fromJson(json, PostsResponse::class.java)
assertEquals(2, response.posts.size)
assertTrue(response.posts[0].featured!!)
assertFalse(response.posts[1].featured!!)
}
@Test
fun `toggle featured API request body is correct`() {
val post = GhostPost(featured = true, updated_at = "2025-01-01T00:00:00Z")
val wrapper = PostWrapper(listOf(post))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"featured\":true"))
assertTrue(json.contains("\"updated_at\""))
}
// --- Helper ---
private fun createFeedPost(
ghostId: String? = null,
featured: Boolean = false,
publishedAt: String? = null
) = FeedPost(
ghostId = ghostId,
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
featured = featured,
publishedAt = publishedAt,
createdAt = null,
updatedAt = null
)
}

View file

@ -63,7 +63,7 @@ class GhostModelsTest {
} }
@Test @Test
fun `GhostPost all fields default to null except visibility`() { fun `GhostPost all fields default to null except visibility and featured`() {
val post = GhostPost() val post = GhostPost()
assertNull(post.id) assertNull(post.id)
assertNull(post.title) assertNull(post.title)
@ -73,6 +73,7 @@ class GhostModelsTest {
assertNull(post.plaintext) assertNull(post.plaintext)
assertNull(post.mobiledoc) assertNull(post.mobiledoc)
assertNull(post.status) assertNull(post.status)
assertEquals(false, post.featured)
assertNull(post.feature_image) assertNull(post.feature_image)
assertNull(post.feature_image_alt) assertNull(post.feature_image_alt)
assertNull(post.created_at) assertNull(post.created_at)