feat: add empty state, filter, and overlay animations in feed

This commit is contained in:
Paweł Orzech 2026-03-19 14:18:31 +01:00
parent 64662f6bd4
commit 71d58008c6
No known key found for this signature in database

View file

@ -9,6 +9,10 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
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 kotlinx.coroutines.delay
import androidx.compose.foundation.gestures.detectTapGestures
@ -287,7 +291,11 @@ fun FeedScreen(
.padding(padding)
) {
// Filter chips bar (only when not searching)
if (!isSearchActive) {
AnimatedVisibility(
visible = !isSearchActive,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
) {
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
@ -312,7 +320,11 @@ fun FeedScreen(
}
// Loading overlay during account switch
if (state.isSwitchingAccount) {
AnimatedVisibility(
visible = state.isSwitchingAccount,
enter = fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
Box(
modifier = Modifier.fillMaxSize(),
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
RecentSearchesList(
recentSearches = recentSearches,
@ -336,49 +349,65 @@ fun FeedScreen(
)
} else if (isSearchActive && searchQuery.isNotBlank() && isSearching) {
// Searching indicator
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
AnimatedVisibility(
visible = true,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
initialScale = 0.9f,
animationSpec = SwooshMotion.quick()
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Searching...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
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) {
// No results empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
AnimatedVisibility(
visible = true,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
initialScale = 0.9f,
animationSpec = SwooshMotion.quick()
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No results found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No posts matching \"$searchQuery\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No results found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No posts matching \"$searchQuery\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
} else if (!isSearchActive && displayPosts.isEmpty() && !state.isRefreshing) {
@ -390,34 +419,46 @@ fun FeedScreen(
if (state.isConnectionError && state.error != null) {
// Connection error empty state
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
modifier = Modifier.fillMaxSize(),
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)
AnimatedVisibility(
visible = true,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
initialScale = 0.9f,
animationSpec = SwooshMotion.quick()
)
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 {
@ -427,24 +468,36 @@ fun FeedScreen(
verticalArrangement = Arrangement.Center,
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
AnimatedVisibility(
visible = true,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
initialScale = 0.9f,
animationSpec = SwooshMotion.quick()
)
) {
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
// Pinned posts (no section header — pin icon on post)
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 ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
@ -921,7 +984,6 @@ fun SwipeablePostCard(
SwipeBackground(dismissState)
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.semantics {
customActions = listOf(
CustomAccessibilityAction("Edit post") {
@ -935,18 +997,23 @@ fun SwipeablePostCard(
)
}
) {
PostCardContent(
post = post,
onClick = onClick,
onCancelQueue = onCancelQueue,
onShare = onShare,
onCopyLink = onCopyLink,
onEdit = onEdit,
onDelete = onDelete,
onTogglePin = onTogglePin,
onTagClick = onTagClick,
snackbarHostState = snackbarHostState
)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
PostCardContent(
post = post,
onClick = onClick,
onCancelQueue = onCancelQueue,
onShare = onShare,
onCopyLink = onCopyLink,
onEdit = onEdit,
onDelete = onDelete,
onTogglePin = onTogglePin,
onTagClick = onTagClick,
snackbarHostState = snackbarHostState
)
}
}
}
@ -985,7 +1052,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color, MaterialTheme.shapes.medium)
.background(color)
.padding(horizontal = 24.dp),
contentAlignment = alignment
) {