mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add swipe-to-edit and swipe-to-delete actions on post cards
This commit is contained in:
parent
74f42fd2f1
commit
636c9f7649
4 changed files with 653 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ fun SwooshNavGraph(
|
|||
onCompose = {
|
||||
editPost = null
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onEditPost = { post ->
|
||||
editPost = post
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue