package com.parseable.android.ui.screens.streams import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun StreamsScreen( onStreamClick: (String) -> Unit, onAlertsClick: () -> Unit, onSettingsClick: () -> Unit, onLogout: () -> Unit, viewModel: StreamsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showLogoutConfirmation by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } val snackbarHostState = remember { SnackbarHostState() } val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) { val favorites = state.favoriteNames val filtered = if (searchQuery.isBlank()) { state.streams } else { state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) } } // Partition by favorites instead of full sort for O(n) vs O(n log n) val (favs, rest) = filtered.partition { it.name in favorites } favs + rest } val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.refresh() } } LaunchedEffect(Unit) { viewModel.snackbarEvent.collectLatest { message -> snackbarHostState.showSnackbar( message = message, duration = SnackbarDuration.Short, ) } } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Column { Text("Parseable", fontWeight = FontWeight.Bold) if (state.aboutInfo != null) { Text( text = "v${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, actions = { IconButton(onClick = onAlertsClick) { Icon(Icons.Filled.Notifications, contentDescription = "Alerts") } IconButton(onClick = onSettingsClick) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } IconButton(onClick = { showLogoutConfirmation = true }) { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout") } }, ) }, ) { padding -> PullToRefreshBox( isRefreshing = state.isLoading, onRefresh = viewModel::refresh, modifier = Modifier .fillMaxSize() .padding(padding), ) { if (state.error != null && state.streams.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( Icons.Filled.ErrorOutline, contentDescription = "Error loading streams", modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = state.error.orEmpty(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(16.dp)) OutlinedButton(onClick = viewModel::refresh) { Text("Retry") } } } } else if (state.streams.isEmpty() && !state.isLoading) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( Icons.Filled.Inbox, contentDescription = "No log streams found", modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "No log streams found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } else { Column { if (state.streams.size > 5) { OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, placeholder = { Text("Search streams...") }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") }, trailingIcon = { if (searchQuery.isNotEmpty()) { IconButton(onClick = { searchQuery = "" }) { Icon(Icons.Filled.Clear, contentDescription = "Clear search") } } }, singleLine = true, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) } LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), ) { items(filteredStreams, key = { it.name }) { stream -> val stats = state.streamStats[stream.name] val isFavorite = stream.name in state.favoriteNames StreamCard( streamName = stream.name, stats = stats, statsFailed = stream.name in state.failedStats, isFavorite = isFavorite, onToggleFavorite = { viewModel.toggleFavorite(stream.name) }, onRetryStats = { viewModel.retryStats(stream.name) }, onClick = { onStreamClick(stream.name) }, ) } if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) { item { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center, ) { Text( text = "No streams matching \"$searchQuery\"", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } } } if (showLogoutConfirmation) { AlertDialog( onDismissRequest = { showLogoutConfirmation = false }, title = { Text("Logout") }, text = { Text("Are you sure you want to disconnect from this server?") }, confirmButton = { Button( onClick = { showLogoutConfirmation = false scope.launch { viewModel.logout() onLogout() } }, ) { Text("Logout") } }, dismissButton = { TextButton(onClick = { showLogoutConfirmation = false }) { Text("Cancel") } }, ) } } @Composable private fun StreamCard( streamName: String, stats: StreamsViewModel.StreamStatsUi?, statsFailed: Boolean = false, isFavorite: Boolean = false, onToggleFavorite: () -> Unit = {}, onRetryStats: () -> Unit = {}, onClick: () -> Unit, ) { Card( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { Column( modifier = Modifier.padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), ) { Icon( Icons.Filled.Storage, contentDescription = "Log stream", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = streamName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) } IconButton(onClick = onToggleFavorite) { Icon( if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } Icon( Icons.Filled.ChevronRight, contentDescription = null, // Decorative; parent Card handles click tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (stats != null) { Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { if (stats.eventCount != null) { StatChip(label = "Events", value = formatCount(stats.eventCount)) } if (stats.ingestionSize != null) { StatChip(label = "Ingested", value = stats.ingestionSize) } if (stats.storageSize != null) { StatChip(label = "Storage", value = stats.storageSize) } } } else if (statsFailed) { Spacer(modifier = Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = "Stats unavailable", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) TextButton( onClick = onRetryStats, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), modifier = Modifier.defaultMinSize(minHeight = 48.dp), ) { Text("Retry", style = MaterialTheme.typography.labelSmall) } } } } } } @Composable private fun StatChip(label: String, value: String) { Column { Text( text = label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, ) } } private fun formatCount(count: Long): String { return when { count >= 1_000_000_000 -> String.format(java.util.Locale.US, "%.1fB", count / 1_000_000_000.0) count >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", count / 1_000_000.0) count >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", count / 1_000.0) else -> count.toString() } }