From 71d58008c63ab95f2f6f9e04bfca54d9edab068d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 14:18:31 +0100 Subject: [PATCH] feat: add empty state, filter, and overlay animations in feed --- .../swoosh/microblog/ui/feed/FeedScreen.kt | 255 +++++++++++------- 1 file changed, 161 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 72af59d..abdaa02 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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 ) {