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 package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.GhostSite 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.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostWrapper
@ -43,6 +44,21 @@ interface GhostApiService {
@GET("ghost/api/admin/site/") @GET("ghost/api/admin/site/")
suspend fun getSite(): Response<GhostSite> 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/") @GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse> 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.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import com.swoosh.microblog.data.model.FeedPost 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.ComposerScreen
import com.swoosh.microblog.ui.composer.ComposerViewModel import com.swoosh.microblog.ui.composer.ComposerViewModel
import com.swoosh.microblog.ui.detail.DetailScreen import com.swoosh.microblog.ui.detail.DetailScreen
import com.swoosh.microblog.ui.feed.FeedScreen import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.pages.PagesScreen import com.swoosh.microblog.ui.pages.PagesScreen
import com.swoosh.microblog.ui.feed.FeedViewModel 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.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.setup.SetupScreen
@ -48,6 +51,8 @@ object Routes {
const val PREVIEW = "preview" const val PREVIEW = "preview"
const val ADD_ACCOUNT = "add_account" const val ADD_ACCOUNT = "add_account"
const val PAGES = "pages" const val PAGES = "pages"
const val MEMBERS = "members"
const val MEMBER_DETAIL = "member_detail"
} }
data class BottomNavItem( data class BottomNavItem(
@ -75,6 +80,7 @@ fun SwooshNavGraph(
var selectedPost by remember { mutableStateOf<FeedPost?>(null) } var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) } var editPost by remember { mutableStateOf<FeedPost?>(null) }
var previewHtml by remember { mutableStateOf("") } var previewHtml by remember { mutableStateOf("") }
var selectedMember by remember { mutableStateOf<GhostMember?>(null) }
val feedViewModel: FeedViewModel = viewModel() val feedViewModel: FeedViewModel = viewModel()
@ -271,7 +277,11 @@ fun SwooshNavGraph(
popEnterTransition = { fadeIn(tween(200)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { fadeOut(tween(150)) } popExitTransition = { fadeOut(tween(150)) }
) { ) {
StatsScreen() StatsScreen(
onNavigateToMembers = {
navController.navigate(Routes.MEMBERS)
}
)
} }
composable( composable(
@ -317,6 +327,38 @@ fun SwooshNavGraph(
onBack = { navController.popBackStack() } 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 package com.swoosh.microblog.ui.stats
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -7,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -23,11 +21,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StatsScreen( fun StatsScreen(
viewModel: StatsViewModel = viewModel() viewModel: StatsViewModel = viewModel(),
onNavigateToMembers: (() -> Unit)? = null
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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( val animatedTotal by animateIntAsState(
targetValue = state.stats.totalPosts, targetValue = state.stats.totalPosts,
animationSpec = tween(400), animationSpec = tween(400),
@ -49,6 +48,39 @@ fun StatsScreen(
label = "scheduled" 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( Scaffold(
topBar = { topBar = {
TopAppBar( 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) { if (state.error != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( 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 @Composable
private fun StatsCard( private fun StatsCard(
modifier: Modifier = Modifier, 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 @Composable
private fun WritingStatRow(label: String, value: String) { private fun WritingStatRow(label: String, value: String) {
Row( Row(

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.OverallStats 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 com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -15,6 +17,7 @@ import kotlinx.coroutines.launch
class StatsViewModel(application: Application) : AndroidViewModel(application) { class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application) private val repository = PostRepository(application)
private val memberRepository = MemberRepository(application)
private val _uiState = MutableStateFlow(StatsUiState()) private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow() 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 uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
val stats = OverallStats.calculate(localPosts, uniqueRemotePosts) 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) { } catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) } _uiState.update { it.copy(isLoading = false, error = e.message) }
} }
@ -83,6 +97,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
data class StatsUiState( data class StatsUiState(
val stats: OverallStats = OverallStats(), val stats: OverallStats = OverallStats(),
val memberStats: MemberStats? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null 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)
}
}