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.material.icons.Icons
7import androidx.compose.material.icons.automirrored.filled.Logout
8import androidx.compose.material.icons.filled.*
9import androidx.compose.material3.*
10import androidx.compose.material3.pulltorefresh.PullToRefreshBox
11import androidx.compose.runtime.*
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
14import androidx.lifecycle.compose.LocalLifecycleOwner
15import androidx.compose.ui.text.font.FontWeight
16import androidx.compose.ui.unit.dp
17import androidx.hilt.navigation.compose.hiltViewModel
18import androidx.lifecycle.Lifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import androidx.lifecycle.repeatOnLifecycle
21import kotlinx.coroutines.flow.collectLatest
22import kotlinx.coroutines.launch
24@OptIn(ExperimentalMaterial3Api::class)
27 onStreamClick: (String) -> Unit,
28 onAlertsClick: () -> Unit,
29 onSettingsClick: () -> Unit,
31 viewModel: StreamsViewModel = hiltViewModel(),
33 val state by viewModel.state.collectAsStateWithLifecycle()
34 val scope = rememberCoroutineScope()
35 var showLogoutConfirmation by remember { mutableStateOf(false) }
36 var searchQuery by remember { mutableStateOf("") }
37 val snackbarHostState = remember { SnackbarHostState() }
39 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
40 val favorites = state.favoriteNames
41 val filtered = if (searchQuery.isBlank()) {
44 state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) }
46 // Partition by favorites instead of full sort for O(n) vs O(n log n)
47 val (favs, rest) = filtered.partition { it.name in favorites }
51 val lifecycleOwner = LocalLifecycleOwner.current
52 LaunchedEffect(lifecycleOwner) {
53 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
58 LaunchedEffect(Unit) {
59 viewModel.snackbarEvent.collectLatest { message ->
60 snackbarHostState.showSnackbar(
62 duration = SnackbarDuration.Short,
68 snackbarHost = { SnackbarHost(snackbarHostState) },
73 Text("Parseable", fontWeight = FontWeight.Bold)
74 if (state.aboutInfo != null) {
76 text = "v${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
77 style = MaterialTheme.typography.bodySmall,
78 color = MaterialTheme.colorScheme.onSurfaceVariant,
84 IconButton(onClick = onAlertsClick) {
85 Icon(Icons.Filled.Notifications, contentDescription = "Alerts")
87 IconButton(onClick = onSettingsClick) {
88 Icon(Icons.Filled.Settings, contentDescription = "Settings")
90 IconButton(onClick = { showLogoutConfirmation = true }) {
91 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout")
98 isRefreshing = state.isLoading,
99 onRefresh = viewModel::refresh,
104 if (state.error != null && state.streams.isEmpty()) {
106 modifier = Modifier.fillMaxSize(),
107 contentAlignment = Alignment.Center,
109 Column(horizontalAlignment = Alignment.CenterHorizontally) {
111 Icons.Filled.ErrorOutline,
112 contentDescription = "Error loading streams",
113 modifier = Modifier.size(48.dp),
114 tint = MaterialTheme.colorScheme.error,
116 Spacer(modifier = Modifier.height(16.dp))
118 text = state.error.orEmpty(),
119 style = MaterialTheme.typography.bodyLarge,
120 color = MaterialTheme.colorScheme.error,
122 Spacer(modifier = Modifier.height(16.dp))
123 OutlinedButton(onClick = viewModel::refresh) {
128 } else if (state.streams.isEmpty() && !state.isLoading) {
130 modifier = Modifier.fillMaxSize(),
131 contentAlignment = Alignment.Center,
133 Column(horizontalAlignment = Alignment.CenterHorizontally) {
136 contentDescription = "No log streams found",
137 modifier = Modifier.size(48.dp),
138 tint = MaterialTheme.colorScheme.onSurfaceVariant,
140 Spacer(modifier = Modifier.height(16.dp))
142 text = "No log streams found",
143 style = MaterialTheme.typography.bodyLarge,
144 color = MaterialTheme.colorScheme.onSurfaceVariant,
150 if (state.streams.size > 5) {
153 onValueChange = { searchQuery = it },
154 placeholder = { Text("Search streams...") },
156 Icon(Icons.Filled.Search, contentDescription = "Search")
159 if (searchQuery.isNotEmpty()) {
160 IconButton(onClick = { searchQuery = "" }) {
161 Icon(Icons.Filled.Clear, contentDescription = "Clear search")
168 .padding(horizontal = 16.dp, vertical = 8.dp),
172 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
173 verticalArrangement = Arrangement.spacedBy(8.dp),
174 modifier = Modifier.weight(1f),
176 items(filteredStreams, key = { it.name }) { stream ->
177 val stats = state.streamStats[stream.name]
178 val isFavorite = stream.name in state.favoriteNames
180 streamName = stream.name,
182 statsFailed = stream.name in state.failedStats,
183 isFavorite = isFavorite,
184 onToggleFavorite = { viewModel.toggleFavorite(stream.name) },
185 onRetryStats = { viewModel.retryStats(stream.name) },
186 onClick = { onStreamClick(stream.name) },
189 if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) {
195 contentAlignment = Alignment.Center,
198 text = "No streams matching \"$searchQuery\"",
199 style = MaterialTheme.typography.bodyMedium,
200 color = MaterialTheme.colorScheme.onSurfaceVariant,
211 if (showLogoutConfirmation) {
213 onDismissRequest = { showLogoutConfirmation = false },
214 title = { Text("Logout") },
215 text = { Text("Are you sure you want to disconnect from this server?") },
219 showLogoutConfirmation = false
230 TextButton(onClick = { showLogoutConfirmation = false }) {
239private fun StreamCard(
241 stats: StreamsViewModel.StreamStatsUi?,
242 statsFailed: Boolean = false,
243 isFavorite: Boolean = false,
244 onToggleFavorite: () -> Unit = {},
245 onRetryStats: () -> Unit = {},
250 modifier = Modifier.fillMaxWidth(),
251 colors = CardDefaults.cardColors(
252 containerColor = MaterialTheme.colorScheme.surface,
254 elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
257 modifier = Modifier.padding(16.dp),
260 modifier = Modifier.fillMaxWidth(),
261 horizontalArrangement = Arrangement.SpaceBetween,
262 verticalAlignment = Alignment.CenterVertically,
265 verticalAlignment = Alignment.CenterVertically,
266 modifier = Modifier.weight(1f),
269 Icons.Filled.Storage,
270 contentDescription = "Log stream",
271 tint = MaterialTheme.colorScheme.primary,
272 modifier = Modifier.size(20.dp),
274 Spacer(modifier = Modifier.width(8.dp))
277 style = MaterialTheme.typography.titleMedium,
278 fontWeight = FontWeight.SemiBold,
281 IconButton(onClick = onToggleFavorite) {
283 if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
284 contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
285 tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
289 Icons.Filled.ChevronRight,
290 contentDescription = null, // Decorative; parent Card handles click
291 tint = MaterialTheme.colorScheme.onSurfaceVariant,
296 Spacer(modifier = Modifier.height(8.dp))
298 modifier = Modifier.fillMaxWidth(),
299 horizontalArrangement = Arrangement.spacedBy(16.dp),
301 if (stats.eventCount != null) {
302 StatChip(label = "Events", value = formatCount(stats.eventCount))
304 if (stats.ingestionSize != null) {
305 StatChip(label = "Ingested", value = stats.ingestionSize)
307 if (stats.storageSize != null) {
308 StatChip(label = "Storage", value = stats.storageSize)
311 } else if (statsFailed) {
312 Spacer(modifier = Modifier.height(8.dp))
314 verticalAlignment = Alignment.CenterVertically,
315 horizontalArrangement = Arrangement.spacedBy(4.dp),
318 text = "Stats unavailable",
319 style = MaterialTheme.typography.labelSmall,
320 color = MaterialTheme.colorScheme.onSurfaceVariant,
323 onClick = onRetryStats,
324 contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
325 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
327 Text("Retry", style = MaterialTheme.typography.labelSmall)
336private fun StatChip(label: String, value: String) {
340 style = MaterialTheme.typography.labelSmall,
341 color = MaterialTheme.colorScheme.onSurfaceVariant,
345 style = MaterialTheme.typography.bodySmall,
346 fontWeight = FontWeight.Medium,
351private fun formatCount(count: Long): String {
353 count >= 1_000_000_000 -> String.format(java.util.Locale.US, "%.1fB", count / 1_000_000_000.0)
354 count >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", count / 1_000_000.0)
355 count >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", count / 1_000.0)
356 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