From f2ccf535775a7f7f52ac6aa390e08c02027b7e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:09 +0100 Subject: [PATCH] feat: add feed filters (by status) and sorting options with persistence --- .../swoosh/microblog/data/FeedPreferences.kt | 45 +++ .../microblog/data/api/GhostApiService.kt | 3 +- .../swoosh/microblog/data/db/LocalPostDao.kt | 10 + .../microblog/data/model/GhostModels.kt | 31 ++ .../data/repository/PostRepository.kt | 26 +- .../swoosh/microblog/ui/feed/FeedScreen.kt | 328 ++++++++++++------ .../swoosh/microblog/ui/feed/FeedViewModel.kt | 43 ++- .../microblog/data/FeedPreferencesTest.kt | 155 +++++++++ .../microblog/data/model/PostFilterTest.kt | 110 ++++++ .../microblog/data/model/SortOrderTest.kt | 55 +++ 10 files changed, 700 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/swoosh/microblog/data/FeedPreferences.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/FeedPreferencesTest.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/SortOrderTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/FeedPreferences.kt b/app/src/main/java/com/swoosh/microblog/data/FeedPreferences.kt new file mode 100644 index 0000000..0ebe0e6 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/FeedPreferences.kt @@ -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" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index b51ebe8..3affb54 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -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 @POST("ghost/api/admin/posts/") diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 02a5651..0002ef9 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db import androidx.room.* import com.swoosh.microblog.data.model.LocalPost +import com.swoosh.microblog.data.model.PostStatus import com.swoosh.microblog.data.model.QueueStatus import kotlinx.coroutines.flow.Flow @@ -11,6 +12,15 @@ interface LocalPostDao { @Query("SELECT * FROM local_posts ORDER BY updatedAt DESC") fun getAllPosts(): Flow> + @Query("SELECT * FROM local_posts WHERE status = :status ORDER BY updatedAt DESC") + fun getPostsByStatus(status: PostStatus): Flow> + + @Query("SELECT * FROM local_posts ORDER BY createdAt ASC") + fun getAllPostsOldestFirst(): Flow> + + @Query("SELECT * FROM local_posts WHERE status = :status ORDER BY createdAt ASC") + fun getPostsByStatusOldestFirst(status: PostStatus): Flow> + @Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC") suspend fun getQueuedPosts( statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..f927bd6 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -113,3 +113,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") +} diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..cbc9692 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -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 = + suspend fun fetchPosts( + page: Int = 1, + limit: Int = 15, + filter: PostFilter = PostFilter.ALL, + sortOrder: SortOrder = SortOrder.NEWEST + ): Result = 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> = dao.getAllPosts() + fun getLocalPosts(filter: PostFilter, sortOrder: SortOrder): Flow> { + 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 = dao.getQueuedPosts() suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..f7a9bb3 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -1,16 +1,20 @@ package com.swoosh.microblog.ui.feed +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* 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.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -27,7 +31,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.PostFilter import com.swoosh.microblog.data.model.QueueStatus +import com.swoosh.microblog.data.model.SortOrder @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable @@ -38,6 +44,8 @@ fun FeedScreen( viewModel: FeedViewModel = viewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val activeFilter by viewModel.activeFilter.collectAsStateWithLifecycle() + val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle() val listState = rememberLazyListState() // Pull-to-refresh @@ -63,8 +71,23 @@ 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 = { + SortButton( + currentSort = sortOrder, + onSortSelected = { viewModel.setSortOrder(it) } + ) IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } @@ -80,112 +103,219 @@ fun FeedScreen( } } ) { 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() }) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry") - } - } - } else { - // Normal empty state - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No posts yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Tap + to write your first post", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> - PostCard( - post = post, - onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } - ) - } - - if (state.isLoadingMore) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } - } - } - - PullRefreshIndicator( - refreshing = state.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) + // Filter chips bar + FilterChipsBar( + activeFilter = activeFilter, + onFilterSelected = { viewModel.setFilter(it) } ) - // 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") } + 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( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.error!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton(onClick = { viewModel.refresh() }) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Retry") + } + } + } else { + // Filter-aware empty state + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = activeFilter.emptyMessage(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + if (activeFilter == PostFilter.ALL) { + Text( + text = "Tap + to write your first post", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = "Try a different filter or create a new post", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } - ) { - Text(state.error!!) } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> + PostCard( + post = post, + onClick = { onPostClick(post) }, + onCancelQueue = { viewModel.cancelQueuedPost(post) } + ) + } + + if (state.isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + + // Show non-connection errors as snackbar (when posts are visible) + if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) { + Snackbar( + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + action = { + TextButton(onClick = { viewModel.refresh() }) { Text("Retry") } + }, + dismissAction = { + TextButton(onClick = viewModel::clearError) { Text("Dismiss") } + } + ) { + Text(state.error!!) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterChipsBar( + activeFilter: PostFilter, + onFilterSelected: (PostFilter) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + PostFilter.values().forEach { filter -> + val selected = filter == activeFilter + val containerColor by animateColorAsState( + targetValue = if (selected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface, + label = "chipColor" + ) + FilterChip( + selected = selected, + onClick = { onFilterSelected(filter) }, + label = { Text(filter.displayName) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = containerColor + ) + ) + } + } +} + +@Composable +fun SortButton( + currentSort: SortOrder, + onSortSelected: (SortOrder) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = "Sort: ${currentSort.displayName}", + tint = if (currentSort != SortOrder.NEWEST) + MaterialTheme.colorScheme.primary + else + LocalContentColor.current + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + SortOrder.values().forEach { order -> + DropdownMenuItem( + text = { + Text( + text = order.displayName, + color = if (order == currentSort) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + onSortSelected(order) + expanded = false + }, + leadingIcon = if (order == currentSort) { + { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + } else null + ) } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..cb017ed 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -3,8 +3,10 @@ package com.swoosh.microblog.ui.feed import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +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 @@ -19,13 +21,21 @@ import javax.net.ssl.SSLException class FeedViewModel(application: Application) : AndroidViewModel(application) { private val repository = PostRepository(application) + private val feedPreferences = FeedPreferences(application) private val _uiState = MutableStateFlow(FeedUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _activeFilter = MutableStateFlow(feedPreferences.filter) + val activeFilter: StateFlow = _activeFilter.asStateFlow() + + private val _sortOrder = MutableStateFlow(feedPreferences.sortOrder) + val sortOrder: StateFlow = _sortOrder.asStateFlow() + private var currentPage = 1 private var hasMorePages = true private var remotePosts = listOf() + private var localPostsJob: Job? = null init { observeLocalPosts() @@ -33,8 +43,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() } @@ -43,13 +56,32 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } } + fun setFilter(filter: PostFilter) { + if (filter == _activeFilter.value) return + _activeFilter.value = filter + feedPreferences.filter = filter + observeLocalPosts() + refresh() + } + + fun setSortOrder(order: SortOrder) { + if (order == _sortOrder.value) return + _sortOrder.value = order + feedPreferences.sortOrder = order + observeLocalPosts() + refresh() + } + fun refresh() { 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 @@ -90,7 +122,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 diff --git a/app/src/test/java/com/swoosh/microblog/data/FeedPreferencesTest.kt b/app/src/test/java/com/swoosh/microblog/data/FeedPreferencesTest.kt new file mode 100644 index 0000000..c1cf3d8 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/FeedPreferencesTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt new file mode 100644 index 0000000..1cf1887 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/PostFilterTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/SortOrderTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/SortOrderTest.kt new file mode 100644 index 0000000..df6e4bf --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/SortOrderTest.kt @@ -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) + } +}