From c8d9ae523a00ea77b057f721c6dda7663d406562 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 18 Jan 2026 15:09:14 +0800 Subject: [PATCH 1/3] feat: enhance android version checking --- .../screen/permission/PermissionScreen.kt | 25 +++++++++++-------- .../screen/settings/SettingsScreen.kt | 6 ++--- .../devbyjonathan/stacklens/theme/Theme.kt | 4 +-- .../stacklens/theme/ThemeManager.kt | 4 --- .../stacklens/util/PermissionChecker.kt | 7 +++--- .../devbyjonathan/stacklens/util/_Android.kt | 8 ++++++ 6 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/devbyjonathan/stacklens/util/_Android.kt diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/permission/PermissionScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/permission/PermissionScreen.kt index 19ba8ab..20e70c3 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/permission/PermissionScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/permission/PermissionScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.devbyjonathan.stacklens.theme.StackLensTheme +import com.devbyjonathan.stacklens.util.isAtLeastAndroid15 import com.devbyjonathan.uikit.theme.AppTypography import com.devbyjonathan.uikit.theme.CodeTypography @@ -179,18 +180,20 @@ fun PermissionScreen( } ) - Spacer(modifier = Modifier.height(16.dp)) + if (isAtLeastAndroid15()) { + Spacer(modifier = Modifier.height(16.dp)) - PermissionCard( - title = "Read Dropbox Data Permission", - description = "Required to read crash logs from the system", - icon = Icons.Default.Archive, - isGranted = hasDropbox, - adbCommand = adbCommand_readDropBox, - onCopyCommand = { - clipboardManager.setText(AnnotatedString(adbCommand_readDropBox)) - } - ) + PermissionCard( + title = "Read Dropbox Data Permission", + description = "Required to read crash logs from the system", + icon = Icons.Default.Archive, + isGranted = hasDropbox, + adbCommand = adbCommand_readDropBox, + onCopyCommand = { + clipboardManager.setText(AnnotatedString(adbCommand_readDropBox)) + } + ) + } Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/SettingsScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/SettingsScreen.kt index 63700f4..1c87349 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/SettingsScreen.kt @@ -1,7 +1,6 @@ package com.devbyjonathan.stacklens.screen.settings import android.content.res.Configuration -import android.os.Build import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -27,12 +26,10 @@ import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Security import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -56,6 +53,7 @@ import com.devbyjonathan.stacklens.BuildConfig import com.devbyjonathan.stacklens.R import com.devbyjonathan.stacklens.theme.StackLensTheme import com.devbyjonathan.stacklens.theme.ThemeMode +import com.devbyjonathan.stacklens.util.isAtLeastAndroid12 import com.devbyjonathan.uikit.theme.AppTypography @Composable @@ -71,7 +69,7 @@ fun SettingsScreen( var showThemeDialog by remember { mutableStateOf(false) } var showAppInfoDialog by remember { mutableStateOf(false) } - val isDynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val isDynamicColorSupported = isAtLeastAndroid12() Column( modifier = modifier diff --git a/app/src/main/java/com/devbyjonathan/stacklens/theme/Theme.kt b/app/src/main/java/com/devbyjonathan/stacklens/theme/Theme.kt index 0a9da70..4796d0c 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/theme/Theme.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/theme/Theme.kt @@ -1,7 +1,6 @@ package com.devbyjonathan.stacklens.theme import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme @@ -14,6 +13,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import com.devbyjonathan.stacklens.util.isAtLeastAndroid12 import com.devbyjonathan.uikit.theme.AppTypography import com.devbyjonathan.uikit.theme.darkScheme import com.devbyjonathan.uikit.theme.lightScheme @@ -42,7 +42,7 @@ fun StackLensTheme( val dynamicColorState = themeManager?.dynamicColorEnabled?.collectAsState() val dynamicColorEnabled = dynamicColorState?.value ?: false - val useDynamicColor = dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val useDynamicColor = dynamicColorEnabled && isAtLeastAndroid12() val colorScheme = when { useDynamicColor && effectiveDarkTheme -> dynamicDarkColorScheme(context) diff --git a/app/src/main/java/com/devbyjonathan/stacklens/theme/ThemeManager.kt b/app/src/main/java/com/devbyjonathan/stacklens/theme/ThemeManager.kt index 08d7cd9..cfb073b 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/theme/ThemeManager.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/theme/ThemeManager.kt @@ -1,7 +1,6 @@ package com.devbyjonathan.stacklens.theme import android.content.SharedPreferences -import android.os.Build import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import kotlinx.coroutines.flow.MutableStateFlow @@ -28,9 +27,6 @@ class ThemeManager @Inject constructor( private val _dynamicColorEnabled = MutableStateFlow(getDynamicColorEnabled()) val dynamicColorEnabled: StateFlow = _dynamicColorEnabled - val isDynamicColorSupported: Boolean - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - fun getThemeMode(): ThemeMode { val savedValue = sharedPreferences.getString(THEME_MODE_KEY, null) return when (savedValue) { diff --git a/app/src/main/java/com/devbyjonathan/stacklens/util/PermissionChecker.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/PermissionChecker.kt index 34fc5ca..b098fb8 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/util/PermissionChecker.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/util/PermissionChecker.kt @@ -3,7 +3,6 @@ package com.devbyjonathan.stacklens.util import android.app.AppOpsManager import android.content.Context import android.content.pm.PackageManager -import android.os.Build import android.os.Process object PermissionChecker { @@ -17,14 +16,14 @@ object PermissionChecker { } fun hasReadDropBoxDataPermission(context: Context): Boolean { - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { - true - } else { + return if (isAtLeastAndroid15()) { context.checkPermission( android.Manifest.permission.READ_DROPBOX_DATA, Process.myPid(), Process.myUid() ) == PackageManager.PERMISSION_GRANTED + } else { + true } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/util/_Android.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/_Android.kt new file mode 100644 index 0000000..888d179 --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/util/_Android.kt @@ -0,0 +1,8 @@ +package com.devbyjonathan.stacklens.util + +import android.os.Build + +fun isAtLeastAndroid8() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O +fun isAtLeastAndroid12() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +fun isAtLeastAndroid13() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU +fun isAtLeastAndroid15() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM \ No newline at end of file From f254ad39c4176ffe80a5bf68531dee10bc29bfe5 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 20 Apr 2026 23:21:05 +0800 Subject: [PATCH 2/3] feat: AI related enhancements 1. Added AI powered search 2. Enhanced AI Insight content render --- .../stacklens/ai/CrashInsightService.kt | 146 +++++++++ .../screen/detail/CrashDetailScreen.kt | 25 +- .../screen/list/CrashLogListScreen.kt | 284 +++++++++++++----- .../screen/list/CrashLogViewModel.kt | 150 ++++++++- .../stacklens/screen/main/HomeScreen.kt | 18 +- .../stacklens/screen/main/MainActivity.kt | 5 +- .../stacklens/util/MarkdownRenderer.kt | 95 ++++++ 7 files changed, 635 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/devbyjonathan/stacklens/util/MarkdownRenderer.kt diff --git a/app/src/main/java/com/devbyjonathan/stacklens/ai/CrashInsightService.kt b/app/src/main/java/com/devbyjonathan/stacklens/ai/CrashInsightService.kt index 18b1c56..08ee61a 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/ai/CrashInsightService.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/ai/CrashInsightService.kt @@ -14,6 +14,8 @@ import com.devbyjonathan.stacklens.R import com.devbyjonathan.stacklens.data.local.dao.CrashInsightDao import com.devbyjonathan.stacklens.data.local.entity.CrashInsightEntity import com.devbyjonathan.stacklens.model.CrashLog +import com.devbyjonathan.stacklens.model.CrashTypeFilter +import com.devbyjonathan.stacklens.model.SortOrder import com.google.mlkit.genai.common.DownloadStatus import com.google.mlkit.genai.common.FeatureStatus import com.google.mlkit.genai.prompt.Generation @@ -55,6 +57,20 @@ sealed class DownloadState { data class Failed(val error: String) : DownloadState() } +data class ParsedSearchQuery( + val timeRangeHours: Int? = null, + val typeFilter: CrashTypeFilter? = null, + val searchQuery: String? = null, + val packageName: String? = null, + val sortOrder: SortOrder? = null, +) + +sealed class ParseResult { + data class Success(val query: ParsedSearchQuery) : ParseResult() + data class Fallback(val originalQuery: String) : ParseResult() + data object Unavailable : ParseResult() +} + @Singleton class CrashInsightService @Inject constructor( @ApplicationContext private val context: Context, @@ -449,4 +465,134 @@ AFFECTED_LINE: [The key line from the stack trace, or N/A] return text.substring(contentStart, nextMarkerIndex).trim() } + + /** + * Parse a natural language search query into structured filters. + * Returns Fallback if AI parsing fails or nothing meaningful is extracted. + */ + suspend fun parseNaturalLanguageQuery(query: String): ParseResult = + withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Parsing natural language query: $query") + + val model = getOrCreateModel() + val status = model.checkStatus() + currentStatus = status + + if (status != FeatureStatus.AVAILABLE) { + Log.d(TAG, "Gemini Nano not available for query parsing") + return@withContext ParseResult.Unavailable + } + + val prompt = buildQueryParsePrompt(query) + Log.d(TAG, "Query parse prompt built, length: ${prompt.length} chars") + + val request = generateContentRequest(TextPart(prompt)) { + temperature = 0.1f + topK = 8 + } + + Log.d(TAG, "Calling generateContent for query parsing...") + val startTime = System.currentTimeMillis() + val response = model.generateContent(request) + val elapsed = System.currentTimeMillis() - startTime + Log.d(TAG, "Query parsing completed in ${elapsed}ms") + + val text = response.candidates.firstOrNull()?.text + if (text == null) { + Log.w(TAG, "Empty response for query parsing") + return@withContext ParseResult.Fallback(query) + } + + Log.d(TAG, "Query parse response:\n$text") + parseQueryResponse(text, query) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse natural language query", e) + ParseResult.Fallback(query) + } + } + + private fun buildQueryParsePrompt(query: String): String { + return """ +Parse this crash log search query into structured filters. + +Query: "$query" + +Extract these fields if mentioned (use NONE if not mentioned): +- TIME_RANGE: Number of hours (1, 6, 24, 72, 168). Map: "last hour"=1, "today"/"yesterday"=24, "3 days"=72, "week"=168 +- TYPE_FILTER: One of ALL, CRASHES, ANRS, NATIVE +- SEARCH_QUERY: Exception names, error types (e.g., NullPointerException, OutOfMemory) +- PACKAGE_HINT: App name or package mentioned (e.g., "Gmail", "Chrome") +- SORT_ORDER: NEWEST_FIRST or OLDEST_FIRST + +Respond in this exact format: +TIME_RANGE: [value or NONE] +TYPE_FILTER: [value or NONE] +SEARCH_QUERY: [value or NONE] +PACKAGE_HINT: [value or NONE] +SORT_ORDER: [value or NONE] +""".trimIndent() + } + + private fun parseQueryResponse(response: String, originalQuery: String): ParseResult { + try { + val timeRangeStr = extractQueryField(response, "TIME_RANGE:") + val typeFilterStr = extractQueryField(response, "TYPE_FILTER:") + val searchQueryStr = extractQueryField(response, "SEARCH_QUERY:") + val packageHintStr = extractQueryField(response, "PACKAGE_HINT:") + val sortOrderStr = extractQueryField(response, "SORT_ORDER:") + + val timeRange = timeRangeStr?.toIntOrNull() + val typeFilter = when (typeFilterStr?.uppercase()) { + "ALL" -> CrashTypeFilter.ALL + "CRASHES" -> CrashTypeFilter.CRASHES + "ANRS" -> CrashTypeFilter.ANRS + "NATIVE" -> CrashTypeFilter.NATIVE + else -> null + } + val searchQuery = searchQueryStr?.takeIf { it.isNotBlank() } + val packageName = packageHintStr?.takeIf { it.isNotBlank() } + val sortOrder = when (sortOrderStr?.uppercase()) { + "NEWEST_FIRST" -> SortOrder.NEWEST_FIRST + "OLDEST_FIRST" -> SortOrder.OLDEST_FIRST + else -> null + } + + // If nothing meaningful was extracted, fall back to text search + if (timeRange == null && typeFilter == null && searchQuery == null && + packageName == null && sortOrder == null + ) { + Log.d(TAG, "No meaningful filters extracted, falling back to text search") + return ParseResult.Fallback(originalQuery) + } + + val parsed = ParsedSearchQuery( + timeRangeHours = timeRange, + typeFilter = typeFilter, + searchQuery = searchQuery, + packageName = packageName, + sortOrder = sortOrder + ) + Log.d(TAG, "Parsed query: $parsed") + return ParseResult.Success(parsed) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse query response", e) + return ParseResult.Fallback(originalQuery) + } + } + + private fun extractQueryField(text: String, marker: String): String? { + val startIndex = text.indexOf(marker, ignoreCase = true) + if (startIndex == -1) return null + + val contentStart = startIndex + marker.length + val lineEnd = text.indexOf('\n', contentStart).takeIf { it > 0 } ?: text.length + val value = text.substring(contentStart, lineEnd).trim() + + return if (value.equals("NONE", ignoreCase = true) || value.isBlank()) { + null + } else { + value + } + } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt index 066f371..3391a50 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/CrashDetailScreen.kt @@ -69,6 +69,7 @@ import com.devbyjonathan.stacklens.model.CrashType import com.devbyjonathan.stacklens.theme.StackLensTheme import com.devbyjonathan.stacklens.util.StackTraceColors import com.devbyjonathan.stacklens.util.highlightStackTrace +import com.devbyjonathan.stacklens.util.renderInlineMarkdown import com.devbyjonathan.uikit.theme.AppTypography import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -575,17 +576,21 @@ private fun InsightSection( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) - Text( - text = content, - style = if (isCode) { - MaterialTheme.typography.bodySmall.copy( + if (isCode) { + Text( + text = content, + style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace - ) - } else { - MaterialTheme.typography.bodyMedium - }, - color = MaterialTheme.colorScheme.onSurface - ) + ), + color = MaterialTheme.colorScheme.onSurface + ) + } else { + Text( + text = renderInlineMarkdown(content), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt index 1e72c9d..4c0853a 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogListScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -27,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.Check @@ -40,12 +42,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,7 +69,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -89,6 +98,9 @@ fun CrashLogListContent( onSortOrderChange: (SortOrder) -> Unit, onTypeFilterChange: (CrashTypeFilter) -> Unit, onGroupExpand: (String) -> Unit = {}, + onToggleAiSearch: () -> Unit = {}, + onDismissAiTooltip: () -> Unit = {}, + onSuggestedPromptClick: (String) -> Unit = {}, ) { var searchQuery by remember { mutableStateOf("") } var showDurationSheet by remember { mutableStateOf(false) } @@ -123,7 +135,10 @@ fun CrashLogListContent( showSortSheet = { showSortSheet = true }, - onGroupExpand = onGroupExpand + onGroupExpand = onGroupExpand, + onToggleAiSearch = onToggleAiSearch, + onDismissAiTooltip = onDismissAiTooltip, + onSuggestedPromptClick = onSuggestedPromptClick ) } @@ -440,7 +455,7 @@ fun CrashTypeFilterChip( ) } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun CrashLogList( uiState: CrashLogUiState, @@ -451,6 +466,9 @@ fun CrashLogList( showDurationSheet: () -> Unit, showSortSheet: () -> Unit, onGroupExpand: (String) -> Unit = {}, + onToggleAiSearch: () -> Unit = {}, + onDismissAiTooltip: () -> Unit = {}, + onSuggestedPromptClick: (String) -> Unit = {}, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -459,7 +477,15 @@ fun CrashLogList( item { Search( searchQuery = searchQuery, - onSearchQueryChange = onSearchQueryChange + onSearchQueryChange = onSearchQueryChange, + isAiSearchEnabled = uiState.isAiSearchEnabled, + isAiSearchAvailable = uiState.isAiSearchAvailable, + isParsingQuery = uiState.isParsingQuery, + showAiTooltip = uiState.showAiTooltip, + suggestedPrompts = uiState.suggestedPrompts, + onToggleAiSearch = onToggleAiSearch, + onDismissTooltip = onDismissAiTooltip, + onSuggestedPromptClick = onSuggestedPromptClick ) } @@ -662,97 +688,208 @@ fun EmptyState() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun Search( searchQuery: String, onSearchQueryChange: (String) -> Unit, + isAiSearchEnabled: Boolean, + isAiSearchAvailable: Boolean, + isParsingQuery: Boolean, + showAiTooltip: Boolean, + suggestedPrompts: List, + onToggleAiSearch: () -> Unit, + onDismissTooltip: () -> Unit, + onSuggestedPromptClick: (String) -> Unit, ) { var isHintDisplayed by remember { mutableStateOf(true) } val focusManager = LocalFocusManager.current + val tooltipState = rememberTooltipState() + val scope = rememberCoroutineScope() - Box(modifier = Modifier.background(Color.Transparent)) { - BasicTextField( - value = searchQuery, - onValueChange = { - onSearchQueryChange(it) - }, - maxLines = 1, - singleLine = true, - textStyle = TextStyle( - color = MaterialTheme.colorScheme.onSurface, - fontSize = 16.sp - ), - keyboardActions = KeyboardActions( - onDone = { - // Hide keyboard on done - focusManager.clearFocus() - } - ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .onFocusChanged { - isHintDisplayed = it.isFocused.not() && searchQuery.isBlank() + // Show tooltip when needed + if (showAiTooltip && isAiSearchAvailable) { + scope.launch { + tooltipState.show() + onDismissTooltip() + } + } + + Column { + Box(modifier = Modifier.background(Color.Transparent)) { + BasicTextField( + value = searchQuery, + onValueChange = { + onSearchQueryChange(it) }, - decorationBox = { innerTextField -> - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(size = 50.dp) - ) - .padding(all = 16.dp), // inner padding - ) { + maxLines = 1, + singleLine = true, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + keyboardActions = KeyboardActions( + onDone = { + // Hide keyboard on done + focusManager.clearFocus() + } + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .onFocusChanged { + isHintDisplayed = it.isFocused.not() && searchQuery.isBlank() + }, + decorationBox = { innerTextField -> Row( modifier = Modifier .fillMaxWidth() - .weight(4f) .background( color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(size = 16.dp) - ), + shape = RoundedCornerShape(size = 50.dp) + ) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically ) { + // Search icon Icon( imageVector = Icons.Default.Search, - contentDescription = "Favorite icon", + contentDescription = "Search icon", tint = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(width = 8.dp)) - if (isHintDisplayed) { - Text( - text = "Search crashes...", - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + + // Search text field + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart + ) { + if (isHintDisplayed) { + Text( + text = if (isAiSearchEnabled) "Try \"NullPointer crashes from Gmail\"" else "Search crashes...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + + // Loading indicator when parsing query + if (isParsingQuery) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp ) + Spacer(modifier = Modifier.width(8.dp)) } - innerTextField() - } - Column( - modifier = Modifier - .weight(0.4f) - .clickable( - onClick = { onSearchQueryChange("") }, - role = Role.Button - ), - horizontalAlignment = Alignment.End - ) { + + // Clear button if (searchQuery.isNotEmpty()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear icon", - tint = MaterialTheme.colorScheme.onSurface, - ) + IconButton( + onClick = { onSearchQueryChange("") }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + + // AI toggle button (only show if available) + if (isAiSearchAvailable) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text("AI-powered search") + } + }, + state = tooltipState + ) { + IconButton( + onClick = onToggleAiSearch, + modifier = Modifier + .size(36.dp) + .background( + color = if (isAiSearchEnabled) + MaterialTheme.colorScheme.primaryContainer + else + Color.Transparent, + shape = CircleShape + ), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (isAiSearchEnabled) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = if (isAiSearchEnabled) "Disable AI search" else "Enable AI search", + modifier = Modifier.size(20.dp) + ) + } + } } } } - } - ) + ) + } + + // Suggested prompts row (only show when AI mode enabled, prompts exist, and query is blank) + if (isAiSearchEnabled && suggestedPrompts.isNotEmpty() && searchQuery.isBlank()) { + SuggestedPromptsRow( + prompts = suggestedPrompts, + onPromptClick = onSuggestedPromptClick + ) + } + } +} + +@Composable +private fun SuggestedPromptsRow( + prompts: List, + onPromptClick: (String) -> Unit, +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(prompts) { prompt -> + SuggestionChip( + onClick = { onPromptClick(prompt) }, + label = { + Text( + text = prompt, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + iconContentColor = MaterialTheme.colorScheme.primary + ) + ) + } } } @@ -767,7 +904,10 @@ private fun CrashLogListContentPreview() { onCrashClick = {}, onTimeRangeChange = {}, onSortOrderChange = {}, - onTypeFilterChange = {} + onTypeFilterChange = {}, + onToggleAiSearch = {}, + onDismissAiTooltip = {}, + onSuggestedPromptClick = {} ) } } @@ -783,7 +923,10 @@ private fun CrashLogListContentDarkPreview() { onCrashClick = {}, onTimeRangeChange = {}, onSortOrderChange = {}, - onTypeFilterChange = {} + onTypeFilterChange = {}, + onToggleAiSearch = {}, + onDismissAiTooltip = {}, + onSuggestedPromptClick = {} ) } } @@ -801,7 +944,10 @@ private fun EmptyStatePreview() { onCrashClick = {}, onTimeRangeChange = {}, onSortOrderChange = {}, - onTypeFilterChange = {} + onTypeFilterChange = {}, + onToggleAiSearch = {}, + onDismissAiTooltip = {}, + onSuggestedPromptClick = {} ) } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt index cb3f5e4..9bc26ab 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt @@ -1,8 +1,11 @@ package com.devbyjonathan.stacklens.screen.list import android.app.Application +import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.devbyjonathan.stacklens.ai.CrashInsightService +import com.devbyjonathan.stacklens.ai.ParseResult import com.devbyjonathan.stacklens.model.CrashFilter import com.devbyjonathan.stacklens.model.CrashGroup import com.devbyjonathan.stacklens.model.CrashLog @@ -12,6 +15,7 @@ import com.devbyjonathan.stacklens.model.SortOrder import com.devbyjonathan.stacklens.repository.CrashLogRepository import com.devbyjonathan.stacklens.util.PermissionChecker import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,7 +25,9 @@ import javax.inject.Inject @HiltViewModel class CrashLogViewModel @Inject constructor( private val application: Application, - private val repository: CrashLogRepository + private val repository: CrashLogRepository, + private val crashInsightService: CrashInsightService, + private val sharedPreferences: SharedPreferences, ) : ViewModel() { private val _uiState = MutableStateFlow(CrashLogUiState()) @@ -30,8 +36,15 @@ class CrashLogViewModel @Inject constructor( private val _selectedCrash = MutableStateFlow(null) val selectedCrash: StateFlow = _selectedCrash.asStateFlow() + private var aiSearchJob: Job? = null + + companion object { + private const val PREF_AI_TOOLTIP_SHOWN = "ai_search_tooltip_shown" + } + init { checkPermissions() + checkAiAvailability() } fun checkPermissions() { @@ -113,10 +126,14 @@ class CrashLogViewModel @Inject constructor( } fun setSearchQuery(query: String) { - val newFilter = _uiState.value.filter.copy( - searchQuery = query.ifBlank { null } - ) - updateFilter(newFilter) + if (_uiState.value.isAiSearchEnabled && query.isNotBlank()) { + performAiSearch(query) + } else { + val newFilter = _uiState.value.filter.copy( + searchQuery = query.ifBlank { null } + ) + updateFilter(newFilter) + } } fun setTimeRange(hours: Int) { @@ -162,6 +179,124 @@ class CrashLogViewModel @Inject constructor( } _uiState.value = _uiState.value.copy(expandedGroups = newExpanded) } + + private fun checkAiAvailability() { + viewModelScope.launch { + val isAvailable = crashInsightService.isAvailable() + val tooltipShown = sharedPreferences.getBoolean(PREF_AI_TOOLTIP_SHOWN, false) + _uiState.value = _uiState.value.copy( + isAiSearchAvailable = isAvailable, + showAiTooltip = isAvailable && !tooltipShown + ) + if (isAvailable) { + generateSuggestedPrompts() + } + } + } + + fun toggleAiSearchMode() { + val newEnabled = !_uiState.value.isAiSearchEnabled + _uiState.value = _uiState.value.copy( + isAiSearchEnabled = newEnabled, + showAiTooltip = false + ) + // Mark tooltip as shown when user first toggles AI mode + if (newEnabled && !sharedPreferences.getBoolean(PREF_AI_TOOLTIP_SHOWN, false)) { + sharedPreferences.edit().putBoolean(PREF_AI_TOOLTIP_SHOWN, true).apply() + } + // Regenerate prompts when AI mode is enabled + if (newEnabled) { + generateSuggestedPrompts() + } + } + + fun dismissAiTooltip() { + _uiState.value = _uiState.value.copy(showAiTooltip = false) + sharedPreferences.edit().putBoolean(PREF_AI_TOOLTIP_SHOWN, true).apply() + } + + private fun generateSuggestedPrompts() { + viewModelScope.launch { + val groups = _uiState.value.crashGroups.take(5) + val prompts = mutableListOf() + + // Generate prompts based on crash groups + for (group in groups) { + val appName = group.appName ?: continue + val exceptionType = group.exceptionType.substringAfterLast('.') + if (exceptionType.isNotBlank() && appName.isNotBlank()) { + prompts.add("$exceptionType from $appName") + } + } + + // Add some generic prompts if we don't have enough + if (prompts.size < 3) { + prompts.add("Crashes from today") + } + if (prompts.size < 3) { + prompts.add("ANRs from last 3 days") + } + + _uiState.value = _uiState.value.copy( + suggestedPrompts = prompts.distinct().take(4) + ) + } + } + + fun applySuggestedPrompt(prompt: String) { + performAiSearch(prompt) + } + + private fun performAiSearch(query: String) { + aiSearchJob?.cancel() + aiSearchJob = viewModelScope.launch { + _uiState.value = _uiState.value.copy(isParsingQuery = true) + + when (val result = crashInsightService.parseNaturalLanguageQuery(query)) { + is ParseResult.Success -> { + val parsed = result.query + val currentFilter = _uiState.value.filter + val newFilter = currentFilter.copy( + timeRangeHours = parsed.timeRangeHours ?: currentFilter.timeRangeHours, + typeFilter = parsed.typeFilter ?: currentFilter.typeFilter, + searchQuery = parsed.searchQuery ?: parsed.packageName, + sortOrder = parsed.sortOrder ?: currentFilter.sortOrder + ) + _uiState.value = _uiState.value.copy( + filter = newFilter, + isParsingQuery = false + ) + loadCrashLogs() + } + + is ParseResult.Fallback -> { + // Fall back to regular text search + val newFilter = _uiState.value.filter.copy( + searchQuery = result.originalQuery.ifBlank { null } + ) + _uiState.value = _uiState.value.copy( + filter = newFilter, + isParsingQuery = false + ) + loadCrashLogs() + } + + is ParseResult.Unavailable -> { + // AI not available, fall back to text search + val newFilter = _uiState.value.filter.copy( + searchQuery = query.ifBlank { null } + ) + _uiState.value = _uiState.value.copy( + filter = newFilter, + isParsingQuery = false, + isAiSearchAvailable = false, + isAiSearchEnabled = false + ) + loadCrashLogs() + } + } + } + } } data class CrashLogUiState( @@ -176,4 +311,9 @@ data class CrashLogUiState( val stats: Map = emptyMap(), val filter: CrashFilter = CrashFilter(), val error: String? = null, + val isAiSearchEnabled: Boolean = false, + val isAiSearchAvailable: Boolean = false, + val isParsingQuery: Boolean = false, + val suggestedPrompts: List = emptyList(), + val showAiTooltip: Boolean = false, ) diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt index 13f3391..1bc7d95 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/HomeScreen.kt @@ -76,6 +76,9 @@ fun HomeScreen( onDynamicColorChange: (Boolean) -> Unit, onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, + onToggleAiSearch: () -> Unit = {}, + onDismissAiTooltip: () -> Unit = {}, + onSuggestedPromptClick: (String) -> Unit = {}, ) { val navItems = listOf( BottomNavItem( @@ -181,7 +184,10 @@ fun HomeScreen( onTimeRangeChange = onTimeRangeChange, onSortOrderChange = onSortOrderChange, onTypeFilterChange = onTypeFilterChange, - onGroupExpand = onGroupExpand + onGroupExpand = onGroupExpand, + onToggleAiSearch = onToggleAiSearch, + onDismissAiTooltip = onDismissAiTooltip, + onSuggestedPromptClick = onSuggestedPromptClick ) 1 -> SettingsScreen( modifier = Modifier.padding(padding), @@ -215,7 +221,10 @@ fun HomeScreenPreview() { onThemeChange = {}, onDynamicColorChange = {}, onTermsClick = {}, - onPrivacyClick = {} + onPrivacyClick = {}, + onToggleAiSearch = {}, + onDismissAiTooltip = {}, + onSuggestedPromptClick = {} ) } } @@ -238,7 +247,10 @@ fun HomeScreenDarkPreview() { onThemeChange = {}, onDynamicColorChange = {}, onTermsClick = {}, - onPrivacyClick = {} + onPrivacyClick = {}, + onToggleAiSearch = {}, + onDismissAiTooltip = {}, + onSuggestedPromptClick = {} ) } } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt index 5aaed99..df8d1bb 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt @@ -156,7 +156,10 @@ class MainActivity : ComponentActivity() { onThemeChange = { themeManager.setThemeMode(it) }, onDynamicColorChange = { themeManager.setDynamicColorEnabled(it) }, onTermsClick = { navController.navigate(Screen.Terms.route) }, - onPrivacyClick = { navController.navigate(Screen.Privacy.route) } + onPrivacyClick = { navController.navigate(Screen.Privacy.route) }, + onToggleAiSearch = { vm.toggleAiSearchMode() }, + onDismissAiTooltip = { vm.dismissAiTooltip() }, + onSuggestedPromptClick = { vm.applySuggestedPrompt(it) } ) } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/util/MarkdownRenderer.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/MarkdownRenderer.kt new file mode 100644 index 0000000..19facfa --- /dev/null +++ b/app/src/main/java/com/devbyjonathan/stacklens/util/MarkdownRenderer.kt @@ -0,0 +1,95 @@ +package com.devbyjonathan.stacklens.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle + +/** + * Renders a minimal subset of markdown that Gemini Nano commonly emits: + * **bold**, *italic*, `code`, and `-`/`*` bullet lines. + * Not a full CommonMark parser — intentionally small and predictable. + */ +fun renderInlineMarkdown(source: String): AnnotatedString { + val normalized = normalizeBullets(source) + return buildAnnotatedString { + var i = 0 + while (i < normalized.length) { + val c = normalized[i] + + if (c == '*' && i + 1 < normalized.length && normalized[i + 1] == '*') { + val end = normalized.indexOf("**", startIndex = i + 2) + if (end > i + 2) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(normalized.substring(i + 2, end)) + } + i = end + 2 + continue + } + } + + if (c == '*' && + (i == 0 || normalized[i - 1] != '*') && + i + 1 < normalized.length && + normalized[i + 1] != '*' && + !normalized[i + 1].isWhitespace() + ) { + val end = findItalicClose(normalized, i + 1) + if (end > i + 1) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(normalized.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + if (c == '`') { + val end = normalized.indexOf('`', i + 1) + if (end > i + 1) { + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(normalized.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + append(c) + i++ + } + } +} + +private fun normalizeBullets(source: String): String = buildString { + source.lines().forEachIndexed { index, raw -> + if (index > 0) append('\n') + val trimmed = raw.trimStart() + val indent = raw.length - trimmed.length + val isBullet = (trimmed.startsWith("- ") || trimmed.startsWith("* ")) && + !trimmed.startsWith("**") + if (isBullet) { + repeat(indent) { append(' ') } + append("• ") + append(trimmed.drop(2)) + } else { + append(raw) + } + } +} + +private fun findItalicClose(text: String, from: Int): Int { + var j = from + while (j < text.length) { + if (text[j] == '*' && + text[j - 1] != ' ' && + text[j - 1] != '*' && + (j + 1 >= text.length || text[j + 1] != '*') + ) return j + j++ + } + return -1 +} From a6ba6719f10da5b52da38230ed3d61605185cb8e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 20 Apr 2026 23:59:04 +0800 Subject: [PATCH 3/3] fix: random blank page issue --- .../stacklens/data/local/dao/CrashLogDao.kt | 3 + .../stacklens/navigation/Navigation.kt | 5 +- .../repository/CrashLogRepository.kt | 8 +++ .../screen/list/CrashLogViewModel.kt | 19 ++++++ .../stacklens/screen/main/MainActivity.kt | 65 ++++++++++++++++--- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt index 82eb9bc..8b4820c 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/dao/CrashLogDao.kt @@ -12,6 +12,9 @@ interface CrashLogDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertAll(crashes: List) + @Query("SELECT * FROM crash_logs WHERE id = :id LIMIT 1") + suspend fun getCrashById(id: Long): CrashLogEntity? + @Query("SELECT * FROM crash_logs ORDER BY timestamp DESC") suspend fun getAllCrashes(): List diff --git a/app/src/main/java/com/devbyjonathan/stacklens/navigation/Navigation.kt b/app/src/main/java/com/devbyjonathan/stacklens/navigation/Navigation.kt index 27891fb..4c466d2 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/navigation/Navigation.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/navigation/Navigation.kt @@ -3,7 +3,10 @@ package com.devbyjonathan.stacklens.navigation sealed class Screen(val route: String) { data object Permission : Screen("permission") data object Home : Screen("home") - data object CrashDetail : Screen("crash_detail") + data object CrashDetail : Screen("crash_detail/{crashId}") { + const val ARG_CRASH_ID = "crashId" + fun buildRoute(crashId: Long) = "crash_detail/$crashId" + } data object Terms : Screen("terms") data object Privacy : Screen("privacy") } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt b/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt index 8d25f6f..0b29ce4 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt @@ -79,6 +79,14 @@ class CrashLogRepository @Inject constructor( emit(getCrashLogs(filter)) } + /** + * Look up a single persisted crash by its id. Used to re-hydrate the + * detail screen after the process was killed. + */ + suspend fun getCrashById(id: Long): CrashLog? { + return crashLogDao.getCrashById(id)?.toCrashLog() + } + /** * Get unique packages that have crashed */ diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt index 9bc26ab..074d391 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashLogViewModel.kt @@ -36,6 +36,9 @@ class CrashLogViewModel @Inject constructor( private val _selectedCrash = MutableStateFlow(null) val selectedCrash: StateFlow = _selectedCrash.asStateFlow() + private val _isLoadingSelectedCrash = MutableStateFlow(false) + val isLoadingSelectedCrash: StateFlow = _isLoadingSelectedCrash.asStateFlow() + private var aiSearchJob: Job? = null companion object { @@ -166,6 +169,22 @@ class CrashLogViewModel @Inject constructor( _selectedCrash.value = crash } + /** + * Ensure `selectedCrash` is populated for the given id. Called from the + * detail screen so the page re-hydrates correctly after process death. + */ + fun ensureSelectedCrash(id: Long) { + if (_selectedCrash.value?.id == id) return + viewModelScope.launch { + _isLoadingSelectedCrash.value = true + try { + _selectedCrash.value = repository.getCrashById(id) + } finally { + _isLoadingSelectedCrash.value = false + } + } + } + fun refresh() { loadCrashLogs() } diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt index df8d1bb..93cae37 100644 --- a/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt +++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/main/MainActivity.kt @@ -10,16 +10,25 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.devbyjonathan.stacklens.ai.CrashInsightService import com.devbyjonathan.stacklens.navigation.Screen import com.devbyjonathan.stacklens.screen.detail.CrashDetailScreen @@ -147,7 +156,7 @@ class MainActivity : ComponentActivity() { onSearchQueryChange = { vm.setSearchQuery(it) }, onCrashClick = { crash -> vm.selectCrash(crash) - navController.navigate(Screen.CrashDetail.route) + navController.navigate(Screen.CrashDetail.buildRoute(crash.id)) }, onTimeRangeChange = { vm.setTimeRange(it) }, onSortOrderChange = { vm.setSortOrder(it) }, @@ -163,13 +172,53 @@ class MainActivity : ComponentActivity() { ) } - composable(Screen.CrashDetail.route) { - selectedCrash?.let { crash -> - CrashDetailScreen( - crash = crash, - onBackClick = { navController.popBackStack() }, - crashInsightService = crashInsightService - ) + composable( + route = Screen.CrashDetail.route, + arguments = listOf( + navArgument(Screen.CrashDetail.ARG_CRASH_ID) { + type = NavType.LongType + } + ) + ) { backStackEntry -> + val crashId = backStackEntry.arguments + ?.getLong(Screen.CrashDetail.ARG_CRASH_ID) + + LaunchedEffect(crashId) { + crashId?.let { vm.ensureSelectedCrash(it) } + } + + val resolved = selectedCrash?.takeIf { it.id == crashId } + val loading by vm.isLoadingSelectedCrash.collectAsState() + + when { + resolved != null -> { + CrashDetailScreen( + crash = resolved, + onBackClick = { navController.popBackStack() }, + crashInsightService = crashInsightService + ) + } + + loading || crashId == null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + else -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Crash no longer available.") + } + LaunchedEffect(Unit) { + navController.popBackStack() + } + } } }