From 689b8cc8c26464d2c14ae2ab60058fdac231958b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:26:32 +0100 Subject: [PATCH 1/5] feat: add Member model, API endpoints, and model parsing tests Add MemberModels.kt with GhostMember, MemberLabel, MemberNewsletter, MemberSubscription, SubscriptionPrice, and SubscriptionTier data classes. Add getMembers() and getMember() endpoints to GhostApiService. Add comprehensive JSON parsing tests for all member model types. --- .../microblog/data/api/GhostApiService.kt | 16 + .../microblog/data/model/MemberModels.kt | 35 +++ .../microblog/data/model/MemberModelsTest.kt | 280 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt 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 cd41155..75e0af2 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,5 +1,6 @@ package com.swoosh.microblog.data.api +import com.swoosh.microblog.data.model.MembersResponse import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostsResponse import okhttp3.MultipartBody @@ -37,6 +38,21 @@ interface GhostApiService { @Path("id") id: String ): 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/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) + } +} From 64a573a95ce375bc835d0a65a6387651140b10ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:28:10 +0100 Subject: [PATCH 2/5] feat: add MemberRepository with fetchMembers, fetchAllMembers, and getMemberStats MemberRepository follows the same pattern as PostRepository: Context constructor, AccountManager, ApiClient. Includes fetchMembers (paged), fetchMember (single), fetchAllMembers (all pages, max 20), and getMemberStats (pure function computing total/free/paid/newThisWeek/avgOpenRate/MRR). Comprehensive tests for getMemberStats. --- .../data/repository/MemberRepository.kt | 139 +++++++ .../data/repository/MemberRepositoryTest.kt | 371 ++++++++++++++++++ 2 files changed, 510 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/repository/MemberRepository.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/repository/MemberRepositoryTest.kt 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/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) + } +} From e99d88e10afe1b263866ddb7343a984d57593be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:29:50 +0100 Subject: [PATCH 3/5] feat: show member stats tiles in Stats screen with animated counters StatsViewModel now fetches members via MemberRepository and computes MemberStats. StatsScreen shows a 2x3 grid of ElevatedCard tiles when memberStats is available: Total, New this week, Open rate, Free, Paid, MRR. Includes animated counters and a "See all members" navigation button. Member fetch failure is non-fatal (tiles simply hidden). --- .../swoosh/microblog/ui/stats/StatsScreen.kt | 163 +++++++++++++++++- .../microblog/ui/stats/StatsViewModel.kt | 17 +- 2 files changed, 173 insertions(+), 7 deletions(-) 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 ) From afa0005a47a83ecdcbcf106a7ecd8822997d83ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:32:45 +0100 Subject: [PATCH 4/5] feat: add Members list screen with search, filter, pagination, and nav routes MembersViewModel manages members list state with loading, pagination, filter (All/Free/Paid), and debounced search. MembersScreen shows TopAppBar with total count, search field, segmented filter buttons, and LazyColumn with member rows (avatar via Coil or colored initial, name, email, open rate progress bar, relative time, PAID/NEW badges). Add Routes.MEMBERS and Routes.MEMBER_DETAIL to NavGraph (not in bottomBarRoutes). Wire "See all members" button from Stats screen. --- .../ui/members/MemberDetailScreen.kt | 40 ++ .../microblog/ui/members/MembersScreen.kt | 385 ++++++++++++++++++ .../microblog/ui/members/MembersViewModel.kt | 135 ++++++ .../microblog/ui/navigation/NavGraph.kt | 44 +- 4 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/members/MembersScreen.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/members/MembersViewModel.kt 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..0249175 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt @@ -0,0 +1,40 @@ +package com.swoosh.microblog.ui.members + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.swoosh.microblog.data.model.GhostMember + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberDetailScreen( + member: GhostMember, + onBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(member.name ?: member.email ?: "Member") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Loading...") + } + } +} 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 bb4c93f..acf9ee4 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,11 +25,14 @@ 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.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 @@ -46,6 +49,8 @@ object Routes { const val STATS = "stats" const val PREVIEW = "preview" const val ADD_ACCOUNT = "add_account" + const val MEMBERS = "members" + const val MEMBER_DETAIL = "member_detail" } data class BottomNavItem( @@ -73,6 +78,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() @@ -266,7 +272,11 @@ fun SwooshNavGraph( popEnterTransition = { fadeIn(tween(200)) }, popExitTransition = { fadeOut(tween(150)) } ) { - StatsScreen() + StatsScreen( + onNavigateToMembers = { + navController.navigate(Routes.MEMBERS) + } + ) } composable( @@ -300,6 +310,38 @@ fun SwooshNavGraph( } ) } + + 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() } + ) + } + } } } } From 33647d41d6db800989e6f25a9ac68f9f2fd11270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:35:01 +0100 Subject: [PATCH 5/5] feat: add Member detail screen with profile, subscriptions, activity, and labels MemberDetailScreen shows scrollable profile: large avatar header with name and email, 3 quick stat tiles (status, open rate, emails sent), subscription details for paid members (tier, price, renewal date, cancellation status), activity section (joined date, last seen, geolocation), newsletters list with read-only checkboxes, labels as FlowRow of AssistChips, email activity with open rate progress bar, and member notes. --- .../ui/members/MemberDetailScreen.kt | 409 +++++++++++++++++- 1 file changed, 403 insertions(+), 6 deletions(-) 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 index 0249175..05f1983 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt @@ -1,16 +1,30 @@ 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) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun MemberDetailScreen( member: GhostMember, @@ -19,7 +33,7 @@ fun MemberDetailScreen( Scaffold( topBar = { TopAppBar( - title = { Text(member.name ?: member.email ?: "Member") }, + title = { Text("Member") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -28,13 +42,396 @@ fun MemberDetailScreen( ) } ) { padding -> - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("Loading...") + // 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 + } +}