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.
This commit is contained in:
Paweł Orzech 2026-03-20 00:35:01 +01:00
parent afa0005a47
commit 33647d41d6

View file

@ -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
}
}