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