diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt index 457eb87..296208b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt @@ -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( diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt index 790a7c1..fbd5456 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt @@ -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 = _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 )