feat: add staggered stats cards and count-up animations

This commit is contained in:
Paweł Orzech 2026-03-19 14:24:05 +01:00
parent 188c62f076
commit a6429f16d3
No known key found for this signature in database

View file

@ -1,5 +1,11 @@
package com.swoosh.microblog.ui.stats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -17,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.ui.animation.SwooshMotion
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -26,6 +34,42 @@ fun StatsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Staggered entrance for stats cards
val cardVisible = remember { List(4) { mutableStateOf(false) } }
var writingStatsVisible by remember { mutableStateOf(false) }
LaunchedEffect(state.isLoading) {
if (!state.isLoading) {
cardVisible.forEachIndexed { index, vis ->
delay(SwooshMotion.StaggerDelayMs * index)
vis.value = true
}
delay(SwooshMotion.StaggerDelayMs * 4)
writingStatsVisible = true
}
}
// Animated counters (ST3)
val animatedTotal by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.totalPosts else 0,
animationSpec = tween(600),
label = "totalPosts"
)
val animatedPublished by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.publishedCount else 0,
animationSpec = tween(600),
label = "published"
)
val animatedDrafts by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.draftCount else 0,
animationSpec = tween(600),
label = "drafts"
)
val animatedScheduled by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.scheduledCount else 0,
animationSpec = tween(600),
label = "scheduled"
)
Scaffold(
topBar = {
TopAppBar(
@ -65,36 +109,56 @@ fun StatsScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatsCard(
AnimatedVisibility(
visible = cardVisible[0].value,
modifier = Modifier.weight(1f),
value = "${state.stats.totalPosts}",
label = "Total Posts",
icon = Icons.AutoMirrored.Filled.Article
)
StatsCard(
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(
value = "$animatedTotal",
label = "Total Posts",
icon = Icons.AutoMirrored.Filled.Article
)
}
AnimatedVisibility(
visible = cardVisible[1].value,
modifier = Modifier.weight(1f),
value = "${state.stats.publishedCount}",
label = "Published",
icon = Icons.Default.Create
)
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(
value = "$animatedPublished",
label = "Published",
icon = Icons.Default.Create
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatsCard(
AnimatedVisibility(
visible = cardVisible[2].value,
modifier = Modifier.weight(1f),
value = "${state.stats.draftCount}",
label = "Drafts",
icon = Icons.Default.TextFields
)
StatsCard(
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(
value = "$animatedDrafts",
label = "Drafts",
icon = Icons.Default.TextFields
)
}
AnimatedVisibility(
visible = cardVisible[3].value,
modifier = Modifier.weight(1f),
value = "${state.stats.scheduledCount}",
label = "Scheduled",
icon = Icons.Default.Schedule
)
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(
value = "$animatedScheduled",
label = "Scheduled",
icon = Icons.Default.Schedule
)
}
}
Spacer(modifier = Modifier.height(8.dp))
@ -106,22 +170,27 @@ fun StatsScreen(
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
WritingStatRow("Total words written", "${state.stats.totalWords}")
HorizontalDivider()
WritingStatRow("Total characters", "${state.stats.totalCharacters}")
HorizontalDivider()
WritingStatRow("Average post length", "${state.stats.averageWordCount} words")
HorizontalDivider()
WritingStatRow("Average characters", "${state.stats.averageCharCount} chars")
HorizontalDivider()
WritingStatRow("Longest post", "${state.stats.longestPostWords} words")
HorizontalDivider()
WritingStatRow("Shortest post", "${state.stats.shortestPostWords} words")
AnimatedVisibility(
visible = writingStatsVisible,
enter = slideInVertically(initialOffsetY = { it / 3 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
WritingStatRow("Total words written", "${state.stats.totalWords}")
HorizontalDivider()
WritingStatRow("Total characters", "${state.stats.totalCharacters}")
HorizontalDivider()
WritingStatRow("Average post length", "${state.stats.averageWordCount} words")
HorizontalDivider()
WritingStatRow("Average characters", "${state.stats.averageCharCount} chars")
HorizontalDivider()
WritingStatRow("Longest post", "${state.stats.longestPostWords} words")
HorizontalDivider()
WritingStatRow("Shortest post", "${state.stats.shortestPostWords} words")
}
}
}