feat: add pinned/featured posts with toggle and feed section

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:08 +01:00
parent 74f42fd2f1
commit b119d75bac
No known key found for this signature in database
13 changed files with 598 additions and 17 deletions

View file

@ -5,9 +5,11 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.swoosh.microblog.data.model.LocalPost import com.swoosh.microblog.data.model.LocalPost
@Database(entities = [LocalPost::class], version = 1, exportSchema = false) @Database(entities = [LocalPost::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
}
}
fun getInstance(context: Context): AppDatabase { fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"swoosh_database" "swoosh_database"
).build() )
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View file

@ -39,4 +39,10 @@ interface LocalPostDao {
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId") @Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE) suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE)
@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

@ -35,6 +35,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 created_at: String? = null, val created_at: String? = null,
val updated_at: String? = null, val updated_at: String? = null,
@ -60,6 +61,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,
@ -100,6 +102,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

@ -142,6 +142,30 @@ class PostRepository(private val context: Context) {
suspend fun markUploaded(localId: Long, ghostId: String) = suspend fun markUploaded(localId: Long, ghostId: String) =
dao.markUploaded(localId, ghostId) dao.markUploaded(localId, ghostId)
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

@ -11,6 +11,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
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.PushPin
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -214,6 +215,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() }
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))

View file

@ -41,6 +41,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
) )
} }
@ -71,6 +72,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(scheduledAt = dateTimeIso) } _uiState.update { it.copy(scheduledAt = dateTimeIso) }
} }
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)
@ -97,6 +102,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(),
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
@ -133,6 +139,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,
published_at = state.scheduledAt, published_at = state.scheduledAt,
visibility = "public" visibility = "public"
@ -157,6 +164,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,
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
@ -188,6 +196,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

@ -7,6 +7,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.PushPin
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.Modifier import androidx.compose.ui.Modifier
@ -24,7 +26,8 @@ fun DetailScreen(
post: FeedPost, post: FeedPost,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: (FeedPost) -> Unit, onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit onDelete: (FeedPost) -> Unit,
onTogglePin: (FeedPost) -> Unit = {}
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
@ -38,6 +41,13 @@ fun DetailScreen(
} }
}, },
actions = { actions = {
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
)
}
IconButton(onClick = { onEdit(post) }) { IconButton(onClick = { onEdit(post) }) {
Icon(Icons.Default.Edit, "Edit") Icon(Icons.Default.Edit, "Edit")
} }
@ -142,6 +152,9 @@ fun DetailScreen(
MetadataRow("Published", post.publishedAt) MetadataRow("Published", post.publishedAt)
} }
MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() }) MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
if (post.featured) {
MetadataRow("Featured", "Pinned")
}
} }
} }

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.ui.feed package com.swoosh.microblog.ui.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -8,9 +9,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
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.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
@ -20,6 +24,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -40,6 +45,10 @@ fun FeedScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// 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,
@ -147,11 +156,36 @@ 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 ->
PostCard( PostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) } onCancelQueue = { viewModel.cancelQueuedPost(post) },
onTogglePin = { viewModel.toggleFeatured(post) }
)
}
// 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 ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onTogglePin = { viewModel.toggleFeatured(post) }
) )
} }
@ -173,6 +207,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(
@ -191,13 +241,38 @@ fun FeedScreen(
} }
} }
@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
)
}
}
@Composable @Composable
fun PostCard( fun PostCard(
post: FeedPost, post: FeedPost,
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit onCancelQueue: () -> Unit,
onTogglePin: () -> Unit = {}
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
val displayText = if (expanded || post.textContent.length <= 280) { val displayText = if (expanded || post.textContent.length <= 280) {
post.textContent post.textContent
} else { } else {
@ -210,23 +285,71 @@ fun PostCard(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
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
) )
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Status row // Status row with menu
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
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
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt), text = formatRelativeTime(post.publishedAt ?: post.createdAt),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(if (post.featured) "Unpin post" else "Pin post") },
leadingIcon = {
Icon(
imageVector = if (post.featured) Icons.Outlined.PushPin else Icons.Filled.PushPin,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
},
onClick = {
showMenu = false
onTogglePin()
}
)
}
}
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View file

@ -128,6 +128,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) }
} }
@ -135,7 +183,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(
@ -149,6 +203,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,
@ -167,6 +222,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,
@ -180,7 +236,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

@ -84,6 +84,11 @@ fun SwooshNavGraph(
onDelete = { p -> onDelete = { p ->
feedViewModel.deletePost(p) feedViewModel.deletePost(p)
navController.popBackStack() navController.popBackStack()
},
onTogglePin = { p ->
feedViewModel.toggleFeatured(p)
// Update selected post so UI reflects the change
selectedPost = p.copy(featured = !p.featured)
} }
) )
} }

View file

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

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)
@ -71,6 +71,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.created_at) assertNull(post.created_at)
assertNull(post.updated_at) assertNull(post.updated_at)