1package com.parseable.android.ui.screens.streams
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.foundation.lazy.rememberLazyListState
7import androidx.compose.material.icons.Icons
8import androidx.compose.material.icons.automirrored.filled.Logout
9import androidx.compose.material.icons.filled.*
10import androidx.compose.material3.*
11import androidx.compose.material3.pulltorefresh.PullToRefreshBox
12import androidx.compose.runtime.*
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
15import androidx.lifecycle.compose.LocalLifecycleOwner
16import androidx.compose.ui.text.font.FontWeight
17import androidx.compose.ui.unit.dp
18import androidx.hilt.navigation.compose.hiltViewModel
19import androidx.lifecycle.Lifecycle
20import androidx.lifecycle.compose.collectAsStateWithLifecycle
21import androidx.lifecycle.repeatOnLifecycle
22import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.launch
25@OptIn(ExperimentalMaterial3Api::class)
28 onStreamClick: (String) -> Unit,
29 onAlertsClick: () -> Unit,
30 onSettingsClick: () -> Unit,
32 viewModel: StreamsViewModel = hiltViewModel(),
34 val state by viewModel.state.collectAsStateWithLifecycle()
35 val scope = rememberCoroutineScope()
36 var showLogoutConfirmation by remember { mutableStateOf(false) }
37 var searchQuery by remember { mutableStateOf("") }
38 val snackbarHostState = remember { SnackbarHostState() }
40 val listState = rememberLazyListState()
42 // Scroll to top when a refresh completes (isLoading transitions to false)
43 LaunchedEffect(state.isLoading) {
44 if (!state.isLoading) {
45 listState.scrollToItem(0)
49 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
50 val favorites = state.favoriteNames
51 val filtered = if (searchQuery.isBlank()) {
54 state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) }
56 // Partition by favorites instead of full sort for O(n) vs O(n log n)
57 val (favs, rest) = filtered.partition { it.name in favorites }
61 val lifecycleOwner = LocalLifecycleOwner.current
62 LaunchedEffect(lifecycleOwner) {
63 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
68 LaunchedEffect(Unit) {
69 viewModel.snackbarEvent.collectLatest { message ->
70 snackbarHostState.showSnackbar(
72 duration = SnackbarDuration.Short,
78 snackbarHost = { SnackbarHost(snackbarHostState) },
83 Text("Parseable", fontWeight = FontWeight.Bold)
84 if (state.aboutInfo != null) {
86 text = "${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
87 style = MaterialTheme.typography.bodySmall,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
94 IconButton(onClick = onAlertsClick) {
95 Icon(Icons.Filled.Notifications, contentDescription = "Alerts")
97 IconButton(onClick = onSettingsClick) {
98 Icon(Icons.Filled.Settings, contentDescription = "Settings")
100 IconButton(onClick = { showLogoutConfirmation = true }) {
101 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout")
108 isRefreshing = state.isLoading,
109 onRefresh = viewModel::refresh,
114 if (state.error != null && state.streams.isEmpty()) {
116 modifier = Modifier.fillMaxSize(),
117 contentAlignment = Alignment.Center,
119 Column(horizontalAlignment = Alignment.CenterHorizontally) {
121 Icons.Filled.ErrorOutline,
122 contentDescription = "Error loading streams",
123 modifier = Modifier.size(48.dp),
124 tint = MaterialTheme.colorScheme.error,
126 Spacer(modifier = Modifier.height(16.dp))
128 text = state.error.orEmpty(),
129 style = MaterialTheme.typography.bodyLarge,
130 color = MaterialTheme.colorScheme.error,
132 Spacer(modifier = Modifier.height(16.dp))
133 OutlinedButton(onClick = viewModel::refresh) {
138 } else if (state.streams.isEmpty() && !state.isLoading) {
140 modifier = Modifier.fillMaxSize(),
141 contentAlignment = Alignment.Center,
143 Column(horizontalAlignment = Alignment.CenterHorizontally) {
146 contentDescription = "No log streams found",
147 modifier = Modifier.size(48.dp),
148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
150 Spacer(modifier = Modifier.height(16.dp))
152 text = "No log streams found",
153 style = MaterialTheme.typography.bodyLarge,
154 color = MaterialTheme.colorScheme.onSurfaceVariant,
160 if (state.streams.size > 5) {
163 onValueChange = { searchQuery = it },
164 placeholder = { Text("Search streams...") },
166 Icon(Icons.Filled.Search, contentDescription = "Search")
169 if (searchQuery.isNotEmpty()) {
170 IconButton(onClick = { searchQuery = "" }) {
171 Icon(Icons.Filled.Clear, contentDescription = "Clear search")
178 .padding(horizontal = 16.dp, vertical = 8.dp),
183 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
184 verticalArrangement = Arrangement.spacedBy(8.dp),
185 modifier = Modifier.weight(1f),
187 items(filteredStreams, key = { it.name }) { stream ->
188 val stats = state.streamStats[stream.name]
189 val isFavorite = stream.name in state.favoriteNames
191 streamName = stream.name,
193 statsFailed = stream.name in state.failedStats,
194 isFavorite = isFavorite,
195 onToggleFavorite = { viewModel.toggleFavorite(stream.name) },
196 onRetryStats = { viewModel.retryStats(stream.name) },
197 onClick = { onStreamClick(stream.name) },
200 if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) {
206 contentAlignment = Alignment.Center,
209 text = "No streams matching \"$searchQuery\"",
210 style = MaterialTheme.typography.bodyMedium,
211 color = MaterialTheme.colorScheme.onSurfaceVariant,
222 if (showLogoutConfirmation) {
224 onDismissRequest = { showLogoutConfirmation = false },
225 title = { Text("Logout") },
226 text = { Text("Are you sure you want to disconnect from this server?") },
230 showLogoutConfirmation = false
241 TextButton(onClick = { showLogoutConfirmation = false }) {
250private fun StreamCard(
252 stats: StreamsViewModel.StreamStatsUi?,
253 statsFailed: Boolean = false,
254 isFavorite: Boolean = false,
255 onToggleFavorite: () -> Unit = {},
256 onRetryStats: () -> Unit = {},
261 modifier = Modifier.fillMaxWidth(),
262 colors = CardDefaults.cardColors(
263 containerColor = MaterialTheme.colorScheme.surface,
265 elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
268 modifier = Modifier.padding(16.dp),
271 modifier = Modifier.fillMaxWidth(),
272 horizontalArrangement = Arrangement.SpaceBetween,
273 verticalAlignment = Alignment.CenterVertically,
276 verticalAlignment = Alignment.CenterVertically,
277 modifier = Modifier.weight(1f),
280 Icons.Filled.Storage,
281 contentDescription = "Log stream",
282 tint = MaterialTheme.colorScheme.primary,
283 modifier = Modifier.size(20.dp),
285 Spacer(modifier = Modifier.width(8.dp))
288 style = MaterialTheme.typography.titleMedium,
289 fontWeight = FontWeight.SemiBold,
292 IconButton(onClick = onToggleFavorite) {
294 if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
295 contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
296 tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
300 Icons.Filled.ChevronRight,
301 contentDescription = null, // Decorative; parent Card handles click
302 tint = MaterialTheme.colorScheme.onSurfaceVariant,
307 Spacer(modifier = Modifier.height(8.dp))
309 modifier = Modifier.fillMaxWidth(),
310 horizontalArrangement = Arrangement.spacedBy(16.dp),
312 if (stats.eventCount != null) {
313 StatChip(label = "Events", value = formatCount(stats.eventCount))
315 if (stats.ingestionSize != null) {
316 StatChip(label = "Ingested", value = stats.ingestionSize)
318 if (stats.storageSize != null) {
319 StatChip(label = "Storage", value = stats.storageSize)
322 } else if (statsFailed) {
323 Spacer(modifier = Modifier.height(8.dp))
325 verticalAlignment = Alignment.CenterVertically,
326 horizontalArrangement = Arrangement.spacedBy(4.dp),
329 text = "Stats unavailable",
330 style = MaterialTheme.typography.labelSmall,
331 color = MaterialTheme.colorScheme.onSurfaceVariant,
334 onClick = onRetryStats,
335 contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
336 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
338 Text("Retry", style = MaterialTheme.typography.labelSmall)
347private fun StatChip(label: String, value: String) {
351 style = MaterialTheme.typography.labelSmall,
352 color = MaterialTheme.colorScheme.onSurfaceVariant,
356 style = MaterialTheme.typography.bodySmall,
357 fontWeight = FontWeight.Medium,
362private fun formatCount(count: Long): String {
364 count >= 1_000_000_000 -> String.format(java.util.Locale.US, "%.1fB", count / 1_000_000_000.0)
365 count >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", count / 1_000_000.0)
366 count >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", count / 1_000.0)
367 else -> count.toString()
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list