merge: integrate feed filters and sorting feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:56:43 +01:00
commit cf5aa93567
No known key found for this signature in database
10 changed files with 738 additions and 146 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

@ -12,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

@ -124,3 +124,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

@ -9,11 +9,13 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import kotlinx.coroutines.launch
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
@ -59,8 +62,10 @@ import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.model.SortOrder
import com.swoosh.microblog.ui.theme.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel
@ -76,6 +81,8 @@ fun FeedScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@ -125,7 +132,18 @@ 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 = {
if (themeViewModel != null) {
val currentMode = themeMode?.value ?: ThemeMode.SYSTEM
@ -138,6 +156,10 @@ fun FeedScreen(
Icon(icon, contentDescription = description)
}
}
SortButton(
currentSort = sortOrder,
onSortSelected = { viewModel.setSortOrder(it) }
)
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
@ -154,79 +176,137 @@ fun FeedScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { 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() }) {
// Filter chips bar
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
)
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(
Icons.Default.Refresh,
imageVector = Icons.Default.WifiOff,
contentDescription = null,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
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
)
}
}
}
} 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)
) {
// Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
@ -255,93 +335,54 @@ fun FeedScreen(
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
// 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()
}
}
}
PullRefreshIndicator(
refreshing = state.isRefreshing,
state = pullRefreshState,
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") }
// 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!!)
}
) {
Text(state.snackbarMessage!!)
}
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
}
}
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
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!!)
}
}
}
@ -372,6 +413,94 @@ fun FeedScreen(
}
}
@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
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PinnedSectionHeader() {

View file

@ -4,8 +4,10 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.CredentialsManager
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
@ -26,6 +28,7 @@ data class SnackbarEvent(
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()
@ -33,9 +36,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _snackbarEvent = MutableSharedFlow<SnackbarEvent>(extraBufferCapacity = 1)
val snackbarEvent: SharedFlow<SnackbarEvent> = _snackbarEvent.asSharedFlow()
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()
@ -45,8 +55,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() }
@ -55,13 +68,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
@ -102,7 +134,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)
}
}