mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate Phase 3 (Members API) with existing phases
This commit is contained in:
commit
7d199e9fe9
11 changed files with 2014 additions and 8 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -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("+")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue