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