From 636c9f7649792147e1e4f00c324ccdc757d06247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:07 +0100 Subject: [PATCH] feat: add swipe-to-edit and swipe-to-delete actions on post cards --- .../swoosh/microblog/ui/feed/FeedScreen.kt | 253 ++++++++++++- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 53 +++ .../microblog/ui/navigation/NavGraph.kt | 4 + .../microblog/ui/feed/SwipeActionsTest.kt | 354 ++++++++++++++++++ 4 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/swoosh/microblog/ui/feed/SwipeActionsTest.kt 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..e1e2d46 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,5 +1,7 @@ package com.swoosh.microblog.ui.feed +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -7,10 +9,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.filled.* import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -19,7 +18,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -35,10 +38,15 @@ fun FeedScreen( onSettingsClick: () -> Unit, onPostClick: (FeedPost) -> Unit, onCompose: () -> Unit, + onEditPost: (FeedPost) -> Unit, viewModel: FeedViewModel = viewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + + // Track which post is pending delete confirmation + var postPendingDelete by remember { mutableStateOf(null) } // Pull-to-refresh val pullRefreshState = rememberPullRefreshState( @@ -60,6 +68,20 @@ fun FeedScreen( } } + // Listen for snackbar events from the ViewModel + LaunchedEffect(Unit) { + viewModel.snackbarEvent.collect { event -> + val result = snackbarHostState.showSnackbar( + message = event.message, + actionLabel = event.actionLabel, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed && event.onAction != null) { + event.onAction.invoke() + } + } + } + Scaffold( topBar = { TopAppBar( @@ -78,7 +100,8 @@ fun FeedScreen( FloatingActionButton(onClick = onCompose) { Icon(Icons.Default.Add, contentDescription = "New post") } - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box( modifier = Modifier @@ -148,10 +171,12 @@ fun FeedScreen( contentPadding = PaddingValues(vertical = 8.dp) ) { items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> - PostCard( + SwipeablePostCard( post = post, onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } + onCancelQueue = { viewModel.cancelQueuedPost(post) }, + onEdit = { onEditPost(post) }, + onDelete = { postPendingDelete = post } ) } @@ -189,13 +214,169 @@ fun FeedScreen( } } } + + // Delete confirmation dialog + if (postPendingDelete != null) { + AlertDialog( + onDismissRequest = { postPendingDelete = null }, + title = { Text("Delete this post?") }, + text = { Text("This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + val post = postPendingDelete!! + postPendingDelete = null + viewModel.deletePostWithUndo(post) + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { postPendingDelete = null }) { Text("Cancel") } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeablePostCard( + post: FeedPost, + onClick: () -> Unit, + onCancelQueue: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + when (value) { + SwipeToDismissBoxValue.StartToEnd -> { + // Swipe right -> Edit + onEdit() + false // Don't settle in dismissed state; snap back + } + SwipeToDismissBoxValue.EndToStart -> { + // Swipe left -> Delete confirmation + onDelete() + false // Don't settle; snap back and show dialog + } + SwipeToDismissBoxValue.Settled -> true + } + }, + positionalThreshold = { totalDistance -> totalDistance * 0.4f } + ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + SwipeBackground(dismissState) + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .semantics { + customActions = listOf( + CustomAccessibilityAction("Edit post") { + onEdit() + true + }, + CustomAccessibilityAction("Delete post") { + onDelete() + true + } + ) + } + ) { + PostCardContent( + post = post, + onClick = onClick, + onCancelQueue = onCancelQueue, + onEdit = onEdit, + onDelete = onDelete + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeBackground(dismissState: SwipeToDismissBoxState) { + val direction = dismissState.dismissDirection + + val color by animateColorAsState( + when (direction) { + SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primary + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error + SwipeToDismissBoxValue.Settled -> Color.Transparent + }, + label = "swipe_bg_color" + ) + + val icon = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> Icons.Default.Edit + SwipeToDismissBoxValue.EndToStart -> Icons.Default.Delete + SwipeToDismissBoxValue.Settled -> null + } + + val label = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> "Edit" + SwipeToDismissBoxValue.EndToStart -> "Delete" + SwipeToDismissBoxValue.Settled -> null + } + + val alignment = when (direction) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + SwipeToDismissBoxValue.Settled -> Alignment.Center + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(color, MaterialTheme.shapes.medium) + .padding(horizontal = 24.dp), + contentAlignment = alignment + ) { + if (icon != null && label != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (direction == SwipeToDismissBoxValue.StartToEnd) { + Icon( + imageVector = icon, + contentDescription = label, + tint = Color.White + ) + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.labelLarge + ) + } else { + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.labelLarge + ) + Icon( + imageVector = icon, + contentDescription = label, + tint = Color.White + ) + } + } + } + } } @Composable -fun PostCard( +fun PostCardContent( post: FeedPost, onClick: () -> Unit, - onCancelQueue: () -> Unit + onCancelQueue: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit ) { var expanded by remember { mutableStateOf(false) } val displayText = if (expanded || post.textContent.length <= 280) { @@ -204,11 +385,16 @@ fun PostCard( post.textContent.take(280) + "..." } + // Long-press context menu for accessibility + var showContextMenu by remember { mutableStateOf(false) } + Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick), + .clickable( + onClick = onClick, + onClickLabel = "View post details" + ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) @@ -322,6 +508,51 @@ fun PostCard( } } } + + // Context menu dropdown for accessibility (alternative to swipe) + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + showContextMenu = false + onEdit() + }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + showContextMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } +} + +// Keep the old PostCard signature for backward compatibility (used in tests/other screens) +@Composable +fun PostCard( + post: FeedPost, + onClick: () -> Unit, + onCancelQueue: () -> Unit +) { + PostCardContent( + post = post, + onClick = onClick, + onCancelQueue = onCancelQueue, + onEdit = {}, + onDelete = {} + ) } @Composable 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..bd960ba 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 @@ -16,6 +16,12 @@ import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import javax.net.ssl.SSLException +data class SnackbarEvent( + val message: String, + val actionLabel: String? = null, + val onAction: (() -> Unit)? = null +) + class FeedViewModel(application: Application) : AndroidViewModel(application) { private val repository = PostRepository(application) @@ -23,6 +29,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(FeedUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _snackbarEvent = MutableSharedFlow(extraBufferCapacity = 1) + val snackbarEvent: SharedFlow = _snackbarEvent.asSharedFlow() + private var currentPage = 1 private var hasMorePages = true private var remotePosts = listOf() @@ -122,6 +131,50 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Delete a post with undo support. + * For local posts: deletes from Room and offers undo (re-insert). + * For remote posts: deletes via API (no undo since Ghost API has no undelete). + */ + fun deletePostWithUndo(post: FeedPost) { + viewModelScope.launch { + if (post.isLocal && post.localId != null) { + // For local posts, we can support undo by saving the data before deleting + val localPost = repository.getLocalPostById(post.localId) + repository.deleteLocalPost(post.localId) + + if (localPost != null) { + _snackbarEvent.emit( + SnackbarEvent( + message = "Post deleted", + actionLabel = "Undo", + onAction = { + viewModelScope.launch { + repository.saveLocalPost(localPost) + } + } + ) + ) + } else { + _snackbarEvent.emit(SnackbarEvent(message = "Post deleted")) + } + } else if (post.ghostId != null) { + // For remote posts, delete via API (no undo available) + repository.deletePost(post.ghostId).fold( + onSuccess = { + // Remove from local remote posts list immediately + remotePosts = remotePosts.filter { it.ghostId != post.ghostId } + mergePosts() + _snackbarEvent.emit(SnackbarEvent(message = "Post deleted")) + }, + onFailure = { e -> + _uiState.update { it.copy(error = "Delete failed: ${e.message}") } + } + ) + } + } + } + fun cancelQueuedPost(post: FeedPost) { viewModelScope.launch { post.localId?.let { repository.deleteLocalPost(it) } diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index a33cc63..ffe80cf 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -55,6 +55,10 @@ fun SwooshNavGraph( onCompose = { editPost = null navController.navigate(Routes.COMPOSER) + }, + onEditPost = { post -> + editPost = post + navController.navigate(Routes.COMPOSER) } ) } diff --git a/app/src/test/java/com/swoosh/microblog/ui/feed/SwipeActionsTest.kt b/app/src/test/java/com/swoosh/microblog/ui/feed/SwipeActionsTest.kt new file mode 100644 index 0000000..c232151 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/ui/feed/SwipeActionsTest.kt @@ -0,0 +1,354 @@ +package com.swoosh.microblog.ui.feed + +import com.swoosh.microblog.data.model.FeedPost +import com.swoosh.microblog.data.model.QueueStatus +import org.junit.Assert.* +import org.junit.Test + +/** + * Tests for swipe action logic, delete confirmation, and undo behavior. + * These test the pure data/logic aspects that don't require Android framework. + */ +class SwipeActionsTest { + + private fun createLocalPost( + localId: Long = 1L, + ghostId: String? = null, + content: String = "Test post content", + status: String = "draft", + isLocal: Boolean = true, + queueStatus: QueueStatus = QueueStatus.NONE + ) = FeedPost( + localId = localId, + ghostId = ghostId, + title = content.take(60), + textContent = content, + htmlContent = null, + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = status, + publishedAt = null, + createdAt = null, + updatedAt = null, + isLocal = isLocal, + queueStatus = queueStatus + ) + + private fun createRemotePost( + ghostId: String = "remote_1", + content: String = "Remote post content", + status: String = "published" + ) = FeedPost( + localId = null, + ghostId = ghostId, + title = content.take(60), + textContent = content, + htmlContent = "

$content

", + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = status, + publishedAt = "2024-01-15T10:00:00.000Z", + createdAt = "2024-01-15T09:00:00.000Z", + updatedAt = "2024-01-15T10:00:00.000Z", + isLocal = false, + queueStatus = QueueStatus.NONE + ) + + // --- Post identification for swipe actions --- + + @Test + fun `local post has localId for undo support`() { + val post = createLocalPost(localId = 42) + assertTrue(post.isLocal) + assertNotNull(post.localId) + assertEquals(42L, post.localId) + } + + @Test + fun `remote post has ghostId for delete action`() { + val post = createRemotePost(ghostId = "abc123") + assertFalse(post.isLocal) + assertNotNull(post.ghostId) + assertEquals("abc123", post.ghostId) + } + + @Test + fun `local post can have both localId and ghostId`() { + val post = createLocalPost(localId = 1, ghostId = "ghost_123") + assertTrue(post.isLocal) + assertNotNull(post.localId) + assertNotNull(post.ghostId) + } + + // --- Swipe action applicability --- + + @Test + fun `edit action applies to local draft posts`() { + val post = createLocalPost(status = "draft") + // Edit is always available - post has data to populate composer + assertTrue(post.textContent.isNotEmpty()) + } + + @Test + fun `edit action applies to remote published posts`() { + val post = createRemotePost(status = "published") + assertTrue(post.textContent.isNotEmpty()) + assertNotNull(post.ghostId) + } + + @Test + fun `edit action applies to remote draft posts`() { + val post = createRemotePost(ghostId = "draft_1", status = "draft") + assertTrue(post.textContent.isNotEmpty()) + } + + @Test + fun `edit action applies to scheduled posts`() { + val post = createRemotePost(ghostId = "sched_1", status = "scheduled") + assertEquals("scheduled", post.status) + } + + @Test + fun `delete action applies to local posts with localId`() { + val post = createLocalPost(localId = 5) + assertNotNull(post.localId) + assertTrue(post.isLocal) + } + + @Test + fun `delete action applies to remote posts with ghostId`() { + val post = createRemotePost(ghostId = "remote_5") + assertNotNull(post.ghostId) + assertFalse(post.isLocal) + } + + // --- Undo eligibility --- + + @Test + fun `local post is eligible for undo after delete`() { + val post = createLocalPost(localId = 10) + // Undo is supported for local posts (re-insert into Room) + assertTrue(post.isLocal) + assertNotNull(post.localId) + } + + @Test + fun `remote post is not eligible for undo after delete`() { + val post = createRemotePost(ghostId = "remote_10") + // Remote posts cannot be undeleted via Ghost API + assertFalse(post.isLocal) + assertNull(post.localId) + } + + @Test + fun `local post with ghostId - undo re-inserts local copy only`() { + val post = createLocalPost(localId = 3, ghostId = "ghost_3", isLocal = true) + // Even local posts with ghostId can be undone (re-insert local row) + assertTrue(post.isLocal) + assertNotNull(post.localId) + assertNotNull(post.ghostId) + } + + // --- SnackbarEvent data class --- + + @Test + fun `SnackbarEvent with undo action has actionLabel`() { + val event = SnackbarEvent( + message = "Post deleted", + actionLabel = "Undo", + onAction = { /* restore */ } + ) + assertEquals("Post deleted", event.message) + assertEquals("Undo", event.actionLabel) + assertNotNull(event.onAction) + } + + @Test + fun `SnackbarEvent without undo has null action`() { + val event = SnackbarEvent(message = "Post deleted") + assertEquals("Post deleted", event.message) + assertNull(event.actionLabel) + assertNull(event.onAction) + } + + @Test + fun `SnackbarEvent onAction callback is invocable`() { + var undoCalled = false + val event = SnackbarEvent( + message = "Post deleted", + actionLabel = "Undo", + onAction = { undoCalled = true } + ) + event.onAction?.invoke() + assertTrue(undoCalled) + } + + // --- Post card key generation --- + + @Test + fun `post with ghostId uses ghostId as key`() { + val post = createRemotePost(ghostId = "unique_ghost_id") + val key = post.ghostId ?: "local_${post.localId}" + assertEquals("unique_ghost_id", key) + } + + @Test + fun `local post without ghostId uses localId as key`() { + val post = createLocalPost(localId = 42, ghostId = null) + val key = post.ghostId ?: "local_${post.localId}" + assertEquals("local_42", key) + } + + // --- FeedUiState --- + + @Test + fun `FeedUiState default values are correct`() { + val state = FeedUiState() + assertTrue(state.posts.isEmpty()) + assertFalse(state.isRefreshing) + assertFalse(state.isLoadingMore) + assertNull(state.error) + assertFalse(state.isConnectionError) + } + + @Test + fun `FeedUiState posts can be filtered by isLocal`() { + val posts = listOf( + createLocalPost(localId = 1), + createRemotePost(ghostId = "r1"), + createLocalPost(localId = 2), + createRemotePost(ghostId = "r2") + ) + val state = FeedUiState(posts = posts) + val localPosts = state.posts.filter { it.isLocal } + val remotePosts = state.posts.filter { !it.isLocal } + assertEquals(2, localPosts.size) + assertEquals(2, remotePosts.size) + } + + @Test + fun `removing a post from list simulates delete`() { + val posts = listOf( + createRemotePost(ghostId = "r1", content = "First"), + createRemotePost(ghostId = "r2", content = "Second"), + createRemotePost(ghostId = "r3", content = "Third") + ) + val postToDelete = posts[1] + val afterDelete = posts.filter { it.ghostId != postToDelete.ghostId } + assertEquals(2, afterDelete.size) + assertEquals("r1", afterDelete[0].ghostId) + assertEquals("r3", afterDelete[1].ghostId) + } + + @Test + fun `re-adding a post to list simulates undo`() { + val originalPosts = listOf( + createLocalPost(localId = 1, content = "First"), + createLocalPost(localId = 2, content = "Second") + ) + val deleted = originalPosts[0] + val afterDelete = originalPosts.filter { it.localId != deleted.localId } + assertEquals(1, afterDelete.size) + + // Simulate undo: re-add + val afterUndo = listOf(deleted) + afterDelete + assertEquals(2, afterUndo.size) + assertEquals(1L, afterUndo[0].localId) + } + + // --- Queue status interactions --- + + @Test + fun `queued post can still be swiped for edit`() { + val post = createLocalPost( + localId = 1, + queueStatus = QueueStatus.QUEUED_PUBLISH + ) + assertTrue(post.queueStatus != QueueStatus.NONE) + // Edit should still work for queued posts + assertTrue(post.textContent.isNotEmpty()) + } + + @Test + fun `uploading post can be swiped for delete`() { + val post = createLocalPost( + localId = 1, + queueStatus = QueueStatus.UPLOADING + ) + assertTrue(post.queueStatus == QueueStatus.UPLOADING) + assertNotNull(post.localId) + } + + @Test + fun `failed upload post can be swiped for edit and delete`() { + val post = createLocalPost( + localId = 1, + queueStatus = QueueStatus.FAILED + ) + assertTrue(post.queueStatus == QueueStatus.FAILED) + assertTrue(post.textContent.isNotEmpty()) + assertNotNull(post.localId) + } + + // --- Post data preservation for edit --- + + @Test + fun `post preserves all fields needed for edit`() { + val post = FeedPost( + localId = 5L, + ghostId = "g5", + title = "My Title", + textContent = "Full content here", + htmlContent = "

Full content here

", + imageUrl = "https://example.com/image.jpg", + linkUrl = "https://example.com", + linkTitle = "Example", + linkDescription = "An example link", + linkImageUrl = "https://example.com/og.jpg", + status = "published", + publishedAt = "2024-01-15T10:00:00.000Z", + createdAt = "2024-01-15T09:00:00.000Z", + updatedAt = "2024-01-15T10:00:00.000Z", + isLocal = false + ) + // All fields needed by ComposerViewModel.loadForEdit + assertEquals("Full content here", post.textContent) + assertEquals("https://example.com/image.jpg", post.imageUrl) + assertEquals("https://example.com", post.linkUrl) + assertEquals("Example", post.linkTitle) + assertEquals("An example link", post.linkDescription) + assertEquals("https://example.com/og.jpg", post.linkImageUrl) + assertEquals("2024-01-15T10:00:00.000Z", post.updatedAt) + } + + @Test + fun `post with empty content has minimal fields for edit`() { + val post = createLocalPost(content = "") + assertEquals("", post.textContent) + } + + // --- Multiple deletes --- + + @Test + fun `multiple sequential deletes reduce post count correctly`() { + var posts = (1L..5L).map { createLocalPost(localId = it, content = "Post $it") } + assertEquals(5, posts.size) + + posts = posts.filter { it.localId != 3L } + assertEquals(4, posts.size) + + posts = posts.filter { it.localId != 1L } + assertEquals(3, posts.size) + + val remaining = posts.map { it.localId } + assertTrue(2L in remaining) + assertTrue(4L in remaining) + assertTrue(5L in remaining) + } +}