feat: add post statistics with word count, reading time, and writing stats screen

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:02 +01:00
parent 74f42fd2f1
commit c24b2f7fa7
No known key found for this signature in database
15 changed files with 1225 additions and 18 deletions

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db
import androidx.room.* import androidx.room.*
import com.swoosh.microblog.data.model.LocalPost import com.swoosh.microblog.data.model.LocalPost
import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -39,4 +40,13 @@ interface LocalPostDao {
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId") @Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE) suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE)
@Query("SELECT COUNT(*) FROM local_posts")
suspend fun getTotalPostCount(): Int
@Query("SELECT COUNT(*) FROM local_posts WHERE status = :status")
suspend fun getPostCountByStatus(status: PostStatus): Int
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
suspend fun getAllPostsList(): List<LocalPost>
} }

View file

@ -41,7 +41,8 @@ data class GhostPost(
val published_at: String? = null, val published_at: String? = null,
val custom_excerpt: String? = null, val custom_excerpt: String? = null,
val visibility: String? = "public", val visibility: String? = "public",
val authors: List<Author>? = null val authors: List<Author>? = null,
val reading_time: Int? = null
) )
data class Author( data class Author(

View file

@ -0,0 +1,71 @@
package com.swoosh.microblog.data.model
/**
* Aggregate statistics across all posts.
*/
data class OverallStats(
val totalPosts: Int = 0,
val publishedCount: Int = 0,
val draftCount: Int = 0,
val scheduledCount: Int = 0,
val totalWords: Int = 0,
val totalCharacters: Int = 0,
val averageWordCount: Int = 0,
val averageCharCount: Int = 0,
val longestPostWords: Int = 0,
val shortestPostWords: Int = 0
) {
companion object {
/**
* Calculate overall stats from lists of local and remote posts.
*/
fun calculate(
localPosts: List<LocalPost>,
remotePosts: List<FeedPost>
): OverallStats {
// Combine all text content
val allTexts = mutableListOf<String>()
var publishedCount = 0
var draftCount = 0
var scheduledCount = 0
for (post in localPosts) {
allTexts.add(post.content)
when (post.status) {
PostStatus.PUBLISHED -> publishedCount++
PostStatus.DRAFT -> draftCount++
PostStatus.SCHEDULED -> scheduledCount++
}
}
for (post in remotePosts) {
allTexts.add(post.textContent)
when (post.status.lowercase()) {
"published" -> publishedCount++
"draft" -> draftCount++
"scheduled" -> scheduledCount++
}
}
val wordCounts = allTexts.map { PostStats.countWords(it) }
val charCounts = allTexts.map { it.length }
val totalPosts = allTexts.size
val totalWords = wordCounts.sum()
val totalChars = charCounts.sum()
return OverallStats(
totalPosts = totalPosts,
publishedCount = publishedCount,
draftCount = draftCount,
scheduledCount = scheduledCount,
totalWords = totalWords,
totalCharacters = totalChars,
averageWordCount = if (totalPosts > 0) totalWords / totalPosts else 0,
averageCharCount = if (totalPosts > 0) totalChars / totalPosts else 0,
longestPostWords = wordCounts.maxOrNull() ?: 0,
shortestPostWords = wordCounts.minOrNull() ?: 0
)
}
}
}

View file

@ -0,0 +1,94 @@
package com.swoosh.microblog.data.model
/**
* Statistics calculated for a single post.
*/
data class PostStats(
val wordCount: Int,
val charCount: Int,
val readingTimeMinutes: Int,
val hasImage: Boolean,
val hasLink: Boolean
) {
companion object {
private const val WORDS_PER_MINUTE = 200
/**
* Calculate post statistics from text content and metadata.
*/
fun fromContent(
text: String,
hasImage: Boolean = false,
hasLink: Boolean = false
): PostStats {
val charCount = text.length
val wordCount = countWords(text)
val readingTime = estimateReadingTime(wordCount)
return PostStats(
wordCount = wordCount,
charCount = charCount,
readingTimeMinutes = readingTime,
hasImage = hasImage,
hasLink = hasLink
)
}
/**
* Calculate post statistics from a FeedPost.
*/
fun fromFeedPost(post: FeedPost): PostStats {
return fromContent(
text = post.textContent,
hasImage = post.imageUrl != null,
hasLink = post.linkUrl != null
)
}
/**
* Count words in text by splitting on whitespace.
* Handles multiple spaces, newlines, tabs, and empty strings.
*/
fun countWords(text: String): Int {
val trimmed = text.trim()
if (trimmed.isEmpty()) return 0
return trimmed.split(Regex("\\s+")).size
}
/**
* Estimate reading time in minutes based on word count.
* Uses 200 words per minute as average reading speed.
* Returns at least 1 minute for non-empty content.
*/
fun estimateReadingTime(wordCount: Int): Int {
if (wordCount <= 0) return 0
val minutes = wordCount / WORDS_PER_MINUTE
return maxOf(1, minutes)
}
/**
* Format reading time for display.
*/
fun formatReadingTime(minutes: Int): String {
return when {
minutes <= 0 -> ""
minutes == 1 -> "1 min read"
else -> "$minutes min read"
}
}
/**
* Format a live stats string for the composer.
*/
fun formatComposerStats(text: String): String {
val charCount = text.length
val wordCount = countWords(text)
val readingTime = estimateReadingTime(wordCount)
return if (charCount == 0) {
"0 chars"
} else {
val readingLabel = formatReadingTime(readingTime)
"$charCount chars · $wordCount words · ~$readingLabel"
}
}
}
}

View file

@ -142,6 +142,14 @@ class PostRepository(private val context: Context) {
suspend fun markUploaded(localId: Long, ghostId: String) = suspend fun markUploaded(localId: Long, ghostId: String) =
dao.markUploaded(localId, ghostId) dao.markUploaded(localId, ghostId)
// --- Stats queries ---
suspend fun getTotalLocalPostCount(): Int = dao.getTotalPostCount()
suspend fun getLocalPostCountByStatus(status: PostStatus): Int = dao.getPostCountByStatus(status)
suspend fun getAllLocalPostsList(): List<LocalPost> = dao.getAllPostsList()
// --- Connectivity check --- // --- Connectivity check ---
fun isNetworkAvailable(): Boolean { fun isNetworkAvailable(): Boolean {

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -90,12 +91,17 @@ fun ComposerScreen(
.heightIn(min = 150.dp), .heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") }, placeholder = { Text("What's on your mind?") },
supportingText = { supportingText = {
val charCount = state.text.length
val statsText = PostStats.formatComposerStats(state.text)
val color = when {
charCount > 500 -> MaterialTheme.colorScheme.error
charCount > 280 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text( Text(
"${state.text.length} characters", text = statsText,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (state.text.length > 280) color = color
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
) )

View file

@ -1,20 +1,32 @@
package com.swoosh.microblog.ui.detail package com.swoosh.microblog.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState 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.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime import com.swoosh.microblog.ui.feed.formatRelativeTime
@ -130,18 +142,9 @@ fun DetailScreen(
} }
} }
// Metadata // Stats section
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Divider() PostStatsSection(post)
Spacer(modifier = Modifier.height(12.dp))
if (post.createdAt != null) {
MetadataRow("Created", post.createdAt)
}
if (post.publishedAt != null) {
MetadataRow("Published", post.publishedAt)
}
MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
} }
} }
@ -168,6 +171,155 @@ fun DetailScreen(
} }
} }
@Composable
private fun PostStatsSection(post: FeedPost) {
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post)
}
var expanded by remember { mutableStateOf(false) }
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row - always visible, clickable to expand
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Post Statistics",
style = MaterialTheme.typography.titleSmall
)
IconButton(
onClick = { expanded = !expanded },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(20.dp)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Quick stats row - always visible
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.TextFields,
value = "${stats.wordCount}",
label = "Words"
)
StatItem(
icon = Icons.AutoMirrored.Filled.Article,
value = "${stats.charCount}",
label = "Chars"
)
StatItem(
icon = Icons.Default.AccessTime,
value = PostStats.formatReadingTime(stats.readingTimeMinutes).ifEmpty { "< 1 min" },
label = "Read"
)
}
// Expandable details
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
if (post.createdAt != null) {
MetadataRow("Created", post.createdAt)
}
if (post.updatedAt != null) {
MetadataRow("Updated", post.updatedAt)
}
if (post.publishedAt != null) {
MetadataRow("Published", post.publishedAt)
}
Spacer(modifier = Modifier.height(8.dp))
// Content indicators
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (stats.hasImage) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Image,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Image",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
if (stats.hasLink) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Link,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Link",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
}
@Composable
private fun StatItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
label: String
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleSmall
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable @Composable
private fun MetadataRow(label: String, value: String) { private fun MetadataRow(label: String, value: String) {
Row( Row(

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
@ -27,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@ -320,6 +322,40 @@ fun PostCard(
} }
} }
} }
// Post stats badges
if (post.textContent.isNotBlank()) {
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
PostStats.fromFeedPost(post)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Reading time with clock icon
val readingLabel = PostStats.formatReadingTime(stats.readingTimeMinutes)
if (readingLabel.isNotEmpty()) {
Icon(
Icons.Default.AccessTime,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Text(
text = readingLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
// Word count
Text(
text = "${stats.wordCount} words",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} }
} }
} }

