mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
aaf29f1512
commit
a81a65281f
2 changed files with 158 additions and 2 deletions
|
|
@ -1,24 +1,34 @@
|
||||||
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.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.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.Create
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material.icons.filled.TextFields
|
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
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.data.model.GhostTagFull
|
||||||
|
import com.swoosh.microblog.ui.tags.parseHexColor
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -27,7 +37,7 @@ fun StatsScreen(
|
||||||
) {
|
) {
|
||||||
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),
|
||||||
|
|
@ -114,6 +124,57 @@ fun StatsScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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
|
// Writing stats section
|
||||||
Text(
|
Text(
|
||||||
"Writing Stats",
|
"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
|
@Composable
|
||||||
private fun StatsCard(
|
private fun StatsCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
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.GhostTagFull
|
||||||
import com.swoosh.microblog.data.model.OverallStats
|
import com.swoosh.microblog.data.model.OverallStats
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
import com.swoosh.microblog.data.repository.TagRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -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 tagRepository = TagRepository(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(StatsUiState())
|
private val _uiState = MutableStateFlow(StatsUiState())
|
||||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
||||||
|
|
@ -50,6 +53,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
linkTitle = null,
|
linkTitle = null,
|
||||||
linkDescription = null,
|
linkDescription = null,
|
||||||
linkImageUrl = null,
|
linkImageUrl = null,
|
||||||
|
tags = ghost.tags?.map { it.name } ?: emptyList(),
|
||||||
status = ghost.status ?: "draft",
|
status = ghost.status ?: "draft",
|
||||||
publishedAt = ghost.published_at,
|
publishedAt = ghost.published_at,
|
||||||
createdAt = ghost.created_at,
|
createdAt = ghost.created_at,
|
||||||
|
|
@ -73,7 +77,33 @@ 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 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) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +113,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
data class StatsUiState(
|
data class StatsUiState(
|
||||||
val stats: OverallStats = OverallStats(),
|
val stats: OverallStats = OverallStats(),
|
||||||
|
val tagStats: List<GhostTagFull> = emptyList(),
|
||||||
|
val mostUsedTag: GhostTagFull? = null,
|
||||||
|
val postsWithoutTags: Int = 0,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue