feat: add tag statistics section in Stats screen

StatsViewModel fetches tags from TagRepository, computes most used tag
and posts-without-tags count. StatsScreen shows "Tags" section with
horizontal progress bars (LinearProgressIndicator per tag, colored by
accent_color), most used tag, total tags, and posts without tags count.
This commit is contained in:
Paweł Orzech 2026-03-20 00:35:23 +01:00
parent aaf29f1512
commit a81a65281f
2 changed files with 158 additions and 2 deletions

View file

@ -1,24 +1,34 @@
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.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Tag
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.ui.tags.parseHexColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -27,7 +37,7 @@ fun StatsScreen(
) {
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),
@ -114,6 +124,57 @@ fun StatsScreen(
Spacer(modifier = Modifier.height(8.dp))
// Tags section
if (state.tagStats.isNotEmpty()) {
Text(
"Tags",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Most used tag
if (state.mostUsedTag != null) {
WritingStatRow(
"Most used tag",
"#${state.mostUsedTag!!.name} (${state.mostUsedTag!!.count?.posts ?: 0})"
)
HorizontalDivider()
}
// Posts without tags
WritingStatRow(
"Posts without tags",
"${state.postsWithoutTags}"
)
HorizontalDivider()
// Total tags
WritingStatRow(
"Total tags",
"${state.tagStats.size}"
)
// Tag progress bars
val maxCount = state.tagStats.maxOfOrNull { it.count?.posts ?: 0 } ?: 1
state.tagStats.take(10).forEach { tag ->
TagProgressBar(
tag = tag,
maxCount = maxCount.coerceAtLeast(1)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Writing stats section
Text(
"Writing Stats",
@ -152,6 +213,68 @@ fun StatsScreen(
}
}
@Composable
private fun TagProgressBar(
tag: GhostTagFull,
maxCount: Int
) {
val postCount = tag.count?.posts ?: 0
val progress = postCount.toFloat() / maxCount.toFloat()
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(600),
label = "tagProgress_${tag.name}"
)
val barColor = tag.accent_color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(barColor)
)
Text(
text = tag.name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "$postCount",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = barColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
}
}
@Composable
private fun StatsCard(
modifier: Modifier = Modifier,

View file

@ -4,8 +4,10 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.OverallStats
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -15,6 +17,7 @@ import kotlinx.coroutines.launch
class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val tagRepository = TagRepository(application)
private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
@ -50,6 +53,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
tags = ghost.tags?.map { it.name } ?: emptyList(),
status = ghost.status ?: "draft",
publishedAt = ghost.published_at,
createdAt = ghost.created_at,
@ -73,7 +77,33 @@ 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 tag stats
val tagStats = try {
tagRepository.fetchTags().getOrNull()
?.sortedByDescending { it.count?.posts ?: 0 }
?: emptyList()
} catch (e: Exception) {
emptyList()
}
// Count posts without any tags
val totalPosts = localPosts.size + uniqueRemotePosts.size
val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() }
val postsWithoutTags = totalPosts - postsWithTags
// Most used tag
val mostUsedTag = tagStats.firstOrNull()
_uiState.update {
it.copy(
stats = stats,
tagStats = tagStats,
mostUsedTag = mostUsedTag,
postsWithoutTags = postsWithoutTags,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
@ -83,6 +113,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
data class StatsUiState(
val stats: OverallStats = OverallStats(),
val tagStats: List<GhostTagFull> = emptyList(),
val mostUsedTag: GhostTagFull? = null,
val postsWithoutTags: Int = 0,
val isLoading: Boolean = false,
val error: String? = null
)