mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add pinned/featured posts with toggle and feed section
This commit is contained in:
parent
74f42fd2f1
commit
b119d75bac
13 changed files with 598 additions and 17 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
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(
|
PostCard(
|
||||||
post = post,
|
post = post,
|
||||||
onClick = { onPostClick(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)
|
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
|
||||||
) {
|
) {
|
||||||
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(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue