diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 2dec738..a76d169 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -22,6 +22,7 @@ abstract class AppDatabase : RoomDatabase() { val MIGRATION_1_2 = object : Migration(1, 2) { 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 featured INTEGER NOT NULL DEFAULT 0") } } diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 996ade1..5803507 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -49,4 +49,10 @@ interface LocalPostDao { @Query("SELECT * FROM local_posts ORDER BY updatedAt DESC") suspend fun getAllPostsList(): List + + @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) } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9cd32b3..4987f35 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -37,6 +37,7 @@ data class GhostPost( val plaintext: String? = null, val mobiledoc: String? = null, val status: String? = null, + val featured: Boolean? = false, val feature_image: String? = null, val feature_image_alt: String? = null, val created_at: String? = null, @@ -64,6 +65,7 @@ data class LocalPost( val content: String = "", val htmlContent: String? = null, val status: PostStatus = PostStatus.DRAFT, + val featured: Boolean = false, val imageUri: String? = null, val uploadedImageUrl: String? = null, val linkUrl: String? = null, @@ -108,6 +110,7 @@ data class FeedPost( val linkDescription: String?, val linkImageUrl: String?, val status: String, + val featured: Boolean = false, val publishedAt: String?, val createdAt: String?, val updatedAt: String?, diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 4d4dfc7..c25b9c1 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -150,6 +150,32 @@ class PostRepository(private val context: Context) { suspend fun getAllLocalPostsList(): List = 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 = + 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 --- fun isNetworkAvailable(): Boolean { diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 5022b56..2426ab4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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) { Spacer(modifier = Modifier.height(12.dp)) Text( diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 50d402e..2d2a10a 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -47,6 +47,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application description = post.linkDescription, imageUrl = post.linkImageUrl ) else null, + featured = post.featured, isEditing = true ) } @@ -139,6 +140,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application _uiState.update { it.copy(previewHtml = html) } } + fun toggleFeatured() { + _uiState.update { it.copy(featured = !it.featured) } + } + fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) @@ -167,6 +172,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application title = title, content = state.text, status = status, + featured = if (status != PostStatus.DRAFT) state.featured else false, imageUri = state.imageUri?.toString(), imageAlt = altText, linkUrl = state.linkPreview?.url, @@ -211,6 +217,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application title = title, mobiledoc = mobiledoc, status = status.name.lowercase(), + featured = if (status != PostStatus.DRAFT) state.featured else false, feature_image = featureImage, feature_image_alt = altText, published_at = state.scheduledAt, @@ -236,6 +243,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application title = title, content = state.text, status = status, + featured = if (status != PostStatus.DRAFT) state.featured else false, imageUri = state.imageUri?.toString(), uploadedImageUrl = featureImage, imageAlt = altText, @@ -274,6 +282,7 @@ data class ComposerUiState( val linkPreview: LinkPreview? = null, val isLoadingLink: Boolean = false, val scheduledAt: String? = null, + val featured: Boolean = false, val isSubmitting: Boolean = false, val isSuccess: Boolean = false, val isEditing: Boolean = false, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index 6e82b2e..c480677 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -22,9 +22,11 @@ import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Link 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.TextFields import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -55,7 +57,8 @@ fun DetailScreen( onBack: () -> Unit, onEdit: (FeedPost) -> Unit, onDelete: (FeedPost) -> Unit, - onPreview: ((String) -> Unit)? = null + onPreview: ((String) -> Unit)? = null, + onTogglePin: (FeedPost) -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) } @@ -78,6 +81,14 @@ fun DetailScreen( } }, 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 IconButton(onClick = { val html = if (!post.htmlContent.isNullOrBlank()) { @@ -365,6 +376,9 @@ private fun PostStatsSection(post: FeedPost) { if (post.publishedAt != null) { MetadataRow("Published", post.publishedAt) } + if (post.featured) { + MetadataRow("Featured", "Pinned") + } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 1a190af..3dc0ea6 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -25,10 +25,13 @@ import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit 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.Settings import androidx.compose.material.icons.filled.Share 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.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -81,6 +84,10 @@ fun FeedScreen( // Track which post is pending delete confirmation var postPendingDelete by remember { mutableStateOf(null) } + // Split posts into pinned and regular + val pinnedPosts = state.posts.filter { it.featured } + val regularPosts = state.posts.filter { !it.featured } + // Pull-to-refresh val pullRefreshState = rememberPullRefreshState( refreshing = state.isRefreshing, @@ -214,7 +221,52 @@ fun FeedScreen( modifier = Modifier.fillMaxSize(), 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( + 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 + ) + } + // 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) }, @@ -239,6 +291,7 @@ fun FeedScreen( }, onEdit = { onEditPost(post) }, onDelete = { postPendingDelete = post }, + onTogglePin = { viewModel.toggleFeatured(post) }, snackbarHostState = snackbarHostState ) } @@ -261,6 +314,22 @@ fun FeedScreen( 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) if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { 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) @Composable fun SwipeablePostCard( @@ -313,6 +406,7 @@ fun SwipeablePostCard( onCopyLink: () -> Unit = {}, onEdit: () -> Unit, onDelete: () -> Unit, + onTogglePin: () -> Unit = {}, snackbarHostState: SnackbarHostState? = null ) { val dismissState = rememberSwipeToDismissBoxState( @@ -362,6 +456,7 @@ fun SwipeablePostCard( onCopyLink = onCopyLink, onEdit = onEdit, onDelete = onDelete, + onTogglePin = onTogglePin, snackbarHostState = snackbarHostState ) } @@ -449,6 +544,7 @@ fun PostCardContent( onCopyLink: () -> Unit = {}, onEdit: () -> Unit, onDelete: () -> Unit, + onTogglePin: () -> Unit = {}, snackbarHostState: SnackbarHostState? = null ) { var expanded by remember { mutableStateOf(false) } @@ -475,7 +571,10 @@ fun PostCardContent( } ), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = if (post.featured) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + else + MaterialTheme.colorScheme.surface ) ) { Box { @@ -486,7 +585,18 @@ fun PostCardContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - StatusBadge(post) + Row(verticalAlignment = Alignment.CenterVertically) { + 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 = formatRelativeTime(post.publishedAt ?: post.createdAt), @@ -664,6 +774,20 @@ fun PostCardContent( }, 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) { DropdownMenuItem( text = { Text("Copy link") }, diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 0b6609f..f7e2ed2 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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() { _uiState.update { it.copy(error = null) } } @@ -191,7 +239,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { private fun mergePosts(queuedPosts: List? = null) { val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal } val allPosts = queued + remotePosts - _uiState.update { it.copy(posts = allPosts) } + // Sort: featured/pinned posts first, then chronological + val sorted = allPosts.sortedWith( + compareByDescending { 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( @@ -208,6 +262,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { linkDescription = null, linkImageUrl = null, status = status ?: "draft", + featured = featured ?: false, publishedAt = published_at, createdAt = created_at, updatedAt = updated_at, @@ -227,6 +282,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { linkDescription = linkDescription, linkImageUrl = linkImageUrl, status = status.name.lowercase(), + featured = featured, publishedAt = null, createdAt = null, updatedAt = null, @@ -240,7 +296,8 @@ data class FeedUiState( val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, val error: String? = null, - val isConnectionError: Boolean = false + val isConnectionError: Boolean = false, + val snackbarMessage: String? = null ) fun formatRelativeTime(isoString: String?): String { diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index 3c95fc1..3d62e54 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -105,6 +105,11 @@ fun SwooshNavGraph( onPreview = { html -> previewHtml = html navController.navigate(Routes.PREVIEW) + }, + onTogglePin = { p -> + feedViewModel.toggleFeatured(p) + // Update selected post so UI reflects the change + selectedPost = p.copy(featured = !p.featured) } ) } diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index f13cc01..ffbf308 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -50,6 +50,7 @@ class PostUploadWorker( QueueStatus.QUEUED_SCHEDULED -> "scheduled" else -> "draft" }, + featured = post.featured, feature_image = featureImage, feature_image_alt = post.imageAlt, published_at = post.scheduledAt, diff --git a/app/src/test/java/com/swoosh/microblog/data/model/FeaturedPostsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/FeaturedPostsTest.kt new file mode 100644 index 0000000..c594096 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/FeaturedPostsTest.kt @@ -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 { 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 { 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 { 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 { 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().sortedWith( + compareByDescending { 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 { 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 + ) +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 7e6e13e..255e08f 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -63,7 +63,7 @@ class GhostModelsTest { } @Test - fun `GhostPost all fields default to null except visibility`() { + fun `GhostPost all fields default to null except visibility and featured`() { val post = GhostPost() assertNull(post.id) assertNull(post.title) @@ -73,6 +73,7 @@ class GhostModelsTest { assertNull(post.plaintext) assertNull(post.mobiledoc) assertNull(post.status) + assertEquals(false, post.featured) assertNull(post.feature_image) assertNull(post.feature_image_alt) assertNull(post.created_at)