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 5803507..fda6d7f 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 @@ -12,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 4987f35..c9042f1 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 @@ -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") +} 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 c25b9c1..8413acd 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 3dc0ea6..8ca5705 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 @@ -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() { 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 f7e2ed2..6e6e2a8 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 @@ -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 = _uiState.asStateFlow() @@ -33,9 +36,16 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { private val _snackbarEvent = MutableSharedFlow(extraBufferCapacity = 1) val snackbarEvent: SharedFlow = _snackbarEvent.asSharedFlow() + 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() @@ -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 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) + } +}