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 com.swoosh.microblog.data.model.LocalPost
|
||||
import com.swoosh.microblog.data.model.PostStatus
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
|
@ -39,4 +40,13 @@ interface LocalPostDao {
|
|||
|
||||
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
|
||||
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 custom_excerpt: String? = null,
|
||||
val visibility: String? = "public",
|
||||
val authors: List<Author>? = null
|
||||
val authors: List<Author>? = null,
|
||||
val reading_time: Int? = null
|
||||
)
|
||||
|
||||
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) =
|
||||
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 ---
|
||||
|
||||
fun isNetworkAvailable(): Boolean {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
|
@ -90,12 +91,17 @@ fun ComposerScreen(
|
|||
.heightIn(min = 150.dp),
|
||||
placeholder = { Text("What's on your mind?") },
|
||||
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(
|
||||
"${state.text.length} characters",
|
||||
text = statsText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (state.text.length > 280)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = color
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
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.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.filled.AccessTime
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
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.formatRelativeTime
|
||||
|
||||
|
|
@ -130,18 +142,9 @@ fun DetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
// Stats section
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
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() })
|
||||
PostStatsSection(post)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
private fun MetadataRow(label: String, value: String) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Settings
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
|
|
@ -27,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
|
||||
@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.settings.SettingsScreen
|
||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||
import com.swoosh.microblog.ui.stats.StatsScreen
|
||||
|
||||
object Routes {
|
||||
const val SETUP = "setup"
|
||||
|
|
@ -20,6 +21,7 @@ object Routes {
|
|||
const val COMPOSER = "composer"
|
||||
const val DETAIL = "detail"
|
||||
const val SETTINGS = "settings"
|
||||
const val STATS = "stats"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -96,8 +98,17 @@ fun SwooshNavGraph(
|
|||
navController.navigate(Routes.SETUP) {
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -18,7 +19,8 @@ import com.swoosh.microblog.data.api.ApiClient
|
|||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
onLogout: () -> Unit,
|
||||
onStatsClick: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val credentials = remember { CredentialsManager(context) }
|
||||
|
|
@ -91,7 +93,25 @@ fun SettingsScreen(
|
|||
}
|
||||
|
||||
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))
|
||||
|
||||
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.custom_excerpt)
|
||||
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 ---
|
||||
|
|
|
|||
|
|
@ -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