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 package com.swoosh.microblog.ui.members
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.swoosh.microblog.data.model.GhostMember import com.swoosh.microblog.data.model.GhostMember
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun MemberDetailScreen( fun MemberDetailScreen(
member: GhostMember, member: GhostMember,
@ -19,7 +33,7 @@ fun MemberDetailScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(member.name ?: member.email ?: "Member") }, title = { Text("Member") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -28,13 +42,396 @@ fun MemberDetailScreen(
) )
} }
) { padding -> ) { padding ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
contentAlignment = Alignment.Center .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
}
}