fix: stats screen shows layout instantly, only numbers animate (no stagger entrance)

This commit is contained in:
Paweł Orzech 2026-03-19 14:59:28 +01:00
parent 3da3e97e77
commit 4a2a18282c
No known key found for this signature in database

View file

@ -1,11 +1,7 @@
package com.swoosh.microblog.ui.stats package com.swoosh.microblog.ui.stats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween 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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -22,8 +18,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.ui.animation.SwooshMotion
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -32,38 +26,24 @@ fun StatsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
// Staggered entrance for stats cards // Animated counters — numbers count up from 0 when data loads
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) — snappier 400ms count-up
val animatedTotal by animateIntAsState( val animatedTotal by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.totalPosts else 0, targetValue = state.stats.totalPosts,
animationSpec = tween(400), animationSpec = tween(400),
label = "totalPosts" label = "totalPosts"
) )
val animatedPublished by animateIntAsState( val animatedPublished by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.publishedCount else 0, targetValue = state.stats.publishedCount,
animationSpec = tween(400), animationSpec = tween(400),
label = "published" label = "published"
) )
val animatedDrafts by animateIntAsState( val animatedDrafts by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.draftCount else 0, targetValue = state.stats.draftCount,
animationSpec = tween(400), animationSpec = tween(400),
label = "drafts" label = "drafts"
) )
val animatedScheduled by animateIntAsState( val animatedScheduled by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.scheduledCount else 0, targetValue = state.stats.scheduledCount,
animationSpec = tween(400), animationSpec = tween(400),
label = "scheduled" label = "scheduled"
) )
@ -75,126 +55,92 @@ fun StatsScreen(
) )
} }
) { padding -> ) { padding ->
if (state.isLoading) { Column(
Box( modifier = Modifier
modifier = Modifier.fillMaxSize().padding(padding), .fillMaxSize()
contentAlignment = Alignment.Center .padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Post counts section
Text(
"Posts Overview",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
CircularProgressIndicator() StatsCard(
modifier = Modifier.weight(1f),
value = "$animatedTotal",
label = "Total Posts",
icon = Icons.AutoMirrored.Filled.Article
)
StatsCard(
modifier = Modifier.weight(1f),
value = "$animatedPublished",
label = "Published",
icon = Icons.Default.Create
)
} }
} else {
Column( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxSize() horizontalArrangement = Arrangement.spacedBy(12.dp)
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Post counts section StatsCard(
Text( modifier = Modifier.weight(1f),
"Posts Overview", value = "$animatedDrafts",
style = MaterialTheme.typography.titleMedium, label = "Drafts",
fontWeight = FontWeight.SemiBold icon = Icons.Default.TextFields
) )
StatsCard(
modifier = Modifier.weight(1f),
value = "$animatedScheduled",
label = "Scheduled",
icon = Icons.Default.Schedule
)
}
Row( Spacer(modifier = Modifier.height(8.dp))
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) // Writing stats section
Text(
"Writing Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
AnimatedVisibility( WritingStatRow("Total words written", "${state.stats.totalWords}")
visible = cardVisible[0].value, HorizontalDivider()
modifier = Modifier.weight(1f), WritingStatRow("Total characters", "${state.stats.totalCharacters}")
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()) HorizontalDivider()
) { WritingStatRow("Average post length", "${state.stats.averageWordCount} words")
StatsCard( HorizontalDivider()
value = "$animatedTotal", WritingStatRow("Average characters", "${state.stats.averageCharCount} chars")
label = "Total Posts", HorizontalDivider()
icon = Icons.AutoMirrored.Filled.Article WritingStatRow("Longest post", "${state.stats.longestPostWords} words")
) HorizontalDivider()
} WritingStatRow("Shortest post", "${state.stats.shortestPostWords} words")
AnimatedVisibility(
visible = cardVisible[1].value,
modifier = Modifier.weight(1f),
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)
) {
AnimatedVisibility(
visible = cardVisible[2].value,
modifier = Modifier.weight(1f),
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),
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(
value = "$animatedScheduled",
label = "Scheduled",
icon = Icons.Default.Schedule
)
}
} }
}
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Writing stats section
Text( Text(
"Writing Stats", text = "Note: Remote post data may be incomplete. ${state.error}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold color = MaterialTheme.colorScheme.onSurfaceVariant
) )
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")
}
}
}
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Note: Remote post data may be incomplete. ${state.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }