git.iko.soy/parseable-android
on 3b7ea33 help sign in
369 lines 15 kB history raw
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)
26@Composable
27fun StreamsScreen(
28 onStreamClick: (String) -> Unit,
29 onAlertsClick: () -> Unit,
30 onSettingsClick: () -> Unit,
31 onLogout: () -> Unit,
32 viewModel: StreamsViewModel = hiltViewModel(),
33) {
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)
46 }
47 }
49 val filteredStreams = remember(state.streams, searchQuery, state.favoriteNames) {
50 val favorites = state.favoriteNames
51 val filtered = if (searchQuery.isBlank()) {
52 state.streams
53 } else {
54 state.streams.filter { it.name.contains(searchQuery, ignoreCase = true) }
55 }
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 }
58 favs + rest
59 }
61 val lifecycleOwner = LocalLifecycleOwner.current
62 LaunchedEffect(lifecycleOwner) {
63 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
64 viewModel.refresh()
65 }
66 }
68 LaunchedEffect(Unit) {
69 viewModel.snackbarEvent.collectLatest { message ->
70 snackbarHostState.showSnackbar(
71 message = message,
72 duration = SnackbarDuration.Short,
73 )
74 }
75 }
77 Scaffold(
78 snackbarHost = { SnackbarHost(snackbarHostState) },
79 topBar = {
80 TopAppBar(
81 title = {
82 Column {
83 Text("Parseable", fontWeight = FontWeight.Bold)
84 if (state.aboutInfo != null) {
85 Text(
86 text = "${state.aboutInfo?.version ?: "?"} - ${state.aboutInfo?.mode ?: ""}",
87 style = MaterialTheme.typography.bodySmall,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 )
90 }
91 }
92 },
93 actions = {
94 IconButton(onClick = onAlertsClick) {
95 Icon(Icons.Filled.Notifications, contentDescription = "Alerts")
96 }
97 IconButton(onClick = onSettingsClick) {
98 Icon(Icons.Filled.Settings, contentDescription = "Settings")
99 }
100 IconButton(onClick = { showLogoutConfirmation = true }) {
101 Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Logout")
102 }
103 },
104 )
105 },
106 ) { padding ->
107 PullToRefreshBox(
108 isRefreshing = state.isLoading,
109 onRefresh = viewModel::refresh,
110 modifier = Modifier
111 .fillMaxSize()
112 .padding(padding),
113 ) {
114 if (state.error != null && state.streams.isEmpty()) {
115 Box(
116 modifier = Modifier.fillMaxSize(),
117 contentAlignment = Alignment.Center,
118 ) {
119 Column(horizontalAlignment = Alignment.CenterHorizontally) {
120 Icon(
121 Icons.Filled.ErrorOutline,
122 contentDescription = "Error loading streams",
123 modifier = Modifier.size(48.dp),
124 tint = MaterialTheme.colorScheme.error,
125 )
126 Spacer(modifier = Modifier.height(16.dp))
127 Text(
128 text = state.error.orEmpty(),
129 style = MaterialTheme.typography.bodyLarge,
130 color = MaterialTheme.colorScheme.error,
131 )
132 Spacer(modifier = Modifier.height(16.dp))
133 OutlinedButton(onClick = viewModel::refresh) {
134 Text("Retry")
135 }
136 }
137 }
138 } else if (state.streams.isEmpty() && !state.isLoading) {
139 Box(
140 modifier = Modifier.fillMaxSize(),
141 contentAlignment = Alignment.Center,
142 ) {
143 Column(horizontalAlignment = Alignment.CenterHorizontally) {
144 Icon(
145 Icons.Filled.Inbox,
146 contentDescription = "No log streams found",
147 modifier = Modifier.size(48.dp),
148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
149 )
150 Spacer(modifier = Modifier.height(16.dp))
151 Text(
152 text = "No log streams found",
153 style = MaterialTheme.typography.bodyLarge,
154 color = MaterialTheme.colorScheme.onSurfaceVariant,
155 )
156 }
157 }
158 } else {
159 Column {
160 if (state.streams.size > 5) {
161 OutlinedTextField(
162 value = searchQuery,
163 onValueChange = { searchQuery = it },
164 placeholder = { Text("Search streams...") },
165 leadingIcon = {
166 Icon(Icons.Filled.Search, contentDescription = "Search")
167 },
168 trailingIcon = {
169 if (searchQuery.isNotEmpty()) {
170 IconButton(onClick = { searchQuery = "" }) {
171 Icon(Icons.Filled.Clear, contentDescription = "Clear search")
172 }
173 }
174 },
175 singleLine = true,
176 modifier = Modifier
177 .fillMaxWidth()
178 .padding(horizontal = 16.dp, vertical = 8.dp),
179 )
180 }
181 LazyColumn(
182 state = listState,
183 contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
184 verticalArrangement = Arrangement.spacedBy(8.dp),
185 modifier = Modifier.weight(1f),
186 ) {
187 items(filteredStreams, key = { it.name }) { stream ->
188 val stats = state.streamStats[stream.name]
189 val isFavorite = stream.name in state.favoriteNames
190 StreamCard(
191 streamName = stream.name,
192 stats = stats,
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) },
198 )
199 }
200 if (filteredStreams.isEmpty() && searchQuery.isNotBlank()) {
201 item {
202 Box(
203 modifier = Modifier
204 .fillMaxWidth()
205 .padding(32.dp),
206 contentAlignment = Alignment.Center,
207 ) {
208 Text(
209 text = "No streams matching \"$searchQuery\"",
210 style = MaterialTheme.typography.bodyMedium,
211 color = MaterialTheme.colorScheme.onSurfaceVariant,
212 )
213 }
214 }
215 }
216 }
217 }
218 }
219 }
220 }
222 if (showLogoutConfirmation) {
223 AlertDialog(
224 onDismissRequest = { showLogoutConfirmation = false },
225 title = { Text("Logout") },
226 text = { Text("Are you sure you want to disconnect from this server?") },
227 confirmButton = {
228 Button(
229 onClick = {
230 showLogoutConfirmation = false
231 scope.launch {
232 viewModel.logout()
233 onLogout()
234 }
235 },
236 ) {
237 Text("Logout")
238 }
239 },
240 dismissButton = {
241 TextButton(onClick = { showLogoutConfirmation = false }) {
242 Text("Cancel")
243 }
244 },
245 )
246 }
249@Composable
250private fun StreamCard(
251 streamName: String,
252 stats: StreamsViewModel.StreamStatsUi?,
253 statsFailed: Boolean = false,
254 isFavorite: Boolean = false,
255 onToggleFavorite: () -> Unit = {},
256 onRetryStats: () -> Unit = {},
257 onClick: () -> Unit,
258) {
259 Card(
260 onClick = onClick,
261 modifier = Modifier.fillMaxWidth(),
262 colors = CardDefaults.cardColors(
263 containerColor = MaterialTheme.colorScheme.surface,
264 ),
265 elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
266 ) {
267 Column(
268 modifier = Modifier.padding(16.dp),
269 ) {
270 Row(
271 modifier = Modifier.fillMaxWidth(),
272 horizontalArrangement = Arrangement.SpaceBetween,
273 verticalAlignment = Alignment.CenterVertically,
274 ) {
275 Row(
276 verticalAlignment = Alignment.CenterVertically,
277 modifier = Modifier.weight(1f),
278 ) {
279 Icon(
280 Icons.Filled.Storage,
281 contentDescription = "Log stream",
282 tint = MaterialTheme.colorScheme.primary,
283 modifier = Modifier.size(20.dp),
284 )
285 Spacer(modifier = Modifier.width(8.dp))
286 Text(
287 text = streamName,
288 style = MaterialTheme.typography.titleMedium,
289 fontWeight = FontWeight.SemiBold,
290 )
291 }
292 IconButton(onClick = onToggleFavorite) {
293 Icon(
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,
297 )
298 }
299 Icon(
300 Icons.Filled.ChevronRight,
301 contentDescription = null, // Decorative; parent Card handles click
302 tint = MaterialTheme.colorScheme.onSurfaceVariant,
303 )
304 }
306 if (stats != null) {
307 Spacer(modifier = Modifier.height(8.dp))
308 Row(
309 modifier = Modifier.fillMaxWidth(),
310 horizontalArrangement = Arrangement.spacedBy(16.dp),
311 ) {
312 if (stats.eventCount != null) {
313 StatChip(label = "Events", value = formatCount(stats.eventCount))
314 }
315 if (stats.ingestionSize != null) {
316 StatChip(label = "Ingested", value = stats.ingestionSize)
317 }
318 if (stats.storageSize != null) {
319 StatChip(label = "Storage", value = stats.storageSize)
320 }
321 }
322 } else if (statsFailed) {
323 Spacer(modifier = Modifier.height(8.dp))
324 Row(
325 verticalAlignment = Alignment.CenterVertically,
326 horizontalArrangement = Arrangement.spacedBy(4.dp),
327 ) {
328 Text(
329 text = "Stats unavailable",
330 style = MaterialTheme.typography.labelSmall,
331 color = MaterialTheme.colorScheme.onSurfaceVariant,
332 )
333 TextButton(
334 onClick = onRetryStats,
335 contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
336 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
337 ) {
338 Text("Retry", style = MaterialTheme.typography.labelSmall)
339 }
340 }
341 }
342 }
343 }
346@Composable
347private fun StatChip(label: String, value: String) {
348 Column {
349 Text(
350 text = label,
351 style = MaterialTheme.typography.labelSmall,
352 color = MaterialTheme.colorScheme.onSurfaceVariant,
353 )
354 Text(
355 text = value,
356 style = MaterialTheme.typography.bodySmall,
357 fontWeight = FontWeight.Medium,
358 )
359 }
362private fun formatCount(count: Long): String {
363 return when {
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()
368 }