mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add empty state, filter, and overlay animations in feed
This commit is contained in:
parent
64662f6bd4
commit
71d58008c6
1 changed files with 161 additions and 94 deletions
|
|
@ -9,6 +9,10 @@ import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
|
@ -287,7 +291,11 @@ fun FeedScreen(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
// Filter chips bar (only when not searching)
|
// Filter chips bar (only when not searching)
|
||||||
if (!isSearchActive) {
|
AnimatedVisibility(
|
||||||
|
visible = !isSearchActive,
|
||||||
|
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
|
||||||
|
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
|
||||||
|
) {
|
||||||
FilterChipsBar(
|
FilterChipsBar(
|
||||||
activeFilter = activeFilter,
|
activeFilter = activeFilter,
|
||||||
onFilterSelected = { viewModel.setFilter(it) }
|
onFilterSelected = { viewModel.setFilter(it) }
|
||||||
|
|
@ -312,7 +320,11 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading overlay during account switch
|
// Loading overlay during account switch
|
||||||
if (state.isSwitchingAccount) {
|
AnimatedVisibility(
|
||||||
|
visible = state.isSwitchingAccount,
|
||||||
|
enter = fadeIn(SwooshMotion.quick()),
|
||||||
|
exit = fadeOut(SwooshMotion.quick())
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
|
@ -327,7 +339,8 @@ fun FeedScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
|
}
|
||||||
|
if (!state.isSwitchingAccount && isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
|
||||||
// Show recent searches when search is active but query is empty
|
// Show recent searches when search is active but query is empty
|
||||||
RecentSearchesList(
|
RecentSearchesList(
|
||||||
recentSearches = recentSearches,
|
recentSearches = recentSearches,
|
||||||
|
|
@ -336,49 +349,65 @@ fun FeedScreen(
|
||||||
)
|
)
|
||||||
} else if (isSearchActive && searchQuery.isNotBlank() && isSearching) {
|
} else if (isSearchActive && searchQuery.isNotBlank() && isSearching) {
|
||||||
// Searching indicator
|
// Searching indicator
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
modifier = Modifier.fillMaxSize(),
|
visible = true,
|
||||||
contentAlignment = Alignment.Center
|
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
|
||||||
|
initialScale = 0.9f,
|
||||||
|
animationSpec = SwooshMotion.quick()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Box(
|
||||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
modifier = Modifier.fillMaxSize(),
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
contentAlignment = Alignment.Center
|
||||||
Text(
|
) {
|
||||||
text = "Searching...",
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
)
|
Text(
|
||||||
|
text = "Searching...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isSearchActive && searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching) {
|
} else if (isSearchActive && searchQuery.isNotBlank() && searchResults.isEmpty() && !isSearching) {
|
||||||
// No results empty state
|
// No results empty state
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
modifier = Modifier.fillMaxSize(),
|
visible = true,
|
||||||
contentAlignment = Alignment.Center
|
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
|
||||||
|
initialScale = 0.9f,
|
||||||
|
animationSpec = SwooshMotion.quick()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.padding(horizontal = 32.dp)
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column(
|
||||||
imageVector = Icons.Default.SearchOff,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
contentDescription = null,
|
modifier = Modifier.padding(horizontal = 32.dp)
|
||||||
modifier = Modifier.size(48.dp),
|
) {
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
Icon(
|
||||||
)
|
imageVector = Icons.Default.SearchOff,
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
contentDescription = null,
|
||||||
Text(
|
modifier = Modifier.size(48.dp),
|
||||||
text = "No results found",
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
style = MaterialTheme.typography.titleMedium,
|
)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
)
|
Text(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
text = "No results found",
|
||||||
Text(
|
style = MaterialTheme.typography.titleMedium,
|
||||||
text = "No posts matching \"$searchQuery\"",
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
textAlign = TextAlign.Center
|
Text(
|
||||||
)
|
text = "No posts matching \"$searchQuery\"",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!isSearchActive && displayPosts.isEmpty() && !state.isRefreshing) {
|
} else if (!isSearchActive && displayPosts.isEmpty() && !state.isRefreshing) {
|
||||||
|
|
@ -390,34 +419,46 @@ fun FeedScreen(
|
||||||
if (state.isConnectionError && state.error != null) {
|
if (state.isConnectionError && state.error != null) {
|
||||||
// Connection error empty state
|
// Connection error empty state
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 32.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(
|
AnimatedVisibility(
|
||||||
imageVector = Icons.Default.WifiOff,
|
visible = true,
|
||||||
contentDescription = null,
|
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
|
||||||
modifier = Modifier.size(48.dp),
|
initialScale = 0.9f,
|
||||||
tint = MaterialTheme.colorScheme.error
|
animationSpec = SwooshMotion.quick()
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = state.error!!,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
FilledTonalButton(onClick = { viewModel.refresh() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Refresh,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
) {
|
||||||
Text("Retry")
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WifiOff,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
FilledTonalButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -427,24 +468,36 @@ fun FeedScreen(
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
AnimatedVisibility(
|
||||||
text = activeFilter.emptyMessage(),
|
visible = true,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
initialScale = 0.9f,
|
||||||
)
|
animationSpec = SwooshMotion.quick()
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
if (activeFilter == PostFilter.ALL) {
|
|
||||||
Text(
|
|
||||||
text = "Tap + to write your first post",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Try a different filter or create a new post",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = activeFilter.emptyMessage(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
if (activeFilter == PostFilter.ALL) {
|
||||||
|
Text(
|
||||||
|
text = "Tap + to write your first post",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Try a different filter or create a new post",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +548,16 @@ fun FeedScreen(
|
||||||
// Normal feed: pinned section + swipe actions
|
// Normal feed: pinned section + swipe actions
|
||||||
// Pinned posts (no section header — pin icon on post)
|
// Pinned posts (no section header — pin icon on post)
|
||||||
if (pinnedPosts.isNotEmpty()) {
|
if (pinnedPosts.isNotEmpty()) {
|
||||||
|
item(key = "pinned_header") {
|
||||||
|
Column {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = true,
|
||||||
|
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { -it / 2 })
|
||||||
|
) {
|
||||||
|
PinnedSectionHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
|
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
|
||||||
val itemKey = post.ghostId ?: "local_${post.localId}"
|
val itemKey = post.ghostId ?: "local_${post.localId}"
|
||||||
StaggeredItem(
|
StaggeredItem(
|
||||||
|
|
@ -921,7 +984,6 @@ fun SwipeablePostCard(
|
||||||
SwipeBackground(dismissState)
|
SwipeBackground(dismissState)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
.semantics {
|
.semantics {
|
||||||
customActions = listOf(
|
customActions = listOf(
|
||||||
CustomAccessibilityAction("Edit post") {
|
CustomAccessibilityAction("Edit post") {
|
||||||
|
|
@ -935,18 +997,23 @@ fun SwipeablePostCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
PostCardContent(
|
Surface(
|
||||||
post = post,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
onClick = onClick,
|
modifier = Modifier.fillMaxWidth()
|
||||||
onCancelQueue = onCancelQueue,
|
) {
|
||||||
onShare = onShare,
|
PostCardContent(
|
||||||
onCopyLink = onCopyLink,
|
post = post,
|
||||||
onEdit = onEdit,
|
onClick = onClick,
|
||||||
onDelete = onDelete,
|
onCancelQueue = onCancelQueue,
|
||||||
onTogglePin = onTogglePin,
|
onShare = onShare,
|
||||||
onTagClick = onTagClick,
|
onCopyLink = onCopyLink,
|
||||||
snackbarHostState = snackbarHostState
|
onEdit = onEdit,
|
||||||
)
|
onDelete = onDelete,
|
||||||
|
onTogglePin = onTogglePin,
|
||||||
|
onTagClick = onTagClick,
|
||||||
|
snackbarHostState = snackbarHostState
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,7 +1052,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(color, MaterialTheme.shapes.medium)
|
.background(color)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
contentAlignment = alignment
|
contentAlignment = alignment
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue