feat: add feed filters (by status) and sorting options with persistence

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:09 +01:00
parent 74f42fd2f1
commit f2ccf53577
No known key found for this signature in database
10 changed files with 700 additions and 106 deletions

View file

@ -0,0 +1,45 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.SortOrder
/**
* Persists the user's last-selected feed filter and sort order
* using regular (non-encrypted) SharedPreferences since these
* are not sensitive values.
*/
class FeedPreferences(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var filter: PostFilter
get() {
val name = prefs.getString(KEY_FILTER, PostFilter.ALL.name)
return try {
PostFilter.valueOf(name!!)
} catch (e: IllegalArgumentException) {
PostFilter.ALL
}
}
set(value) = prefs.edit().putString(KEY_FILTER, value.name).apply()
var sortOrder: SortOrder
get() {
val name = prefs.getString(KEY_SORT_ORDER, SortOrder.NEWEST.name)
return try {
SortOrder.valueOf(name!!)
} catch (e: IllegalArgumentException) {
SortOrder.NEWEST
}
}
set(value) = prefs.edit().putString(KEY_SORT_ORDER, value.name).apply()
companion object {
const val PREFS_NAME = "swoosh_feed_prefs"
const val KEY_FILTER = "feed_filter"
const val KEY_SORT_ORDER = "feed_sort_order"
}
}

View file

@ -15,7 +15,8 @@ interface GhostApiService {
@Query("page") page: Int = 1,
@Query("include") include: String = "authors",
@Query("formats") formats: String = "html,plaintext,mobiledoc",
@Query("order") order: String = "created_at desc"
@Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null
): Response<PostsResponse>
@POST("ghost/api/admin/posts/")

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db
import androidx.room.*
import com.swoosh.microblog.data.model.LocalPost
import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.Flow
@ -11,6 +12,15 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
fun getAllPosts(): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE status = :status ORDER BY updatedAt DESC")
fun getPostsByStatus(status: PostStatus): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts ORDER BY createdAt ASC")
fun getAllPostsOldestFirst(): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE status = :status ORDER BY createdAt ASC")
fun getPostsByStatusOldestFirst(status: PostStatus): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPosts(
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)

View file

@ -113,3 +113,34 @@ data class LinkPreview(
val description: String?,
val imageUrl: String?
)
// --- Feed Filter & Sort ---
enum class PostFilter(val displayName: String, val ghostFilter: String?) {
ALL("All", null),
PUBLISHED("Published", "status:published"),
DRAFT("Drafts", "status:draft"),
SCHEDULED("Scheduled", "status:scheduled");
/** Returns the matching [PostStatus] for local filtering, or null for ALL. */
fun toPostStatus(): PostStatus? = when (this) {
ALL -> null
PUBLISHED -> PostStatus.PUBLISHED
DRAFT -> PostStatus.DRAFT
SCHEDULED -> PostStatus.SCHEDULED
}
/** Empty-state message shown when filter yields no results. */
fun emptyMessage(): String = when (this) {
ALL -> "No posts yet"
PUBLISHED -> "No published posts yet"
DRAFT -> "No drafts yet"
SCHEDULED -> "No scheduled posts yet"
}
}
enum class SortOrder(val displayName: String, val ghostOrder: String) {
NEWEST("Newest first", "published_at desc"),
OLDEST("Oldest first", "published_at asc"),
RECENTLY_UPDATED("Recently updated", "updated_at desc")
}

View file

