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