mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate feed filters and sorting feature (resolve conflicts)
This commit is contained in:
commit
cf5aa93567
10 changed files with 738 additions and 146 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("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/")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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