From b119d75bac1feadd91a47922d5846faf45ad71fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:08 +0100 Subject: [PATCH] feat: add pinned/featured posts with toggle and feed section --- .../swoosh/microblog/data/db/AppDatabase.kt | 14 +- .../swoosh/microblog/data/db/LocalPostDao.kt | 6 + .../microblog/data/model/GhostModels.kt | 3 + .../data/repository/PostRepository.kt | 24 ++ .../microblog/ui/composer/ComposerScreen.kt | 28 ++ .../ui/composer/ComposerViewModel.kt | 9 + .../microblog/ui/detail/DetailScreen.kt | 15 +- .../swoosh/microblog/ui/feed/FeedScreen.kt | 145 ++++++++- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 61 +++- .../microblog/ui/navigation/NavGraph.kt | 5 + .../microblog/worker/PostUploadWorker.kt | 1 + .../microblog/data/model/FeaturedPostsTest.kt | 301 ++++++++++++++++++ .../microblog/data/model/GhostModelsTest.kt | 3 +- 13 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/FeaturedPostsTest.kt 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 3b3f23f..6f924f6 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 @@ -5,9 +5,11 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase 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) abstract class AppDatabase : RoomDatabase() { @@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() { @Volatile 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 { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "swoosh_database" - ).build() + ) + .addMigrations(MIGRATION_1_2) + .build() INSTANCE = instance instance } 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 02a5651..993b0da 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 @@ -39,4 +39,10 @@ interface LocalPostDao { @Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId") 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) } 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 9adfc02..bd2d63a 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 @@ -35,6 +35,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 created_at: String? = null, val updated_at: String? = null, @@ -60,6 +61,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, @@ -100,6 +102,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 8a567ab..33a2e00 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 @@ -142,6 +142,30 @@ class PostRepository(private val context: Context) { suspend fun markUploaded(localId: Long, ghostId: String) = 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 = + 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 b26f002..ad0dd9c 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 @@ -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.Image import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* 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.weight(1f)) 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 86e9683..d85cbe8 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 @@ -41,6 +41,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application description = post.linkDescription, imageUrl = post.linkImageUrl ) else null, + featured = post.featured, isEditing = true ) } @@ -71,6 +72,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application _uiState.update { it.copy(scheduledAt = dateTimeIso) } } + fun toggleFeatured() { + _uiState.update { it.copy(featured = !it.featured) } + } + fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH) fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE) @@ -97,6 +102,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(), linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, @@ -133,6 +139,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, published_at = state.scheduledAt, visibility = "public" @@ -157,6 +164,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, linkUrl = state.linkPreview?.url, @@ -188,6 +196,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 bc6d465..dd374c3 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 @@ -7,6 +7,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete 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.runtime.* import androidx.compose.ui.Modifier @@ -24,7 +26,8 @@ fun DetailScreen( post: FeedPost, onBack: () -> Unit, onEdit: (FeedPost) -> Unit, - onDelete: (FeedPost) -> Unit + onDelete: (FeedPost) -> Unit, + onTogglePin: (FeedPost) -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } @@ -38,6 +41,13 @@ fun DetailScreen( } }, 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) }) { Icon(Icons.Default.Edit, "Edit") } @@ -142,6 +152,9 @@ fun DetailScreen( MetadataRow("Published", post.publishedAt) } MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() }) + if (post.featured) { + MetadataRow("Featured", "Pinned") + } } } 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 9952c7c..6914b09 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 @@ -1,5 +1,6 @@ package com.swoosh.microblog.ui.feed +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* 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.icons.Icons 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.Settings 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 @@ -20,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.TextOverflow import androidx.compose.ui.unit.dp @@ -40,6 +45,10 @@ fun FeedScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() 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 val pullRefreshState = rememberPullRefreshState( refreshing = state.isRefreshing, @@ -147,11 +156,36 @@ 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 -> + PostCard( + post = post, + onClick = { onPostClick(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) } + onCancelQueue = { viewModel.cancelQueuedPost(post) }, + onTogglePin = { viewModel.toggleFeatured(post) } ) } @@ -173,6 +207,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( @@ -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 fun PostCard( post: FeedPost, onClick: () -> Unit, - onCancelQueue: () -> Unit + onCancelQueue: () -> Unit, + onTogglePin: () -> Unit = {} ) { var expanded by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } val displayText = if (expanded || post.textContent.length <= 280) { post.textContent } else { @@ -210,23 +285,71 @@ fun PostCard( .padding(horizontal = 16.dp, vertical = 4.dp) .clickable(onClick = onClick), 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)) { - // Status row + // Status row with menu Row( modifier = Modifier.fillMaxWidth(), 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), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = formatRelativeTime(post.publishedAt ?: post.createdAt), + style = MaterialTheme.typography.labelSmall, + 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)) 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 350de2c..92299cc 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 @@ -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() { _uiState.update { it.copy(error = null) } } @@ -135,7 +183,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( @@ -149,6 +203,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, @@ -167,6 +222,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { linkDescription = linkDescription, linkImageUrl = linkImageUrl, status = status.name.lowercase(), + featured = featured, publishedAt = null, createdAt = null, updatedAt = null, @@ -180,7 +236,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 a33cc63..4a99c02 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 @@ -84,6 +84,11 @@ fun SwooshNavGraph( onDelete = { p -> feedViewModel.deletePost(p) navController.popBackStack() + }, + 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 17c9357..469d87a 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -47,6 +47,7 @@ class PostUploadWorker( QueueStatus.QUEUED_SCHEDULED -> "scheduled" else -> "draft" }, + featured = post.featured, feature_image = featureImage, published_at = post.scheduledAt, visibility = "public" 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 6f4f370..58bba12 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) @@ -71,6 +71,7 @@ class GhostModelsTest { assertNull(post.plaintext) assertNull(post.mobiledoc) assertNull(post.status) + assertEquals(false, post.featured) assertNull(post.feature_image) assertNull(post.created_at) assertNull(post.updated_at)