git.iko.soy/opencode-android
on 06fd41b help sign in
596 lines 32 kB history raw
1package soy.iko.opencode.ui.settings
3import android.content.ActivityNotFoundException
4import android.content.Intent
5import android.util.Log
6import androidx.compose.foundation.background
7import androidx.compose.foundation.clickable
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.filled.BugReport
22import androidx.compose.material.icons.filled.Close
23import androidx.compose.material.icons.filled.ContentCopy
24import androidx.activity.compose.BackHandler
25import androidx.activity.compose.rememberLauncherForActivityResult
26import androidx.compose.material.icons.filled.Delete
27import androidx.compose.material.icons.filled.DeleteSweep
28import androidx.compose.material.icons.filled.IosShare
29import androidx.compose.material.icons.filled.MoreVert
30import androidx.compose.material.icons.filled.Search
31import androidx.compose.material.icons.filled.Share
32import androidx.compose.material3.AlertDialog
33import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.DropdownMenu
35import androidx.compose.material3.DropdownMenuItem
36import androidx.compose.material3.ExperimentalMaterial3Api
37import androidx.compose.material3.HorizontalDivider
38import androidx.compose.material3.Icon
39import androidx.compose.material3.IconButton
40import androidx.compose.material3.MaterialTheme
41import androidx.compose.material3.OutlinedTextField
42import androidx.compose.material3.Scaffold
43import androidx.compose.material3.SnackbarDuration
44import androidx.compose.material3.SnackbarHost
45import androidx.compose.material3.SnackbarHostState
46import androidx.compose.material3.SnackbarResult
47import androidx.compose.material3.Surface
48import androidx.compose.material3.SwipeToDismissBox
49import androidx.compose.material3.SwipeToDismissBoxValue
50import androidx.compose.material3.Text
51import androidx.compose.material3.TextButton
52import androidx.compose.material3.TopAppBar
53import androidx.compose.material3.rememberSwipeToDismissBoxState
54import androidx.compose.runtime.Composable
55import androidx.compose.runtime.CompositionLocalProvider
56import androidx.compose.runtime.LaunchedEffect
57import androidx.compose.runtime.getValue
58import androidx.compose.runtime.mutableIntStateOf
59import androidx.compose.runtime.mutableStateOf
60import androidx.compose.runtime.remember
61import androidx.compose.runtime.rememberCoroutineScope
62import androidx.compose.runtime.saveable.rememberSaveable
63import androidx.compose.runtime.setValue
64import kotlinx.coroutines.Dispatchers
65import kotlinx.coroutines.channels.BufferOverflow
66import kotlinx.coroutines.coroutineScope
67import kotlinx.coroutines.delay
68import kotlinx.coroutines.flow.MutableSharedFlow
69import kotlinx.coroutines.flow.collectLatest
70import kotlinx.coroutines.launch
71import kotlinx.coroutines.withContext
72import soy.iko.opencode.util.runCatchingCancellable
73import soy.iko.opencode.ui.components.AppTopBar
74import soy.iko.opencode.ui.components.EmptyState
75import soy.iko.opencode.ui.components.copyToClipboard
76import androidx.compose.ui.Alignment
77import androidx.compose.ui.Modifier
78import androidx.compose.ui.draw.clip
79import androidx.compose.ui.platform.LocalContext
80import androidx.compose.ui.platform.LocalHapticFeedback
81import androidx.compose.ui.res.stringResource
82import androidx.compose.ui.text.font.FontFamily
83import androidx.compose.ui.semantics.Role
84import androidx.compose.ui.semantics.contentDescription
85import androidx.compose.ui.semantics.heading
86import androidx.compose.ui.semantics.semantics
87import androidx.compose.ui.unit.dp
88import androidx.compose.ui.window.Dialog
89import androidx.compose.ui.window.DialogProperties
90import androidx.lifecycle.compose.collectAsStateWithLifecycle
91import soy.iko.opencode.R
92import soy.iko.opencode.data.network.NetworkConfig
93import soy.iko.opencode.data.repo.CrashLogger
94import soy.iko.opencode.ui.components.LocalRelativeTimeTick
95import soy.iko.opencode.ui.components.rememberRelativeTime
96import soy.iko.opencode.ui.components.rememberRelativeTimeTick
98@OptIn(ExperimentalMaterial3Api::class)
99@Composable
100fun DiagnosticsScreen(onBack: () -> Unit) {
101 val context = LocalContext.current
102 val logger = remember { CrashLogger.get(context) }
103 val reports by logger.reports.collectAsStateWithLifecycle()
104 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
105 var showClearAll by rememberSaveable { mutableStateOf(false) }
106 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
107 // Optional text filter over the crash report previews/file names. Only surfaced once there
108 // are enough reports that scrolling is slower than typing.
109 var crashQuery by rememberSaveable { mutableStateOf("") }
110 val shareScope = rememberCoroutineScope()
111 val haptics = LocalHapticFeedback.current
112 val snackbar = remember { SnackbarHostState() }
113 val undoLabel = stringResource(R.string.undo)
114 val reportDeletedLabel = stringResource(R.string.report_deleted)
115 val reportsClearedLabel = stringResource(R.string.reports_cleared)
116 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
117 // so they survive both rotation and navigating away from this screen. The screen only
118 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
119 val shareLabel = stringResource(R.string.share)
120 val shareSubject = stringResource(R.string.crash_report_share_subject)
121 val timeTick = rememberRelativeTimeTick()
123 // "Export all": let the user save every crash report into a single text file they pick via
124 // SAF (CreateDocument). Concatenating avoids needing a zip dependency; each report is
125 // delimited so a single file stays readable and shareable for support/debugging.
126 val exportAllLauncher = rememberLauncherForActivityResult(
127 androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain"),
128 ) { uri ->
129 if (uri == null) return@rememberLauncherForActivityResult
130 shareScope.launch {
131 val ok = runCatchingCancellable {
132 withContext(Dispatchers.IO) {
133 val all = reports.joinToString("\n\n${"=".repeat(60)}\n\n") { report ->
134 "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
135 }
136 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
137 }
138 }.isSuccess
139 if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
140 }
141 }
143 // Report-delete undo events, shown via a single collectLatest collector rather than a
144 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
145 // previous one's full window. collectLatest instead cancels the current snackbar and shows
146 // the newest immediately. Because the actual delete is scheduled at confirm time (on the
147 // CrashLogger's own scope, so it still commits if the user navigates away during the undo
148 // window), a superseded report would otherwise be deleted with no reachable Undo; the
149 // collector withdraws the previously-pending report's scheduled delete when a newer one
150 // supersedes its snackbar, keeping every delete undoable for its whole window.
151 val reportDeleteEvents = remember {
152 MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
153 }
154 LaunchedEffect(Unit) {
155 var pending: String? = null
156 reportDeleteEvents.collectLatest { name ->
157 // Only reached when a newer delete arrives (collectLatest supersedes the prior
158 // snackbar) — navigating away tears down this collector without a new emission, so
159 // that report's delete still commits. cancelScheduledDelete no-ops if it already fired.
160 pending?.let { logger.cancelScheduledDelete(it) }
161 pending = name
162 coroutineScope {
163 val dismisser = launch {
164 delay(NetworkConfig.undoReportDeleteDelayMs)
165 snackbar.currentSnackbarData?.dismiss()
166 }
167 val result = snackbar.showSnackbar(
168 message = reportDeletedLabel,
169 actionLabel = undoLabel,
170 duration = SnackbarDuration.Indefinite,
171 )
172 dismisser.cancel()
173 if (result == SnackbarResult.ActionPerformed) {
174 logger.cancelScheduledDelete(name)
175 pending = null
176 }
177 }
178 }
179 }
181 // Clear-all undo: defers the bulk delete for the same window as a single-report delete,
182 // so the previously-irreversible "Clear all" is now recoverable.
183 val clearAllEvents = remember {
184 MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
185 }
186 LaunchedEffect(Unit) {
187 clearAllEvents.collectLatest {
188 logger.scheduleClearAll(NetworkConfig.undoReportDeleteDelayMs)
189 coroutineScope {
190 val dismisser = launch {
191 delay(NetworkConfig.undoReportDeleteDelayMs)
192 snackbar.currentSnackbarData?.dismiss()
193 }
194 val result = snackbar.showSnackbar(
195 message = reportsClearedLabel,
196 actionLabel = undoLabel,
197 duration = SnackbarDuration.Indefinite,
198 )
199 dismisser.cancel()
200 if (result == SnackbarResult.ActionPerformed) logger.cancelScheduledClearAll()
201 }
202 }
203 }
205 Scaffold(
206 topBar = {
207 AppTopBar(
208 title = stringResource(R.string.diagnostics),
209 onBack = onBack,
210 actions = {
211 if (reports.isNotEmpty()) {
212 IconButton(onClick = {
213 runCatching {
214 exportAllLauncher.launch("opencode-crash-reports.txt")
215 }
216 }) {
217 Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))
218 }
219 IconButton(onClick = {
220 showClearAll = true
221 }) {
222 Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
223 }
224 }
225 },
226 )
227 },
228 snackbarHost = { SnackbarHost(snackbar) },
229 ) { padding ->
230 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
231 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
232 Text(
233 stringResource(R.string.diagnostics_desc),
234 style = MaterialTheme.typography.bodySmall,
235 color = MaterialTheme.colorScheme.onSurfaceVariant,
236 modifier = Modifier.padding(16.dp),
237 )
238 Text(
239 stringResource(R.string.crash_reports),
240 style = MaterialTheme.typography.titleSmall,
241 color = MaterialTheme.colorScheme.primary,
242 modifier = Modifier
243 .padding(start = 16.dp, bottom = 8.dp)
244 .semantics { heading() },
245 )
246 if (reports.isEmpty()) {
247 EmptyState(
248 icon = Icons.Filled.BugReport,
249 title = stringResource(R.string.no_crash_reports),
250 description = stringResource(R.string.no_crash_reports_desc),
251 modifier = Modifier.fillMaxWidth().padding(24.dp),
252 )
253 } else {
254 val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
255 LazyColumn(modifier = Modifier.fillMaxSize()) {
256 crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
257 crashQuery = it
258 }
259 items(filtered, key = { it.fileName }) { report ->
260 // rememberSaveable so an open row menu survives a rotation; BackHandler
261 // so system back closes it instead of navigating away, matching the
262 // session/server/file list dropdown back-handling.
263 var menuExpanded by rememberSaveable(report.fileName) { mutableStateOf(false) }
264 BackHandler(enabled = menuExpanded) { menuExpanded = false }
265 // Swipe end-to-start reveals a delete affordance. The delete is
266 // deferred for an undo window (matching the session/server lists);
267 // confirmValueChange snaps back so the row remains while the undo
268 // snackbar is shown, and is removed only when the window expires.
269 val swipeState = rememberSwipeToDismissBoxState(
270 confirmValueChange = {
271 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
272 pendingReportDelete = report.fileName
273 false
274 },
275 )
276 SwipeToDismissBox(
277 state = swipeState,
278 enableDismissFromStartToEnd = false,
279 modifier = Modifier.animateItem(),
280 backgroundContent = {
281 Box(
282 modifier = Modifier
283 .fillMaxSize()
284 .clip(MaterialTheme.shapes.medium)
285 .background(MaterialTheme.colorScheme.errorContainer)
286 .padding(horizontal = 20.dp),
287 contentAlignment = Alignment.CenterEnd,
288 ) {
289 Icon(
290 Icons.Filled.DeleteSweep,
291 contentDescription = stringResource(R.string.delete),
292 tint = MaterialTheme.colorScheme.onErrorContainer,
293 )
294 }
295 },
296 ) {
297 Row(
298 modifier = Modifier
299 .fillMaxWidth()
300 .clickable(role = Role.Button) { viewing = report.fileName }
301 .padding(horizontal = 16.dp, vertical = 12.dp),
302 verticalAlignment = Alignment.CenterVertically,
303 horizontalArrangement = Arrangement.spacedBy(12.dp),
304 ) {
305 Icon(
306 Icons.Filled.BugReport,
307 contentDescription = null,
308 tint = MaterialTheme.colorScheme.error,
309 )
310 Column(modifier = Modifier.weight(1f)) {
311 Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
312 Text(
313 rememberRelativeTime(report.timestamp),
314 style = MaterialTheme.typography.bodySmall,
315 color = MaterialTheme.colorScheme.onSurfaceVariant,
316 )
317 }
318 IconButton(onClick = {
319 shareScope.launch {
320 val content = withContext(Dispatchers.IO) {
321 logger.readReport(report.fileName).orEmpty()
322 }
323 val send = Intent(Intent.ACTION_SEND).apply {
324 type = "text/plain"
325 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
326 putExtra(Intent.EXTRA_TEXT, content)
327 }
328 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
329 .onFailure {
330 Log.w("Diagnostics", "Failed to share crash report", it)
331 // FIX 20: "no app to share" only fits ActivityNotFoundException; other
332 // failures (e.g. an oversized report exceeding the Binder limit →
333 // TransactionTooLargeException) get a generic toast, not that misleading one.
334 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
335 shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
336 }
337 }
338 }) {
339 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
340 }
341 Box {
342 IconButton(onClick = { menuExpanded = true }) {
343 Icon(
344 Icons.Filled.MoreVert,
345 contentDescription = stringResource(R.string.more_options),
346 )
347 }
348 DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
349 DropdownMenuItem(
350 leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
351 text = {
352 Text(
353 stringResource(R.string.delete),
354 color = MaterialTheme.colorScheme.error,
355 )
356 },
357 onClick = {
358 menuExpanded = false
359 pendingReportDelete = report.fileName
360 },
361 )
362 }
363 }
364 }
365 }
366 HorizontalDivider()
367 }
368 }
369 }
370 }
371 }
372 }
374 val reportName = viewing
375 if (reportName != null) {
376 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
377 var loadFailed by remember(reportName) { mutableStateOf(false) }
378 // A retry trigger: bumping this re-runs the load LaunchedEffect (keyed on it) and
379 // clears the failure state, so a transient read error is recoverable without closing
380 // and reopening the report — matching the file viewer's error-retry pattern.
381 var retryKey by remember(reportName) { mutableIntStateOf(0) }
382 LaunchedEffect(reportName, retryKey) {
383 if (retryKey > 0) { reportContent = null; loadFailed = false }
384 val loaded = withContext(Dispatchers.IO) {
385 // runCatchingCancellable (not runCatching) so dismissing the dialog or
386 // switching reports mid-read lets the CancellationException propagate
387 // instead of being swallowed and setting state on a torn-down effect.
388 runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
389 }
390 if (loaded != null) reportContent = loaded else loadFailed = true
391 }
392 val content = reportContent
393 Dialog(
394 onDismissRequest = { viewing = null },
395 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
396 ) {
397 Surface(modifier = Modifier.fillMaxSize()) {
398 Scaffold(
399 topBar = {
400 TopAppBar(
401 title = { Text(stringResource(R.string.crash_report)) },
402 navigationIcon = {
403 IconButton(onClick = { viewing = null }) {
404 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
405 }
406 },
407 actions = {
408 IconButton(
409 // Disable Copy until the report has loaded so it can't copy an empty body.
410 enabled = content != null,
411 onClick = {
412 copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
413 },
414 ) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) }
415 IconButton(
416 // Disable Share until the report has loaded — otherwise tapping it
417 // while the async read is still in flight (content == null) shares an
418 // empty body instead of the report.
419 enabled = content != null,
420 onClick = {
421 val send = Intent(Intent.ACTION_SEND).apply {
422 type = "text/plain"
423 putExtra(Intent.EXTRA_SUBJECT, shareSubject)
424 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
425 }
426 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
427 .onFailure {
428 Log.w("Diagnostics", "Failed to share crash report", it)
429 // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
430 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
431 // get a generic toast, not that misleading one.
432 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
433 shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
434 }
435 },
436 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
437 IconButton(onClick = {
438 // Confirm before deleting a single report, matching clear-all.
439 pendingReportDelete = reportName
440 }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
441 },
442 )
443 },
444 ) { padding ->
445 when {
446 content != null -> SelectionContainer {
447 // Wrap in SelectionContainer so a user can select/copy just the
448 // relevant stack snippet instead of the whole-report Copy button.
449 Text(
450 content,
451 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
452 modifier = Modifier
453 .fillMaxSize()
454 .verticalScroll(rememberScrollState())
455 .padding(padding)
456 .padding(16.dp),
457 )
458 }
459 loadFailed -> Box(
460 modifier = Modifier.fillMaxSize().padding(padding),
461 contentAlignment = Alignment.Center,
462 ) {
463 Column(
464 horizontalAlignment = Alignment.CenterHorizontally,
465 verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
466 ) {
467 Text(
468 stringResource(R.string.report_load_failed),
469 color = MaterialTheme.colorScheme.error,
470 )
471 TextButton(onClick = { retryKey++ }) {
472 Text(stringResource(R.string.retry))
473 }
474 }
475 }
476 else -> {
477 val loadingLabel = stringResource(R.string.loading)
478 Box(
479 modifier = Modifier.fillMaxSize().padding(padding),
480 contentAlignment = Alignment.Center,
481 ) {
482 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
483 }
484 }
485 }
486 }
487 }
488 }
489 }
491 pendingReportDelete?.let { name ->
492 AlertDialog(
493 onDismissRequest = { pendingReportDelete = null },
494 title = { Text(stringResource(R.string.delete_report_title)) },
495 text = { Text(stringResource(R.string.delete_report_text)) },
496 confirmButton = {
497 TextButton(onClick = {
498 pendingReportDelete = null
499 viewing = null
500 // Defer the actual delete so the Undo snackbar can cancel it. The
501 // deletion is owned by the CrashLogger's own scope, so it commits even
502 // if the user navigates away during the undo window. The Undo snackbar
503 // is shown by the single collectLatest collector above (not a per-confirm
504 // launch) so rapid consecutive deletes don't produce a dead Undo button.
505 logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
506 reportDeleteEvents.tryEmit(name)
507 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
508 },
509 dismissButton = {
510 TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
511 },
512 )
513 }
515 if (showClearAll) {
516 AlertDialog(
517 onDismissRequest = { showClearAll = false },
518 title = { Text(stringResource(R.string.clear_all)) },
519 text = { Text(stringResource(R.string.clear_all_confirm)) },
520 confirmButton = {
521 TextButton(onClick = {
522 showClearAll = false
523 shareScope.launch { clearAllEvents.emit(Unit) }
524 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
525 },
526 dismissButton = {
527 TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
528 },
529 )
530 }
533/** Filter crash reports by preview/file name. Extracted from [DiagnosticsScreen] to keep its
534 * cyclomatic complexity under the detekt threshold. Empty query returns all reports. */
535private fun filterCrashReports(
536 reports: List<CrashLogger.CrashReport>,
537 query: String,
538): List<CrashLogger.CrashReport> {
539 val q = query.trim()
540 if (q.isEmpty()) return reports
541 return reports.filter {
542 it.preview.contains(q, ignoreCase = true) || it.fileName.contains(q, ignoreCase = true)
543 }
546/** The diagnostics filter field with a clear button. Extracted from [DiagnosticsScreen] to keep
547 * its cyclomatic complexity under the detekt threshold. */
548@Composable
549private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
550 val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
551 OutlinedTextField(
552 value = query,
553 onValueChange = onQueryChange,
554 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
555 placeholder = { Text(stringResource(R.string.search_crash_reports)) },
556 singleLine = true,
557 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
558 trailingIcon = {
559 if (query.isNotEmpty()) {
560 IconButton(onClick = { onQueryChange("") }) {
561 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
562 }
563 }
564 },
565 // Match the session/file/server search fields: ImeAction.Search dismisses the keyboard
566 // on submit so the IME doesn't keep covering the crash list after filtering.
567 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
568 imeAction = androidx.compose.ui.text.input.ImeAction.Search,
569 ),
570 keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { keyboard?.hide() }),
571 )
574/** Conditionally emits the search field and the no-match placeholder into the crash list.
575 * Extracted from [DiagnosticsScreen] to keep its cyclomatic complexity under the threshold. */
576private fun androidx.compose.foundation.lazy.LazyListScope.crashSearchHeader(
577 reports: List<CrashLogger.CrashReport>,
578 filtered: List<CrashLogger.CrashReport>,
579 crashQuery: String,
580 onQueryChange: (String) -> Unit,
581) {
582 if (reports.size >= NetworkConfig.diagnosticsSearchThreshold) {
583 item(key = "__search") {
584 CrashQueryField(query = crashQuery, onQueryChange = onQueryChange)
585 }
586 }
587 if (crashQuery.isNotBlank() && filtered.isEmpty()) {
588 item(key = "__no_match") {
589 EmptyState(
590 icon = Icons.Filled.Search,
591 title = stringResource(R.string.no_crash_matches),
592 modifier = Modifier.fillMaxWidth().padding(24.dp),
593 )
594 }
595 }