mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
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:
parent
e99d88e10a
commit
afa0005a47
4 changed files with 603 additions and 1 deletions
|
|
@ -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...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -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("+")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue