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