feat: show member stats tiles in Stats screen with animated counters

StatsViewModel now fetches members via MemberRepository and computes
MemberStats. StatsScreen shows a 2x3 grid of ElevatedCard tiles when
memberStats is available: Total, New this week, Open rate, Free, Paid,
MRR. Includes animated counters and a "See all members" navigation button.
Member fetch failure is non-fatal (tiles simply hidden).
This commit is contained in:
Paweł Orzech 2026-03-20 00:29:50 +01:00
parent 64a573a95c
commit e99d88e10a
2 changed files with 173 additions and 7 deletions

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.ui.stats
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
@ -7,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -23,11 +21,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
viewModel: StatsViewModel = viewModel()
viewModel: StatsViewModel = viewModel(),
onNavigateToMembers: (() -> Unit)? = null
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Animated counters numbers count up from 0 when data loads
// Animated counters -- numbers count up from 0 when data loads
val animatedTotal by animateIntAsState(
targetValue = state.stats.totalPosts,
animationSpec = tween(400),
@ -49,6 +48,39 @@ fun StatsScreen(
label = "scheduled"
)
// Member stat animations
val memberStats = state.memberStats
val animatedMembersTotal by animateIntAsState(
targetValue = memberStats?.total ?: 0,
animationSpec = tween(400),
label = "membersTotal"
)
val animatedMembersNew by animateIntAsState(
targetValue = memberStats?.newThisWeek ?: 0,
animationSpec = tween(400),
label = "membersNew"
)
val animatedMembersFree by animateIntAsState(
targetValue = memberStats?.free ?: 0,
animationSpec = tween(400),
label = "membersFree"
)
val animatedMembersPaid by animateIntAsState(
targetValue = memberStats?.paid ?: 0,
animationSpec = tween(400),
label = "membersPaid"
)
val animatedMembersMrr by animateIntAsState(
targetValue = memberStats?.mrr ?: 0,
animationSpec = tween(400),
label = "membersMrr"
)
val animatedOpenRate by animateFloatAsState(
targetValue = memberStats?.avgOpenRate?.toFloat() ?: 0f,
animationSpec = tween(400),
label = "openRate"
)
Scaffold(
topBar = {
TopAppBar(
@ -140,6 +172,80 @@ fun StatsScreen(
}
}
// Members section
if (memberStats != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Members",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersTotal",
label = "Total",
icon = Icons.Default.People
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "+$animatedMembersNew",
label = "New this week",
icon = Icons.Default.PersonAdd
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "${String.format("%.1f", animatedOpenRate)}%",
label = "Open rate",
icon = Icons.Default.MailOutline
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersFree",
label = "Free",
icon = Icons.Default.Person
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersPaid",
label = "Paid",
icon = Icons.Default.Diamond
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = formatMrr(animatedMembersMrr),
label = "MRR",
icon = Icons.Default.AttachMoney
)
}
if (onNavigateToMembers != null) {
TextButton(
onClick = onNavigateToMembers,
modifier = Modifier.align(Alignment.End)
) {
Text("See all members")
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
@ -152,6 +258,15 @@ fun StatsScreen(
}
}
private fun formatMrr(cents: Int): String {
val dollars = cents / 100.0
return if (dollars >= 1000) {
String.format("$%.1fk", dollars / 1000)
} else {
String.format("$%.0f", dollars)
}
}
@Composable
private fun StatsCard(
modifier: Modifier = Modifier,
@ -188,6 +303,42 @@ private fun StatsCard(
}
}
@Composable
private fun MemberStatsCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WritingStatRow(label: String, value: String) {
Row(

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.OverallStats
import com.swoosh.microblog.data.repository.MemberRepository
import com.swoosh.microblog.data.repository.MemberStats
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -15,6 +17,7 @@ import kotlinx.coroutines.launch
class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val memberRepository = MemberRepository(application)
private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
@ -73,7 +76,18 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
_uiState.update { it.copy(stats = stats, isLoading = false) }
// Fetch member stats (non-fatal if it fails)
val memberStats = try {
val membersResult = memberRepository.fetchAllMembers()
membersResult.getOrNull()?.let { members ->
memberRepository.getMemberStats(members)
}
} catch (e: Exception) {
null
}
_uiState.update { it.copy(stats = stats, memberStats = memberStats, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
@ -83,6 +97,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
data class StatsUiState(
val stats: OverallStats = OverallStats(),
val memberStats: MemberStats? = null,
val isLoading: Boolean = false,
val error: String? = null
)