mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +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
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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