@ -11,6 +11,8 @@ import com.swoosh.microblog.data.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.SortOrder
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
@ -30,10 +32,20 @@ class PostRepository(private val context: Context) {
// --- Remote operations ---
suspend fun fetchPosts(page: Int = 1, limit: Int = 15): Result<PostsResponse> =
suspend fun fetchPosts(
page: Int = 1,
limit: Int = 15,
filter: PostFilter = PostFilter.ALL,
sortOrder: SortOrder = SortOrder.NEWEST
): Result<PostsResponse> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getPosts(limit = limit, page = page)
val response = getApi().getPosts(
limit = limit,
page = page,
order = sortOrder.ghostOrder,
filter = filter.ghostFilter
)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
@ -124,6 +136,16 @@ class PostRepository(private val context: Context) {
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()
fun getLocalPosts(filter: PostFilter, sortOrder: SortOrder): Flow<List<LocalPost>> {
val status = filter.toPostStatus()
return when {
status != null && sortOrder == SortOrder.OLDEST -> dao.getPostsByStatusOldestFirst(status)
status != null -> dao.getPostsByStatus(status)
sortOrder == SortOrder.OLDEST -> dao.getAllPostsOldestFirst()
else -> dao.getAllPosts() // NEWEST and RECENTLY_UPDATED both use updatedAt DESC
}
}
suspend fun getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)

View file

@ -1,16 +1,20 @@
package com.swoosh.microblog.ui.feed
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.FilterList
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -27,7 +31,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.model.SortOrder
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
@ -38,6 +44,8 @@ fun FeedScreen(
viewModel: FeedViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Pull-to-refresh
@ -63,8 +71,23 @@ fun FeedScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Swoosh") },
title = {
Column {
Text("Swoosh")
if (activeFilter != PostFilter.ALL) {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
},
actions = {
SortButton(
currentSort = sortOrder,
onSortSelected = { viewModel.setSortOrder(it) }
)
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
@ -80,112 +103,219 @@ fun FeedScreen(
}
}
) { padding ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.pullRefresh(pullRefreshState)
) {
if (state.posts.isEmpty() && !state.isRefreshing) {
if (state.isConnectionError && state.error != null) {
// Connection error empty state
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.WifiOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(onClick = { viewModel.refresh() }) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
} else {
// Normal empty state
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No posts yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to write your first post",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
// Filter chips bar
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
)
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
},
dismissAction = {
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
if (state.posts.isEmpty() && !state.isRefreshing) {
if (state.isConnectionError && state.error != null) {
// Connection error empty state
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.WifiOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.error!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(onClick = { viewModel.refresh() }) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
} else {
// Filter-aware empty state
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = activeFilter.emptyMessage(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
if (activeFilter == PostFilter.ALL) {
Text(
text = "Tap + to write your first post",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "Try a different filter or create a new post",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
) {
Text(state.error!!)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
},
dismissAction = {
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
}
) {
Text(state.error!!)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterChipsBar(
activeFilter: PostFilter,
onFilterSelected: (PostFilter) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
PostFilter.values().forEach { filter ->
val selected = filter == activeFilter
val containerColor by animateColorAsState(
targetValue = if (selected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface,
label = "chipColor"
)
FilterChip(
selected = selected,
onClick = { onFilterSelected(filter) },
label = { Text(filter.displayName) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = containerColor
)
)
}
}
}
@Composable
fun SortButton(
currentSort: SortOrder,
onSortSelected: (SortOrder) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = "Sort: ${currentSort.displayName}",
tint = if (currentSort != SortOrder.NEWEST)
MaterialTheme.colorScheme.primary
else
LocalContentColor.current
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
SortOrder.values().forEach { order ->
DropdownMenuItem(
text = {
Text(
text = order.displayName,
color = if (order == currentSort)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
},
onClick = {
onSortSelected(order)
expanded = false
},
leadingIcon = if (order == currentSort) {
{
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
} else null
)
}
}
}

View file

@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.feed
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.net.ConnectException
@ -19,13 +21,21 @@ import javax.net.ssl.SSLException
class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val feedPreferences = FeedPreferences(application)
private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
private val _activeFilter = MutableStateFlow(feedPreferences.filter)
val activeFilter: StateFlow<PostFilter> = _activeFilter.asStateFlow()
private val _sortOrder = MutableStateFlow(feedPreferences.sortOrder)
val sortOrder: StateFlow<SortOrder> = _sortOrder.asStateFlow()
private var currentPage = 1
private var hasMorePages = true
private var remotePosts = listOf<FeedPost>()
private var localPostsJob: Job? = null
init {
observeLocalPosts()
@ -33,8 +43,11 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
private fun observeLocalPosts() {
viewModelScope.launch {
repository.getLocalPosts().collect { localPosts ->
localPostsJob?.cancel()
localPostsJob = viewModelScope.launch {
val filter = _activeFilter.value
val sort = _sortOrder.value
repository.getLocalPosts(filter, sort).collect { localPosts ->
val queuedPosts = localPosts
.filter { it.queueStatus != QueueStatus.NONE }
.map { it.toFeedPost() }
@ -43,13 +56,32 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun setFilter(filter: PostFilter) {
if (filter == _activeFilter.value) return
_activeFilter.value = filter
feedPreferences.filter = filter
observeLocalPosts()
refresh()
}
fun setSortOrder(order: SortOrder) {
if (order == _sortOrder.value) return
_sortOrder.value = order
feedPreferences.sortOrder = order
observeLocalPosts()
refresh()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
currentPage = 1
hasMorePages = true
repository.fetchPosts(page = 1).fold(
val filter = _activeFilter.value
val sort = _sortOrder.value
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort).fold(
onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() }
hasMorePages = response.meta?.pagination?.next != null
@ -90,7 +122,10 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isLoadingMore = true) }
currentPage++
repository.fetchPosts(page = currentPage).fold(
val filter = _activeFilter.value
val sort = _sortOrder.value
repository.fetchPosts(page = currentPage, filter = filter, sortOrder = sort).fold(
onSuccess = { response ->
val newPosts = response.posts.map { it.toFeedPost() }
remotePosts = remotePosts + newPosts

View file

@ -0,0 +1,155 @@
package com.swoosh.microblog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.SortOrder
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = android.app.Application::class)
class FeedPreferencesTest {
private lateinit var context: Context
private lateinit var feedPreferences: FeedPreferences
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
// Clear any previously stored preferences
context.getSharedPreferences(FeedPreferences.PREFS_NAME, Context.MODE_PRIVATE)
.edit().clear().commit()
feedPreferences = FeedPreferences(context)
}
// --- Default values ---
@Test
fun `default filter is ALL`() {
assertEquals(PostFilter.ALL, feedPreferences.filter)
}
@Test
fun `default sortOrder is NEWEST`() {
assertEquals(SortOrder.NEWEST, feedPreferences.sortOrder)
}
// --- Filter persistence ---
@Test
fun `setting filter to PUBLISHED persists`() {
feedPreferences.filter = PostFilter.PUBLISHED
assertEquals(PostFilter.PUBLISHED, feedPreferences.filter)
}
@Test
fun `setting filter to DRAFT persists`() {
feedPreferences.filter = PostFilter.DRAFT
assertEquals(PostFilter.DRAFT, feedPreferences.filter)
}
@Test
fun `setting filter to SCHEDULED persists`() {
feedPreferences.filter = PostFilter.SCHEDULED
assertEquals(PostFilter.SCHEDULED, feedPreferences.filter)
}
@Test
fun `setting filter to ALL persists`() {
feedPreferences.filter = PostFilter.DRAFT
feedPreferences.filter = PostFilter.ALL
assertEquals(PostFilter.ALL, feedPreferences.filter)
}
@Test
fun `filter persists across instances`() {
feedPreferences.filter = PostFilter.SCHEDULED
val newInstance = FeedPreferences(context)
assertEquals(PostFilter.SCHEDULED, newInstance.filter)
}
// --- Sort order persistence ---
@Test
fun `setting sortOrder to OLDEST persists`() {
feedPreferences.sortOrder = SortOrder.OLDEST
assertEquals(SortOrder.OLDEST, feedPreferences.sortOrder)
}
@Test
fun `setting sortOrder to RECENTLY_UPDATED persists`() {
feedPreferences.sortOrder = SortOrder.RECENTLY_UPDATED
assertEquals(SortOrder.RECENTLY_UPDATED, feedPreferences.sortOrder)
}
@Test
fun `setting sortOrder to NEWEST persists`() {
feedPreferences.sortOrder = SortOrder.RECENTLY_UPDATED
feedPreferences.sortOrder = SortOrder.NEWEST
assertEquals(SortOrder.NEWEST, feedPreferences.sortOrder)
}
@Test
fun `sortOrder persists across instances`() {
feedPreferences.sortOrder = SortOrder.OLDEST
val newInstance = FeedPreferences(context)
assertEquals(SortOrder.OLDEST, newInstance.sortOrder)
}
// --- Filter and sort are independent ---
@Test
fun `changing filter does not affect sortOrder`() {
feedPreferences.sortOrder = SortOrder.RECENTLY_UPDATED
feedPreferences.filter = PostFilter.DRAFT
assertEquals(SortOrder.RECENTLY_UPDATED, feedPreferences.sortOrder)
}
@Test
fun `changing sortOrder does not affect filter`() {
feedPreferences.filter = PostFilter.SCHEDULED
feedPreferences.sortOrder = SortOrder.OLDEST
assertEquals(PostFilter.SCHEDULED, feedPreferences.filter)
}
// --- Invalid stored values fallback to defaults ---
@Test
fun `invalid stored filter falls back to ALL`() {
context.getSharedPreferences(FeedPreferences.PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(FeedPreferences.KEY_FILTER, "INVALID_VALUE").commit()
val prefs = FeedPreferences(context)
assertEquals(PostFilter.ALL, prefs.filter)
}
@Test
fun `invalid stored sortOrder falls back to NEWEST`() {
context.getSharedPreferences(FeedPreferences.PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(FeedPreferences.KEY_SORT_ORDER, "INVALID_VALUE").commit()
val prefs = FeedPreferences(context)
assertEquals(SortOrder.NEWEST, prefs.sortOrder)
}
// --- All filter values round-trip ---
@Test
fun `all PostFilter values round-trip correctly`() {
for (filter in PostFilter.values()) {
feedPreferences.filter = filter
assertEquals("Round-trip failed for $filter", filter, feedPreferences.filter)
}
}
@Test
fun `all SortOrder values round-trip correctly`() {
for (order in SortOrder.values()) {
feedPreferences.sortOrder = order
assertEquals("Round-trip failed for $order", order, feedPreferences.sortOrder)
}
}
}

View file

@ -0,0 +1,110 @@
package com.swoosh.microblog.data.model
import org.junit.Assert.*
import org.junit.Test
class PostFilterTest {
// --- Enum values ---
@Test
fun `PostFilter has exactly 4 values`() {
assertEquals(4, PostFilter.values().size)
}
@Test
fun `PostFilter valueOf works for all values`() {
assertEquals(PostFilter.ALL, PostFilter.valueOf("ALL"))
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
}
// --- Display names ---
@Test
fun `ALL displayName is All`() {
assertEquals("All", PostFilter.ALL.displayName)
}
@Test
fun `PUBLISHED displayName is Published`() {
assertEquals("Published", PostFilter.PUBLISHED.displayName)
}
@Test
fun `DRAFT displayName is Drafts`() {
assertEquals("Drafts", PostFilter.DRAFT.displayName)
}
@Test
fun `SCHEDULED displayName is Scheduled`() {
assertEquals("Scheduled", PostFilter.SCHEDULED.displayName)
}
// --- Ghost API filter strings ---
@Test
fun `ALL ghostFilter is null`() {
assertNull(PostFilter.ALL.ghostFilter)
}
@Test
fun `PUBLISHED ghostFilter is status_published`() {
assertEquals("status:published", PostFilter.PUBLISHED.ghostFilter)
}
@Test
fun `DRAFT ghostFilter is status_draft`() {
assertEquals("status:draft", PostFilter.DRAFT.ghostFilter)
}
@Test
fun `SCHEDULED ghostFilter is status_scheduled`() {
assertEquals("status:scheduled", PostFilter.SCHEDULED.ghostFilter)
}
// --- toPostStatus mapping ---
@Test
fun `ALL toPostStatus returns null`() {
assertNull(PostFilter.ALL.toPostStatus())
}
@Test
fun `PUBLISHED toPostStatus returns PostStatus PUBLISHED`() {
assertEquals(PostStatus.PUBLISHED, PostFilter.PUBLISHED.toPostStatus())
}
@Test
fun `DRAFT toPostStatus returns PostStatus DRAFT`() {
assertEquals(PostStatus.DRAFT, PostFilter.DRAFT.toPostStatus())
}
@Test
fun `SCHEDULED toPostStatus returns PostStatus SCHEDULED`() {
assertEquals(PostStatus.SCHEDULED, PostFilter.SCHEDULED.toPostStatus())
}
// --- Empty messages ---
@Test
fun `ALL emptyMessage returns No posts yet`() {
assertEquals("No posts yet", PostFilter.ALL.emptyMessage())
}
@Test
fun `PUBLISHED emptyMessage returns No published posts yet`() {
assertEquals("No published posts yet", PostFilter.PUBLISHED.emptyMessage())
}
@Test
fun `DRAFT emptyMessage returns No drafts yet`() {
assertEquals("No drafts yet", PostFilter.DRAFT.emptyMessage())
}
@Test
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage())
}
}

View file

@ -0,0 +1,55 @@
package com.swoosh.microblog.data.model
import org.junit.Assert.*
import org.junit.Test
class SortOrderTest {
// --- Enum values ---
@Test
fun `SortOrder has exactly 3 values`() {
assertEquals(3, SortOrder.values().size)
}
@Test
fun `SortOrder valueOf works for all values`() {
assertEquals(SortOrder.NEWEST, SortOrder.valueOf("NEWEST"))
assertEquals(SortOrder.OLDEST, SortOrder.valueOf("OLDEST"))
assertEquals(SortOrder.RECENTLY_UPDATED, SortOrder.valueOf("RECENTLY_UPDATED"))
}
// --- Display names ---
@Test
fun `NEWEST displayName is Newest first`() {
assertEquals("Newest first", SortOrder.NEWEST.displayName)
}
@Test
fun `OLDEST displayName is Oldest first`() {
assertEquals("Oldest first", SortOrder.OLDEST.displayName)
}
@Test
fun `RECENTLY_UPDATED displayName is Recently updated`() {
assertEquals("Recently updated", SortOrder.RECENTLY_UPDATED.displayName)
}
// --- Ghost API order strings ---
@Test
fun `NEWEST ghostOrder is published_at desc`() {
assertEquals("published_at desc", SortOrder.NEWEST.ghostOrder)
}
@Test
fun `OLDEST ghostOrder is published_at asc`() {
assertEquals("published_at asc", SortOrder.OLDEST.ghostOrder)
}
@Test
fun `RECENTLY_UPDATED ghostOrder is updated_at desc`() {
assertEquals("updated_at desc", SortOrder.RECENTLY_UPDATED.ghostOrder)
}
}