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("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/")

View file

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

View file

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

View file

@ -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)

View file

@ -9,11 +9,13 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons 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.Settings
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
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.icons.outlined.PushPin import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@ -59,8 +62,10 @@ import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.ShareUtils
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.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus 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.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel import com.swoosh.microblog.ui.theme.ThemeViewModel
@ -76,6 +81,8 @@ fun FeedScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -125,7 +132,18 @@ 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 = {
if (themeViewModel != null) { if (themeViewModel != null) {
val currentMode = themeMode?.value ?: ThemeMode.SYSTEM val currentMode = themeMode?.value ?: ThemeMode.SYSTEM
@ -138,6 +156,10 @@ fun FeedScreen(
Icon(icon, contentDescription = description) Icon(icon, contentDescription = description)
} }
} }
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")
} }
@ -154,79 +176,137 @@ fun FeedScreen(
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
) { 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), Box(
verticalArrangement = Arrangement.Center, modifier = Modifier
horizontalAlignment = Alignment.CenterHorizontally .fillMaxSize()
) { .pullRefresh(pullRefreshState)
Icon( ) {
imageVector = Icons.Default.WifiOff, if (state.posts.isEmpty() && !state.isRefreshing) {
contentDescription = null, if (state.isConnectionError && state.error != null) {
modifier = Modifier.size(48.dp), // Connection error empty state
tint = MaterialTheme.colorScheme.error Column(
) modifier = Modifier
Spacer(modifier = Modifier.height(16.dp)) .fillMaxSize()
Text( .padding(horizontal = 32.dp),
text = state.error!!, verticalArrangement = Arrangement.Center,
style = MaterialTheme.typography.bodyMedium, horizontalAlignment = Alignment.CenterHorizontally
color = MaterialTheme.colorScheme.onSurfaceVariant, ) {
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(onClick = { viewModel.refresh() }) {
Icon( Icon(
Icons.Default.Refresh, imageVector = Icons.Default.WifiOff,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.height(16.dp))
Text("Retry") 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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
// Pinned section header // Pinned section header
if (pinnedPosts.isNotEmpty()) { if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") { item(key = "pinned_header") {
PinnedSectionHeader() 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( SwipeablePostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
@ -255,93 +335,54 @@ fun FeedScreen(
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) { if (state.isLoadingMore) {
item(key = "pinned_separator") { item {
HorizontalDivider( Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
color = MaterialTheme.colorScheme.outlineVariant contentAlignment = Alignment.Center
) ) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} }
} }
} }
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> PullRefreshIndicator(
SwipeablePostCard( refreshing = state.isRefreshing,
post = post, state = pullRefreshState,
onClick = { onPostClick(post) }, modifier = Modifier.align(Alignment.TopCenter)
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
)
}
if (state.isLoadingMore) { // Show snackbar for pin/unpin confirmation
item { if (state.snackbarMessage != null) {
Box( Snackbar(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
contentAlignment = Alignment.Center dismissAction = {
) { TextButton(onClick = viewModel::clearSnackbar) { Text("OK") }
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} }
) {
Text(state.snackbarMessage!!)
}
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
} }
} }
}
PullRefreshIndicator( // Show non-connection errors as snackbar (when posts are visible)
refreshing = state.isRefreshing, if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
state = pullRefreshState, Snackbar(
modifier = Modifier.align(Alignment.TopCenter) modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
) action = {
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
// Show snackbar for pin/unpin confirmation },
if (state.snackbarMessage != null) { dismissAction = {
Snackbar( TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), }
dismissAction = { ) {
TextButton(onClick = viewModel::clearSnackbar) { Text("OK") } 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PinnedSectionHeader() { fun PinnedSectionHeader() {

View file

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

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)
}
}