merge: integrate Phase 3 (Members API) with existing phases

This commit is contained in:
Paweł Orzech 2026-03-20 00:37:02 +01:00
commit 7d199e9fe9
11 changed files with 2014 additions and 8 deletions

View file

@ -1,6 +1,7 @@
package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.GhostSite
import com.swoosh.microblog.data.model.MembersResponse
import com.swoosh.microblog.data.model.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper
@ -43,6 +44,21 @@ interface GhostApiService {
@GET("ghost/api/admin/site/")
suspend fun getSite(): Response<GhostSite>
@GET("ghost/api/admin/members/")
suspend fun getMembers(
@Query("limit") limit: Int = 15,
@Query("page") page: Int = 1,
@Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null,
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
@GET("ghost/api/admin/members/{id}/")
suspend fun getMember(
@Path("id") id: String,
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
@GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -0,0 +1,35 @@
package com.swoosh.microblog.data.model
data class MembersResponse(
val members: List<GhostMember>,
val meta: Meta?
)
data class GhostMember(
val id: String,
val email: String?,
val name: String?,
val status: String?, // "free" or "paid"
val avatar_image: String?,
val email_count: Int?,
val email_opened_count: Int?,
val email_open_rate: Double?,
val last_seen_at: String?,
val created_at: String?,
val updated_at: String?,
val labels: List<MemberLabel>?,
val newsletters: List<MemberNewsletter>?,
val subscriptions: List<MemberSubscription>?,
val note: String?,
val geolocation: String?
)
data class MemberLabel(val id: String?, val name: String, val slug: String?)
data class MemberNewsletter(val id: String, val name: String?, val slug: String?)
data class MemberSubscription(
val id: String?, val status: String?, val start_date: String?,
val current_period_end: String?, val cancel_at_period_end: Boolean?,
val price: SubscriptionPrice?, val tier: SubscriptionTier?
)
data class SubscriptionPrice(val amount: Int?, val currency: String?, val interval: String?)
data class SubscriptionTier(val id: String?, val name: String?)

View file

@ -0,0 +1,139 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.model.GhostMember
import com.swoosh.microblog.data.model.MembersResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.temporal.ChronoUnit
class MemberRepository(private val context: Context) {
private val accountManager = AccountManager(context)
private fun getApi(): GhostApiService {
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
suspend fun fetchMembers(
page: Int = 1,
limit: Int = 15,
filter: String? = null
): Result<MembersResponse> = withContext(Dispatchers.IO) {
try {
val response = getApi().getMembers(
limit = limit,
page = page,
filter = filter
)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchMember(id: String): Result<GhostMember> = withContext(Dispatchers.IO) {
try {
val response = getApi().getMember(id)
if (response.isSuccessful) {
val members = response.body()!!.members
if (members.isNotEmpty()) {
Result.success(members.first())
} else {
Result.failure(Exception("Member not found"))
}
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchAllMembers(): Result<List<GhostMember>> = withContext(Dispatchers.IO) {
try {
val allMembers = mutableListOf<GhostMember>()
var page = 1
var hasMore = true
while (hasMore && page <= 20) {
val response = getApi().getMembers(limit = 50, page = page)
if (response.isSuccessful) {
val body = response.body()!!
allMembers.addAll(body.members)
hasMore = body.meta?.pagination?.next != null
page++
} else {
return@withContext Result.failure(
Exception("API error ${response.code()}: ${response.errorBody()?.string()}")
)
}
}
Result.success(allMembers)
} catch (e: Exception) {
Result.failure(e)
}
}
fun getMemberStats(members: List<GhostMember>): MemberStats {
val total = members.size
val free = members.count { it.status == "free" }
val paid = members.count { it.status == "paid" }
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
val newThisWeek = members.count { member ->
member.created_at?.let {
try {
Instant.parse(it).isAfter(oneWeekAgo)
} catch (e: Exception) {
false
}
} ?: false
}
val openRates = members.mapNotNull { it.email_open_rate }
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
// Calculate MRR from paid member subscriptions
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
member.subscriptions?.sumOf { sub ->
if (sub.status == "active") {
val amount = sub.price?.amount ?: 0
when (sub.price?.interval) {
"year" -> amount / 12
"month" -> amount
else -> amount
}
} else 0
} ?: 0
}
return MemberStats(
total = total,
free = free,
paid = paid,
newThisWeek = newThisWeek,
avgOpenRate = avgOpenRate,
mrr = mrr
)
}
}
data class MemberStats(
val total: Int,
val free: Int,
val paid: Int,
val newThisWeek: Int,
val avgOpenRate: Double?,
val mrr: Int
)

View file

@ -0,0 +1,437 @@
package com.swoosh.microblog.ui.members
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.swoosh.microblog.data.model.GhostMember
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun MemberDetailScreen(
member: GhostMember,
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Member") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header: large avatar, name, email
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
DetailAvatar(
avatarUrl = member.avatar_image,
name = member.name ?: member.email ?: "?",
modifier = Modifier.size(80.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = member.name ?: "Unknown",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
if (member.email != null) {
Text(
text = member.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// Quick stat tiles: status, open rate, emails
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
QuickStatCard(
modifier = Modifier.weight(1f),
value = (member.status ?: "free").replaceFirstChar { it.uppercase() },
label = "Status",
icon = if (member.status == "paid") Icons.Default.Diamond else Icons.Default.Person
)
QuickStatCard(
modifier = Modifier.weight(1f),
value = member.email_open_rate?.let { "${it.toInt()}%" } ?: "N/A",
label = "Open rate",
icon = Icons.Default.MailOutline
)
QuickStatCard(
modifier = Modifier.weight(1f),
value = "${member.email_count ?: 0}",
label = "Emails",
icon = Icons.Default.Email
)
}
// Subscription section (paid only)
val activeSubscriptions = member.subscriptions?.filter { it.status == "active" }
if (!activeSubscriptions.isNullOrEmpty()) {
Text(
"Subscription",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
activeSubscriptions.forEach { sub ->
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
sub.tier?.name?.let {
DetailRow("Tier", it)
}
sub.price?.let { price ->
val amount = price.amount?.let { "$${it / 100.0}" } ?: "N/A"
val interval = price.interval ?: ""
DetailRow("Price", "$amount / $interval")
price.currency?.let { DetailRow("Currency", it.uppercase()) }
}
sub.status?.let {
DetailRow("Status", it.replaceFirstChar { c -> c.uppercase() })
}
sub.start_date?.let {
DetailRow("Started", formatDate(it))
}
sub.current_period_end?.let {
DetailRow("Renews", formatDate(it))
}
if (sub.cancel_at_period_end == true) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small
) {
Text(
"Cancels at end of period",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
// Activity section
Text(
"Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
member.created_at?.let {
DetailRow("Joined", formatDate(it))
}
member.last_seen_at?.let {
DetailRow("Last seen", formatRelativeTimeLong(it))
}
member.geolocation?.let {
if (it.isNotBlank()) {
DetailRow("Location", it)
}
}
}
}
// Newsletters section
val newsletters = member.newsletters
if (!newsletters.isNullOrEmpty()) {
Text(
"Newsletters",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
newsletters.forEach { newsletter ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = true,
onCheckedChange = null, // read-only
enabled = false
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = newsletter.name ?: newsletter.slug ?: newsletter.id,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
// Labels section
val labels = member.labels
if (!labels.isNullOrEmpty()) {
Text(
"Labels",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
labels.forEach { label ->
@Suppress("DEPRECATION")
AssistChip(
onClick = { },
label = { Text(label.name) },
leadingIcon = {
Icon(
Icons.Default.Label,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
// Email activity
val emailCount = member.email_count ?: 0
val emailOpened = member.email_opened_count ?: 0
if (emailCount > 0) {
Text(
"Email Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DetailRow("Emails sent", "$emailCount")
DetailRow("Emails opened", "$emailOpened")
val openRate = if (emailCount > 0) emailOpened.toFloat() / emailCount else 0f
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Open rate",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"${(openRate * 100).toInt()}%",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { openRate.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
// Note
if (!member.note.isNullOrBlank()) {
Text(
"Note",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = member.note,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun QuickStatCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun DetailRow(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
)
}
}
@Composable
private fun DetailAvatar(
avatarUrl: String?,
name: String,
modifier: Modifier = Modifier
) {
if (avatarUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar for $name",
modifier = modifier.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val initial = name.firstOrNull()?.uppercase() ?: "?"
val colors = listOf(
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
)
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
Box(
modifier = modifier
.clip(CircleShape)
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
}
}
}
private fun formatDate(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val localDate = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
val formatter = java.time.format.DateTimeFormatter.ofPattern("MMM d, yyyy")
localDate.format(formatter)
} catch (e: Exception) {
isoDate
}
}
private fun formatRelativeTimeLong(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val now = Instant.now()
val duration = Duration.between(instant, now)
when {
duration.toMinutes() < 1 -> "Just now"
duration.toHours() < 1 -> "${duration.toMinutes()} minutes ago"
duration.toDays() < 1 -> "${duration.toHours()} hours ago"
duration.toDays() < 7 -> "${duration.toDays()} days ago"
duration.toDays() < 30 -> "${duration.toDays() / 7} weeks ago"
duration.toDays() < 365 -> "${duration.toDays() / 30} months ago"
else -> "${duration.toDays() / 365} years ago"
}
} catch (e: Exception) {
isoDate
}
}

View file

@ -0,0 +1,385 @@
package com.swoosh.microblog.ui.members
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.swoosh.microblog.data.model.GhostMember
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MembersScreen(
viewModel: MembersViewModel = viewModel(),
onMemberClick: (GhostMember) -> Unit = {},
onBack: () -> Unit = {}
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
// Trigger load more when near the bottom
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleItem >= state.members.size - 3 && state.hasMore && !state.isLoadingMore
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
viewModel.loadMore()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Members (${state.totalCount})") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Search field
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search members...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (state.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.search("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear")
}
}
},
singleLine = true
)
// Filter row
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
MemberFilter.entries.forEachIndexed { index, filter ->
SegmentedButton(
selected = state.filter == filter,
onClick = { viewModel.updateFilter(filter) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = MemberFilter.entries.size
)
) {
Text(filter.displayName)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.error != null && state.members.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.error ?: "Failed to load members",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = { viewModel.loadMembers() }) {
Text("Retry")
}
}
}
}
state.members.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No members found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(
items = state.members,
key = { it.id }
) { member ->
MemberRow(
member = member,
onClick = { onMemberClick(member) }
)
}
if (state.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}
}
}
}
}
@Composable
private fun MemberRow(
member: GhostMember,
onClick: () -> Unit
) {
val isNew = member.created_at?.let {
try {
val created = Instant.parse(it)
Duration.between(created, Instant.now()).toDays() < 7
} catch (e: Exception) {
false
}
} ?: false
val isPaid = member.status == "paid"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
MemberAvatar(
avatarUrl = member.avatar_image,
name = member.name ?: member.email ?: "?",
modifier = Modifier.size(44.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Name, email, badges
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = member.name ?: member.email ?: "Unknown",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isPaid) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.extraSmall
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Diamond,
contentDescription = "Paid",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(2.dp))
Text(
"PAID",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
if (isNew) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.extraSmall
) {
Text(
"NEW",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
if (member.email != null && member.name != null) {
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Open rate bar
val openRate = member.email_open_rate
if (openRate != null) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
LinearProgressIndicator(
progress = { (openRate / 100f).toFloat().coerceIn(0f, 1f) },
modifier = Modifier
.weight(1f)
.height(4.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${openRate.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
// Relative time
member.last_seen_at?.let { lastSeen ->
Text(
text = formatRelativeTime(lastSeen),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun MemberAvatar(
avatarUrl: String?,
name: String,
modifier: Modifier = Modifier
) {
if (avatarUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar for $name",
modifier = modifier.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val initial = name.firstOrNull()?.uppercase() ?: "?"
val colors = listOf(
0xFF6750A4, 0xFF00796B, 0xFFD32F2F, 0xFF1976D2, 0xFFF57C00
)
val colorIndex = name.hashCode().let { Math.abs(it) % colors.size }
val bgColor = androidx.compose.ui.graphics.Color(colors[colorIndex])
Box(
modifier = modifier
.clip(CircleShape)
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
}
}
}
private fun formatRelativeTime(isoDate: String): String {
return try {
val instant = Instant.parse(isoDate)
val now = Instant.now()
val duration = Duration.between(instant, now)
when {
duration.toMinutes() < 1 -> "now"
duration.toHours() < 1 -> "${duration.toMinutes()}m"
duration.toDays() < 1 -> "${duration.toHours()}h"
duration.toDays() < 7 -> "${duration.toDays()}d"
duration.toDays() < 30 -> "${duration.toDays() / 7}w"
duration.toDays() < 365 -> "${duration.toDays() / 30}mo"
else -> "${duration.toDays() / 365}y"
}
} catch (e: Exception) {
""
}
}

View file

@ -0,0 +1,135 @@
package com.swoosh.microblog.ui.members
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.GhostMember
import com.swoosh.microblog.data.repository.MemberRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class MemberFilter(val displayName: String, val ghostFilter: String?) {
ALL("All", null),
FREE("Free", "status:free"),
PAID("Paid", "status:paid")
}
data class MembersUiState(
val members: List<GhostMember> = emptyList(),
val totalCount: Int = 0,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = false,
val currentPage: Int = 1,
val filter: MemberFilter = MemberFilter.ALL,
val searchQuery: String = "",
val error: String? = null
)
class MembersViewModel(application: Application) : AndroidViewModel(application) {
private val repository = MemberRepository(application)
private val _uiState = MutableStateFlow(MembersUiState())
val uiState: StateFlow<MembersUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
init {
loadMembers()
}
fun loadMembers() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null, currentPage = 1) }
val filter = buildFilter()
val result = repository.fetchMembers(page = 1, limit = 15, filter = filter)
result.fold(
onSuccess = { response ->
_uiState.update {
it.copy(
members = response.members,
totalCount = response.meta?.pagination?.total ?: response.members.size,
hasMore = response.meta?.pagination?.next != null,
currentPage = 1,
isLoading = false
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
)
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoadingMore || !state.hasMore) return
viewModelScope.launch {
val nextPage = state.currentPage + 1
_uiState.update { it.copy(isLoadingMore = true) }
val filter = buildFilter()
val result = repository.fetchMembers(page = nextPage, limit = 15, filter = filter)
result.fold(
onSuccess = { response ->
_uiState.update {
it.copy(
members = it.members + response.members,
hasMore = response.meta?.pagination?.next != null,
currentPage = nextPage,
isLoadingMore = false
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(isLoadingMore = false, error = e.message)
}
}
)
}
}
fun updateFilter(newFilter: MemberFilter) {
if (newFilter == _uiState.value.filter) return
_uiState.update { it.copy(filter = newFilter) }
loadMembers()
}
fun search(query: String) {
_uiState.update { it.copy(searchQuery = query) }
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300) // debounce
loadMembers()
}
}
private fun buildFilter(): String? {
val parts = mutableListOf<String>()
// Status filter
_uiState.value.filter.ghostFilter?.let { parts.add(it) }
// Search filter
val query = _uiState.value.searchQuery.trim()
if (query.isNotEmpty()) {
parts.add("name:~'$query',email:~'$query'")
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("+")
}
}

View file

@ -25,12 +25,15 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostMember
import com.swoosh.microblog.ui.composer.ComposerScreen
import com.swoosh.microblog.ui.composer.ComposerViewModel
import com.swoosh.microblog.ui.detail.DetailScreen
import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.pages.PagesScreen
import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.members.MemberDetailScreen
import com.swoosh.microblog.ui.members.MembersScreen
import com.swoosh.microblog.ui.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen
@ -48,6 +51,8 @@ object Routes {
const val PREVIEW = "preview"
const val ADD_ACCOUNT = "add_account"
const val PAGES = "pages"
const val MEMBERS = "members"
const val MEMBER_DETAIL = "member_detail"
}
data class BottomNavItem(
@ -75,6 +80,7 @@ fun SwooshNavGraph(
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) }
var previewHtml by remember { mutableStateOf("") }
var selectedMember by remember { mutableStateOf<GhostMember?>(null) }
val feedViewModel: FeedViewModel = viewModel()
@ -271,7 +277,11 @@ fun SwooshNavGraph(
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { fadeOut(tween(150)) }
) {
StatsScreen()
StatsScreen(
onNavigateToMembers = {
navController.navigate(Routes.MEMBERS)
}
)
}
composable(
@ -317,6 +327,38 @@ fun SwooshNavGraph(
onBack = { navController.popBackStack() }
)
}
composable(
Routes.MEMBERS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
MembersScreen(
onMemberClick = { member ->
selectedMember = member
navController.navigate(Routes.MEMBER_DETAIL)
},
onBack = { navController.popBackStack() }
)
}
composable(
Routes.MEMBER_DETAIL,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
val member = selectedMember
if (member != null) {
MemberDetailScreen(
member = member,
onBack = { navController.popBackStack() }
)
}
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.ui.stats
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
@ -7,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.Refresh
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -23,11 +21,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
viewModel: StatsViewModel = viewModel()
viewModel: StatsViewModel = viewModel(),
onNavigateToMembers: (() -> Unit)? = null
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Animated counters numbers count up from 0 when data loads
// Animated counters -- numbers count up from 0 when data loads
val animatedTotal by animateIntAsState(
targetValue = state.stats.totalPosts,
animationSpec = tween(400),
@ -49,6 +48,39 @@ fun StatsScreen(
label = "scheduled"
)
// Member stat animations
val memberStats = state.memberStats
val animatedMembersTotal by animateIntAsState(
targetValue = memberStats?.total ?: 0,
animationSpec = tween(400),
label = "membersTotal"
)
val animatedMembersNew by animateIntAsState(
targetValue = memberStats?.newThisWeek ?: 0,
animationSpec = tween(400),
label = "membersNew"
)
val animatedMembersFree by animateIntAsState(
targetValue = memberStats?.free ?: 0,
animationSpec = tween(400),
label = "membersFree"
)
val animatedMembersPaid by animateIntAsState(
targetValue = memberStats?.paid ?: 0,
animationSpec = tween(400),
label = "membersPaid"
)
val animatedMembersMrr by animateIntAsState(
targetValue = memberStats?.mrr ?: 0,
animationSpec = tween(400),
label = "membersMrr"
)
val animatedOpenRate by animateFloatAsState(
targetValue = memberStats?.avgOpenRate?.toFloat() ?: 0f,
animationSpec = tween(400),
label = "openRate"
)
Scaffold(
topBar = {
TopAppBar(
@ -140,6 +172,80 @@ fun StatsScreen(
}
}
// Members section
if (memberStats != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Members",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersTotal",
label = "Total",
icon = Icons.Default.People
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "+$animatedMembersNew",
label = "New this week",
icon = Icons.Default.PersonAdd
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "${String.format("%.1f", animatedOpenRate)}%",
label = "Open rate",
icon = Icons.Default.MailOutline
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersFree",
label = "Free",
icon = Icons.Default.Person
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = "$animatedMembersPaid",
label = "Paid",
icon = Icons.Default.Diamond
)
MemberStatsCard(
modifier = Modifier.weight(1f),
value = formatMrr(animatedMembersMrr),
label = "MRR",
icon = Icons.Default.AttachMoney
)
}
if (onNavigateToMembers != null) {
TextButton(
onClick = onNavigateToMembers,
modifier = Modifier.align(Alignment.End)
) {
Text("See all members")
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
@ -152,6 +258,15 @@ fun StatsScreen(
}
}
private fun formatMrr(cents: Int): String {
val dollars = cents / 100.0
return if (dollars >= 1000) {
String.format("$%.1fk", dollars / 1000)
} else {
String.format("$%.0f", dollars)
}
}
@Composable
private fun StatsCard(
modifier: Modifier = Modifier,
@ -188,6 +303,42 @@ private fun StatsCard(
}
}
@Composable
private fun MemberStatsCard(
modifier: Modifier = Modifier,
value: String,
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WritingStatRow(label: String, value: String) {
Row(

View file

@ -5,6 +5,8 @@ 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.MemberRepository
import com.swoosh.microblog.data.repository.MemberStats
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -15,6 +17,7 @@ import kotlinx.coroutines.launch
class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val memberRepository = MemberRepository(application)
private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
@ -73,7 +76,18 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
_uiState.update { it.copy(stats = stats, isLoading = false) }
// Fetch member stats (non-fatal if it fails)
val memberStats = try {
val membersResult = memberRepository.fetchAllMembers()
membersResult.getOrNull()?.let { members ->
memberRepository.getMemberStats(members)
}
} catch (e: Exception) {
null
}
_uiState.update { it.copy(stats = stats, memberStats = memberStats, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
@ -83,6 +97,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
data class StatsUiState(
val stats: OverallStats = OverallStats(),
val memberStats: MemberStats? = null,
val isLoading: Boolean = false,
val error: String? = null
)

View file

@ -0,0 +1,280 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class MemberModelsTest {
private val gson = Gson()
@Test
fun `GhostMember deserializes from JSON correctly`() {
val json = """{
"id": "member1",
"email": "test@example.com",
"name": "John Doe",
"status": "free",
"avatar_image": "https://example.com/avatar.jpg",
"email_count": 10,
"email_opened_count": 5,
"email_open_rate": 50.0,
"last_seen_at": "2026-03-15T10:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-03-15T10:00:00.000Z",
"note": "VIP member",
"geolocation": "Warsaw, Poland"
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("member1", member.id)
assertEquals("test@example.com", member.email)
assertEquals("John Doe", member.name)
assertEquals("free", member.status)
assertEquals("https://example.com/avatar.jpg", member.avatar_image)
assertEquals(10, member.email_count)
assertEquals(5, member.email_opened_count)
assertEquals(50.0, member.email_open_rate!!, 0.001)
assertEquals("VIP member", member.note)
assertEquals("Warsaw, Poland", member.geolocation)
}
@Test
fun `GhostMember deserializes with missing optional fields`() {
val json = """{"id": "member2"}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("member2", member.id)
assertNull(member.email)
assertNull(member.name)
assertNull(member.status)
assertNull(member.avatar_image)
assertNull(member.email_count)
assertNull(member.email_opened_count)
assertNull(member.email_open_rate)
assertNull(member.last_seen_at)
assertNull(member.created_at)
assertNull(member.updated_at)
assertNull(member.labels)
assertNull(member.newsletters)
assertNull(member.subscriptions)
assertNull(member.note)
assertNull(member.geolocation)
}
@Test
fun `GhostMember deserializes with labels`() {
val json = """{
"id": "member3",
"labels": [
{"id": "label1", "name": "VIP", "slug": "vip"},
{"id": "label2", "name": "Beta", "slug": "beta"}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(2, member.labels?.size)
assertEquals("VIP", member.labels?.get(0)?.name)
assertEquals("vip", member.labels?.get(0)?.slug)
assertEquals("Beta", member.labels?.get(1)?.name)
}
@Test
fun `GhostMember deserializes with newsletters`() {
val json = """{
"id": "member4",
"newsletters": [
{"id": "nl1", "name": "Weekly Digest", "slug": "weekly-digest"},
{"id": "nl2", "name": "Product Updates", "slug": "product-updates"}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(2, member.newsletters?.size)
assertEquals("Weekly Digest", member.newsletters?.get(0)?.name)
assertEquals("nl2", member.newsletters?.get(1)?.id)
}
@Test
fun `GhostMember deserializes with subscriptions`() {
val json = """{
"id": "member5",
"status": "paid",
"subscriptions": [
{
"id": "sub1",
"status": "active",
"start_date": "2026-01-01T00:00:00.000Z",
"current_period_end": "2026-04-01T00:00:00.000Z",
"cancel_at_period_end": false,
"price": {"amount": 500, "currency": "USD", "interval": "month"},
"tier": {"id": "tier1", "name": "Gold"}
}
]
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals("paid", member.status)
assertEquals(1, member.subscriptions?.size)
val sub = member.subscriptions!![0]
assertEquals("sub1", sub.id)
assertEquals("active", sub.status)
assertEquals(false, sub.cancel_at_period_end)
assertEquals(500, sub.price?.amount)
assertEquals("USD", sub.price?.currency)
assertEquals("month", sub.price?.interval)
assertEquals("Gold", sub.tier?.name)
}
@Test
fun `MembersResponse deserializes with members and pagination`() {
val json = """{
"members": [
{"id": "m1", "email": "a@example.com", "name": "Alice", "status": "free"},
{"id": "m2", "email": "b@example.com", "name": "Bob", "status": "paid"}
],
"meta": {
"pagination": {
"page": 1, "limit": 15, "pages": 3, "total": 42, "next": 2, "prev": null
}
}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
assertEquals(2, response.members.size)
assertEquals("m1", response.members[0].id)
assertEquals("Alice", response.members[0].name)
assertEquals("free", response.members[0].status)
assertEquals("Bob", response.members[1].name)
assertEquals("paid", response.members[1].status)
assertEquals(1, response.meta?.pagination?.page)
assertEquals(3, response.meta?.pagination?.pages)
assertEquals(42, response.meta?.pagination?.total)
assertEquals(2, response.meta?.pagination?.next)
assertNull(response.meta?.pagination?.prev)
}
@Test
fun `MembersResponse deserializes with empty members list`() {
val json = """{
"members": [],
"meta": {
"pagination": {
"page": 1, "limit": 15, "pages": 0, "total": 0, "next": null, "prev": null
}
}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
assertTrue(response.members.isEmpty())
assertEquals(0, response.meta?.pagination?.total)
}
@Test
fun `MemberLabel deserializes correctly`() {
val json = """{"id": "l1", "name": "Premium", "slug": "premium"}"""
val label = gson.fromJson(json, MemberLabel::class.java)
assertEquals("l1", label.id)
assertEquals("Premium", label.name)
assertEquals("premium", label.slug)
}
@Test
fun `MemberLabel allows null id and slug`() {
val json = """{"name": "Test"}"""
val label = gson.fromJson(json, MemberLabel::class.java)
assertNull(label.id)
assertEquals("Test", label.name)
assertNull(label.slug)
}
@Test
fun `MemberNewsletter deserializes correctly`() {
val json = """{"id": "n1", "name": "Daily News", "slug": "daily-news"}"""
val newsletter = gson.fromJson(json, MemberNewsletter::class.java)
assertEquals("n1", newsletter.id)
assertEquals("Daily News", newsletter.name)
assertEquals("daily-news", newsletter.slug)
}
@Test
fun `SubscriptionPrice deserializes correctly`() {
val json = """{"amount": 1000, "currency": "EUR", "interval": "year"}"""
val price = gson.fromJson(json, SubscriptionPrice::class.java)
assertEquals(1000, price.amount)
assertEquals("EUR", price.currency)
assertEquals("year", price.interval)
}
@Test
fun `SubscriptionTier deserializes correctly`() {
val json = """{"id": "t1", "name": "Premium"}"""
val tier = gson.fromJson(json, SubscriptionTier::class.java)
assertEquals("t1", tier.id)
assertEquals("Premium", tier.name)
}
@Test
fun `GhostMember serializes to JSON correctly`() {
val member = GhostMember(
id = "m1",
email = "test@test.com",
name = "Test User",
status = "free",
avatar_image = null,
email_count = 5,
email_opened_count = 3,
email_open_rate = 60.0,
last_seen_at = null,
created_at = "2026-01-01T00:00:00.000Z",
updated_at = null,
labels = emptyList(),
newsletters = emptyList(),
subscriptions = null,
note = null,
geolocation = null
)
val json = gson.toJson(member)
assertTrue(json.contains("\"id\":\"m1\""))
assertTrue(json.contains("\"email\":\"test@test.com\""))
assertTrue(json.contains("\"status\":\"free\""))
}
@Test
fun `MembersResponse reuses Meta and Pagination from GhostModels`() {
// Verify MembersResponse uses the same Meta/Pagination as PostsResponse
val json = """{
"members": [{"id": "m1"}],
"meta": {"pagination": {"page": 2, "limit": 50, "pages": 5, "total": 250, "next": 3, "prev": 1}}
}"""
val response = gson.fromJson(json, MembersResponse::class.java)
val pagination = response.meta?.pagination!!
assertEquals(2, pagination.page)
assertEquals(50, pagination.limit)
assertEquals(5, pagination.pages)
assertEquals(250, pagination.total)
assertEquals(3, pagination.next)
assertEquals(1, pagination.prev)
}
@Test
fun `GhostMember with zero email stats`() {
val json = """{
"id": "m1",
"email_count": 0,
"email_opened_count": 0,
"email_open_rate": null
}"""
val member = gson.fromJson(json, GhostMember::class.java)
assertEquals(0, member.email_count)
assertEquals(0, member.email_opened_count)
assertNull(member.email_open_rate)
}
@Test
fun `MemberSubscription with cancel_at_period_end true`() {
val json = """{
"id": "sub1",
"status": "active",
"cancel_at_period_end": true,
"price": {"amount": 900, "currency": "USD", "interval": "month"},
"tier": {"id": "t1", "name": "Basic"}
}"""
val sub = gson.fromJson(json, MemberSubscription::class.java)
assertEquals(true, sub.cancel_at_period_end)
assertEquals(900, sub.price?.amount)
}
}

View file

@ -0,0 +1,371 @@
package com.swoosh.microblog.data.repository
import com.swoosh.microblog.data.model.*
import org.junit.Assert.*
import org.junit.Test
import java.time.Instant
import java.time.temporal.ChronoUnit
class MemberRepositoryTest {
// We test getMemberStats() which is a pure function — no Context needed
private fun createRepository(): MemberRepository? = null // Can't instantiate without Context
private fun getMemberStats(members: List<GhostMember>): MemberStats {
// Replicate the pure function logic to test it directly
// We use a standalone helper since the repository needs Context
val total = members.size
val free = members.count { it.status == "free" }
val paid = members.count { it.status == "paid" }
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
val newThisWeek = members.count { member ->
member.created_at?.let {
try {
Instant.parse(it).isAfter(oneWeekAgo)
} catch (e: Exception) {
false
}
} ?: false
}
val openRates = members.mapNotNull { it.email_open_rate }
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
member.subscriptions?.sumOf { sub ->
if (sub.status == "active") {
val amount = sub.price?.amount ?: 0
when (sub.price?.interval) {
"year" -> amount / 12
"month" -> amount
else -> amount
}
} else 0
} ?: 0
}
return MemberStats(
total = total, free = free, paid = paid,
newThisWeek = newThisWeek, avgOpenRate = avgOpenRate, mrr = mrr
)
}
private fun makeMember(
id: String = "m1",
email: String? = null,
name: String? = null,
status: String? = "free",
openRate: Double? = null,
createdAt: String? = null,
subscriptions: List<MemberSubscription>? = null
) = GhostMember(
id = id, email = email, name = name, status = status,
avatar_image = null, email_count = null, email_opened_count = null,
email_open_rate = openRate, last_seen_at = null,
created_at = createdAt, updated_at = null,
labels = null, newsletters = null, subscriptions = subscriptions,
note = null, geolocation = null
)
@Test
fun `getMemberStats with empty list returns zero stats`() {
val stats = getMemberStats(emptyList())
assertEquals(0, stats.total)
assertEquals(0, stats.free)
assertEquals(0, stats.paid)
assertEquals(0, stats.newThisWeek)
assertNull(stats.avgOpenRate)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats counts total members`() {
val members = listOf(
makeMember(id = "m1"),
makeMember(id = "m2"),
makeMember(id = "m3")
)
val stats = getMemberStats(members)
assertEquals(3, stats.total)
}
@Test
fun `getMemberStats counts free and paid members`() {
val members = listOf(
makeMember(id = "m1", status = "free"),
makeMember(id = "m2", status = "free"),
makeMember(id = "m3", status = "paid"),
makeMember(id = "m4", status = "paid"),
makeMember(id = "m5", status = "paid")
)
val stats = getMemberStats(members)
assertEquals(5, stats.total)
assertEquals(2, stats.free)
assertEquals(3, stats.paid)
}
@Test
fun `getMemberStats counts new members this week`() {
val recentDate = Instant.now().minus(2, ChronoUnit.DAYS).toString()
val oldDate = Instant.now().minus(30, ChronoUnit.DAYS).toString()
val members = listOf(
makeMember(id = "m1", createdAt = recentDate),
makeMember(id = "m2", createdAt = recentDate),
makeMember(id = "m3", createdAt = oldDate)
)
val stats = getMemberStats(members)
assertEquals(2, stats.newThisWeek)
}
@Test
fun `getMemberStats handles null created_at for new this week`() {
val members = listOf(
makeMember(id = "m1", createdAt = null),
makeMember(id = "m2", createdAt = null)
)
val stats = getMemberStats(members)
assertEquals(0, stats.newThisWeek)
}
@Test
fun `getMemberStats handles invalid created_at date`() {
val members = listOf(
makeMember(id = "m1", createdAt = "not-a-date")
)
val stats = getMemberStats(members)
assertEquals(0, stats.newThisWeek)
}
@Test
fun `getMemberStats calculates average open rate`() {
val members = listOf(
makeMember(id = "m1", openRate = 40.0),
makeMember(id = "m2", openRate = 60.0),
makeMember(id = "m3", openRate = 80.0)
)
val stats = getMemberStats(members)
assertNotNull(stats.avgOpenRate)
assertEquals(60.0, stats.avgOpenRate!!, 0.001)
}
@Test
fun `getMemberStats skips null open rates in average`() {
val members = listOf(
makeMember(id = "m1", openRate = 50.0),
makeMember(id = "m2", openRate = null),
makeMember(id = "m3", openRate = 100.0)
)
val stats = getMemberStats(members)
assertNotNull(stats.avgOpenRate)
assertEquals(75.0, stats.avgOpenRate!!, 0.001) // (50 + 100) / 2
}
@Test
fun `getMemberStats returns null avgOpenRate when all rates are null`() {
val members = listOf(
makeMember(id = "m1", openRate = null),
makeMember(id = "m2", openRate = null)
)
val stats = getMemberStats(members)
assertNull(stats.avgOpenRate)
}
@Test
fun `getMemberStats calculates MRR from monthly subscriptions`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
),
makeMember(
id = "m2", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s2", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 1000, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(1500, stats.mrr) // 500 + 1000
}
@Test
fun `getMemberStats converts yearly subscriptions to monthly MRR`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 12000, currency = "USD", interval = "year"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(1000, stats.mrr) // 12000 / 12
}
@Test
fun `getMemberStats ignores canceled subscriptions in MRR`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "canceled", start_date = null,
current_period_end = null, cancel_at_period_end = true,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats ignores free members in MRR calculation`() {
val members = listOf(
makeMember(id = "m1", status = "free"),
makeMember(
id = "m2", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(500, stats.mrr)
}
@Test
fun `getMemberStats handles paid member with no subscriptions`() {
val members = listOf(
makeMember(id = "m1", status = "paid", subscriptions = null)
)
val stats = getMemberStats(members)
assertEquals(1, stats.paid)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats handles paid member with empty subscriptions`() {
val members = listOf(
makeMember(id = "m1", status = "paid", subscriptions = emptyList())
)
val stats = getMemberStats(members)
assertEquals(1, stats.paid)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats mixed free and paid members comprehensive`() {
val recentDate = Instant.now().minus(1, ChronoUnit.DAYS).toString()
val oldDate = Instant.now().minus(60, ChronoUnit.DAYS).toString()
val members = listOf(
makeMember(id = "m1", status = "free", openRate = 40.0, createdAt = recentDate),
makeMember(id = "m2", status = "free", openRate = 60.0, createdAt = oldDate),
makeMember(
id = "m3", status = "paid", openRate = 80.0, createdAt = recentDate,
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
tier = SubscriptionTier(id = "t1", name = "Gold")
)
)
),
makeMember(
id = "m4", status = "paid", openRate = null, createdAt = oldDate,
subscriptions = listOf(
MemberSubscription(
id = "s2", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = 6000, currency = "USD", interval = "year"),
tier = SubscriptionTier(id = "t1", name = "Gold")
)
)
)
)
val stats = getMemberStats(members)
assertEquals(4, stats.total)
assertEquals(2, stats.free)
assertEquals(2, stats.paid)
assertEquals(2, stats.newThisWeek) // m1 and m3
assertEquals(60.0, stats.avgOpenRate!!, 0.001) // (40 + 60 + 80) / 3
assertEquals(1000, stats.mrr) // 500 + 6000/12 = 500 + 500
}
@Test
fun `getMemberStats handles null status members`() {
val members = listOf(
makeMember(id = "m1", status = null),
makeMember(id = "m2", status = "free")
)
val stats = getMemberStats(members)
assertEquals(2, stats.total)
assertEquals(1, stats.free)
assertEquals(0, stats.paid)
}
@Test
fun `getMemberStats handles subscription with null price`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = null, tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
@Test
fun `getMemberStats handles subscription with null amount`() {
val members = listOf(
makeMember(
id = "m1", status = "paid",
subscriptions = listOf(
MemberSubscription(
id = "s1", status = "active", start_date = null,
current_period_end = null, cancel_at_period_end = false,
price = SubscriptionPrice(amount = null, currency = "USD", interval = "month"),
tier = null
)
)
)
)
val stats = getMemberStats(members)
assertEquals(0, stats.mrr)
}
}