feat: add swipe-to-edit and swipe-to-delete actions on post cards

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:07 +01:00
parent 74f42fd2f1
commit 636c9f7649
No known key found for this signature in database
4 changed files with 653 additions and 11 deletions

View file

@ -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<FeedPost?>(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

View file

@ -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<FeedUiState> = _uiState.asStateFlow()
private val _snackbarEvent = MutableSharedFlow<SnackbarEvent>(extraBufferCapacity = 1)
val snackbarEvent: SharedFlow<SnackbarEvent> = _snackbarEvent.asSharedFlow()
private var currentPage = 1
private var hasMorePages = true
private var remotePosts = listOf<FeedPost>()
@ -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) }

View file

@ -55,6 +55,10 @@ fun SwooshNavGraph(
onCompose = {
editPost = null
navController.navigate(Routes.COMPOSER)
},
onEditPost = { post ->
editPost = post
navController.navigate(Routes.COMPOSER)
}
)
}

View file

@ -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 = "<p>$content</p>",
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 = "<p>Full content here</p>",
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)
}
}