mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add post statistics with word count, reading time, and writing stats screen
This commit is contained in:
parent
74f42fd2f1
commit
c24b2f7fa7
15 changed files with 1225 additions and 18 deletions
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
195
app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
Normal file
195
app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue