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.
This commit is contained in:
Paweł Orzech 2026-03-20 00:32:45 +01:00
parent e99d88e10a
commit afa0005a47
4 changed files with 603 additions and 1 deletions

View file

@ -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...")
}
}
}

View file

@ -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) {
""
}
}

View file

@ -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<GhostMember> = 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<MembersUiState> = _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<String>()
// 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("+")
}
}

View file

@ -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<FeedPost?>(null) }
var editPost by remember { mutableStateOf<FeedPost?>(null) }
var previewHtml by remember { mutableStateOf("") }
var selectedMember by remember { mutableStateOf<GhostMember?>(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() }
)
}
}
}
}
}