View file

@ -13,6 +13,7 @@ import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.setup.SetupScreen
import com.swoosh.microblog.ui.stats.StatsScreen
object Routes { object Routes {
const val SETUP = "setup" const val SETUP = "setup"
@ -20,6 +21,7 @@ object Routes {
const val COMPOSER = "composer" const val COMPOSER = "composer"
const val DETAIL = "detail" const val DETAIL = "detail"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val STATS = "stats"
} }
@Composable @Composable
@ -96,8 +98,17 @@ fun SwooshNavGraph(
navController.navigate(Routes.SETUP) { navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
},
onStatsClick = {
navController.navigate(Routes.STATS)
} }
) )
} }
composable(Routes.STATS) {
StatsScreen(
onBack = { navController.popBackStack() }
)
}
} }
} }

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -18,7 +19,8 @@ import com.swoosh.microblog.data.api.ApiClient
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit,
onStatsClick: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val credentials = remember { CredentialsManager(context) } val credentials = remember { CredentialsManager(context) }
@ -91,7 +93,25 @@ fun SettingsScreen(
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Divider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Writing Statistics button
FilledTonalButton(
onClick = onStatsClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.BarChart,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Writing Statistics")
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedButton( OutlinedButton(

View file

@ -0,0 +1,195 @@
package com.swoosh.microblog.ui.stats
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.TextFields
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
onBack: () -> Unit,
viewModel: StatsViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Writing Statistics") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Post counts section
Text(
"Posts Overview",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatsCard(
modifier = Modifier.weight(1f),
value = "${state.stats.totalPosts}",
label = "Total Posts",
icon = Icons.AutoMirrored.Filled.Article
)
StatsCard(
modifier = Modifier.weight(1f),
value = "${state.stats.publishedCount}",
label = "Published",
icon = Icons.Default.Create
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatsCard(
modifier = Modifier.weight(1f),
value = "${state.stats.draftCount}",
label = "Drafts",
icon = Icons.Default.TextFields
)
StatsCard(
modifier = Modifier.weight(1f),
value = "${state.stats.scheduledCount}",
label = "Scheduled",
icon = Icons.Default.Schedule
)
}
Spacer(modifier = Modifier.height(8.dp))
// Writing stats section
Text(
"Writing Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
WritingStatRow("Total words written", "${state.stats.totalWords}")
HorizontalDivider()
WritingStatRow("Total characters", "${state.stats.totalCharacters}")
HorizontalDivider()
WritingStatRow("Average post length", "${state.stats.averageWordCount} words")
HorizontalDivider()
WritingStatRow("Average characters", "${state.stats.averageCharCount} chars")
HorizontalDivider()
WritingStatRow("Longest post", "${state.stats.longestPostWords} words")
HorizontalDivider()
WritingStatRow("Shortest post", "${state.stats.shortestPostWords} words")
}
}
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Note: Remote post data may be incomplete. ${state.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun StatsCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
OutlinedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WritingStatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
}
}

View file

@ -0,0 +1,88 @@
package com.swoosh.microblog.ui.stats
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.OverallStats
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
init {
loadStats()
}
fun loadStats() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
// Get local posts
val localPosts = repository.getAllLocalPostsList()
// Get remote posts
val remotePosts = mutableListOf<FeedPost>()
var page = 1
var hasMore = true
while (hasMore) {
val result = repository.fetchPosts(page = page, limit = 50)
result.fold(
onSuccess = { response ->
remotePosts.addAll(response.posts.map { ghost ->
FeedPost(
ghostId = ghost.id,
title = ghost.title ?: "",
textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
htmlContent = ghost.html,
imageUrl = ghost.feature_image,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = ghost.status ?: "draft",
publishedAt = ghost.published_at,
createdAt = ghost.created_at,
updatedAt = ghost.updated_at,
isLocal = false
)
})
hasMore = response.meta?.pagination?.next != null
page++
},
onFailure = {
hasMore = false
}
)
// Safety limit
if (page > 20) break
}
// Remove remote duplicates that exist locally
val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
_uiState.update { it.copy(stats = stats, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
data class StatsUiState(
val stats: OverallStats = OverallStats(),
val isLoading: Boolean = false,
val error: String? = null
)

View file

@ -77,6 +77,14 @@ class GhostModelsTest {
assertNull(post.published_at) assertNull(post.published_at)
assertNull(post.custom_excerpt) assertNull(post.custom_excerpt)
assertNull(post.authors) assertNull(post.authors)
assertNull(post.reading_time)
}
@Test
fun `GhostPost reading_time deserializes from JSON`() {
val json = """{"id":"test","reading_time":3}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertEquals(3, post.reading_time)
} }
// --- FeedPost --- // --- FeedPost ---

View file

@ -0,0 +1,169 @@
package com.swoosh.microblog.data.model
import org.junit.Assert.*
import org.junit.Test
class OverallStatsTest {
@Test
fun `calculate with empty lists returns zero stats`() {
val stats = OverallStats.calculate(emptyList(), emptyList())
assertEquals(0, stats.totalPosts)
assertEquals(0, stats.publishedCount)
assertEquals(0, stats.draftCount)
assertEquals(0, stats.scheduledCount)
assertEquals(0, stats.totalWords)
assertEquals(0, stats.totalCharacters)
assertEquals(0, stats.averageWordCount)
assertEquals(0, stats.averageCharCount)
assertEquals(0, stats.longestPostWords)
assertEquals(0, stats.shortestPostWords)
}
@Test
fun `calculate counts local posts by status`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello", status = PostStatus.DRAFT),
LocalPost(localId = 2, content = "world", status = PostStatus.PUBLISHED),
LocalPost(localId = 3, content = "foo bar", status = PostStatus.SCHEDULED)
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(3, stats.totalPosts)
assertEquals(1, stats.publishedCount)
assertEquals(1, stats.draftCount)
assertEquals(1, stats.scheduledCount)
}
@Test
fun `calculate counts remote posts by status`() {
val remotePosts = listOf(
makeFeedPost("hello world", "published"),
makeFeedPost("foo bar", "published"),
makeFeedPost("baz", "draft")
)
val stats = OverallStats.calculate(emptyList(), remotePosts)
assertEquals(3, stats.totalPosts)
assertEquals(2, stats.publishedCount)
assertEquals(1, stats.draftCount)
assertEquals(0, stats.scheduledCount)
}
@Test
fun `calculate computes total words`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello world"), // 2 words
LocalPost(localId = 2, content = "foo bar baz") // 3 words
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(5, stats.totalWords)
}
@Test
fun `calculate computes total characters`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello"), // 5 chars
LocalPost(localId = 2, content = "world") // 5 chars
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(10, stats.totalCharacters)
}
@Test
fun `calculate computes average word count`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello world"), // 2 words
LocalPost(localId = 2, content = "one two three four") // 4 words
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(3, stats.averageWordCount) // (2 + 4) / 2 = 3
}
@Test
fun `calculate computes average char count`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello"), // 5 chars
LocalPost(localId = 2, content = "world!!") // 7 chars
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(6, stats.averageCharCount) // (5 + 7) / 2 = 6
}
@Test
fun `calculate finds longest post`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello"), // 1 word
LocalPost(localId = 2, content = "one two three four five six") // 6 words
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(6, stats.longestPostWords)
}
@Test
fun `calculate finds shortest post`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "hello"), // 1 word
LocalPost(localId = 2, content = "one two three four five six") // 6 words
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(1, stats.shortestPostWords)
}
@Test
fun `calculate combines local and remote posts`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "local post", status = PostStatus.DRAFT)
)
val remotePosts = listOf(
makeFeedPost("remote post here", "published")
)
val stats = OverallStats.calculate(localPosts, remotePosts)
assertEquals(2, stats.totalPosts)
assertEquals(1, stats.publishedCount)
assertEquals(1, stats.draftCount)
assertEquals(5, stats.totalWords) // 2 + 3
}
@Test
fun `calculate handles scheduled remote posts`() {
val remotePosts = listOf(
makeFeedPost("scheduled post", "scheduled")
)
val stats = OverallStats.calculate(emptyList(), remotePosts)
assertEquals(1, stats.scheduledCount)
}
@Test
fun `calculate handles single post`() {
val localPosts = listOf(
LocalPost(localId = 1, content = "single word test post")
)
val stats = OverallStats.calculate(localPosts, emptyList())
assertEquals(1, stats.totalPosts)
assertEquals(4, stats.averageWordCount) // same as total since only 1 post
assertEquals(stats.longestPostWords, stats.shortestPostWords)
}
@Test
fun `default OverallStats has zero values`() {
val stats = OverallStats()
assertEquals(0, stats.totalPosts)
assertEquals(0, stats.totalWords)
}
private fun makeFeedPost(text: String, status: String): FeedPost {
return FeedPost(
title = "",
textContent = text,
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = status,
publishedAt = null,
createdAt = null,
updatedAt = null
)
}
}

View file

@ -0,0 +1,338 @@
package com.swoosh.microblog.data.model
import org.junit.Assert.*
import org.junit.Test
class PostStatsTest {
// --- Word count tests ---
@Test
fun `countWords returns 0 for empty string`() {
assertEquals(0, PostStats.countWords(""))
}
@Test
fun `countWords returns 0 for whitespace only`() {
assertEquals(0, PostStats.countWords(" "))
}
@Test
fun `countWords returns 0 for tabs and newlines only`() {
assertEquals(0, PostStats.countWords("\t\n\r\n"))
}
@Test
fun `countWords counts single word`() {
assertEquals(1, PostStats.countWords("hello"))
}
@Test
fun `countWords counts multiple words`() {
assertEquals(5, PostStats.countWords("the quick brown fox jumps"))
}
@Test
fun `countWords handles multiple spaces between words`() {
assertEquals(3, PostStats.countWords("hello world foo"))
}
@Test
fun `countWords handles newlines between words`() {
assertEquals(3, PostStats.countWords("hello\nworld\nfoo"))
}
@Test
fun `countWords handles tabs between words`() {
assertEquals(2, PostStats.countWords("hello\tworld"))
}
@Test
fun `countWords handles mixed whitespace`() {
assertEquals(4, PostStats.countWords(" hello\n\tworld foo\nbar "))
}
@Test
fun `countWords handles leading and trailing whitespace`() {
assertEquals(2, PostStats.countWords(" hello world "))
}
@Test
fun `countWords handles unicode characters`() {
assertEquals(2, PostStats.countWords("caf\u00E9 latt\u00E9"))
}
@Test
fun `countWords handles emoji as word`() {
assertEquals(3, PostStats.countWords("hello \uD83D\uDE00 world"))
}
@Test
fun `countWords handles punctuation attached to words`() {
assertEquals(4, PostStats.countWords("hello, world! foo. bar?"))
}
@Test
fun `countWords handles long text`() {
val text = (1..200).joinToString(" ") { "word$it" }
assertEquals(200, PostStats.countWords(text))
}
@Test
fun `countWords handles single character`() {
assertEquals(1, PostStats.countWords("a"))
}
@Test
fun `countWords handles hyphenated words as single word`() {
assertEquals(1, PostStats.countWords("well-known"))
}
// --- Reading time estimation tests ---
@Test
fun `estimateReadingTime returns 0 for 0 words`() {
assertEquals(0, PostStats.estimateReadingTime(0))
}
@Test
fun `estimateReadingTime returns 0 for negative words`() {
assertEquals(0, PostStats.estimateReadingTime(-5))
}
@Test
fun `estimateReadingTime returns 1 for small word count`() {
assertEquals(1, PostStats.estimateReadingTime(50))
}
@Test
fun `estimateReadingTime returns 1 for 199 words`() {
assertEquals(1, PostStats.estimateReadingTime(199))
}
@Test
fun `estimateReadingTime returns 1 for exactly 200 words`() {
assertEquals(1, PostStats.estimateReadingTime(200))
}
@Test
fun `estimateReadingTime returns 2 for 400 words`() {
assertEquals(2, PostStats.estimateReadingTime(400))
}
@Test
fun `estimateReadingTime returns 5 for 1000 words`() {
assertEquals(5, PostStats.estimateReadingTime(1000))
}
@Test
fun `estimateReadingTime returns 1 for 1 word`() {
assertEquals(1, PostStats.estimateReadingTime(1))
}
// --- PostStats creation tests ---
@Test
fun `fromContent creates correct stats for normal text`() {
val stats = PostStats.fromContent("hello world foo bar baz")
assertEquals(5, stats.wordCount)
assertEquals(23, stats.charCount)
assertEquals(1, stats.readingTimeMinutes)
assertFalse(stats.hasImage)
assertFalse(stats.hasLink)
}
@Test
fun `fromContent creates correct stats for empty text`() {
val stats = PostStats.fromContent("")
assertEquals(0, stats.wordCount)
assertEquals(0, stats.charCount)
assertEquals(0, stats.readingTimeMinutes)
}
@Test
fun `fromContent sets hasImage correctly`() {
val stats = PostStats.fromContent("hello", hasImage = true)
assertTrue(stats.hasImage)
assertFalse(stats.hasLink)
}
@Test
fun `fromContent sets hasLink correctly`() {
val stats = PostStats.fromContent("hello", hasLink = true)
assertFalse(stats.hasImage)
assertTrue(stats.hasLink)
}
@Test
fun `fromContent sets both hasImage and hasLink`() {
val stats = PostStats.fromContent("hello", hasImage = true, hasLink = true)
assertTrue(stats.hasImage)
assertTrue(stats.hasLink)
}
@Test
fun `fromFeedPost calculates from text content`() {
val post = FeedPost(
title = "Test",
textContent = "This is a test post with some words",
htmlContent = null,
imageUrl = "https://example.com/img.jpg",
linkUrl = "https://example.com",
linkTitle = "Example",
linkDescription = null,
linkImageUrl = null,
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
val stats = PostStats.fromFeedPost(post)
assertEquals(8, stats.wordCount)
assertTrue(stats.hasImage)
assertTrue(stats.hasLink)
}
@Test
fun `fromFeedPost with no image and no link`() {
val post = FeedPost(
title = "Test",
textContent = "Just text",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "draft",
publishedAt = null,
createdAt = null,
updatedAt = null
)
val stats = PostStats.fromFeedPost(post)
assertEquals(2, stats.wordCount)
assertFalse(stats.hasImage)
assertFalse(stats.hasLink)
}
// --- Format reading time tests ---
@Test
fun `formatReadingTime returns empty string for 0 minutes`() {
assertEquals("", PostStats.formatReadingTime(0))
}
@Test
fun `formatReadingTime returns singular for 1 minute`() {
assertEquals("1 min read", PostStats.formatReadingTime(1))
}
@Test
fun `formatReadingTime returns plural for 2 minutes`() {
assertEquals("2 min read", PostStats.formatReadingTime(2))
}
@Test
fun `formatReadingTime handles large values`() {
assertEquals("10 min read", PostStats.formatReadingTime(10))
}
@Test
fun `formatReadingTime returns empty for negative`() {
assertEquals("", PostStats.formatReadingTime(-1))
}
// --- formatComposerStats tests ---
@Test
fun `formatComposerStats for empty text`() {
assertEquals("0 chars", PostStats.formatComposerStats(""))
}
@Test
fun `formatComposerStats for single word`() {
val result = PostStats.formatComposerStats("hello")
assertTrue(result.contains("5 chars"))
assertTrue(result.contains("1 words"))
assertTrue(result.contains("~1 min read"))
}
@Test
fun `formatComposerStats for multiple words`() {
val result = PostStats.formatComposerStats("hello world foo")
assertTrue(result.contains("15 chars"))
assertTrue(result.contains("3 words"))
}
@Test
fun `formatComposerStats uses dot separator`() {
val result = PostStats.formatComposerStats("hello world")
assertTrue(result.contains(" · "))
}
// --- Character count edge cases ---
@Test
fun `charCount for empty string is 0`() {
val stats = PostStats.fromContent("")
assertEquals(0, stats.charCount)
}
@Test
fun `charCount for unicode string`() {
val stats = PostStats.fromContent("cafe\u0301")
// "cafe" + combining accent = 5 chars in Kotlin
assertEquals(5, stats.charCount)
}
@Test
fun `charCount for emoji string`() {
val text = "\uD83D\uDE00\uD83D\uDE01\uD83D\uDE02"
val stats = PostStats.fromContent(text)
// Each emoji is 2 chars (surrogate pair) in Kotlin
assertEquals(6, stats.charCount)
}
@Test
fun `charCount for string with only spaces`() {
val stats = PostStats.fromContent(" ")
assertEquals(5, stats.charCount)
}
@Test
fun `charCount for string with newlines`() {
val stats = PostStats.fromContent("hello\nworld")
assertEquals(11, stats.charCount)
}
@Test
fun `charCount matches string length`() {
val text = "A test string with 35 characters!!"
val stats = PostStats.fromContent(text)
assertEquals(text.length, stats.charCount)
}
// --- PostStats data class tests ---
@Test
fun `PostStats equality`() {
val a = PostStats(10, 50, 1, false, false)
val b = PostStats(10, 50, 1, false, false)
assertEquals(a, b)
}
@Test
fun `PostStats inequality`() {
val a = PostStats(10, 50, 1, false, false)
val b = PostStats(10, 50, 1, true, false)
assertNotEquals(a, b)
}
@Test
fun `PostStats copy`() {
val original = PostStats(10, 50, 1, false, false)
val copied = original.copy(hasImage = true)
assertEquals(10, copied.wordCount)
assertTrue(copied.hasImage)
}
}