diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index 70f529e..28a28e2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -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 + @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 + + @GET("ghost/api/admin/members/{id}/") + suspend fun getMember( + @Path("id") id: String, + @Query("include") include: String = "newsletters,labels" + ): Response + @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response diff --git a/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt new file mode 100644 index 0000000..bab6ceb --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt @@ -0,0 +1,35 @@ +package com.swoosh.microblog.data.model + +data class MembersResponse( + val members: List, + 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?, + val newsletters: List?, + val subscriptions: List?, + 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?) diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/MemberRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/MemberRepository.kt new file mode 100644 index 0000000..d7cafa3 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/MemberRepository.kt @@ -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 = 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 = 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> = withContext(Dispatchers.IO) { + try { + val allMembers = mutableListOf() + 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): 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 +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt new file mode 100644 index 0000000..05f1983 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt @@ -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 + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/members/MembersScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/members/MembersScreen.kt new file mode 100644 index 0000000..af66e46 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/members/MembersScreen.kt @@ -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) { + "" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/members/MembersViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/members/MembersViewModel.kt new file mode 100644 index 0000000..25b0d0e --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/members/MembersViewModel.kt @@ -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 = 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 = _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() + + // 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("+") + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index 8325ee4..5c90ae6 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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(null) } var editPost by remember { mutableStateOf(null) } var previewHtml by remember { mutableStateOf("") } + var selectedMember by remember { mutableStateOf(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() } + ) + } + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt index 457eb87..296208b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt @@ -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( diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt index 790a7c1..fbd5456 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt @@ -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 = _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 ) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt new file mode 100644 index 0000000..f624544 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/repository/MemberRepositoryTest.kt b/app/src/test/java/com/swoosh/microblog/data/repository/MemberRepositoryTest.kt new file mode 100644 index 0000000..435f3c1 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/repository/MemberRepositoryTest.kt @@ -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): 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? = 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) + } +}