mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
64a573a95c
commit
e99d88e10a
2 changed files with 173 additions and 7 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
package com.swoosh.microblog.ui.stats
|
package com.swoosh.microblog.ui.stats
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
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.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
@ -7,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.Create
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.TextFields
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -23,11 +21,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StatsScreen(
|
fun StatsScreen(
|
||||||
viewModel: StatsViewModel = viewModel()
|
viewModel: StatsViewModel = viewModel(),
|
||||||
|
onNavigateToMembers: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
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(
|
val animatedTotal by animateIntAsState(
|
||||||
targetValue = state.stats.totalPosts,
|
targetValue = state.stats.totalPosts,
|
||||||
animationSpec = tween(400),
|
animationSpec = tween(400),
|
||||||
|
|
@ -49,6 +48,39 @@ fun StatsScreen(
|
||||||
label = "scheduled"
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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) {
|
if (state.error != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
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
|
@Composable
|
||||||
private fun StatsCard(
|
private fun StatsCard(
|
||||||
modifier: Modifier = Modifier,
|
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
|
@Composable
|
||||||
private fun WritingStatRow(label: String, value: String) {
|
private fun WritingStatRow(label: String, value: String) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
import com.swoosh.microblog.data.model.OverallStats
|
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 com.swoosh.microblog.data.repository.PostRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -15,6 +17,7 @@ import kotlinx.coroutines.launch
|
||||||
class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val repository = PostRepository(application)
|
||||||
|
private val memberRepository = MemberRepository(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(StatsUiState())
|
private val _uiState = MutableStateFlow(StatsUiState())
|
||||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
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 uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
|
||||||
|
|
||||||
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
|
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) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +97,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
data class StatsUiState(
|
data class StatsUiState(
|
||||||
val stats: OverallStats = OverallStats(),
|
val stats: OverallStats = OverallStats(),
|
||||||
|
val memberStats: MemberStats? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue