mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate swipe actions feature (resolve conflicts)
This commit is contained in:
commit
881b2f016f
4 changed files with 659 additions and 21 deletions
|
|
@ -4,6 +4,7 @@ import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -16,11 +17,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.AccessTime
|
import androidx.compose.material.icons.filled.AccessTime
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.LightMode
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
|
@ -34,9 +37,12 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -61,6 +67,7 @@ fun FeedScreen(
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onPostClick: (FeedPost) -> Unit,
|
onPostClick: (FeedPost) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
|
onEditPost: (FeedPost) -> Unit,
|
||||||
viewModel: FeedViewModel = viewModel(),
|
viewModel: FeedViewModel = viewModel(),
|
||||||
themeViewModel: ThemeViewModel? = null
|
themeViewModel: ThemeViewModel? = null
|
||||||
) {
|
) {
|
||||||
|
|
@ -71,6 +78,9 @@ fun FeedScreen(
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
||||||
|
|
||||||
|
// Track which post is pending delete confirmation
|
||||||
|
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
val pullRefreshState = rememberPullRefreshState(
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isRefreshing,
|
refreshing = state.isRefreshing,
|
||||||
|
|
@ -91,6 +101,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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -191,7 +215,7 @@ fun FeedScreen(
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
||||||
PostCard(
|
SwipeablePostCard(
|
||||||
post = post,
|
post = post,
|
||||||
onClick = { onPostClick(post) },
|
onClick = { onPostClick(post) },
|
||||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||||
|
|
@ -213,6 +237,8 @@ fun FeedScreen(
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
|
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onEdit = { onEditPost(post) },
|
||||||
|
onDelete = { postPendingDelete = post },
|
||||||
snackbarHostState = snackbarHostState
|
snackbarHostState = snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -251,16 +277,178 @@ 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(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PostCard(
|
fun SwipeablePostCard(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onCancelQueue: () -> Unit,
|
onCancelQueue: () -> Unit,
|
||||||
onShare: () -> Unit = {},
|
onShare: () -> Unit = {},
|
||||||
onCopyLink: () -> Unit = {},
|
onCopyLink: () -> Unit = {},
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
snackbarHostState: SnackbarHostState? = null
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
onShare = onShare,
|
||||||
|
onCopyLink = onCopyLink,
|
||||||
|
onEdit = onEdit,
|
||||||
|
onDelete = onDelete,
|
||||||
|
snackbarHostState = snackbarHostState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PostCardContent(
|
||||||
|
post: FeedPost,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onCancelQueue: () -> Unit,
|
||||||
|
onShare: () -> Unit = {},
|
||||||
|
onCopyLink: () -> Unit = {},
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
snackbarHostState: SnackbarHostState? = null
|
snackbarHostState: SnackbarHostState? = null
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
@ -281,11 +469,10 @@ fun PostCard(
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onClickLabel = "View post details",
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
if (isPublished && hasShareableUrl) {
|
|
||||||
showContextMenu = true
|
showContextMenu = true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
),
|
),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
|
@ -463,12 +650,21 @@ fun PostCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long-press context menu for copy link
|
// Long-press context menu
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showContextMenu,
|
expanded = showContextMenu,
|
||||||
onDismissRequest = { showContextMenu = false },
|
onDismissRequest = { showContextMenu = false },
|
||||||
offset = DpOffset(16.dp, 0.dp)
|
offset = DpOffset(16.dp, 0.dp)
|
||||||
) {
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Edit") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
onEdit()
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
|
||||||
|
)
|
||||||
|
if (isPublished && hasShareableUrl) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Copy link") },
|
text = { Text("Copy link") },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -495,6 +691,21 @@ fun PostCard(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Post stats badges
|
// Post stats badges
|
||||||
if (post.textContent.isNotBlank()) {
|
if (post.textContent.isNotBlank()) {
|
||||||
|
|
@ -533,6 +744,22 @@ fun PostCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
@Composable
|
||||||
fun StatusBadge(post: FeedPost) {
|
fun StatusBadge(post: FeedPost) {
|
||||||
val (label, color) = when {
|
val (label, color) = when {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import javax.net.ssl.SSLException
|
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) {
|
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val repository = PostRepository(application)
|
||||||
|
|
@ -24,6 +30,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val _uiState = MutableStateFlow(FeedUiState())
|
private val _uiState = MutableStateFlow(FeedUiState())
|
||||||
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
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 currentPage = 1
|
||||||
private var hasMorePages = true
|
private var hasMorePages = true
|
||||||
private var remotePosts = listOf<FeedPost>()
|
private var remotePosts = listOf<FeedPost>()
|
||||||
|
|
@ -125,6 +134,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) {
|
fun cancelQueuedPost(post: FeedPost) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
post.localId?.let { repository.deleteLocalPost(it) }
|
post.localId?.let { repository.deleteLocalPost(it) }
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ fun SwooshNavGraph(
|
||||||
onCompose = {
|
onCompose = {
|
||||||
editPost = null
|
editPost = null
|
||||||
navController.navigate(Routes.COMPOSER)
|
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