mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add feed filters (by status) and sorting options with persistence
This commit is contained in:
parent
74f42fd2f1
commit
f2ccf53577
10 changed files with 700 additions and 106 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ interface GhostApiService {
|
||||||
@Query("page") page: Int = 1,
|
@Query("page") page: Int = 1,
|
||||||
@Query("include") include: String = "authors",
|
@Query("include") include: String = "authors",
|
||||||
@Query("formats") formats: String = "html,plaintext,mobiledoc",
|
@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>
|
): Response<PostsResponse>
|
||||||
|
|
||||||
@POST("ghost/api/admin/posts/")
|
@POST("ghost/api/admin/posts/")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.swoosh.microblog.data.model.LocalPost
|
import com.swoosh.microblog.data.model.LocalPost
|
||||||
|
import com.swoosh.microblog.data.model.PostStatus
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
|
@ -11,6 +12,15 @@ interface LocalPostDao {
|
||||||
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
|
||||||
fun getAllPosts(): Flow<List<LocalPost>>
|
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")
|
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||||
suspend fun getQueuedPosts(
|
suspend fun getQueuedPosts(
|
||||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,34 @@ data class LinkPreview(
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val imageUrl: 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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import com.swoosh.microblog.data.model.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
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.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
|
@ -30,10 +32,20 @@ class PostRepository(private val context: Context) {
|
||||||
|
|
||||||
// --- Remote operations ---
|
// --- 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) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
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) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!)
|
Result.success(response.body()!!)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -124,6 +136,16 @@ class PostRepository(private val context: Context) {
|
||||||
|
|
||||||
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()
|
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 getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
|
||||||
|
|
||||||
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)
|
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
package com.swoosh.microblog.ui.feed
|
package com.swoosh.microblog.ui.feed
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
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.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.FilterList
|
||||||
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
|
||||||
|
|
@ -27,7 +31,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
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.QueueStatus
|
||||||
|
import com.swoosh.microblog.data.model.SortOrder
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -38,6 +44,8 @@ fun FeedScreen(
|
||||||
viewModel: FeedViewModel = viewModel()
|
viewModel: FeedViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
|
||||||
|
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
|
|
@ -63,8 +71,23 @@ fun FeedScreen(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 = {
|
actions = {
|
||||||
|
SortButton(
|
||||||
|
currentSort = sortOrder,
|
||||||
|
onSortSelected = { viewModel.setSortOrder(it) }
|
||||||
|
)
|
||||||
IconButton(onClick = { viewModel.refresh() }) {
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
}
|
}
|
||||||
|
|
@ -80,112 +103,219 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.pullRefresh(pullRefreshState)
|
|
||||||
) {
|
) {
|
||||||
if (state.posts.isEmpty() && !state.isRefreshing) {
|
// Filter chips bar
|
||||||
if (state.isConnectionError && state.error != null) {
|
FilterChipsBar(
|
||||||
// Connection error empty state
|
activeFilter = activeFilter,
|
||||||
Column(
|
onFilterSelected = { viewModel.setFilter(it) }
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show non-connection errors as snackbar (when posts are visible)
|
Box(
|
||||||
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
|
modifier = Modifier
|
||||||
Snackbar(
|
.fillMaxSize()
|
||||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
.pullRefresh(pullRefreshState)
|
||||||
action = {
|
) {
|
||||||
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
|
if (state.posts.isEmpty() && !state.isRefreshing) {
|
||||||
},
|
if (state.isConnectionError && state.error != null) {
|
||||||
dismissAction = {
|
// Connection error empty state
|
||||||
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.feed
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.swoosh.microblog.data.FeedPreferences
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
|
|
@ -19,13 +21,21 @@ import javax.net.ssl.SSLException
|
||||||
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val repository = PostRepository(application)
|
||||||
|
private val feedPreferences = FeedPreferences(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FeedUiState())
|
private val _uiState = MutableStateFlow(FeedUiState())
|
||||||
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
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 currentPage = 1
|
||||||
private var hasMorePages = true
|
private var hasMorePages = true
|
||||||
private var remotePosts = listOf<FeedPost>()
|
private var remotePosts = listOf<FeedPost>()
|
||||||
|
private var localPostsJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeLocalPosts()
|
observeLocalPosts()
|
||||||
|
|
@ -33,8 +43,11 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeLocalPosts() {
|
private fun observeLocalPosts() {
|
||||||
viewModelScope.launch {
|
localPostsJob?.cancel()
|
||||||
repository.getLocalPosts().collect { localPosts ->
|
localPostsJob = viewModelScope.launch {
|
||||||
|
val filter = _activeFilter.value
|
||||||
|
val sort = _sortOrder.value
|
||||||
|
repository.getLocalPosts(filter, sort).collect { localPosts ->
|
||||||
val queuedPosts = localPosts
|
val queuedPosts = localPosts
|
||||||
.filter { it.queueStatus != QueueStatus.NONE }
|
.filter { it.queueStatus != QueueStatus.NONE }
|
||||||
.map { it.toFeedPost() }
|
.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() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
hasMorePages = true
|
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 ->
|
onSuccess = { response ->
|
||||||
remotePosts = response.posts.map { it.toFeedPost() }
|
remotePosts = response.posts.map { it.toFeedPost() }
|
||||||
hasMorePages = response.meta?.pagination?.next != null
|
hasMorePages = response.meta?.pagination?.next != null
|
||||||
|
|
@ -90,7 +122,10 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(isLoadingMore = true) }
|
_uiState.update { it.copy(isLoadingMore = true) }
|
||||||
currentPage++
|
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 ->
|
onSuccess = { response ->
|
||||||
val newPosts = response.posts.map { it.toFeedPost() }
|
val newPosts = response.posts.map { it.toFeedPost() }
|
||||||
remotePosts = remotePosts + newPosts
|
remotePosts = remotePosts + newPosts
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue