diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 02a5651..996ade1 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -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 } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..530bb49 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -41,7 +41,8 @@ data class GhostPost( val published_at: String? = null, val custom_excerpt: String? = null, val visibility: String? = "public", - val authors: List? = null + val authors: List? = null, + val reading_time: Int? = null ) data class Author( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt new file mode 100644 index 0000000..570603d --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt @@ -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, + remotePosts: List + ): OverallStats { + // Combine all text content + val allTexts = mutableListOf() + 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 + ) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt new file mode 100644 index 0000000..3d8d95a --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt @@ -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" + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..4d4dfc7 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -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 = dao.getAllPostsList() + // --- Connectivity check --- fun isNetworkAvailable(): Boolean { diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index b26f002..5decbae 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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 ) } ) diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index bc6d465..855327c 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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( diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..c68c956 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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) + ) + } + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index a33cc63..ea32746 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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() } + ) + } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index dac1611..a993dd9 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..196fe2d --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt new file mode 100644 index 0000000..790a7c1 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt @@ -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 = _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() + 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 +) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt index 6f4f370..42be602 100644 --- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -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 --- diff --git a/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt new file mode 100644 index 0000000..a05cd8c --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt @@ -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 + ) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt new file mode 100644 index 0000000..e123783 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt @@ -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) + } +}