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] 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() } + ) + } + } } } }