diff --git a/README.md b/README.md
index 21f415b..9b34278 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,8 @@
[](https://developer.android.com/)
[](https://kotlinlang.org/)
[](https://developer.android.com/jetpack/compose)
-[](https://android-arsenal.com/api?level=21)
+[](https://android-arsenal.com/api?level=23)
+[](https://github.com/jonathanlee06/StackLens/releases)
[](LICENSE)
*A powerful Android crash log viewer that reads system crash logs directly from your device. Built with Jetpack Compose and Material 3.*
@@ -21,20 +22,31 @@
## Features
- **View Crash Logs** - Read app crashes, ANRs, and native crashes from the system DropBox
-- **Filter & Search** - Filter by crash type (Crashes, ANRs, Native) and search through logs
-- **Sort Options** - Sort by newest or oldest first
-- **Time Range** - Filter crashes from the last hour to the last 7 days
-- **Detailed View** - View full stack traces with syntax highlighting
-- **Share & Copy** - Easily share or copy crash logs
-- **Dark Mode** - Full dark mode support with system theme following
-- **Dynamic Colors** - Material You dynamic colors on Android 12+
+- **AI Crash Insights** - On-device Gemini Nano (ML Kit GenAI) explains stack traces, flags the
+ probable cause, and suggests fixes — no network required
+- **Natural-Language Search** - Ask things like *"NullPointer from Gmail"* or *"ANRs from last 3
+ days"*; suggested prompts appear in AI mode
+- **Crash Grouping** - Similar crashes are grouped by signature with occurrence counts and
+ expandable detail
+- **Events Trend** - 7-day activity sparkline on the list screen
+- **Rich Filters** - Bottom sheet with categories and per-package selection, plus quick Crashes /
+ ANRs / Native chips
+- **Sort & Time Range** - Newest / oldest, and last hour through last 7 days
+- **Detailed View** - Full stack traces with syntax highlighting and frame parsing
+- **Background Detection** - WorkManager-driven scans surface new crashes via notifications
+- **Local Persistence** - Crashes are stored in Room so they survive DropBox eviction
+- **Share & Copy** - One-tap share or copy of any crash
+- **Loading Skeletons** - Shimmer placeholders while data loads
+- **Dark Mode & Material You** - Full dark support with dynamic colors on Android 12+
---
## Requirements
-- Android 5.0 (API 21) or higher
+- Android 6.0 (API 23) or higher
- ADB access for granting special permissions
+- Android 14+ with a compatible device (Pixel 8+, etc.) for on-device AI insights — gracefully
+ degrades on unsupported devices
---
@@ -42,11 +54,11 @@
StackLens requires special permissions that must be granted via ADB:
-| Permission | Purpose |
-|------------|---------|
-| `READ_LOGS` | Read system crash logs |
-| `READ_DROPBOX_DATA` | Access crash data from DropBoxManager |
-| `PACKAGE_USAGE_STATS` | Get app names and icons (granted via Settings) |
+| Permission | Purpose | How it's granted |
+|-----------------------|---------------------------------------|------------------|
+| `READ_LOGS` | Read system crash logs | ADB |
+| `READ_DROPBOX_DATA` | Access crash data from DropBoxManager | ADB |
+| `PACKAGE_USAGE_STATS` | Get app names and icons | System settings |
---
@@ -84,6 +96,9 @@ After installing the app, you need to grant the required permissions:
> **Note:** The app will close automatically when granting ADB permissions. This is expected Android behavior - simply reopen the app.
+> **Debug builds:** `installDebug` installs with the `.dev` application-id suffix. Grant permissions
+> against `com.devbyjonathan.stacklens.dev` instead of `com.devbyjonathan.stacklens`.
+
---
## Tech Stack
@@ -91,9 +106,14 @@ After installing the app, you need to grant the required permissions:
- **Kotlin** - 100% Kotlin
- **Jetpack Compose** - Modern declarative UI
- **Material 3** - Material Design 3 with dynamic colors
+- **Navigation Compose** - Single activity navigation
- **Hilt** - Dependency injection
- **Coroutines & Flow** - Asynchronous programming
-- **Navigation Compose** - Single activity navigation
+- **Room** - Local persistence for crash history
+- **WorkManager + Hilt Worker** - Background crash detection
+- **ML Kit GenAI (Gemini Nano)** - On-device AI crash insights
+- **AndroidX WebKit** - In-app WebView with theme-aware rendering
+- **Firebase Analytics & Crashlytics** - Usage and stability telemetry
- **DropBoxManager** - System crash log access
---
@@ -155,7 +175,8 @@ own.
-**⭐ [Star](https://github.com/jonathanlee06/Teleport/stargazers) this repo if you find it helpful!**
+**⭐ [Star](https://github.com/jonathanlee06/StackLens/stargazers) this repo if you find it helpful!
+**
Made with ❤️ and ☕ by Jonathan
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 315cd3a..0e82c0f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -100,6 +100,7 @@ dependencies {
implementation(libs.coroutines.android)
implementation(libs.androidx.browser)
implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.webkit)
testImplementation(libs.coroutines.test)
// Firebase
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
new file mode 100644
index 0000000..cdc798a
Binary files /dev/null and b/app/src/debug/ic_launcher-playstore.png differ
diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml
index 4463a49..ca01627 100644
--- a/app/src/debug/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml
@@ -9,21 +9,21 @@
android:translateY="150.08">
+ android:fillColor="#FF8975" />
+ android:fillColor="#FF8975" />
+ android:fillColor="#6B655A" />
+ android:fillColor="#6B655A" />
+ android:fillColor="#191613" />
+ android:fillColor="#191613" />
diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..ac94b34
--- /dev/null
+++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..0043289
Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b57d830
Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..751b073
Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..53507be
Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..45169aa
Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..978ef70
Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aaf1a55
Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..31477fe
Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..a5cf48f
Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7cb11d7
Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml
index 1a97ea1..d66b399 100644
--- a/app/src/debug/res/values/ic_launcher_background.xml
+++ b/app/src/debug/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #FF8800
+ #F0EAE1
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b55a08a..c704c77 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,6 +30,7 @@
(DownloadState.Idle)
val downloadState: StateFlow = _downloadState.asStateFlow()
+ private val inFlightMutex = Mutex()
+ private val inFlight = mutableMapOf>()
+
companion object {
private const val TAG = "CrashInsightService"
private const val NOTIFICATION_CHANNEL_ID = "gemini_nano_download"
@@ -291,8 +322,41 @@ class CrashInsightService @Inject constructor(
/**
* Analyze a crash log and return AI-powered insights.
* First checks for cached insight, otherwise calls AI and caches the result.
+ * Concurrent calls for the same crash id share a single in-flight generation.
+ *
+ * @param groupCount number of occurrences of this crash pattern; feeds into the
+ * deterministic confidence heuristic.
*/
- suspend fun analyzeCrash(crash: CrashLog): InsightResult = withContext(Dispatchers.IO) {
+ suspend fun analyzeCrash(crash: CrashLog, groupCount: Int = 1): InsightResult {
+ val deferred = inFlightMutex.withLock {
+ inFlight[crash.id] ?: serviceScope.async {
+ doAnalyzeCrash(crash, groupCount)
+ }.also { job ->
+ inFlight[crash.id] = job
+ job.invokeOnCompletion {
+ serviceScope.launch {
+ inFlightMutex.withLock { inFlight.remove(crash.id) }
+ }
+ }
+ }
+ }
+ return deferred.await()
+ }
+
+ /**
+ * Kick off analysis in the background so the insight is cached by the time
+ * the user opens the AI screen. Safe to call multiple times — in-flight
+ * dedupe ensures only one generation runs per crash id.
+ */
+ fun preloadInsight(crash: CrashLog, groupCount: Int = 1) {
+ serviceScope.launch {
+ runCatching { analyzeCrash(crash, groupCount) }
+ .onFailure { Log.w(TAG, "preloadInsight failed for crash ${crash.id}", it) }
+ }
+ }
+
+ private suspend fun doAnalyzeCrash(crash: CrashLog, groupCount: Int): InsightResult =
+ withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Starting crash analysis for: ${crash.packageName} (id=${crash.id})")
@@ -353,7 +417,7 @@ class CrashInsightService @Inject constructor(
}
Log.d(TAG, "AI Response received (${text.length} chars):\n$text")
- val result = parseInsightResponse(text)
+ val result = parseInsightResponse(text, crash, groupCount)
// Cache successful insight
if (result is InsightResult.Success) {
@@ -398,6 +462,8 @@ $stackTrace
Respond in this exact format:
+TITLE: [A short 4–8 word headline naming the issue — noun phrase, no trailing punctuation]
+
SUMMARY: [One sentence explaining what happened]
ROOT_CAUSE: [The specific cause of the crash]
@@ -405,10 +471,16 @@ ROOT_CAUSE: [The specific cause of the crash]
SUGGESTED_FIX: [How to fix this issue]
AFFECTED_LINE: [The key line from the stack trace, or N/A]
+
+SEVERITY: [HIGH, MEDIUM, or LOW — judge user-facing impact]
""".trimIndent()
}
- private fun parseInsightResponse(response: String): InsightResult {
+ private fun parseInsightResponse(
+ response: String,
+ crash: CrashLog,
+ groupCount: Int,
+ ): InsightResult {
try {
val summary = extractSection(response, "SUMMARY:") ?: "Unable to determine summary"
val rootCause =
@@ -416,15 +488,36 @@ AFFECTED_LINE: [The key line from the stack trace, or N/A]
val suggestedFix = extractSection(response, "SUGGESTED_FIX:") ?: "Unable to suggest fix"
val affectedLine = extractSection(response, "AFFECTED_LINE:")?.takeIf {
it.isNotBlank() && !it.equals("N/A", ignoreCase = true)
- }
+ }?.trim()
+ val severity = parseSeverity(extractSection(response, "SEVERITY:"))
+ val title = extractSection(response, "TITLE:")
+ ?.trim()
+ ?.trimEnd('.', ',', ';', ':')
+ ?.takeIf { it.isNotBlank() }
+ ?: deriveFallbackTitle(summary)
+
+ val exceptionType = signatureGenerator.extractExceptionType(crash.content, crash.tag)
+ val appFrames = countAppFrames(crash.content)
+ val confidence = computeConfidence(
+ exceptionType = exceptionType,
+ affectedLine = affectedLine,
+ appFrameCount = appFrames,
+ groupCount = groupCount,
+ )
- Log.d(TAG, "Parsed insight - Summary: ${summary.take(50)}...")
+ Log.d(
+ TAG,
+ "Parsed insight — title='${title.take(40)}' severity=$severity confidence=$confidence appFrames=$appFrames groupCount=$groupCount"
+ )
return InsightResult.Success(
CrashInsight(
+ title = title,
summary = summary.trim(),
rootCause = rootCause.trim(),
suggestedFix = suggestedFix.trim(),
- affectedLine = affectedLine?.trim()
+ affectedLine = affectedLine,
+ severity = severity,
+ confidence = confidence,
)
)
} catch (e: Exception) {
@@ -433,12 +526,56 @@ AFFECTED_LINE: [The key line from the stack trace, or N/A]
}
}
+ /**
+ * Last-resort title if the model omits TITLE or emits blank. Take the first clause of the
+ * summary up to ~8 words.
+ */
+ private fun deriveFallbackTitle(summary: String): String {
+ val firstClause = summary.substringBefore('.').substringBefore(';').trim()
+ val words = firstClause.split(Regex("\\s+")).filter { it.isNotBlank() }
+ return if (words.size <= 8) firstClause else words.take(8).joinToString(" ")
+ }
+
+ private fun parseSeverity(raw: String?): Severity {
+ return when (raw?.trim()?.uppercase()) {
+ "HIGH" -> Severity.HIGH
+ "LOW" -> Severity.LOW
+ "MEDIUM", "MED" -> Severity.MEDIUM
+ else -> Severity.MEDIUM
+ }
+ }
+
+ /**
+ * Deterministic confidence in [0f, 1f]. Pure function of parse quality + trace shape +
+ * how often this pattern recurs. No probing of model internals.
+ */
+ internal fun computeConfidence(
+ exceptionType: String,
+ affectedLine: String?,
+ appFrameCount: Int,
+ groupCount: Int,
+ ): Float {
+ var score = 0.50f
+ if (exceptionType != "UnknownException") score += 0.15f
+ if (affectedLine != null) score += 0.10f
+ score += 0.05f * minOf(4, appFrameCount)
+ if (groupCount >= 3) score += 0.05f
+ return score.coerceIn(0f, 1f)
+ }
+
private fun extractSection(text: String, marker: String): String? {
val startIndex = text.indexOf(marker, ignoreCase = true)
if (startIndex == -1) return null
val contentStart = startIndex + marker.length
- val markers = listOf("SUMMARY:", "ROOT_CAUSE:", "SUGGESTED_FIX:", "AFFECTED_LINE:")
+ val markers = listOf(
+ "TITLE:",
+ "SUMMARY:",
+ "ROOT_CAUSE:",
+ "SUGGESTED_FIX:",
+ "AFFECTED_LINE:",
+ "SEVERITY:"
+ )
val nextMarkerIndex = markers
.filter { !it.equals(marker, ignoreCase = true) }
.mapNotNull { nextMarker ->
@@ -449,4 +586,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/common/CrashBreadcrumb.kt b/app/src/main/java/com/devbyjonathan/stacklens/common/CrashBreadcrumb.kt
new file mode 100644
index 0000000..38bd668
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/common/CrashBreadcrumb.kt
@@ -0,0 +1,136 @@
+package com.devbyjonathan.stacklens.common
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
+
+/**
+ * Short id for a crash — last 5 digits of the timestamp-based long id. Rendered in breadcrumbs.
+ */
+fun shortCrashId(id: Long): String {
+ val mod = (id % 100000).toString().padStart(5, '0')
+ return "#$mod"
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CrashBreadcrumb(
+ crashId: Long,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ subRoute: String? = null,
+ colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = scheme.background,
+ ),
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ CenterAlignedTopAppBar(
+ modifier = modifier,
+ colors = colors,
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ title = {
+ val muted = scheme.onSurfaceVariant
+ val emphasised = scheme.onSurface
+ val text = buildAnnotatedString {
+ withStyle(SpanStyle(color = muted, fontFamily = GoogleSansCode)) {
+ append("crashes")
+ }
+ withStyle(SpanStyle(color = muted, fontFamily = GoogleSansCode)) {
+ append(" / ")
+ }
+ withStyle(
+ SpanStyle(
+ color = emphasised,
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ )
+ ) {
+ append(shortCrashId(crashId))
+ }
+ if (subRoute != null) {
+ withStyle(SpanStyle(color = muted, fontFamily = GoogleSansCode)) {
+ append(" / ")
+ }
+ withStyle(
+ SpanStyle(
+ color = emphasised,
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ )
+ ) {
+ append(subRoute)
+ }
+ }
+ }
+ Text(text = text, style = typo.titleMedium)
+ },
+ actions = actions,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun CrashBreadcrumbPreview() {
+ StackLensTheme {
+ CrashBreadcrumb(
+ crashId = 1761234518440L,
+ onBackClick = {},
+ actions = {
+ IconButton(onClick = {}) {
+ Icon(Icons.Default.AutoAwesome, contentDescription = null)
+ }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun CrashBreadcrumbWithSubRoutePreview() {
+ StackLensTheme {
+ CrashBreadcrumb(
+ crashId = 1761234518440L,
+ onBackClick = {},
+ subRoute = "ai",
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CrashBreadcrumbDarkPreview() {
+ StackLensTheme {
+ CrashBreadcrumb(
+ crashId = 1761234518440L,
+ onBackClick = {},
+ subRoute = "ai",
+ )
+ }
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/common/CrashTypeBadge.kt b/app/src/main/java/com/devbyjonathan/stacklens/common/CrashTypeBadge.kt
index a357e62..7efd642 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/common/CrashTypeBadge.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/common/CrashTypeBadge.kt
@@ -1,34 +1,42 @@
package com.devbyjonathan.stacklens.common
import android.content.res.Configuration
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.devbyjonathan.stacklens.model.CrashType
import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
@Composable
fun CrashTypeBadge(type: CrashType) {
val (color, text) = when (type) {
CrashType.DATA_APP_CRASH, CrashType.SYSTEM_APP_CRASH ->
- MaterialTheme.colorScheme.error to "CRASH"
+ scheme.error to "CRASH"
CrashType.DATA_APP_ANR, CrashType.SYSTEM_APP_ANR ->
- MaterialTheme.colorScheme.tertiary to "ANR"
+ scheme.tertiary to "ANR"
CrashType.SYSTEM_TOMBSTONE ->
- MaterialTheme.colorScheme.secondary to "NATIVE"
+ scheme.secondary to "NATIVE"
else ->
- MaterialTheme.colorScheme.outline to type.displayName.uppercase()
+ scheme.outline to type.displayName.uppercase()
}
Surface(
@@ -38,7 +46,7 @@ fun CrashTypeBadge(type: CrashType) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
- style = MaterialTheme.typography.labelSmall.copy(
+ style = typo.labelSmall.copy(
fontWeight = FontWeight.SemiBold
),
color = color
@@ -46,6 +54,74 @@ fun CrashTypeBadge(type: CrashType) {
}
}
+@Composable
+fun CrashTypeBadgeDetail(type: CrashType, timestamp: Long) {
+ val (color, text) = when (type) {
+ CrashType.DATA_APP_CRASH, CrashType.SYSTEM_APP_CRASH ->
+ scheme.errorContainer to "CRASH"
+
+ CrashType.DATA_APP_ANR, CrashType.SYSTEM_APP_ANR ->
+ scheme.tertiaryContainer to "ANR"
+
+ CrashType.SYSTEM_TOMBSTONE ->
+ scheme.secondaryContainer to "NATIVE"
+
+ else ->
+ scheme.outline to type.displayName.uppercase()
+ }
+
+ Row(
+ modifier = Modifier
+ .background(
+ color = color,
+ shape = RoundedCornerShape(16.dp)
+ )
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = text,
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ ),
+ )
+ Text(
+ text = "·",
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ ),
+ )
+ Text(
+ text = relativeTime(timestamp),
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ ),
+ )
+ }
+}
+
+private fun relativeTime(timestamp: Long): String {
+ val delta = (System.currentTimeMillis() - timestamp).coerceAtLeast(0)
+ val seconds = delta / 1000
+ val minutes = seconds / 60
+ val hours = minutes / 60
+ val days = hours / 24
+ return when {
+ seconds < 60 -> "just now"
+ minutes < 60 -> "${minutes}m ago"
+ hours < 24 -> "${hours}h ago"
+ days < 7 -> "${days}d ago"
+ else -> "${days / 7}w ago"
+ }
+}
+
@Preview(showBackground = true)
@Composable
private fun CrashTypeBadgePreview() {
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/common/ShimmerBox.kt b/app/src/main/java/com/devbyjonathan/stacklens/common/ShimmerBox.kt
new file mode 100644
index 0000000..43ea03e
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/common/ShimmerBox.kt
@@ -0,0 +1,53 @@
+package com.devbyjonathan.stacklens.common
+
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.dp
+import com.devbyjonathan.uikit.theme.scheme
+
+/**
+ * Neutral shimmering placeholder block used by skeleton-loading screens.
+ * Sweeps a linear-gradient highlight across a dimmed base fill to suggest
+ * content is coming. Sized and shaped by the caller.
+ */
+@Composable
+fun ShimmerBox(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(6.dp),
+) {
+ val base = scheme.onSurface.copy(alpha = 0.07f)
+ val highlight = scheme.onSurface.copy(alpha = 0.18f)
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val progress by transition.animateColor(
+ initialValue = base,
+ targetValue = highlight,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 600, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "shimmer-progress",
+ )
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .drawBehind {
+ drawRoundRect(
+ color = progress,
+ cornerRadius = CornerRadius(x = 4f, y = 4f),
+ )
+ },
+ )
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt
index 59b7dbc..d70609a 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/StackLensDatabase.kt
@@ -9,7 +9,7 @@ import com.devbyjonathan.stacklens.data.local.entity.CrashLogEntity
@Database(
entities = [CrashLogEntity::class, CrashInsightEntity::class],
- version = 2,
+ version = 4,
exportSchema = false
)
abstract class StackLensDatabase : RoomDatabase() {
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/data/local/entity/CrashInsightEntity.kt b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt
index 534010f..a0bda0b 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/data/local/entity/CrashInsightEntity.kt
@@ -5,6 +5,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.devbyjonathan.stacklens.ai.CrashInsight
+import com.devbyjonathan.stacklens.ai.Severity
@Entity(
tableName = "crash_insights",
@@ -21,18 +22,25 @@ import com.devbyjonathan.stacklens.ai.CrashInsight
data class CrashInsightEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val crashId: Long,
+ val title: String = "",
val summary: String,
val rootCause: String,
val suggestedFix: String,
val affectedLine: String?,
+ val severity: String = "MEDIUM",
+ val confidence: Float = 0.7f,
val createdAt: Long = System.currentTimeMillis(),
) {
fun toCrashInsight(): CrashInsight {
+ val resolvedTitle = title.ifBlank { summary.substringBefore('.').take(60) }
return CrashInsight(
+ title = resolvedTitle,
summary = summary,
rootCause = rootCause,
suggestedFix = suggestedFix,
- affectedLine = affectedLine
+ affectedLine = affectedLine,
+ severity = runCatching { Severity.valueOf(severity.uppercase()) }.getOrDefault(Severity.MEDIUM),
+ confidence = confidence,
)
}
@@ -40,11 +48,14 @@ data class CrashInsightEntity(
fun fromCrashInsight(crashId: Long, insight: CrashInsight): CrashInsightEntity {
return CrashInsightEntity(
crashId = crashId,
+ title = insight.title,
summary = insight.summary,
rootCause = insight.rootCause,
suggestedFix = insight.suggestedFix,
- affectedLine = insight.affectedLine
+ affectedLine = insight.affectedLine,
+ severity = insight.severity.name,
+ confidence = insight.confidence,
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt b/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt
index 2248f12..306a351 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/di/DatabaseModule.kt
@@ -38,6 +38,19 @@ object DatabaseModule {
}
}
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE crash_insights ADD COLUMN severity TEXT NOT NULL DEFAULT 'MEDIUM'")
+ db.execSQL("ALTER TABLE crash_insights ADD COLUMN confidence REAL NOT NULL DEFAULT 0.7")
+ }
+ }
+
+ private val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE crash_insights ADD COLUMN title TEXT NOT NULL DEFAULT ''")
+ }
+ }
+
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): StackLensDatabase {
@@ -46,7 +59,7 @@ object DatabaseModule {
StackLensDatabase::class.java,
StackLensDatabase.DATABASE_NAME
)
- .addMigrations(MIGRATION_1_2)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
// Enable foreign key constraints
.addCallback(object : androidx.room.RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/model/CrashFilter.kt b/app/src/main/java/com/devbyjonathan/stacklens/model/CrashFilter.kt
index 95fbc02..1bc0c44 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/model/CrashFilter.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/model/CrashFilter.kt
@@ -12,11 +12,40 @@ enum class CrashTypeFilter(val displayName: String) {
NATIVE("Native")
}
+/**
+ * Coarse crash category used by the filter sheet's multi-select UI.
+ * Maps to one or more underlying [CrashType]s via [crashTypes].
+ */
+enum class CrashCategory(
+ val displayName: String,
+ val badgeLabel: String,
+ val crashTypes: List,
+) {
+ CRASH("Crash", "CRASH", CrashType.appCrashTags),
+ ANR("ANR", "ANR", CrashType.anrTags),
+ NATIVE("Native crash", "NATIVE", CrashType.nativeTags);
+
+ companion object {
+ fun fromCrashType(type: CrashType): CrashCategory? =
+ entries.firstOrNull { type in it.crashTypes }
+ }
+}
+
+/**
+ * Inclusive custom time range in epoch milliseconds used by the filter sheet's
+ * "Custom…" chip. When non-null on [CrashFilter], takes precedence over [CrashFilter.timeRangeHours].
+ */
+data class CustomTimeRange(val startMs: Long, val endMs: Long)
+
data class CrashFilter(
val types: Set = CrashType.entries.toSet(),
val packageName: String? = null,
val searchQuery: String? = null,
val timeRangeHours: Int = 168, // Default to last 7 days
val sortOrder: SortOrder = SortOrder.NEWEST_FIRST,
- val typeFilter: CrashTypeFilter = CrashTypeFilter.ALL
+ val typeFilter: CrashTypeFilter = CrashTypeFilter.ALL,
+ // Filter-sheet dimensions. Empty sets and null range mean "no restriction".
+ val selectedCategories: Set = emptySet(),
+ val selectedPackages: Set = emptySet(),
+ val customTimeRange: CustomTimeRange? = null,
)
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/model/fake/PreviewData.kt b/app/src/main/java/com/devbyjonathan/stacklens/model/fake/PreviewData.kt
index fa427bd..335d12d 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/model/fake/PreviewData.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/model/fake/PreviewData.kt
@@ -1,8 +1,13 @@
package com.devbyjonathan.stacklens.model.fake
+import com.devbyjonathan.stacklens.ai.CrashInsight
+import com.devbyjonathan.stacklens.ai.Severity
import com.devbyjonathan.stacklens.model.CrashFilter
+import com.devbyjonathan.stacklens.model.CrashGroup
import com.devbyjonathan.stacklens.model.CrashLog
import com.devbyjonathan.stacklens.model.CrashType
+import com.devbyjonathan.stacklens.repository.DayBucket
+import com.devbyjonathan.stacklens.repository.EventsTrend
import com.devbyjonathan.stacklens.screen.list.CrashLogUiState
object PreviewData {
@@ -95,6 +100,39 @@ object PreviewData {
CrashType.SYSTEM_TOMBSTONE to 2
)
+ val sampleInsight = CrashInsight(
+ title = "Unhandled exception in a Compose tap lambda",
+ summary = "The app crashes due to an unhandled exception within a Compose lambda related to a tap gesture.",
+ rootCause = "The crash occurs within a nested Compose lambda during tap-gesture detection. The gesture handler propagates an unexpected exception to the composition root.",
+ suggestedFix = "Wrap the gesture lambda in runCatching or use Compose's error boundary.",
+ affectedLine = "MainActivity.kt:29",
+ severity = Severity.HIGH,
+ confidence = 0.91f,
+ )
+
+ val sampleCrashGroup = CrashGroup(
+ signature = "RuntimeException:data_app_crash:abc123",
+ exceptionType = "RuntimeException",
+ crashes = listOf(sampleCrashLogs.first()),
+ count = 12,
+ firstOccurrence = currentTime - 1000 * 60 * 60 * 24,
+ lastOccurrence = currentTime - 1000 * 60 * 5,
+ )
+
+ val sampleEventsTrend: EventsTrend = run {
+ val dayMs = 24L * 60 * 60 * 1000
+ val today = (currentTime / dayMs) * dayMs
+ val counts = listOf(2, 1, 4, 3, 5, 4, 4)
+ EventsTrend(
+ current = counts.sum(),
+ previous = 17,
+ deltaPercent = 35.3f,
+ buckets = counts.mapIndexed { idx, c ->
+ DayBucket(today - ((counts.size - 1 - idx) * dayMs), c)
+ },
+ )
+ }
+
val sampleUiState = CrashLogUiState(
isLoading = false,
hasPermissions = true,
@@ -104,6 +142,7 @@ object PreviewData {
crashLogs = sampleCrashLogs,
stats = sampleStats,
filter = CrashFilter(),
- error = null
+ error = null,
+ eventsTrend = sampleEventsTrend,
)
}
\ No newline at end of file
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..37fc502 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,14 @@ 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 AiInsight : Screen("ai_insight/{crashId}") {
+ const val ARG_CRASH_ID = "crashId"
+ fun buildRoute(crashId: Long) = "ai_insight/$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..c21762b 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/repository/CrashLogRepository.kt
@@ -10,9 +10,19 @@ import com.devbyjonathan.stacklens.service.CrashLogReader
import com.devbyjonathan.stacklens.service.CrashSignatureGenerator
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
+import java.util.Calendar
import javax.inject.Inject
import javax.inject.Singleton
+data class DayBucket(val dayStartMs: Long, val count: Int)
+
+data class EventsTrend(
+ val current: Int,
+ val previous: Int,
+ val deltaPercent: Float,
+ val buckets: List,
+)
+
@Singleton
class CrashLogRepository @Inject constructor(
private val crashLogReader: CrashLogReader,
@@ -28,11 +38,23 @@ class CrashLogRepository @Inject constructor(
// Clean up old entries first
cleanupOldEntries()
+ // Resolve time window: custom range overrides timeRangeHours when set.
+ val now = System.currentTimeMillis()
+ val (sinceTimestamp, untilTimestamp) = filter.customTimeRange?.let {
+ it.startMs to it.endMs
+ } ?: (now - (filter.timeRangeHours * 60 * 60 * 1000L) to now)
+
+ // DropBoxManager reads use sinceHours; for a custom range, read from the window
+ // start up to "now" (DropBoxManager can't read future entries anyway).
+ val readSinceHours = ((now - sinceTimestamp) / (60 * 60 * 1000L))
+ .toInt()
+ .coerceAtLeast(1)
+
// Read fresh crashes from DropBox
val freshLogs = try {
crashLogReader.readCrashLogs(
types = filter.types.toList(),
- sinceHours = filter.timeRangeHours
+ sinceHours = readSinceHours
)
} catch (e: SecurityException) {
// Permission not granted - return only persisted data
@@ -45,8 +67,6 @@ class CrashLogRepository @Inject constructor(
crashLogDao.insertAll(entities)
}
- // Calculate time range for query
- val sinceTimestamp = System.currentTimeMillis() - (filter.timeRangeHours * 60 * 60 * 1000L)
val tags = filter.types.map { it.tag }
// Get persisted crashes within time range
@@ -56,6 +76,7 @@ class CrashLogRepository @Inject constructor(
// Merge and deduplicate (ID is timestamp-based, so duplicates have same ID)
val allLogs = (freshLogs + persistedLogs)
.distinctBy { it.id }
+ .filter { it.timestamp in sinceTimestamp..untilTimestamp }
.sortedByDescending { it.timestamp }
return allLogs.filter { log ->
@@ -71,7 +92,15 @@ class CrashLogRepository @Inject constructor(
log.appName?.contains(query, ignoreCase = true) == true
} ?: true
- matchesPackage && matchesSearch
+ // Filter-sheet category selection (empty set == no restriction)
+ val matchesCategory = filter.selectedCategories.isEmpty() ||
+ filter.selectedCategories.any { log.tag in it.crashTypes }
+
+ // Filter-sheet package selection (empty set == no restriction)
+ val matchesSheetPackage = filter.selectedPackages.isEmpty() ||
+ (log.packageName != null && log.packageName in filter.selectedPackages)
+
+ matchesPackage && matchesSearch && matchesCategory && matchesSheetPackage
}
}
@@ -79,6 +108,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
*/
@@ -148,6 +185,66 @@ class CrashLogRepository @Inject constructor(
.sortedByDescending { it.lastOccurrence }
}
+ /**
+ * Compute an events-over-time summary: daily counts for the current [days]-day window
+ * plus a percentage delta vs the prior equal-length window.
+ *
+ * `buckets` is ordered oldest → newest and length == days. Missing days are returned
+ * with count = 0 so the sparkline can render a flat baseline.
+ */
+ suspend fun getEventsTrend(days: Int = 7): EventsTrend {
+ cleanupOldEntries()
+
+ val now = System.currentTimeMillis()
+ val dayMs = 24L * 60 * 60 * 1000
+ val windowMs = days * dayMs
+
+ val fromTimestamp = now - (2 * windowMs)
+ val tags = CrashType.entries.map { it.tag }
+ val logs = crashLogDao.getCrashesByTagsSince(tags, fromTimestamp)
+
+ val currentStart = now - windowMs
+ val previousStart = now - (2 * windowMs)
+ var current = 0
+ var previous = 0
+ for (log in logs) {
+ when {
+ log.timestamp >= currentStart -> current++
+ log.timestamp >= previousStart -> previous++
+ }
+ }
+
+ val startOfToday = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ val countsByDay = HashMap()
+ for (log in logs) {
+ if (log.timestamp < currentStart) continue
+ val bucketOffsetDays = ((startOfToday - log.timestamp) / dayMs).coerceAtLeast(0)
+ val bucketStart = startOfToday - (bucketOffsetDays * dayMs)
+ countsByDay[bucketStart] = (countsByDay[bucketStart] ?: 0) + 1
+ }
+ val buckets = (days - 1 downTo 0).map { offset ->
+ val dayStart = startOfToday - (offset * dayMs)
+ DayBucket(dayStart, countsByDay[dayStart] ?: 0)
+ }
+
+ val delta = if (previous == 0) {
+ if (current == 0) 0f else 100f
+ } else {
+ (current - previous) / previous.toFloat() * 100f
+ }
+ return EventsTrend(
+ current = current,
+ previous = previous,
+ deltaPercent = delta,
+ buckets = buckets
+ )
+ }
+
/**
* Delete crash logs older than retention period (7 days)
*/
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightContent.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightContent.kt
new file mode 100644
index 0000000..6fd03ef
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightContent.kt
@@ -0,0 +1,381 @@
+package com.devbyjonathan.stacklens.screen.detail
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.devbyjonathan.stacklens.ai.CrashInsight
+import com.devbyjonathan.stacklens.ai.Severity
+import com.devbyjonathan.stacklens.model.fake.PreviewData
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.stacklens.util.MarkdownText
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
+import java.util.Locale
+
+@Composable
+fun AiInsightContent(
+ insight: CrashInsight,
+ similarCount: Int,
+ onAffectedLineClick: (() -> Unit)? = null,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SourceAttributionCard(insight = insight)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(scheme.surface)
+ .border(1.dp, scheme.outlineVariant, shape = RoundedCornerShape(16.dp))
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ InsightSectionBlock(label = "ROOT CAUSE", body = insight.rootCause)
+ HorizontalDivider(thickness = 1.dp, color = scheme.outlineVariant)
+ InsightSectionBlock(label = "SUGGESTED FIX", body = insight.suggestedFix)
+ }
+ StatsRow(
+ severity = insight.severity,
+ confidence = insight.confidence,
+ similarCount = similarCount,
+ )
+ insight.affectedLine?.let { line ->
+ AffectedLineRow(location = line, onClick = onAffectedLineClick)
+ }
+ }
+}
+
+@Composable
+private fun SourceAttributionCard(insight: CrashInsight) {
+ Surface(
+ color = scheme.primaryContainer,
+ shape = RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomEnd = 16.dp,
+ bottomStart = 6.dp
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(scheme.surface.copy(alpha = 0.5f))
+ .padding(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.AutoAwesome,
+ contentDescription = null,
+ tint = scheme.onPrimaryContainer,
+ modifier = Modifier.size(12.dp),
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Gemini Nano",
+ style = typo.labelMedium.copy(
+ fontFamily = GoogleSansCode
+ ),
+ color = scheme.onPrimaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = "·",
+ style = typo.labelMedium,
+ color = scheme.onPrimaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = "on device",
+ style = typo.labelMedium.copy(
+ fontFamily = GoogleSansCode
+ ),
+ color = scheme.onPrimaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = String.format(Locale.US, "%.2f", insight.confidence),
+ style = typo.labelMedium.copy(fontFamily = GoogleSansCode),
+ color = scheme.onPrimaryContainer,
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ MarkdownText(
+ text = insight.summary,
+ style = typo.bodyMedium.copy(lineHeight = 21.sp),
+ color = scheme.onPrimaryContainer,
+ inlineCodeBackground = scheme.onPrimaryContainer.copy(alpha = 0.12f),
+ inlineCodeColor = scheme.onPrimaryContainer,
+ )
+ }
+ }
+}
+
+@Composable
+private fun InsightSectionBlock(label: String, body: String) {
+ Column {
+ Text(
+ text = label,
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode
+ ),
+ color = scheme.tertiary,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 1.sp,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ MarkdownText(
+ text = body,
+ style = typo.bodyMedium.copy(lineHeight = 21.sp),
+ color = scheme.onSurface,
+ )
+ }
+}
+
+@Composable
+private fun StatsRow(severity: Severity, confidence: Float, similarCount: Int) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ StatsCard(
+ label = "SEVERITY",
+ modifier = Modifier.weight(1f),
+ ) {
+ SeverityPill(severity = severity)
+ }
+ StatsCard(
+ label = "CONFIDENCE",
+ modifier = Modifier.weight(1f),
+ ) {
+ StatsValueText(text = String.format(Locale.US, "%.2f", confidence))
+ }
+ StatsCard(
+ label = "SIMILAR",
+ modifier = Modifier.weight(1f),
+ ) {
+ StatsValueText(text = similarCount.toString())
+ }
+ }
+}
+
+@Composable
+private fun StatsCard(
+ label: String,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = scheme.surface,
+ ),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 10.dp, vertical = 10.dp),
+ ) {
+ Text(
+ text = label,
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode,
+ fontSize = 11.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ color = scheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ softWrap = false,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ // Shared min-height so the pill (with its own padding) and the bare number
+ // texts sit on the same baseline across the three cards.
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 28.dp)
+ .wrapContentHeight(Alignment.CenterVertically),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatsValueText(text: String) {
+ Text(
+ text = text,
+ style = typo.titleMedium.copy(
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ ),
+ color = scheme.onSurface,
+ maxLines = 1,
+ softWrap = false,
+ )
+}
+
+@Composable
+private fun SeverityPill(severity: Severity) {
+ val (label, color) = when (severity) {
+ Severity.HIGH -> "High" to scheme.error
+ Severity.MEDIUM -> "Medium" to scheme.tertiary
+ Severity.LOW -> "Low" to scheme.secondary
+ }
+ Surface(
+ color = color.copy(alpha = 0.18f),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Text(
+ text = label,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
+ style = typo.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp
+ ),
+ color = color,
+ maxLines = 1,
+ softWrap = false,
+ )
+ }
+}
+
+@Composable
+private fun AffectedLineRow(location: String, onClick: (() -> Unit)?) {
+ val shape = RoundedCornerShape(12.dp)
+ val rowModifier = Modifier
+ .fillMaxWidth()
+ .background(scheme.surface, shape = shape)
+ .border(1.dp, scheme.outlineVariant, shape = shape)
+ val clickableModifier = if (onClick != null) {
+ rowModifier.clickable(onClick = onClick)
+ } else rowModifier
+ Row(
+ modifier = clickableModifier.padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
+ contentDescription = null,
+ tint = scheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = "Affected ",
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = location,
+ style = typo.bodyMedium.copy(fontFamily = GoogleSansCode),
+ color = scheme.error,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ if (onClick != null) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+ contentDescription = "Open in stack",
+ tint = scheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, heightDp = 700)
+@Composable
+private fun AiInsightContentPreview() {
+ StackLensTheme {
+ Column(modifier = Modifier
+ .background(scheme.background)
+ .padding(vertical = 16.dp)) {
+ AiInsightContent(
+ insight = PreviewData.sampleInsight,
+ similarCount = 12,
+ onAffectedLineClick = {},
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, heightDp = 700, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun AiInsightContentDarkPreview() {
+ StackLensTheme {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ AiInsightContent(
+ insight = PreviewData.sampleInsight,
+ similarCount = 12,
+ onAffectedLineClick = {},
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, heightDp = 700)
+@Composable
+private fun AiInsightContentLowSeverityPreview() {
+ StackLensTheme {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ AiInsightContent(
+ insight = PreviewData.sampleInsight.copy(
+ severity = Severity.LOW,
+ confidence = 0.62f,
+ affectedLine = null,
+ ),
+ similarCount = 1,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightScreen.kt
new file mode 100644
index 0000000..d28bdfd
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightScreen.kt
@@ -0,0 +1,375 @@
+package com.devbyjonathan.stacklens.screen.detail
+
+import android.content.Intent
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.devbyjonathan.stacklens.ai.CrashInsight
+import com.devbyjonathan.stacklens.ai.CrashInsightService
+import com.devbyjonathan.stacklens.ai.DownloadState
+import com.devbyjonathan.stacklens.ai.InsightResult
+import com.devbyjonathan.stacklens.common.CrashBreadcrumb
+import com.devbyjonathan.stacklens.model.CrashLog
+import com.devbyjonathan.stacklens.model.fake.PreviewData
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.stacklens.util.MarkdownText
+import com.devbyjonathan.uikit.theme.AppTypography
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiInsightScreen(
+ crash: CrashLog,
+ similarCount: Int,
+ crashInsightService: CrashInsightService?,
+ onBackClick: () -> Unit,
+) {
+ val context = LocalContext.current
+ val clipboardManager = LocalClipboardManager.current
+ val scope = rememberCoroutineScope()
+
+ var result by remember { mutableStateOf(null) }
+ val downloadState by crashInsightService?.downloadState?.collectAsState()
+ ?: remember { mutableStateOf(DownloadState.Idle) }
+
+ LaunchedEffect(crashInsightService, crash.id) {
+ if (crashInsightService == null) {
+ result = InsightResult.Unavailable
+ return@LaunchedEffect
+ }
+ val cached = crashInsightService.getCachedInsight(crash.id)
+ if (cached != null) {
+ result = InsightResult.Success(cached)
+ } else {
+ result = InsightResult.Loading
+ result = crashInsightService.analyzeCrash(crash, groupCount = similarCount)
+ }
+ }
+
+ LaunchedEffect(downloadState) {
+ if (downloadState is DownloadState.Completed && result is InsightResult.Downloading) {
+ result = InsightResult.Loading
+ result = crashInsightService?.analyzeCrash(crash, groupCount = similarCount)
+ ?: InsightResult.Unavailable
+ }
+ }
+
+ AiInsightScreenLayout(
+ crashId = crash.id,
+ similarCount = similarCount,
+ result = result,
+ onBackClick = onBackClick,
+ onCopyInsight = { insight ->
+ clipboardManager.setText(AnnotatedString(formatShareText(insight)))
+ },
+ onShareFix = { insight ->
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, "Fix hint: ${crash.packageName}")
+ putExtra(Intent.EXTRA_TEXT, formatShareText(insight))
+ }
+ context.startActivity(Intent.createChooser(intent, "Share fix"))
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AiInsightScreenLayout(
+ crashId: Long,
+ similarCount: Int,
+ result: InsightResult?,
+ onBackClick: () -> Unit,
+ onCopyInsight: (CrashInsight) -> Unit,
+ onShareFix: (CrashInsight) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ CrashBreadcrumb(
+ crashId = crashId,
+ onBackClick = onBackClick,
+ subRoute = "ai",
+ )
+ },
+ bottomBar = {
+ val insight = (result as? InsightResult.Success)?.insight ?: return@Scaffold
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(scheme.background)
+ .padding(16.dp)
+ .navigationBarsPadding(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = scheme.surfaceContainer,
+ ),
+ onClick = { insight?.let(onCopyInsight) },
+ ) {
+ Row(
+ modifier = Modifier.padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.ContentCopy,
+ modifier = Modifier.size(14.dp),
+ contentDescription = null,
+ tint = scheme.onSurface
+ )
+ Text(
+ text = "Copy insight",
+ modifier = Modifier.basicMarquee(),
+ style = AppTypography.bodyMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
+ ),
+ color = scheme.onSurface,
+ maxLines = 1
+ )
+ }
+ }
+ Button(
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = scheme.primary,
+ ),
+ onClick = { insight?.let(onShareFix) },
+ ) {
+ Row(
+ modifier = Modifier.padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.Share,
+ modifier = Modifier.size(14.dp),
+ contentDescription = null,
+ tint = scheme.onPrimary,
+ )
+ Text(
+ text = "Share fix",
+ modifier = Modifier.basicMarquee(),
+ style = AppTypography.bodyMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
+ ),
+ color = scheme.onPrimary,
+ maxLines = 1
+ )
+ }
+ }
+ }
+ },
+ contentColor = scheme.background,
+ contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ text = "AI INSIGHT",
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp),
+ style = typo.labelSmall.copy(
+ fontFamily = GoogleSansCode
+ ),
+ color = scheme.onSurfaceVariant,
+ fontWeight = FontWeight.Medium,
+ )
+ when (val r = result) {
+ is InsightResult.Success -> {
+ MarkdownText(
+ text = r.insight.title,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = typo.displayLarge.copy(
+ fontWeight = FontWeight.Light,
+ fontSize = 28.sp,
+ ),
+ color = scheme.onSurface,
+ )
+ AiInsightContent(
+ insight = r.insight,
+ similarCount = similarCount,
+ onAffectedLineClick = onBackClick,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ is InsightResult.Loading -> {
+ AiInsightSkeleton()
+ }
+
+ is InsightResult.Downloading -> {
+ LoadingBlock(message = "Preparing on-device model…")
+ }
+
+ is InsightResult.Error -> {
+ Text(
+ text = r.message,
+ modifier = Modifier.padding(16.dp),
+ style = typo.bodyMedium,
+ color = scheme.error,
+ )
+ }
+
+ InsightResult.Unavailable -> {
+ Text(
+ text = "On-device AI is not available on this device. Requires Android 14+ with Gemini Nano support (Pixel 8+, Samsung S24+).",
+ modifier = Modifier.padding(16.dp),
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant,
+ )
+ }
+
+ null -> Unit
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadingBlock(message: String) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 48.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = message,
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun AiInsightScreenSuccessPreview() {
+ StackLensTheme {
+ AiInsightScreenLayout(
+ crashId = 1761234518440L,
+ similarCount = 12,
+ result = InsightResult.Success(PreviewData.sampleInsight),
+ onBackClick = {},
+ onCopyInsight = {},
+ onShareFix = {},
+ )
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun AiInsightScreenSuccessDarkPreview() {
+ StackLensTheme {
+ AiInsightScreenLayout(
+ crashId = 1761234518440L,
+ similarCount = 12,
+ result = InsightResult.Success(PreviewData.sampleInsight),
+ onBackClick = {},
+ onCopyInsight = {},
+ onShareFix = {},
+ )
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun AiInsightScreenLoadingPreview() {
+ StackLensTheme {
+ AiInsightScreenLayout(
+ crashId = 1761234518440L,
+ similarCount = 1,
+ result = InsightResult.Loading,
+ onBackClick = {},
+ onCopyInsight = {},
+ onShareFix = {},
+ )
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun AiInsightScreenUnavailablePreview() {
+ StackLensTheme {
+ AiInsightScreenLayout(
+ crashId = 1761234518440L,
+ similarCount = 1,
+ result = InsightResult.Unavailable,
+ onBackClick = {},
+ onCopyInsight = {},
+ onShareFix = {},
+ )
+ }
+}
+
+private fun formatShareText(insight: CrashInsight): String {
+ return buildString {
+ append(insight.title).append("\n\n")
+ append("Summary: ").append(insight.summary).append("\n\n")
+ append("Root Cause: ").append(insight.rootCause).append("\n\n")
+ append("Suggested Fix: ").append(insight.suggestedFix)
+ insight.affectedLine?.let { append("\n\nAffected: ").append(it) }
+ append("\n\nSeverity: ").append(insight.severity.name)
+ append(" · Confidence: ").append(
+ String.format(
+ java.util.Locale.US,
+ "%.2f",
+ insight.confidence
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightSkeleton.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightSkeleton.kt
new file mode 100644
index 0000000..294ef26
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/detail/AiInsightSkeleton.kt
@@ -0,0 +1,199 @@
+package com.devbyjonathan.stacklens.screen.detail
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.devbyjonathan.stacklens.common.ShimmerBox
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.scheme
+
+@Composable
+fun AiInsightSkeleton(modifier: Modifier = Modifier) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ ShimmerBox(Modifier
+ .fillMaxWidth(0.88f)
+ .height(28.dp))
+ ShimmerBox(Modifier
+ .fillMaxWidth(0.55f)
+ .height(28.dp))
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SourceAttributionSkeleton()
+ InsightPanelSkeleton()
+ StatsRowSkeleton()
+ AffectedLineSkeleton()
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
+
+@Composable
+private fun SourceAttributionSkeleton() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(
+ RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomEnd = 16.dp,
+ bottomStart = 6.dp,
+ )
+ )
+ .background(scheme.primaryContainer.copy(alpha = 0.45f))
+ .padding(16.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ShimmerBox(modifier = Modifier.size(28.dp), shape = CircleShape)
+ Spacer(modifier = Modifier.width(8.dp))
+ ShimmerBox(Modifier
+ .width(140.dp)
+ .height(14.dp))
+ Spacer(modifier = Modifier.weight(1f))
+ ShimmerBox(Modifier
+ .width(36.dp)
+ .height(14.dp))
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ ShimmerBox(Modifier
+ .fillMaxWidth()
+ .height(14.dp))
+ ShimmerBox(Modifier
+ .fillMaxWidth(0.92f)
+ .height(14.dp))
+ ShimmerBox(Modifier
+ .fillMaxWidth(0.6f)
+ .height(14.dp))
+ }
+ }
+}
+
+@Composable
+private fun InsightPanelSkeleton() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(scheme.surface)
+ .border(1.dp, scheme.outlineVariant, RoundedCornerShape(16.dp))
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SkeletonSection(lineWidths = listOf(1f, 0.9f, 0.75f))
+ HorizontalDivider(thickness = 1.dp, color = scheme.outlineVariant)
+ SkeletonSection(lineWidths = listOf(1f, 0.85f, 0.7f, 0.5f))
+ }
+}
+
+@Composable
+private fun SkeletonSection(lineWidths: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ShimmerBox(Modifier
+ .width(96.dp)
+ .height(10.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ lineWidths.forEach { fraction ->
+ ShimmerBox(Modifier
+ .fillMaxWidth(fraction)
+ .height(14.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatsRowSkeleton() {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ repeat(3) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(14.dp))
+ .background(scheme.surface)
+ .padding(horizontal = 10.dp, vertical = 10.dp),
+ ) {
+ ShimmerBox(Modifier
+ .width(64.dp)
+ .height(10.dp))
+ Spacer(modifier = Modifier.height(10.dp))
+ ShimmerBox(Modifier
+ .width(56.dp)
+ .height(20.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AffectedLineSkeleton() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(scheme.surface)
+ .border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp))
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ShimmerBox(Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(10.dp))
+ ShimmerBox(Modifier
+ .width(56.dp)
+ .height(14.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ ShimmerBox(Modifier
+ .width(120.dp)
+ .height(14.dp))
+ Spacer(modifier = Modifier.weight(1f))
+ ShimmerBox(Modifier.size(18.dp))
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun AiInsightSkeletonPreview() {
+ StackLensTheme {
+ AiInsightSkeleton(modifier = Modifier.background(scheme.background))
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun AiInsightSkeletonDarkPreview() {
+ StackLensTheme {
+ AiInsightSkeleton(modifier = Modifier.background(scheme.background))
+ }
+}
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..abc90d4 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
@@ -1,11 +1,10 @@
package com.devbyjonathan.stacklens.screen.detail
import android.content.Intent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
+import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,12 +18,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.WrapText
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ContentCopy
@@ -34,18 +32,18 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -63,14 +61,20 @@ import androidx.compose.ui.unit.sp
import com.devbyjonathan.stacklens.ai.CrashInsightService
import com.devbyjonathan.stacklens.ai.DownloadState
import com.devbyjonathan.stacklens.ai.InsightResult
-import com.devbyjonathan.stacklens.common.CrashTypeBadge
+import com.devbyjonathan.stacklens.common.CrashBreadcrumb
+import com.devbyjonathan.stacklens.common.CrashTypeBadgeDetail
import com.devbyjonathan.stacklens.model.CrashLog
import com.devbyjonathan.stacklens.model.CrashType
import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.stacklens.util.CrashMetadata
+import com.devbyjonathan.stacklens.util.MarkdownText
import com.devbyjonathan.stacklens.util.StackTraceColors
import com.devbyjonathan.stacklens.util.highlightStackTrace
+import com.devbyjonathan.stacklens.util.parseCrashMetadata
import com.devbyjonathan.uikit.theme.AppTypography
-import kotlinx.coroutines.launch
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -81,98 +85,90 @@ fun CrashDetailScreen(
crash: CrashLog,
onBackClick: () -> Unit,
crashInsightService: CrashInsightService? = null,
+ onAiInsightClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
- val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) }
- var wrapText by remember { mutableStateOf(false) }
+ var selectedTab by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
- var aiAvailable by remember { mutableStateOf(null) }
+ var aiAvailable by remember { mutableStateOf(false) }
var insightResult by remember { mutableStateOf(null) }
- var showInsight by remember { mutableStateOf(false) }
val downloadState by crashInsightService?.downloadState?.collectAsState()
?: remember { mutableStateOf(DownloadState.Idle) }
- LaunchedEffect(crashInsightService) {
+ LaunchedEffect(crashInsightService, crash.id) {
crashInsightService?.let {
- // Check if AI is available or downloadable (show button for both)
val available = it.isAvailable()
val status = it.getStatus()
aiAvailable =
available || status == com.google.mlkit.genai.common.FeatureStatus.DOWNLOADABLE
|| status == com.google.mlkit.genai.common.FeatureStatus.DOWNLOADING
- // Check for cached insight and auto-expand if available
val cachedInsight = it.getCachedInsight(crash.id)
if (cachedInsight != null) {
insightResult = InsightResult.Success(cachedInsight)
- showInsight = true
}
}
}
- // Auto-retry when download completes
+ LaunchedEffect(selectedTab, aiAvailable) {
+ if (selectedTab == 1 && insightResult == null && aiAvailable == true) {
+ insightResult = InsightResult.Loading
+ insightResult = crashInsightService?.analyzeCrash(crash) ?: InsightResult.Unavailable
+ }
+ }
+
LaunchedEffect(downloadState) {
if (downloadState is DownloadState.Completed && insightResult is InsightResult.Downloading) {
- // Download completed, retry the analysis
insightResult = InsightResult.Loading
insightResult = crashInsightService?.analyzeCrash(crash) ?: InsightResult.Unavailable
}
}
+ CrashDetailScreenLayout(
+ crash = crash,
+ aiAvailable = aiAvailable,
+ onBackClick = onBackClick,
+ onAiInsightClick = onAiInsightClick,
+ onCopyLog = { clipboardManager.setText(AnnotatedString(crash.content)) },
+ onShareLog = {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, "Crash Log: ${crash.packageName}")
+ putExtra(Intent.EXTRA_TEXT, crash.content)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share crash log"))
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CrashDetailScreenLayout(
+ crash: CrashLog,
+ aiAvailable: Boolean,
+ onBackClick: () -> Unit,
+ onAiInsightClick: (() -> Unit)?,
+ onCopyLog: () -> Unit,
+ onShareLog: () -> Unit,
+) {
+ val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) }
+ var wrapText by remember { mutableStateOf(false) }
+ val crashMeta = remember(crash.id) { parseCrashMetadata(crash.content) }
+
Scaffold(
topBar = {
- TopAppBar(
- title = {
- Text(
- modifier = Modifier.basicMarquee(),
- text = crash.appName ?: crash.packageName ?: "Crash Details",
- style = AppTypography.titleLarge.copy(
- fontWeight = FontWeight.SemiBold,
- fontSize = 16.sp
- ),
- maxLines = 1
- )
- },
- navigationIcon = {
- IconButton(onClick = onBackClick) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
- }
- },
- actions = {
- if (aiAvailable == true) {
- IconButton(
- onClick = {
- showInsight = !showInsight
- if (showInsight && insightResult == null) {
- insightResult = InsightResult.Loading
- scope.launch {
- insightResult = crashInsightService?.analyzeCrash(crash)
- ?: InsightResult.Unavailable
- }
- }
- }
- ) {
- Icon(
- Icons.Default.AutoAwesome,
- contentDescription = "AI Insight",
- tint = if (showInsight) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- }
- )
- }
- }
- }
+ CrashBreadcrumb(
+ crashId = crash.id,
+ onBackClick = onBackClick,
)
},
bottomBar = {
Row(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.background)
+ .background(scheme.background)
.padding(16.dp)
.navigationBarsPadding(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -181,29 +177,29 @@ fun CrashDetailScreen(
Button(
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
+ containerColor = scheme.surfaceContainer,
),
- onClick = {
- clipboardManager.setText(AnnotatedString(crash.content))
- }
+ onClick = onCopyLog,
) {
Row(
- modifier = Modifier.padding(8.dp),
+ modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
+ modifier = Modifier.size(14.dp),
imageVector = Icons.Default.ContentCopy,
contentDescription = "copy icon",
- tint = MaterialTheme.colorScheme.onPrimaryContainer
+ tint = scheme.onSurface
)
Text(
modifier = Modifier.basicMarquee(),
text = "Copy log",
style = AppTypography.bodyMedium.copy(
- fontWeight = FontWeight.Medium
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
),
- color = MaterialTheme.colorScheme.onPrimaryContainer,
+ color = scheme.onSurface,
maxLines = 1
)
}
@@ -211,168 +207,269 @@ fun CrashDetailScreen(
Button(
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ containerColor = scheme.primary,
),
- onClick = {
- val intent = Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(Intent.EXTRA_SUBJECT, "Crash Log: ${crash.packageName}")
- putExtra(Intent.EXTRA_TEXT, crash.content)
- }
- context.startActivity(Intent.createChooser(intent, "Share crash log"))
- }
+ onClick = onShareLog,
) {
Row(
- modifier = Modifier.padding(8.dp),
+ modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
+ modifier = Modifier.size(14.dp),
imageVector = Icons.Default.Share,
contentDescription = "share icon",
- tint = MaterialTheme.colorScheme.primaryContainer
+ tint = scheme.onPrimary
)
Text(
modifier = Modifier.basicMarquee(),
text = "Share Trace",
style = AppTypography.bodyMedium.copy(
- fontWeight = FontWeight.Medium
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
),
- color = MaterialTheme.colorScheme.primaryContainer,
+ color = scheme.onPrimary,
maxLines = 1
)
}
}
}
},
- contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)
+ contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
- .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
) {
- // AI Insight Card (collapsible)
- AnimatedVisibility(
- visible = showInsight,
- enter = expandVertically(),
- exit = shrinkVertically()
+ Spacer(modifier = Modifier.height(16.dp))
+ CrashTitleBlock(
+ crash = crash,
+ aiAvailable = aiAvailable,
+ onAiInsightClick = onAiInsightClick
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ StackTabContent(
+ crash = crash,
+ crashMeta = crashMeta,
+ wrapText = wrapText,
+ onToggleWrap = { wrapText = !wrapText },
+ dateFormat = dateFormat,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+private fun CrashTitleBlock(
+ crash: CrashLog,
+ aiAvailable: Boolean,
+ onAiInsightClick: (() -> Unit)?,
+) {
+ val exceptionLine = remember(crash.id) {
+ crash.content.lines().firstOrNull { line ->
+ line.contains("Exception") || line.contains("Error") || line.startsWith("Caused by:")
+ }?.trim()
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ //.padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CrashTypeBadgeDetail(type = crash.tag, timestamp = crash.timestamp)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ modifier = Modifier.basicMarquee(),
+ text = crash.appName ?: crash.packageName ?: "Crash",
+ style = AppTypography.headlineMedium.copy(
+ fontWeight = FontWeight.Light,
+ fontSize = 28.sp
+ ),
+ maxLines = 1,
+ )
+ if (exceptionLine != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = exceptionLine,
+ style = typo.bodyMedium.copy(fontFamily = GoogleSansCode),
+ color = scheme.error,
+ maxLines = 2,
+ )
+ }
+ if (aiAvailable && onAiInsightClick != null) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ colors = ButtonDefaults.buttonColors(
+ containerColor = scheme.primaryContainer,
+ ),
+ onClick = onAiInsightClick
) {
- AiInsightCard(
- result = insightResult,
- downloadState = downloadState,
- onRetry = {
- insightResult = InsightResult.Loading
- scope.launch {
- insightResult = crashInsightService?.analyzeCrash(crash)
- ?: InsightResult.Unavailable
- }
- }
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.AutoAwesome,
+ contentDescription = null,
+ tint = scheme.onPrimaryContainer,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ modifier = Modifier.basicMarquee(),
+ text = "AI Insight",
+ style = AppTypography.bodyMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ ),
+ color = scheme.onPrimaryContainer,
+ maxLines = 1,
+ )
+ }
}
+ }
+ }
+}
- // Metadata card
- Card(
+@Composable
+private fun StackTabContent(
+ crash: CrashLog,
+ crashMeta: CrashMetadata,
+ wrapText: Boolean,
+ onToggleWrap: () -> Unit,
+ dateFormat: SimpleDateFormat,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(scheme.surface, RoundedCornerShape(16.dp))
+ .border(1.dp, scheme.outlineVariant, RoundedCornerShape(16.dp)),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ MetadataMonoRow("package", crash.packageName ?: "—")
+ crash.processName?.let { MetadataMonoRow("process", it) }
+ MetadataMonoRow("time", dateFormat.format(Date(crash.timestamp)))
+ val pidUid = buildString {
+ append(crash.pid?.toString() ?: "—")
+ append(" / ")
+ append(crashMeta.uid?.toString() ?: "—")
+ }
+ MetadataMonoRow("pid / uid", pidUid)
+ crashMeta.systemUptimeMs?.let { MetadataMonoRow("SystemUptimeMs", it.toString()) }
+ crashMeta.foreground?.let {
+ MetadataMonoRow("Foreground", if (it) "Yes" else "No")
+ }
+ crashMeta.processRuntimeSec?.let {
+ MetadataMonoRow("Process-Runtime", it.toString())
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(scheme.surface, RoundedCornerShape(16.dp))
+ .border(1.dp, scheme.outlineVariant, RoundedCornerShape(16.dp)),
+ ) {
+ Column() {
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp)
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = "Type:",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(80.dp)
- )
- CrashTypeBadge(type = crash.tag)
- }
- Spacer(modifier = Modifier.height(4.dp))
- MetadataRow("Package", crash.packageName ?: "Unknown")
- MetadataRow("Time", dateFormat.format(Date(crash.timestamp)))
- crash.processName?.let { MetadataRow("Process", it) }
- crash.pid?.let { MetadataRow("PID", it.toString()) }
+ Text(
+ text = "Stack trace",
+ style = typo.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ ),
+ )
+ IconButton(onClick = onToggleWrap) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.WrapText,
+ contentDescription = if (wrapText) "Disable wrap" else "Enable wrap",
+ tint = if (wrapText) scheme.primary
+ else scheme.onSurfaceVariant,
+ )
}
}
- // Stack trace
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp)
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "Stack Trace",
- style = MaterialTheme.typography.titleMedium.copy(
- fontWeight = FontWeight.SemiBold,
- fontSize = 16.sp
- )
- )
- IconButton(onClick = { wrapText = !wrapText }) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.WrapText,
- contentDescription = if (wrapText) "Disable wrap" else "Enable wrap",
- tint = if (wrapText) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- }
- )
- }
- }
+ HorizontalDivider(thickness = 1.dp, color = scheme.outlineVariant)
+
+ val stackTraceColors = StackTraceColors(
+ exception = scheme.error,
+ causedBy = scheme.error.copy(alpha = 0.8f),
+ atKeyword = scheme.onSurfaceVariant.copy(alpha = 0.6f),
+ className = scheme.onSurfaceVariant,
+ methodName = scheme.primary,
+ lineNumber = scheme.tertiary,
+ fileName = scheme.secondary,
+ nativeMethod = scheme.onSurfaceVariant.copy(alpha = 0.5f),
+ message = scheme.onSurfaceVariant.copy(alpha = 0.9f),
+ default = scheme.onSurfaceVariant,
+ )
- // Syntax highlighting colors for stack trace
- val stackTraceColors = StackTraceColors(
- exception = MaterialTheme.colorScheme.error,
- causedBy = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
- atKeyword = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- className = MaterialTheme.colorScheme.onSurfaceVariant,
- methodName = MaterialTheme.colorScheme.primary,
- lineNumber = MaterialTheme.colorScheme.tertiary,
- fileName = MaterialTheme.colorScheme.secondary,
- nativeMethod = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- message = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f),
- default = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ val highlighted = highlightStackTrace(
+ content = crash.content,
+ colors = stackTraceColors,
+ )
- val highlightedContent = highlightStackTrace(
- content = crash.content,
- colors = stackTraceColors
+ Box(
+ modifier = if (wrapText) {
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ } else {
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .horizontalScroll(rememberScrollState())
+ }
+ ) {
+ SelectionContainer {
+ Text(
+ text = highlighted,
+ style = typo.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ ),
)
-
- Box(
- modifier = if (wrapText) {
- Modifier.fillMaxWidth()
- } else {
- Modifier
- .fillMaxWidth()
- .horizontalScroll(rememberScrollState())
- }
- ) {
- SelectionContainer {
- Text(
- text = highlightedContent,
- style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
- )
- )
- }
- }
}
}
-
- Spacer(modifier = Modifier.height(16.dp))
}
}
}
+@Composable
+private fun MetadataMonoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ ) {
+ Text(
+ text = label,
+ style = typo.bodySmall.copy(fontFamily = GoogleSansCode),
+ color = scheme.onSurfaceVariant,
+ modifier = Modifier.weight(0.4f),
+ )
+ Text(
+ text = value,
+ style = typo.bodyMedium.copy(fontFamily = GoogleSansCode),
+ color = scheme.onSurface,
+ modifier = Modifier.weight(0.6f),
+ )
+ }
+}
+
@Composable
private fun AiInsightCard(
result: InsightResult?,
@@ -384,7 +481,7 @@ private fun AiInsightCard(
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ containerColor = scheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Column(
@@ -397,20 +494,20 @@ private fun AiInsightCard(
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
+ tint = scheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = "AI Insight",
- style = MaterialTheme.typography.titleMedium.copy(
+ style = typo.titleMedium.copy(
fontWeight = FontWeight.SemiBold
),
- color = MaterialTheme.colorScheme.primary
+ color = scheme.primary
)
Text(
text = "(Gemini Nano)",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.labelSmall,
+ color = scheme.onSurfaceVariant
)
}
@@ -423,16 +520,16 @@ private fun AiInsightCard(
) {
Text(
text = "Analyzing crash...",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(4.dp),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.primaryContainer
+ color = scheme.primary,
+ trackColor = scheme.primaryContainer
)
}
}
@@ -456,11 +553,11 @@ private fun AiInsightCard(
Text(
text = progressText,
- style = MaterialTheme.typography.bodyMedium,
+ style = typo.bodyMedium,
color = if (downloadState is DownloadState.Failed) {
- MaterialTheme.colorScheme.error
+ scheme.error
} else {
- MaterialTheme.colorScheme.onSurfaceVariant
+ scheme.onSurfaceVariant
}
)
@@ -478,8 +575,8 @@ private fun AiInsightCard(
modifier = Modifier
.fillMaxWidth()
.height(4.dp),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.primaryContainer
+ color = scheme.primary,
+ trackColor = scheme.primaryContainer
)
}
@@ -489,8 +586,8 @@ private fun AiInsightCard(
modifier = Modifier
.fillMaxWidth()
.height(4.dp),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.primaryContainer
+ color = scheme.primary,
+ trackColor = scheme.primaryContainer
)
}
}
@@ -498,8 +595,8 @@ private fun AiInsightCard(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This is a one-time download. You can leave the app - download will continue in background.",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.labelSmall,
+ color = scheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
@@ -530,8 +627,8 @@ private fun AiInsightCard(
Column {
Text(
text = result.message,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error
+ style = typo.bodyMedium,
+ color = scheme.error
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onRetry) {
@@ -543,16 +640,16 @@ private fun AiInsightCard(
is InsightResult.Unavailable -> {
Text(
text = "On-device AI is not available on this device. Requires Android 14+ with Gemini Nano support (Pixel 8+, Samsung S24+).",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant
)
}
null -> {
Text(
text = "Tap to analyze this crash with on-device AI.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant
)
}
}
@@ -569,84 +666,105 @@ private fun InsightSection(
Column {
Text(
text = title,
- style = MaterialTheme.typography.labelMedium.copy(
+ style = typo.labelMedium.copy(
fontWeight = FontWeight.SemiBold
),
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = scheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = content,
- style = if (isCode) {
- MaterialTheme.typography.bodySmall.copy(
+ if (isCode) {
+ Text(
+ text = content,
+ style = typo.bodySmall.copy(
fontFamily = FontFamily.Monospace
- )
- } else {
- MaterialTheme.typography.bodyMedium
- },
- color = MaterialTheme.colorScheme.onSurface
- )
+ ),
+ color = scheme.onSurface
+ )
+ } else {
+ MarkdownText(
+ text = content,
+ style = typo.bodyMedium,
+ color = scheme.onSurface,
+ )
+ }
}
}
+private val sampleCrashPreview = CrashLog(
+ id = 1,
+ timestamp = System.currentTimeMillis(),
+ packageName = "com.example.app",
+ appName = "Example App",
+ processName = "com.example.app:service",
+ pid = 1234,
+ tag = CrashType.DATA_APP_CRASH,
+ content = """
+ FATAL EXCEPTION: main
+ Process: com.example.ecomm, PID: 23456
+ Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String com.example.ecomm.data.model.Product.getName()' on a null object reference
+ at com.example.ecomm.ui.products.ProductDetailFragment.bindProductData(ProductDetailFragment.kt:145)
+ at com.example.ecomm.ui.products.ProductDetailFragment.access${'$'}bindProductData(ProductDetailFragment.kt:32)
+ at com.example.ecomm.ui.products.ProductDetailFragment${'$'}onViewCreated${'$'}1${'$'}1.emit(ProductDetailFragment.kt:88)
+ at com.example.ecomm.ui.products.ProductDetailFragment${'$'}onViewCreated${'$'}1${'$'}1.emit(ProductDetailFragment.kt:86)
+ at kotlinx.coroutines.flow.FlowKt__TransformKt${'$'}onEach${'$'}${'$'}inlined${'$'}unsafeTransform${'$'}1${'$'}2.emit(SafeCollector.common.kt:113)
+ at kotlinx.coroutines.flow.FlowKt__ChannelsKt.emitAllImpl${'$'}FlowKt__ChannelsKt(Channels.kt:51)
+ at kotlinx.coroutines.flow.FlowKt__ChannelsKt.emitAll(Channels.kt:37)
+ at kotlinx.coroutines.flow.FlowKt__ChannelsKt.access${'$'}emitAll(Channels.kt:1)
+ at kotlinx.coroutines.flow.FlowKt__ChannelsKt${'$'}emitAll${'$'}1.invokeSuspend(Unknown Source:11)
+ at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
+ at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
+ at android.os.Handler.handleCallback(Handler.java:938)
+ at android.os.Handler.dispatchMessage(Handler.java:99)
+ at android.os.Looper.loop(Looper.java:223)
+ at android.app.ActivityThread.main(ActivityThread.java:7656)
+ at java.lang.reflect.Method.invoke(Native Method)
+ at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:592)
+ at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
+ ...
+ """.trimIndent()
+)
+
+@Preview(showBackground = true)
@Composable
-fun MetadataRow(label: String, value: String) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- ) {
- Text(
- text = "$label:",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(80.dp)
- )
- Text(
- text = value,
- style = MaterialTheme.typography.bodyMedium
+private fun CrashDetailScreenPreview() {
+ StackLensTheme {
+ CrashDetailScreenLayout(
+ crash = sampleCrashPreview,
+ aiAvailable = false,
+ onBackClick = {},
+ onAiInsightClick = null,
+ onCopyLog = {},
+ onShareLog = {},
)
}
}
-@Preview
+@Preview(showBackground = true)
@Composable
-private fun CrashDetailScreenPreview() {
- val sampleCrash = CrashLog(
- id = 1,
- timestamp = System.currentTimeMillis(),
- packageName = "com.example.app",
- appName = "Example App",
- processName = "com.example.app:service",
- pid = 1234,
- tag = CrashType.DATA_APP_CRASH,
- content = """
- FATAL EXCEPTION: main
- Process: com.example.ecomm, PID: 23456
- Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String com.example.ecomm.data.model.Product.getName()' on a null object reference
- at com.example.ecomm.ui.products.ProductDetailFragment.bindProductData(ProductDetailFragment.kt:145)
- at com.example.ecomm.ui.products.ProductDetailFragment.access${'$'}bindProductData(ProductDetailFragment.kt:32)
- at com.example.ecomm.ui.products.ProductDetailFragment${'$'}onViewCreated${'$'}1${'$'}1.emit(ProductDetailFragment.kt:88)
- at com.example.ecomm.ui.products.ProductDetailFragment${'$'}onViewCreated${'$'}1${'$'}1.emit(ProductDetailFragment.kt:86)
- at kotlinx.coroutines.flow.FlowKt__TransformKt${'$'}onEach${'$'}${'$'}inlined${'$'}unsafeTransform${'$'}1${'$'}2.emit(SafeCollector.common.kt:113)
- at kotlinx.coroutines.flow.FlowKt__ChannelsKt.emitAllImpl${'$'}FlowKt__ChannelsKt(Channels.kt:51)
- at kotlinx.coroutines.flow.FlowKt__ChannelsKt.emitAll(Channels.kt:37)
- at kotlinx.coroutines.flow.FlowKt__ChannelsKt.access${'$'}emitAll(Channels.kt:1)
- at kotlinx.coroutines.flow.FlowKt__ChannelsKt${'$'}emitAll${'$'}1.invokeSuspend(Unknown Source:11)
- at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
- at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
- at android.os.Handler.handleCallback(Handler.java:938)
- at android.os.Handler.dispatchMessage(Handler.java:99)
- at android.os.Looper.loop(Looper.java:223)
- at android.app.ActivityThread.main(ActivityThread.java:7656)
- at java.lang.reflect.Method.invoke(Native Method)
- at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:592)
- at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
- ...
- """.trimIndent()
- )
+private fun CrashDetailScreenAiAvailablePreview() {
+ StackLensTheme {
+ CrashDetailScreenLayout(
+ crash = sampleCrashPreview,
+ aiAvailable = true,
+ onBackClick = {},
+ onAiInsightClick = {},
+ onCopyLog = {},
+ onShareLog = {},
+ )
+ }
+}
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CrashDetailScreenAiAvailableDarkPreview() {
StackLensTheme {
- CrashDetailScreen(crash = sampleCrash, onBackClick = {})
+ CrashDetailScreenLayout(
+ crash = sampleCrashPreview,
+ aiAvailable = true,
+ onBackClick = {},
+ onAiInsightClick = {},
+ onCopyLog = {},
+ onShareLog = {},
+ )
}
}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt
index d07e116..7c734c9 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashGroupItem.kt
@@ -1,5 +1,6 @@
package com.devbyjonathan.stacklens.screen.list
+import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -29,10 +30,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.devbyjonathan.stacklens.common.CrashTypeBadge
import com.devbyjonathan.stacklens.model.CrashGroup
import com.devbyjonathan.stacklens.model.CrashLog
+import com.devbyjonathan.stacklens.model.fake.PreviewData
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -90,9 +98,9 @@ fun CrashGroupItem(
) {
Text(
text = group.appName ?: group.packageName ?: "Unknown",
- style = MaterialTheme.typography.titleMedium,
+ style = typo.titleMedium,
fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface,
+ color = scheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@@ -106,9 +114,11 @@ fun CrashGroupItem(
Text(
text = "${group.count}x",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Bold,
- color = color
+ style = typo.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = color,
+ fontFamily = GoogleSansCode,
+ fontSize = 12.sp
)
}
}
@@ -118,10 +128,11 @@ fun CrashGroupItem(
// Exception type
Text(
text = group.exceptionType,
- style = MaterialTheme.typography.bodyMedium,
+ style = typo.bodyMedium,
color = color,
maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ overflow = TextOverflow.Ellipsis,
+ fontFamily = GoogleSansCode
)
Spacer(modifier = Modifier.height(4.dp))
@@ -136,10 +147,12 @@ fun CrashGroupItem(
if (group.appName != null && packageName != null) {
Text(
text = packageName,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = typo.bodySmall,
+ color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
+ fontFamily = GoogleSansCode,
+ fontSize = 12.sp,
modifier = Modifier.weight(1f)
)
}
@@ -154,7 +167,7 @@ fun CrashGroupItem(
Icons.Default.KeyboardArrowDown
},
contentDescription = if (isExpanded) "Collapse" else "Expand",
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ tint = scheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
@@ -165,8 +178,9 @@ fun CrashGroupItem(
// Time info
Text(
text = "Last: ${dateFormat.format(Date(group.lastOccurrence))}",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.labelSmall,
+ color = scheme.onSurfaceVariant,
+ fontSize = 12.sp
)
}
}
@@ -238,8 +252,8 @@ private fun CrashGroupChildItem(
Text(
text = preview,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface,
+ style = typo.bodySmall,
+ color = scheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -248,10 +262,137 @@ private fun CrashGroupChildItem(
Text(
text = dateFormat.format(Date(crash.timestamp)),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.labelSmall,
+ color = scheme.onSurfaceVariant
)
}
}
}
}
+
+@Composable
+private fun CrashGroupItemAllStates() {
+ val now = System.currentTimeMillis()
+ val appCrash = PreviewData.sampleCrashLogs[0]
+ val anr = PreviewData.sampleCrashLogs[1]
+ val tombstone = PreviewData.sampleCrashLogs[2]
+ val messenger = PreviewData.sampleCrashLogs[3]
+
+ val expandedGroup = CrashGroup(
+ signature = "sig-multi",
+ exceptionType = "IllegalStateException",
+ crashes = listOf(
+ messenger,
+ appCrash.copy(id = 101, timestamp = now - 1000 * 60 * 45),
+ messenger.copy(id = 102, timestamp = now - 1000 * 60 * 120),
+ ),
+ count = 8,
+ firstOccurrence = now - 1000 * 60 * 60 * 24,
+ lastOccurrence = now - 1000 * 60 * 3,
+ )
+ val anrGroup = CrashGroup(
+ signature = "sig-anr",
+ exceptionType = "ANR: Input dispatching timed out",
+ crashes = listOf(anr),
+ count = 3,
+ firstOccurrence = now - 1000 * 60 * 60 * 10,
+ lastOccurrence = now - 1000 * 60 * 15,
+ )
+ val tombstoneGroup = CrashGroup(
+ signature = "sig-tomb",
+ exceptionType = "Native abort: FORTIFY pthread_mutex_lock called on a destroyed mutex",
+ crashes = listOf(tombstone),
+ count = 1,
+ firstOccurrence = now - 1000 * 60 * 60 * 2,
+ lastOccurrence = now - 1000 * 60 * 30,
+ )
+ val noAppNameGroup = CrashGroup(
+ signature = "sig-noapp",
+ exceptionType = "RuntimeException",
+ crashes = listOf(appCrash.copy(appName = null)),
+ count = 2,
+ firstOccurrence = now - 1000 * 60 * 60,
+ lastOccurrence = now - 1000 * 60 * 10,
+ )
+ val singleCountGroup = PreviewData.sampleCrashGroup.copy(count = 1)
+
+ Column(
+ modifier = Modifier.padding(vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ PreviewLabel("Collapsed — app crash")
+ CrashGroupItem(
+ group = PreviewData.sampleCrashGroup,
+ isExpanded = false,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+
+ PreviewLabel("Expanded — multiple children")
+ CrashGroupItem(
+ group = expandedGroup,
+ isExpanded = true,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+
+ PreviewLabel("Collapsed — ANR")
+ CrashGroupItem(
+ group = anrGroup,
+ isExpanded = false,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+
+ PreviewLabel("Expanded — tombstone (long text)")
+ CrashGroupItem(
+ group = tombstoneGroup,
+ isExpanded = true,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+
+ PreviewLabel("Collapsed — missing app name")
+ CrashGroupItem(
+ group = noAppNameGroup,
+ isExpanded = false,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+
+ PreviewLabel("Collapsed — single occurrence")
+ CrashGroupItem(
+ group = singleCountGroup,
+ isExpanded = false,
+ onGroupClick = {},
+ onCrashClick = {},
+ )
+ }
+}
+
+@Composable
+private fun PreviewLabel(text: String) {
+ Text(
+ text = text,
+ style = typo.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = scheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+}
+
+@Preview(showBackground = true, heightDp = 1800)
+@Composable
+private fun CrashGroupItemAllStatesPreview() {
+ StackLensTheme {
+ CrashGroupItemAllStates()
+ }
+}
+
+@Preview(showBackground = true, heightDp = 1800, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CrashGroupItemAllStatesDarkPreview() {
+ StackLensTheme {
+ CrashGroupItemAllStates()
+ }
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashListSkeleton.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashListSkeleton.kt
new file mode 100644
index 0000000..506acf7
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/CrashListSkeleton.kt
@@ -0,0 +1,154 @@
+package com.devbyjonathan.stacklens.screen.list
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.devbyjonathan.stacklens.common.ShimmerBox
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.scheme
+
+private val SkeletonTitleWidths = listOf(0.55f, 0.72f, 0.42f, 0.66f)
+private val SkeletonExceptionWidths = listOf(0.75f, 0.60f, 0.82f, 0.50f)
+private val SkeletonPackageWidths = listOf(0.45f, 0.38f, 0.52f, 0.32f)
+
+/**
+ * Skeleton-loading placeholder for the crash group list. Mirrors [CrashGroupItem]
+ * shape (tinted card, 44dp icon tile, two text rows, badge+chevron row) so the
+ * layout doesn't shift when real content arrives. Widths vary per row to avoid
+ * a uniform "grid" look.
+ */
+@Composable
+fun CrashListSkeleton(
+ modifier: Modifier = Modifier,
+ itemCount: Int = 4,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ repeat(itemCount) { i ->
+ CrashGroupSkeletonItem(
+ titleWidthFraction = SkeletonTitleWidths[i % SkeletonTitleWidths.size],
+ exceptionWidthFraction = SkeletonExceptionWidths[i % SkeletonExceptionWidths.size],
+ packageWidthFraction = SkeletonPackageWidths[i % SkeletonPackageWidths.size],
+ )
+ }
+ }
+}
+
+@Composable
+private fun CrashGroupSkeletonItem(
+ titleWidthFraction: Float,
+ exceptionWidthFraction: Float,
+ packageWidthFraction: Float,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(scheme.onSurface.copy(alpha = 0.04f)),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Type-icon tile placeholder.
+ ShimmerBox(
+ modifier = Modifier.size(44.dp),
+ shape = RoundedCornerShape(8.dp),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ // Title row: app name + count badge on the right.
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ShimmerBox(
+ modifier = Modifier
+ .fillMaxWidth(titleWidthFraction)
+ .height(16.dp),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ ShimmerBox(
+ modifier = Modifier
+ .width(36.dp)
+ .height(22.dp),
+ shape = RoundedCornerShape(12.dp),
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ // Exception line.
+ ShimmerBox(
+ modifier = Modifier
+ .fillMaxWidth(exceptionWidthFraction)
+ .height(14.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ // Package + type badge + chevron row.
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ShimmerBox(
+ modifier = Modifier
+ .fillMaxWidth(packageWidthFraction)
+ .height(12.dp),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ ShimmerBox(
+ modifier = Modifier
+ .width(52.dp)
+ .height(20.dp),
+ shape = RoundedCornerShape(4.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ ShimmerBox(
+ modifier = Modifier.size(20.dp),
+ shape = RoundedCornerShape(4.dp),
+ )
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ // "Last: ..." timestamp line.
+ ShimmerBox(
+ modifier = Modifier
+ .width(140.dp)
+ .height(12.dp),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun CrashListSkeletonPreview() {
+ StackLensTheme {
+ CrashListSkeleton(modifier = Modifier.padding(vertical = 16.dp))
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CrashListSkeletonDarkPreview() {
+ StackLensTheme {
+ CrashListSkeleton(modifier = Modifier.padding(vertical = 16.dp))
+ }
+}
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..b4997f1 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,15 +20,15 @@ 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
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
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Error
@@ -40,11 +40,14 @@ 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.SuggestionChip
+import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,8 +62,8 @@ 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -72,6 +75,10 @@ import com.devbyjonathan.stacklens.model.CrashTypeFilter
import com.devbyjonathan.stacklens.model.SortOrder
import com.devbyjonathan.stacklens.model.fake.PreviewData.sampleUiState
import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.AppTypography
+import com.devbyjonathan.uikit.theme.CodeTypography
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
@@ -89,6 +96,8 @@ fun CrashLogListContent(
onSortOrderChange: (SortOrder) -> Unit,
onTypeFilterChange: (CrashTypeFilter) -> Unit,
onGroupExpand: (String) -> Unit = {},
+ onToggleAiSearch: () -> Unit = {},
+ onSuggestedPromptClick: (String) -> Unit = {},
) {
var searchQuery by remember { mutableStateOf("") }
var showDurationSheet by remember { mutableStateOf(false) }
@@ -100,7 +109,7 @@ fun CrashLogListContent(
Column(
modifier = modifier
.fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
+ .background(scheme.background)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
@@ -123,7 +132,12 @@ fun CrashLogListContent(
showSortSheet = {
showSortSheet = true
},
- onGroupExpand = onGroupExpand
+ onGroupExpand = onGroupExpand,
+ onToggleAiSearch = onToggleAiSearch,
+ onSuggestedPromptClick = { prompt ->
+ searchQuery = prompt
+ onSuggestedPromptClick(prompt)
+ }
)
}
@@ -186,7 +200,7 @@ fun DurationOptionsContent(
) {
Text(
text = "Time Range",
- style = MaterialTheme.typography.titleLarge,
+ style = typo.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
@@ -201,20 +215,20 @@ fun DurationOptionsContent(
Icon(
imageVector = Icons.Default.Timer,
contentDescription = null,
- tint = if (selectedHours == hours) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.onSurfaceVariant
+ tint = if (selectedHours == hours) scheme.primary
+ else scheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = label,
- style = MaterialTheme.typography.bodyLarge,
+ style = typo.bodyLarge,
modifier = Modifier.weight(1f)
)
if (selectedHours == hours) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
- tint = MaterialTheme.colorScheme.primary
+ tint = scheme.primary
)
}
}
@@ -234,7 +248,7 @@ fun SortOptionsContent(
) {
Text(
text = "Sort Order",
- style = MaterialTheme.typography.titleLarge,
+ style = typo.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
@@ -252,20 +266,20 @@ fun SortOptionsContent(
SortOrder.OLDEST_FIRST -> Icons.Default.ArrowUpward
},
contentDescription = null,
- tint = if (selectedOrder == order) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.onSurfaceVariant
+ tint = if (selectedOrder == order) scheme.primary
+ else scheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = order.displayName,
- style = MaterialTheme.typography.bodyLarge,
+ style = typo.bodyLarge,
modifier = Modifier.weight(1f)
)
if (selectedOrder == order) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
- tint = MaterialTheme.colorScheme.primary
+ tint = scheme.primary
)
}
}
@@ -290,75 +304,75 @@ fun CrashTypeFilterRow(
LazyRow(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.background),
+ .background(scheme.background),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
- item {
- // Duration pill
- FilterChip(
- shape = RoundedCornerShape(50.dp),
- selected = false,
- onClick = { onClickDuration() },
- label = {
- Text(
- when (uiState.filter.timeRangeHours) {
- 1 -> "Last 1 hour"
- 6 -> "Last 6 hours"
- 24 -> "Last 24 hours"
- 72 -> "Last 3 days"
- 168 -> "Last 7 days"
- else -> "Last ${uiState.filter.timeRangeHours} hour(s)"
- }
- )
- },
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.CalendarToday,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- }
- )
- }
- item {
- // Sort pill
- FilterChip(
- shape = RoundedCornerShape(50.dp),
- selected = false,
- onClick = onClickSort,
- label = {
- Text(
- when (uiState.filter.sortOrder) {
- SortOrder.NEWEST_FIRST -> "Newest"
- SortOrder.OLDEST_FIRST -> "Oldest"
- }
- )
- },
- leadingIcon = {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.Sort,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- }
- )
- }
- item {
- VerticalDivider(
- modifier = Modifier
- .padding(vertical = 8.dp)
- .height(24.dp),
- thickness = 1.dp
- )
- }
+// item {
+// // Duration pill
+// FilterChip(
+// shape = RoundedCornerShape(50.dp),
+// selected = false,
+// onClick = { onClickDuration() },
+// label = {
+// Text(
+// when (uiState.filter.timeRangeHours) {
+// 1 -> "Last 1 hour"
+// 6 -> "Last 6 hours"
+// 24 -> "Last 24 hours"
+// 72 -> "Last 3 days"
+// 168 -> "Last 7 days"
+// else -> "Last ${uiState.filter.timeRangeHours} hour(s)"
+// }
+// )
+// },
+// leadingIcon = {
+// Icon(
+// imageVector = Icons.Default.CalendarToday,
+// contentDescription = null,
+// modifier = Modifier.size(18.dp)
+// )
+// }
+// )
+// }
+// item {
+// // Sort pill
+// FilterChip(
+// shape = RoundedCornerShape(50.dp),
+// selected = false,
+// onClick = onClickSort,
+// label = {
+// Text(
+// when (uiState.filter.sortOrder) {
+// SortOrder.NEWEST_FIRST -> "Newest"
+// SortOrder.OLDEST_FIRST -> "Oldest"
+// }
+// )
+// },
+// leadingIcon = {
+// Icon(
+// imageVector = Icons.AutoMirrored.Filled.Sort,
+// contentDescription = null,
+// modifier = Modifier.size(18.dp)
+// )
+// }
+// )
+// }
+// item {
+// VerticalDivider(
+// modifier = Modifier
+// .padding(vertical = 8.dp)
+// .height(24.dp),
+// thickness = 1.dp
+// )
+// }
item {
CrashTypeFilterChip(
label = "All",
count = totalCount,
- color = MaterialTheme.colorScheme.primary,
+ color = scheme.primary,
selected = selectedFilter == CrashTypeFilter.ALL,
onClick = { onFilterChange(CrashTypeFilter.ALL) }
)
@@ -367,16 +381,16 @@ fun CrashTypeFilterRow(
CrashTypeFilterChip(
label = "Crashes",
count = crashCount,
- color = MaterialTheme.colorScheme.error,
+ color = scheme.error,
selected = selectedFilter == CrashTypeFilter.CRASHES,
onClick = { onFilterChange(CrashTypeFilter.CRASHES) }
)
}
item {
CrashTypeFilterChip(
- label = "ANRs",
+ label = "ANR",
count = anrCount,
- color = MaterialTheme.colorScheme.tertiary,
+ color = scheme.tertiary,
selected = selectedFilter == CrashTypeFilter.ANRS,
onClick = { onFilterChange(CrashTypeFilter.ANRS) }
)
@@ -385,7 +399,7 @@ fun CrashTypeFilterRow(
CrashTypeFilterChip(
label = "Native",
count = nativeCount,
- color = MaterialTheme.colorScheme.secondary,
+ color = scheme.secondary,
selected = selectedFilter == CrashTypeFilter.NATIVE,
onClick = { onFilterChange(CrashTypeFilter.NATIVE) }
)
@@ -401,46 +415,53 @@ fun CrashTypeFilterChip(
selected: Boolean,
onClick: () -> Unit
) {
+ val isAll = label == "All"
FilterChip(
shape = RoundedCornerShape(50.dp),
selected = selected,
onClick = onClick,
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
- if (selected) {
- Icon(
- imageVector = Icons.Default.Check,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = color
- )
- Spacer(modifier = Modifier.width(4.dp))
- }
- Text(label)
- if (label != "All" && selected.not()) {
- Spacer(modifier = Modifier.width(4.dp))
- Surface(
- color = if (selected) color else color.copy(alpha = 0.2f),
- shape = MaterialTheme.shapes.extraSmall
- ) {
- Text(
- text = count.toString(),
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
- style = MaterialTheme.typography.labelSmall,
- color = if (selected) Color.White else color
+ if (isAll) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
)
+ Spacer(modifier = Modifier.width(6.dp))
}
+ } else {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(color = color, shape = CircleShape)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
}
+ Text(
+ text = label,
+ style = AppTypography.labelSmall.copy(
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = count.toString(),
+ style = CodeTypography.labelSmall.copy(
+ fontSize = 11.sp
+ )
+ )
}
},
colors = FilterChipDefaults.filterChipColors(
- selectedContainerColor = color.copy(alpha = 0.15f),
- selectedLabelColor = color
+ selectedContainerColor = scheme.primary,
+ selectedLabelColor = scheme.onPrimary
)
)
}
-@OptIn(ExperimentalFoundationApi::class)
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CrashLogList(
uiState: CrashLogUiState,
@@ -451,15 +472,32 @@ fun CrashLogList(
showDurationSheet: () -> Unit,
showSortSheet: () -> Unit,
onGroupExpand: (String) -> Unit = {},
+ onToggleAiSearch: () -> Unit = {},
+ onSuggestedPromptClick: (String) -> Unit = {},
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(bottom = 16.dp)
) {
+ item {
+ EventsSparklineHeader(
+ trend = uiState.eventsTrend,
+ windowLabel = "7D",
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
item {
Search(
searchQuery = searchQuery,
- onSearchQueryChange = onSearchQueryChange
+ onSearchQueryChange = onSearchQueryChange,
+ isAiSearchEnabled = uiState.isAiSearchEnabled,
+ isAiSearchAvailable = uiState.isAiSearchAvailable,
+ isParsingQuery = uiState.isParsingQuery,
+ suggestedPrompts = uiState.suggestedPrompts,
+ onToggleAiSearch = onToggleAiSearch,
+ onSuggestedPromptClick = onSuggestedPromptClick
)
}
@@ -478,14 +516,7 @@ fun CrashLogList(
when {
uiState.isLoading -> {
item {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 32.dp),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator()
- }
+ CrashListSkeleton(modifier = Modifier.padding(top = 8.dp))
}
}
uiState.error != null -> {
@@ -506,7 +537,7 @@ fun CrashLogList(
group = group,
isExpanded = group.signature in uiState.expandedGroups,
onGroupClick = { onGroupExpand(group.signature) },
- onCrashClick = onCrashClick
+ onCrashClick = onCrashClick,
)
}
}
@@ -563,8 +594,8 @@ fun CrashLogItem(
) {
Text(
text = crash.appName ?: crash.packageName ?: "Unknown",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleMedium,
+ color = scheme.onSurface,
+ style = typo.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@@ -577,8 +608,8 @@ fun CrashLogItem(
if (crash.appName != null && crash.packageName != null) {
Text(
text = crash.packageName,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodySmall,
+ color = scheme.onSurfaceVariant
)
}
@@ -591,7 +622,7 @@ fun CrashLogItem(
Text(
text = preview,
- style = MaterialTheme.typography.bodySmall,
+ style = typo.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = color
@@ -601,8 +632,8 @@ fun CrashLogItem(
Text(
text = dateFormat.format(Date(crash.timestamp)),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.labelSmall,
+ color = scheme.onSurfaceVariant
)
}
}
@@ -613,13 +644,13 @@ fun CrashLogItem(
fun getCrashTypeIconAndColor(type: CrashType): Pair {
return when (type) {
CrashType.DATA_APP_CRASH, CrashType.SYSTEM_APP_CRASH ->
- Icons.Default.BugReport to MaterialTheme.colorScheme.error
+ Icons.Default.BugReport to scheme.error
CrashType.DATA_APP_ANR, CrashType.SYSTEM_APP_ANR ->
- Icons.Default.Timer to MaterialTheme.colorScheme.tertiary
+ Icons.Default.Timer to scheme.tertiary
CrashType.SYSTEM_TOMBSTONE ->
- Icons.Default.Memory to MaterialTheme.colorScheme.secondary
+ Icons.Default.Memory to scheme.secondary
else ->
- Icons.Default.Error to MaterialTheme.colorScheme.outline
+ Icons.Default.Error to scheme.outline
}
}
@@ -633,8 +664,8 @@ fun ErrorMessage(message: String) {
) {
Text(
text = message,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyLarge
+ color = scheme.error,
+ style = typo.bodyLarge
)
}
}
@@ -650,109 +681,202 @@ fun EmptyState() {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No crashes found",
- style = MaterialTheme.typography.titleLarge
+ style = typo.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your apps are running smoothly!",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant
)
}
}
}
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Search(
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
+ isAiSearchEnabled: Boolean,
+ isAiSearchAvailable: Boolean,
+ isParsingQuery: Boolean,
+ suggestedPrompts: List,
+ onToggleAiSearch: () -> Unit,
+ onSuggestedPromptClick: (String) -> Unit,
) {
var isHintDisplayed by remember {
mutableStateOf(true)
}
val focusManager = LocalFocusManager.current
- 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()
+ 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 = scheme.onSurface,
+ fontSize = 16.sp
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ // Hide keyboard on done
+ focusManager.clearFocus()
+ }
+ ),
+ cursorBrush = SolidColor(scheme.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)
- ),
+ color = scheme.surfaceContainer,
+ 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",
- tint = MaterialTheme.colorScheme.onSurface
+ contentDescription = "Search icon",
+ tint = scheme.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 = scheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = typo.bodySmall.copy(
+ fontWeight = FontWeight.Medium
+ )
+ )
+ }
+ 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 = scheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ // AI toggle button (only show if available)
+ if (isAiSearchAvailable) {
+ IconButton(
+ onClick = onToggleAiSearch,
+ modifier = Modifier
+ .size(36.dp)
+ .background(
+ color = if (isAiSearchEnabled)
+ scheme.primaryContainer
+ else
+ Color.Transparent,
+ shape = CircleShape
+ ),
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = if (isAiSearchEnabled)
+ scheme.onPrimaryContainer
+ else
+ scheme.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(
+ shape = RoundedCornerShape(50.dp),
+ 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 = scheme.surfaceContainer,
+ labelColor = scheme.onSurface,
+ iconContentColor = scheme.primary
+ )
+ )
+ }
}
}
@@ -767,7 +891,9 @@ private fun CrashLogListContentPreview() {
onCrashClick = {},
onTimeRangeChange = {},
onSortOrderChange = {},
- onTypeFilterChange = {}
+ onTypeFilterChange = {},
+ onToggleAiSearch = {},
+ onSuggestedPromptClick = {}
)
}
}
@@ -783,7 +909,45 @@ private fun CrashLogListContentDarkPreview() {
onCrashClick = {},
onTimeRangeChange = {},
onSortOrderChange = {},
- onTypeFilterChange = {}
+ onTypeFilterChange = {},
+ onToggleAiSearch = {},
+ onSuggestedPromptClick = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CrashLogListContentLoadingPreview() {
+ StackLensTheme {
+ CrashLogListContent(
+ uiState = sampleUiState.copy(isLoading = true, crashGroups = emptyList()),
+ onRefresh = {},
+ onSearchQueryChange = {},
+ onCrashClick = {},
+ onTimeRangeChange = {},
+ onSortOrderChange = {},
+ onTypeFilterChange = {},
+ onToggleAiSearch = {},
+ onSuggestedPromptClick = {}
+ )
+ }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CrashLogListContentLoadingDarkPreview() {
+ StackLensTheme {
+ CrashLogListContent(
+ uiState = sampleUiState.copy(isLoading = true, crashGroups = emptyList()),
+ onRefresh = {},
+ onSearchQueryChange = {},
+ onCrashClick = {},
+ onTimeRangeChange = {},
+ onSortOrderChange = {},
+ onTypeFilterChange = {},
+ onToggleAiSearch = {},
+ onSuggestedPromptClick = {}
)
}
}
@@ -801,7 +965,9 @@ private fun EmptyStatePreview() {
onCrashClick = {},
onTimeRangeChange = {},
onSortOrderChange = {},
- onTypeFilterChange = {}
+ onTypeFilterChange = {},
+ onToggleAiSearch = {},
+ 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..3128d4d 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
@@ -3,15 +3,21 @@ package com.devbyjonathan.stacklens.screen.list
import android.app.Application
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.CrashCategory
import com.devbyjonathan.stacklens.model.CrashFilter
import com.devbyjonathan.stacklens.model.CrashGroup
import com.devbyjonathan.stacklens.model.CrashLog
import com.devbyjonathan.stacklens.model.CrashType
import com.devbyjonathan.stacklens.model.CrashTypeFilter
+import com.devbyjonathan.stacklens.model.CustomTimeRange
import com.devbyjonathan.stacklens.model.SortOrder
import com.devbyjonathan.stacklens.repository.CrashLogRepository
+import com.devbyjonathan.stacklens.repository.EventsTrend
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 +27,8 @@ 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,
) : ViewModel() {
private val _uiState = MutableStateFlow(CrashLogUiState())
@@ -30,8 +37,14 @@ 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
+
init {
checkPermissions()
+ checkAiAvailability()
}
fun checkPermissions() {
@@ -58,10 +71,46 @@ class CrashLogViewModel @Inject constructor(
try {
val filter = _uiState.value.filter
- var logs = repository.getCrashLogs(filter)
+ // Broad pool: time-range (custom range respected) + search only. Used for
+ // filter-sheet counts so badges reflect the whole time range regardless of
+ // the category/package selection currently applied.
+ val broadFilter = filter.copy(
+ selectedCategories = emptySet(),
+ selectedPackages = emptySet(),
+ packageName = null,
+ )
+ val broadLogs = repository.getCrashLogs(broadFilter)
val stats = repository.getCrashStats(filter.timeRangeHours)
- // Apply type filter
+ val categoryCounts: Map =
+ CrashCategory.entries.associateWith { cat ->
+ broadLogs.count { it.tag in cat.crashTypes }
+ }
+ val appFilterItems: List = broadLogs
+ .filter { it.packageName != null }
+ .groupBy { it.packageName!! }
+ .map { (pkg, logs) ->
+ AppFilterItem(
+ packageName = pkg,
+ appName = logs.firstNotNullOfOrNull { it.appName },
+ count = logs.size,
+ )
+ }
+ .sortedWith(
+ compareByDescending { it.count }
+ .thenBy { (it.appName ?: it.packageName).lowercase() }
+ )
+
+ // Apply in-memory dimensional filters to produce the displayed list.
+ var logs = broadLogs.filter { log ->
+ val matchesCategory = filter.selectedCategories.isEmpty() ||
+ filter.selectedCategories.any { log.tag in it.crashTypes }
+ val matchesPackage = filter.selectedPackages.isEmpty() ||
+ (log.packageName != null && log.packageName in filter.selectedPackages)
+ matchesCategory && matchesPackage
+ }
+
+ // Apply legacy type-pill filter
logs = when (filter.typeFilter) {
CrashTypeFilter.ALL -> logs
CrashTypeFilter.CRASHES -> logs.filter { it.tag in CrashType.appCrashTags }
@@ -75,9 +124,8 @@ class CrashLogViewModel @Inject constructor(
SortOrder.OLDEST_FIRST -> logs.sortedBy { it.timestamp }
}
- // Load grouped crashes (always enabled)
+ // Load grouped crashes (respects new filter fields via repository).
val groups = repository.getGroupedCrashLogs(filter).let { allGroups ->
- // Apply type filter to groups
when (filter.typeFilter) {
CrashTypeFilter.ALL -> allGroups
CrashTypeFilter.CRASHES -> allGroups.filter { it.crashType in CrashType.appCrashTags }
@@ -86,11 +134,17 @@ class CrashLogViewModel @Inject constructor(
}
}
+ val eventsTrend = runCatching { repository.getEventsTrend(days = 7) }.getOrNull()
+
_uiState.value = _uiState.value.copy(
isLoading = false,
crashLogs = logs,
crashGroups = groups,
- stats = stats
+ stats = stats,
+ eventsTrend = eventsTrend,
+ filterSheetCategoryCounts = categoryCounts,
+ filterSheetApps = appFilterItems,
+ filterSheetMatchingCount = logs.size,
)
} catch (e: SecurityException) {
_uiState.value = _uiState.value.copy(
@@ -113,10 +167,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) {
@@ -134,6 +192,27 @@ class CrashLogViewModel @Inject constructor(
updateFilter(newFilter)
}
+ /**
+ * Commit the filter-sheet selection in one shot. The sheet is the new source of truth
+ * for time range, category, and package filters, so we also reset [CrashTypeFilter]
+ * pills to ALL to avoid the pill and the sheet silently fighting.
+ */
+ fun applyFilterSheet(
+ timeRangeHours: Int,
+ customTimeRange: CustomTimeRange?,
+ selectedCategories: Set,
+ selectedPackages: Set,
+ ) {
+ val newFilter = _uiState.value.filter.copy(
+ timeRangeHours = timeRangeHours,
+ customTimeRange = customTimeRange,
+ selectedCategories = selectedCategories,
+ selectedPackages = selectedPackages,
+ typeFilter = CrashTypeFilter.ALL,
+ )
+ updateFilter(newFilter)
+ }
+
fun toggleCrashType(type: CrashType) {
val currentTypes = _uiState.value.filter.types.toMutableSet()
if (currentTypes.contains(type)) {
@@ -149,6 +228,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()
}
@@ -162,6 +257,107 @@ class CrashLogViewModel @Inject constructor(
}
_uiState.value = _uiState.value.copy(expandedGroups = newExpanded)
}
+
+ private fun checkAiAvailability() {
+ viewModelScope.launch {
+ val isAvailable = crashInsightService.isAvailable()
+ _uiState.value = _uiState.value.copy(isAiSearchAvailable = isAvailable)
+ if (isAvailable) {
+ generateSuggestedPrompts()
+ }
+ }
+ }
+
+ fun toggleAiSearchMode() {
+ val newEnabled = !_uiState.value.isAiSearchEnabled
+ _uiState.value = _uiState.value.copy(isAiSearchEnabled = newEnabled)
+ if (newEnabled) {
+ generateSuggestedPrompts()
+ }
+ }
+
+ 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 +372,19 @@ 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 eventsTrend: EventsTrend? = null,
+ // Filter-sheet state derived from the broad (time-range-only) pool.
+ val filterSheetCategoryCounts: Map = emptyMap(),
+ val filterSheetApps: List = emptyList(),
+ val filterSheetMatchingCount: Int = 0,
+)
+
+data class AppFilterItem(
+ val packageName: String,
+ val appName: String?,
+ val count: Int,
)
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/EventsSparklineHeader.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/EventsSparklineHeader.kt
new file mode 100644
index 0000000..3902205
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/EventsSparklineHeader.kt
@@ -0,0 +1,274 @@
+package com.devbyjonathan.stacklens.screen.list
+
+import android.content.res.Configuration
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.devbyjonathan.stacklens.model.fake.PreviewData
+import com.devbyjonathan.stacklens.repository.DayBucket
+import com.devbyjonathan.stacklens.repository.EventsTrend
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.GoogleSans
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+@Composable
+fun EventsSparklineHeader(
+ trend: EventsTrend?,
+ windowLabel: String = "7D",
+ modifier: Modifier = Modifier,
+) {
+ val accent = scheme.error
+ val onSurfaceVariant = scheme.onSurfaceVariant
+ val dateFormat = remember { SimpleDateFormat("MMM dd", Locale.getDefault()) }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .background(
+ color = scheme.surfaceContainer,
+ shape = RoundedCornerShape(16.dp),
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Column() {
+ Text(
+ text = "EVENTS · $windowLabel",
+ style = typo.labelSmall,
+ color = onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 1.sp,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = trend?.current?.toString() ?: "—",
+ style = typo.displayLarge.copy(
+ fontFamily = GoogleSans,
+ fontWeight = FontWeight.Bold,
+ fontSize = 40.sp
+ ),
+ color = scheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ TrendCaption(trend = trend, accent = accent, muted = onSurfaceVariant)
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Sparkline(
+ points = trend?.buckets?.map { it.count } ?: emptyList(),
+ accent = accent,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(44.dp),
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (trend != null && trend.buckets.isNotEmpty()) {
+ val first = dateFormat.format(Date(trend.buckets.first().dayStartMs))
+ val last = dateFormat.format(Date(trend.buckets.last().dayStartMs))
+ Text(
+ text = "$first",
+ style = typo.labelSmall.copy(fontFamily = GoogleSansCode),
+ color = onSurfaceVariant,
+ fontSize = 12.sp,
+ )
+ Text(
+ text = "$last",
+ style = typo.labelSmall.copy(fontFamily = GoogleSansCode),
+ color = onSurfaceVariant,
+ fontSize = 12.sp,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrendCaption(trend: EventsTrend?, accent: Color, muted: Color) {
+ if (trend == null) {
+ Text(
+ text = "No data yet",
+ style = typo.labelMedium,
+ color = muted,
+ )
+ return
+ }
+ val delta = trend.deltaPercent
+ val symbol = when {
+ delta > 0.5f -> "↑"
+ delta < -0.5f -> "↓"
+ else -> "→"
+ }
+ val color = when {
+ delta > 0.5f -> accent
+ delta < -0.5f -> scheme.primary
+ else -> muted
+ }
+ val pct = abs(delta).roundToInt()
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = when {
+ delta > 0.5f -> Icons.Default.ArrowUpward
+ delta < -0.5f -> Icons.Default.ArrowDownward
+ else -> Icons.AutoMirrored.Filled.ArrowForward
+ },
+ contentDescription = null,
+ tint = color,
+ modifier = Modifier.size(14.dp)
+ )
+ Text(
+ text = "$pct%",
+ style = typo.labelMedium,
+ color = color,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = " vs prev 7d",
+ style = typo.labelMedium,
+ color = scheme.onSurfaceVariant,
+ fontFamily = GoogleSansCode
+ )
+ }
+}
+
+@Composable
+private fun Sparkline(points: List, accent: Color, modifier: Modifier = Modifier) {
+ if (points.isEmpty()) {
+ Box(modifier = modifier)
+ return
+ }
+ val max = (points.max()).coerceAtLeast(1)
+ Canvas(modifier = modifier) {
+ if (points.size < 2) return@Canvas
+ val stepX = size.width / (points.size - 1)
+ val line = Path()
+ val fill = Path()
+ points.forEachIndexed { index, value ->
+ val x = index * stepX
+ val y = size.height - (value / max.toFloat()) * (size.height - 4f) - 2f
+ if (index == 0) {
+ line.moveTo(x, y)
+ fill.moveTo(x, size.height)
+ fill.lineTo(x, y)
+ } else {
+ line.lineTo(x, y)
+ fill.lineTo(x, y)
+ }
+ }
+ fill.lineTo(size.width, size.height)
+ fill.close()
+ drawPath(path = fill, color = accent.copy(alpha = 0.12f))
+ drawPath(
+ path = line,
+ color = accent,
+ style = Stroke(width = 2.dp.toPx()),
+ )
+ val lastValue = points.last()
+ val lastX = (points.size - 1) * stepX
+ val lastY = size.height - (lastValue / max.toFloat()) * (size.height - 4f) - 2f
+ drawCircle(color = accent, radius = 3.dp.toPx(), center = Offset(lastX, lastY))
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EventsSparklineHeaderPreview() {
+ StackLensTheme {
+ EventsSparklineHeader(
+ trend = PreviewData.sampleEventsTrend,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun EventsSparklineHeaderDarkPreview() {
+ StackLensTheme {
+ EventsSparklineHeader(
+ trend = PreviewData.sampleEventsTrend,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EventsSparklineHeaderEmptyPreview() {
+ StackLensTheme {
+ EventsSparklineHeader(
+ trend = null,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EventsSparklineHeaderDownTrendPreview() {
+ val dayMs = 24L * 60 * 60 * 1000
+ val today = (System.currentTimeMillis() / dayMs) * dayMs
+ val counts = listOf(8, 6, 7, 5, 4, 3, 2)
+ StackLensTheme {
+ EventsSparklineHeader(
+ trend = EventsTrend(
+ current = counts.sum(),
+ previous = 50,
+ deltaPercent = -30f,
+ buckets = counts.mapIndexed { idx, c ->
+ DayBucket(today - ((counts.size - 1 - idx) * dayMs), c)
+ },
+ ),
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/list/FilterBottomSheet.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/FilterBottomSheet.kt
new file mode 100644
index 0000000..ee87d19
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/list/FilterBottomSheet.kt
@@ -0,0 +1,708 @@
+package com.devbyjonathan.stacklens.screen.list
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.DateRangePicker
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDateRangePickerState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.devbyjonathan.stacklens.common.CrashTypeBadge
+import com.devbyjonathan.stacklens.model.CrashCategory
+import com.devbyjonathan.stacklens.model.CrashType
+import com.devbyjonathan.stacklens.model.CustomTimeRange
+import com.devbyjonathan.stacklens.model.fake.PreviewData
+import com.devbyjonathan.stacklens.theme.StackLensTheme
+import com.devbyjonathan.uikit.theme.AppTypography
+import com.devbyjonathan.uikit.theme.CodeTypography
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private data class TimeRangePreset(val hours: Int, val label: String)
+
+private val TIME_RANGE_PRESETS = listOf(
+ TimeRangePreset(1, "1h"),
+ TimeRangePreset(24, "24h"),
+ TimeRangePreset(168, "7d"),
+ TimeRangePreset(720, "30d"),
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FilterBottomSheet(
+ uiState: CrashLogUiState,
+ onDismiss: () -> Unit,
+ onApply: (
+ timeRangeHours: Int,
+ customTimeRange: CustomTimeRange?,
+ selectedCategories: Set,
+ selectedPackages: Set,
+ ) -> Unit,
+ sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = scheme.background,
+ contentColor = scheme.onBackground,
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ ) {
+ FilterBottomSheetContent(
+ uiState = uiState,
+ onCancel = onDismiss,
+ onApply = onApply,
+ )
+ }
+}
+
+@Composable
+fun FilterBottomSheetContent(
+ uiState: CrashLogUiState,
+ onCancel: () -> Unit,
+ onApply: (
+ timeRangeHours: Int,
+ customTimeRange: CustomTimeRange?,
+ selectedCategories: Set,
+ selectedPackages: Set,
+ ) -> Unit,
+) {
+ val filter = uiState.filter
+
+ // Resolve initial sheet state from the committed filter. An empty category/package set
+ // on CrashFilter means "no restriction", which we present as "all selected" in the sheet.
+ val availablePackages = remember(uiState.filterSheetApps) {
+ uiState.filterSheetApps.map { it.packageName }.toSet()
+ }
+ val initialCategories = if (filter.selectedCategories.isEmpty()) {
+ CrashCategory.entries.toSet()
+ } else {
+ filter.selectedCategories
+ }
+ val initialPackages = if (filter.selectedPackages.isEmpty()) {
+ availablePackages
+ } else {
+ filter.selectedPackages
+ }
+
+ var draftTimeRangeHours by remember { mutableStateOf(filter.timeRangeHours) }
+ var draftCustomRange by remember { mutableStateOf(filter.customTimeRange) }
+ var draftCategories by remember { mutableStateOf(initialCategories) }
+ var draftPackages by remember { mutableStateOf(initialPackages) }
+ var showDatePicker by remember { mutableStateOf(false) }
+
+ val activeCount = draftCategories.size + draftPackages.size
+ val matchingEvents = computeMatchingEvents(
+ appItems = uiState.filterSheetApps,
+ categoryCounts = uiState.filterSheetCategoryCounts,
+ draftCategories = draftCategories,
+ draftPackages = draftPackages,
+ allPackages = availablePackages,
+ )
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ FilterHeader(
+ activeCount = activeCount,
+ matchingEvents = matchingEvents,
+ onReset = {
+ draftTimeRangeHours = 168
+ draftCustomRange = null
+ draftCategories = CrashCategory.entries.toSet()
+ draftPackages = availablePackages
+ },
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 560.dp),
+ contentPadding = PaddingValues(bottom = 12.dp),
+ ) {
+ item {
+ TimeRangeSection(
+ selectedHours = draftTimeRangeHours,
+ customRange = draftCustomRange,
+ onPresetSelected = { hours ->
+ draftTimeRangeHours = hours
+ draftCustomRange = null
+ },
+ onCustomClick = { showDatePicker = true },
+ )
+ }
+
+ item {
+ CrashTypeSection(
+ counts = uiState.filterSheetCategoryCounts,
+ selected = draftCategories,
+ onToggle = { cat ->
+ draftCategories = if (cat in draftCategories) {
+ draftCategories - cat
+ } else {
+ draftCategories + cat
+ }
+ },
+ )
+ }
+
+ item {
+ AppsSectionHeader(count = uiState.filterSheetApps.size)
+ }
+ items(uiState.filterSheetApps, key = { it.packageName }) { app ->
+ AppFilterRow(
+ item = app,
+ checked = app.packageName in draftPackages,
+ onToggle = {
+ draftPackages = if (app.packageName in draftPackages) {
+ draftPackages - app.packageName
+ } else {
+ draftPackages + app.packageName
+ }
+ },
+ )
+ }
+ if (uiState.filterSheetApps.isEmpty()) {
+ item {
+ Text(
+ text = "No apps with crashes in this time range.",
+ style = typo.bodySmall,
+ color = scheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ )
+ }
+ }
+ }
+
+ FilterFooter(
+ matchingEvents = matchingEvents,
+ onCancel = onCancel,
+ onApply = {
+ val normalizedCategories = if (draftCategories == CrashCategory.entries.toSet()) {
+ emptySet()
+ } else draftCategories
+ val normalizedPackages = if (draftPackages == availablePackages) {
+ emptySet()
+ } else draftPackages
+ onApply(
+ draftTimeRangeHours,
+ draftCustomRange,
+ normalizedCategories,
+ normalizedPackages,
+ )
+ },
+ )
+ }
+
+ if (showDatePicker) {
+ CustomRangeDialog(
+ initial = draftCustomRange,
+ onDismiss = { showDatePicker = false },
+ onConfirm = { range ->
+ draftCustomRange = range
+ showDatePicker = false
+ },
+ )
+ }
+}
+
+@Composable
+private fun FilterHeader(
+ activeCount: Int,
+ matchingEvents: Int,
+ onReset: () -> Unit,
+) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "Filters",
+ style = AppTypography.headlineSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ ),
+ color = scheme.onBackground,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onReset) {
+ Text(
+ text = "Reset",
+ style = AppTypography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = scheme.onPrimaryContainer,
+ )
+ }
+ }
+ Text(
+ text = "$activeCount active · matching $matchingEvents events",
+ style = typo.bodySmall,
+ color = scheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun TimeRangeSection(
+ selectedHours: Int,
+ customRange: CustomTimeRange?,
+ onPresetSelected: (Int) -> Unit,
+ onCustomClick: () -> Unit,
+) {
+ val isCustomSelected = customRange != null
+ Column(modifier = Modifier.padding(top = 12.dp)) {
+ SectionHeader(
+ title = "TIME RANGE",
+ trailing = timeRangeLabel(selectedHours, customRange),
+ )
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 4.dp),
+ ) {
+ items(TIME_RANGE_PRESETS) { preset ->
+ val selected = !isCustomSelected && selectedHours == preset.hours
+ TimeRangeChip(
+ label = preset.label,
+ selected = selected,
+ onClick = { onPresetSelected(preset.hours) },
+ )
+ }
+ item {
+ TimeRangeChip(
+ label = customRange?.let { "Custom" } ?: "Custom…",
+ selected = isCustomSelected,
+ onClick = onCustomClick,
+ leadingIcon = Icons.Default.CalendarToday,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimeRangeChip(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+ leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null,
+) {
+ val bg = if (selected) scheme.inverseSurface else Color.Transparent
+ val fg = if (selected) scheme.inverseOnSurface else scheme.onSurface
+ val borderColor = if (selected) Color.Transparent else scheme.outline
+
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .background(bg)
+ .border(1.dp, borderColor, RoundedCornerShape(percent = 50))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = fg,
+ modifier = Modifier.size(14.dp),
+ )
+ } else if (leadingIcon != null) {
+ Icon(
+ imageVector = leadingIcon,
+ contentDescription = null,
+ tint = fg,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ Text(
+ text = label,
+ style = AppTypography.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ ),
+ color = fg,
+ )
+ }
+}
+
+@Composable
+private fun CrashTypeSection(
+ counts: Map,
+ selected: Set,
+ onToggle: (CrashCategory) -> Unit,
+) {
+ Column(modifier = Modifier.padding(top = 16.dp)) {
+ SectionHeader(title = "CRASH TYPE")
+ Column {
+ CrashCategory.entries.forEach { cat ->
+ CrashCategoryRow(
+ category = cat,
+ count = counts[cat] ?: 0,
+ checked = cat in selected,
+ onToggle = { onToggle(cat) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CrashCategoryRow(
+ category: CrashCategory,
+ count: Int,
+ checked: Boolean,
+ onToggle: () -> Unit,
+) {
+ val representative = category.crashTypes.firstOrNull() ?: CrashType.DATA_APP_CRASH
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onToggle)
+ .padding(horizontal = 20.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SheetCheckbox(checked = checked)
+ Spacer(modifier = Modifier.width(12.dp))
+ CrashTypeBadge(type = representative)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = category.displayName,
+ style = typo.bodyLarge.copy(fontWeight = FontWeight.Medium),
+ color = scheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = count.toString(),
+ style = CodeTypography.labelLarge.copy(fontWeight = FontWeight.Medium),
+ color = scheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun AppsSectionHeader(count: Int) {
+ Box(modifier = Modifier.padding(top = 16.dp)) {
+ SectionHeader(
+ title = "APPS",
+ trailing = "$count installed",
+ )
+ }
+}
+
+@Composable
+private fun AppFilterRow(
+ item: AppFilterItem,
+ checked: Boolean,
+ onToggle: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onToggle)
+ .padding(horizontal = 20.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SheetCheckbox(checked = checked)
+ Spacer(modifier = Modifier.width(12.dp))
+ AppInitialAvatar(name = item.appName ?: item.packageName)
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.appName ?: item.packageName,
+ style = typo.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = scheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = item.packageName,
+ style = CodeTypography.bodySmall,
+ color = scheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = item.count.toString(),
+ style = CodeTypography.labelLarge.copy(fontWeight = FontWeight.Medium),
+ color = scheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun AppInitialAvatar(name: String) {
+ val initial = name.firstOrNull { it.isLetterOrDigit() }?.uppercase() ?: "?"
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(scheme.surfaceContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = initial,
+ style = AppTypography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
+ color = scheme.onSurface,
+ )
+ }
+}
+
+@Composable
+private fun SheetCheckbox(checked: Boolean) {
+ val bg = if (checked) scheme.inverseSurface else Color.Transparent
+ val borderColor = if (checked) Color.Transparent else scheme.outline
+ Box(
+ modifier = Modifier
+ .size(22.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(bg)
+ .border(1.5.dp, borderColor, RoundedCornerShape(6.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (checked) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = scheme.inverseOnSurface,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(title: String, trailing: String? = null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ style = AppTypography.labelMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 1.2.sp,
+ ),
+ color = scheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ if (trailing != null) {
+ Text(
+ text = trailing,
+ style = typo.bodySmall,
+ color = scheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun FilterFooter(
+ matchingEvents: Int,
+ onCancel: () -> Unit,
+ onApply: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .background(scheme.surfaceContainer)
+ .clickable(onClick = onCancel)
+ .padding(horizontal = 28.dp, vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Cancel",
+ style = AppTypography.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ ),
+ color = scheme.onSurface,
+ )
+ }
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(scheme.inverseSurface)
+ .clickable(onClick = onApply)
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "Apply · $matchingEvents",
+ style = AppTypography.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ ),
+ color = scheme.inverseOnSurface,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CustomRangeDialog(
+ initial: CustomTimeRange?,
+ onDismiss: () -> Unit,
+ onConfirm: (CustomTimeRange) -> Unit,
+) {
+ val state = rememberDateRangePickerState(
+ initialSelectedStartDateMillis = initial?.startMs,
+ initialSelectedEndDateMillis = initial?.endMs,
+ )
+ DatePickerDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val start = state.selectedStartDateMillis
+ val end = state.selectedEndDateMillis
+ if (start != null && end != null) {
+ // end is the start-of-day of the selected end date; include the whole day.
+ onConfirm(CustomTimeRange(start, end + (24L * 60 * 60 * 1000) - 1))
+ }
+ },
+ enabled = state.selectedStartDateMillis != null && state.selectedEndDateMillis != null,
+ ) { Text("Apply") }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ },
+ ) {
+ DateRangePicker(state = state, modifier = Modifier.heightIn(max = 520.dp))
+ }
+}
+
+private fun timeRangeLabel(hours: Int, custom: CustomTimeRange?): String {
+ if (custom != null) {
+ val fmt = SimpleDateFormat("MMM d", Locale.getDefault())
+ return "${fmt.format(Date(custom.startMs))} – ${fmt.format(Date(custom.endMs))}"
+ }
+ return when (hours) {
+ 1 -> "last 1 hour"
+ 24 -> "last 24 hours"
+ 72 -> "last 3 days"
+ 168 -> "last 7 days"
+ 720 -> "last 30 days"
+ else -> "last $hours hours"
+ }
+}
+
+private fun computeMatchingEvents(
+ appItems: List,
+ categoryCounts: Map,
+ draftCategories: Set,
+ draftPackages: Set,
+ allPackages: Set,
+): Int {
+ // Coarse estimate: sum counts of selected categories, scaled by the ratio of selected
+ // packages in the time-range pool. Good enough for the "Apply · N" preview; the committed
+ // filter recomputes the accurate total.
+ val categoryTotal = draftCategories.sumOf { categoryCounts[it] ?: 0 }
+ val poolTotal = appItems.sumOf { it.count }
+ if (poolTotal == 0 || draftPackages.size == allPackages.size) return categoryTotal
+ val selectedPackageTotal = appItems
+ .filter { it.packageName in draftPackages }
+ .sumOf { it.count }
+ if (categoryTotal == 0) return 0
+ // If no packages selected, nothing matches.
+ if (selectedPackageTotal == 0) return 0
+ // Approximate the intersection assuming uniform distribution.
+ return ((categoryTotal.toLong() * selectedPackageTotal) / poolTotal).toInt()
+}
+
+@Preview(showBackground = true, heightDp = 900)
+@Composable
+private fun FilterBottomSheetContentPreview() {
+ StackLensTheme {
+ FilterBottomSheetContent(
+ uiState = PreviewData.sampleUiState.copy(
+ filterSheetCategoryCounts = mapOf(
+ CrashCategory.CRASH to 14,
+ CrashCategory.ANR to 3,
+ CrashCategory.NATIVE to 6,
+ ),
+ filterSheetApps = listOf(
+ AppFilterItem("com.devbyjonathan.justcrash", "Just Crash", 12),
+ AppFilterItem("com.facebook.katana", "Facebook", 6),
+ AppFilterItem("com.google.android.apps.maps", "Google Maps", 3),
+ AppFilterItem("com.example.shop", "Shopping", 2),
+ ),
+ filterSheetMatchingCount = 23,
+ ),
+ onCancel = {},
+ onApply = { _, _, _, _ -> },
+ )
+ }
+}
+
+@Preview(showBackground = true, heightDp = 900, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun FilterBottomSheetContentDarkPreview() {
+ StackLensTheme {
+ FilterBottomSheetContent(
+ uiState = PreviewData.sampleUiState.copy(
+ filterSheetCategoryCounts = mapOf(
+ CrashCategory.CRASH to 14,
+ CrashCategory.ANR to 3,
+ CrashCategory.NATIVE to 6,
+ ),
+ filterSheetApps = listOf(
+ AppFilterItem("com.devbyjonathan.justcrash", "Just Crash", 12),
+ AppFilterItem("com.facebook.katana", "Facebook", 6),
+ ),
+ filterSheetMatchingCount = 23,
+ ),
+ onCancel = {},
+ onApply = { _, _, _, _ -> },
+ )
+ }
+}
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..5028b0a 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
@@ -6,30 +6,42 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsTopHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.BugReport
+import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemColors
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -42,16 +54,21 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.devbyjonathan.stacklens.R
+import com.devbyjonathan.stacklens.model.CrashCategory
import com.devbyjonathan.stacklens.model.CrashLog
import com.devbyjonathan.stacklens.model.CrashTypeFilter
+import com.devbyjonathan.stacklens.model.CustomTimeRange
import com.devbyjonathan.stacklens.model.SortOrder
import com.devbyjonathan.stacklens.model.fake.PreviewData
import com.devbyjonathan.stacklens.screen.list.CrashLogListContent
import com.devbyjonathan.stacklens.screen.list.CrashLogUiState
+import com.devbyjonathan.stacklens.screen.list.FilterBottomSheet
import com.devbyjonathan.stacklens.screen.settings.SettingsScreen
import com.devbyjonathan.stacklens.theme.StackLensTheme
import com.devbyjonathan.stacklens.theme.ThemeMode
import com.devbyjonathan.uikit.theme.AppTypography
+import com.devbyjonathan.uikit.theme.CodeTypography
+import com.devbyjonathan.uikit.theme.scheme
data class BottomNavItem(
val title: String,
@@ -76,15 +93,25 @@ fun HomeScreen(
onDynamicColorChange: (Boolean) -> Unit,
onTermsClick: () -> Unit,
onPrivacyClick: () -> Unit,
+ onToggleAiSearch: () -> Unit = {},
+ onSuggestedPromptClick: (String) -> Unit = {},
+ onApplyFilterSheet: (
+ timeRangeHours: Int,
+ customTimeRange: CustomTimeRange?,
+ selectedCategories: Set,
+ selectedPackages: Set,
+ ) -> Unit = { _, _, _, _ -> },
) {
+ var showFilterSheet by remember { mutableStateOf(false) }
+
val navItems = listOf(
BottomNavItem(
- title = "Crashes",
+ title = "crashes",
selectedIcon = Icons.Filled.BugReport,
unselectedIcon = Icons.Outlined.BugReport
),
BottomNavItem(
- title = "Settings",
+ title = "settings",
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings
)
@@ -94,9 +121,12 @@ fun HomeScreen(
Scaffold(
topBar = {
- TopAppBar(
- title = {
- if (selectedIndex == 0) {
+ if (selectedIndex == 0) {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = scheme.background
+ ),
+ title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
@@ -104,7 +134,7 @@ fun HomeScreen(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.logo_static),
contentDescription = "App Icon",
- tint = MaterialTheme.colorScheme.primary
+ tint = scheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
@@ -115,47 +145,96 @@ fun HomeScreen(
)
)
}
- } else {
- Text(
- text = navItems[selectedIndex].title,
- style = AppTypography.titleLarge.copy(
- fontSize = 18.sp,
- fontWeight = FontWeight.SemiBold
+ },
+ actions = {
+ FilledIconButton(
+ onClick = { showFilterSheet = true },
+ shape = RoundedCornerShape(12.dp),
+ colors = IconButtonColors(
+ containerColor = scheme.surfaceContainer,
+ contentColor = scheme.onSurface,
+ disabledContainerColor = scheme.surfaceContainer,
+ disabledContentColor = scheme.onSurfaceVariant
+ )
+ ) {
+ Icon(
+ Icons.Outlined.FilterAlt,
+ contentDescription = "Filters",
+ tint = scheme.onSurfaceVariant
+ )
+ }
+ FilledIconButton(
+ onClick = onRefresh,
+ shape = RoundedCornerShape(12.dp),
+ colors = IconButtonColors(
+ containerColor = scheme.surfaceContainer,
+ contentColor = scheme.onSurface,
+ disabledContainerColor = scheme.surfaceContainer,
+ disabledContentColor = scheme.onSurfaceVariant
+ )
+ ) {
+ Icon(
+ Icons.Default.Refresh,
+ contentDescription = "Refresh",
+ tint = scheme.onSurfaceVariant
)
- )
- }
- },
- actions = {
- if (selectedIndex == 0) {
- IconButton(onClick = onRefresh) {
- Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
- }
- )
+ )
+ } else {
+ // Settings tab runs edge-to-edge; reserve only the status-bar
+ // inset so the redesigned SettingsScreen header sits just below it.
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(scheme.background)
+ .windowInsetsTopHeight(WindowInsets.statusBars)
+ )
+ }
},
bottomBar = {
- NavigationBar(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
- tonalElevation = 4.dp
- ) {
- navItems.forEachIndexed { index, item ->
- NavigationBarItem(
- selected = selectedIndex == index,
- onClick = { selectedIndex = index },
- icon = {
- Icon(
- imageVector = if (selectedIndex == index) item.selectedIcon else item.unselectedIcon,
- contentDescription = item.title
- )
- },
- label = { Text(item.title) }
- )
+ Column {
+ HorizontalDivider(thickness = 1.dp, color = scheme.outlineVariant)
+ NavigationBar(
+ containerColor = scheme.background,
+ contentColor = scheme.onSurfaceVariant,
+ tonalElevation = 4.dp
+ ) {
+ navItems.forEachIndexed { index, item ->
+ NavigationBarItem(
+ colors = NavigationBarItemColors(
+ selectedIndicatorColor = scheme.primary,
+ selectedIconColor = scheme.onPrimary,
+ selectedTextColor = scheme.primary,
+ unselectedIconColor = scheme.secondary,
+ unselectedTextColor = scheme.secondary,
+ disabledIconColor = scheme.secondaryContainer,
+ disabledTextColor = scheme.secondaryContainer,
+ ),
+ selected = selectedIndex == index,
+ onClick = { selectedIndex = index },
+ icon = {
+ Icon(
+ imageVector = if (selectedIndex == index) item.selectedIcon else item.unselectedIcon,
+ contentDescription = item.title
+ )
+ },
+ label = {
+ Text(
+ text = item.title,
+ style = CodeTypography.labelSmall.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp
+ )
+ )
+ }
+ )
+ }
}
}
},
- contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)
+ contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
+ contentColor = scheme.background
) { padding ->
AnimatedContent(
targetState = selectedIndex,
@@ -181,7 +260,9 @@ fun HomeScreen(
onTimeRangeChange = onTimeRangeChange,
onSortOrderChange = onSortOrderChange,
onTypeFilterChange = onTypeFilterChange,
- onGroupExpand = onGroupExpand
+ onGroupExpand = onGroupExpand,
+ onToggleAiSearch = onToggleAiSearch,
+ onSuggestedPromptClick = onSuggestedPromptClick
)
1 -> SettingsScreen(
modifier = Modifier.padding(padding),
@@ -195,6 +276,17 @@ fun HomeScreen(
}
}
}
+
+ if (showFilterSheet) {
+ FilterBottomSheet(
+ uiState = crashLogUiState,
+ onDismiss = { showFilterSheet = false },
+ onApply = { hours, custom, categories, packages ->
+ onApplyFilterSheet(hours, custom, categories, packages)
+ showFilterSheet = false
+ },
+ )
+ }
}
@Preview(showBackground = true)
@@ -215,7 +307,9 @@ fun HomeScreenPreview() {
onThemeChange = {},
onDynamicColorChange = {},
onTermsClick = {},
- onPrivacyClick = {}
+ onPrivacyClick = {},
+ onToggleAiSearch = {},
+ onSuggestedPromptClick = {}
)
}
}
@@ -238,7 +332,9 @@ fun HomeScreenDarkPreview() {
onThemeChange = {},
onDynamicColorChange = {},
onTermsClick = {},
- onPrivacyClick = {}
+ onPrivacyClick = {},
+ onToggleAiSearch = {},
+ 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..9e1df81 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,18 +10,28 @@ 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.AiInsightScreen
import com.devbyjonathan.stacklens.screen.detail.CrashDetailScreen
import com.devbyjonathan.stacklens.screen.list.CrashLogViewModel
import com.devbyjonathan.stacklens.screen.permission.PermissionScreen
@@ -147,7 +157,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) },
@@ -156,17 +166,114 @@ 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() },
+ onSuggestedPromptClick = { vm.applySuggestedPrompt(it) },
+ onApplyFilterSheet = { hours, custom, categories, packages ->
+ vm.applyFilterSheet(hours, custom, categories, packages)
+ }
)
}
- 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,
+ onAiInsightClick = {
+ navController.navigate(Screen.AiInsight.buildRoute(resolved.id))
+ }
+ )
+ }
+
+ 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()
+ }
+ }
+ }
+ }
+
+ composable(
+ route = Screen.AiInsight.route,
+ arguments = listOf(
+ navArgument(Screen.AiInsight.ARG_CRASH_ID) {
+ type = NavType.LongType
+ }
+ )
+ ) { backStackEntry ->
+ val crashId = backStackEntry.arguments
+ ?.getLong(Screen.AiInsight.ARG_CRASH_ID)
+
+ LaunchedEffect(crashId) {
+ crashId?.let { vm.ensureSelectedCrash(it) }
+ }
+
+ val resolved = selectedCrash?.takeIf { it.id == crashId }
+ val loading by vm.isLoadingSelectedCrash.collectAsState()
+ val similarCount = resolved?.let { crash ->
+ uiState.crashGroups.firstOrNull { it.latestCrash.id == crash.id }?.count
+ ?: 1
+ } ?: 1
+
+ when {
+ resolved != null -> {
+ AiInsightScreen(
+ crash = resolved,
+ similarCount = similarCount,
+ crashInsightService = crashInsightService,
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+
+ loading || crashId == null -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ else -> {
+ LaunchedEffect(Unit) {
+ navController.popBackStack()
+ }
+ }
}
}
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..fc7e738 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,8 +52,11 @@ 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
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,13 +81,13 @@ fun PermissionScreen(
bottomBar = {
Box(
modifier = Modifier
- .background(MaterialTheme.colorScheme.background)
+ .background(scheme.background)
.padding(16.dp)
.navigationBarsPadding()
) {
Button(
colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
+ containerColor = scheme.primary,
),
onClick = onCheckPermissions,
modifier = Modifier.fillMaxWidth()
@@ -94,7 +97,7 @@ fun PermissionScreen(
style = AppTypography.bodyMedium.copy(
fontWeight = FontWeight.Medium
),
- color = MaterialTheme.colorScheme.onPrimaryContainer,
+ color = scheme.onPrimary,
)
}
}
@@ -115,14 +118,14 @@ fun PermissionScreen(
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
- .background(MaterialTheme.colorScheme.primaryContainer)
+ .background(scheme.primaryContainer)
.padding(20.dp)
) {
Icon(
modifier = Modifier.size(30.dp),
imageVector = Icons.Default.Security,
contentDescription = "Permissions",
- tint = MaterialTheme.colorScheme.onPrimaryContainer
+ tint = scheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -139,7 +142,7 @@ fun PermissionScreen(
style = AppTypography.bodyMedium.copy(
textAlign = TextAlign.Center
),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = scheme.onSurfaceVariant,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(16.dp))
@@ -179,18 +182,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))
@@ -201,7 +206,7 @@ fun PermissionScreen(
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "How to grant ADB permission",
- style = MaterialTheme.typography.titleMedium
+ style = typo.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
@@ -218,7 +223,7 @@ fun PermissionScreen(
steps.forEachIndexed { index, step ->
Text(
text = "${index + 1}. $step",
- style = MaterialTheme.typography.bodyMedium,
+ style = typo.bodyMedium,
modifier = Modifier.padding(vertical = 4.dp)
)
}
@@ -242,18 +247,18 @@ private fun PermissionCard(
onCopyCommand: (() -> Unit)? = null
) {
val color = if (isGranted) {
- MaterialTheme.colorScheme.onPrimaryContainer
+ scheme.onPrimaryContainer
} else {
- MaterialTheme.colorScheme.error
+ scheme.error
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isGranted) {
- MaterialTheme.colorScheme.primaryContainer
+ scheme.primaryContainer
} else {
- MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
+ scheme.errorContainer.copy(alpha = 0.5f)
}
)
) {
@@ -287,11 +292,12 @@ private fun PermissionCard(
.basicMarquee(),
text = title,
style = AppTypography.titleMedium,
+ fontWeight = FontWeight.Medium,
maxLines = 1,
color = if (isGranted) {
- MaterialTheme.colorScheme.primary
+ scheme.primary
} else {
- MaterialTheme.colorScheme.error
+ scheme.error
}
)
Text(
@@ -300,9 +306,9 @@ private fun PermissionCard(
fontWeight = FontWeight.Medium
),
color = if (isGranted) {
- MaterialTheme.colorScheme.primary
+ scheme.primary
} else {
- MaterialTheme.colorScheme.error
+ scheme.error
}
)
}
@@ -312,7 +318,7 @@ private fun PermissionCard(
Text(
text = description,
style = AppTypography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
+ color = scheme.onSurface
)
if (!isGranted) {
@@ -321,7 +327,7 @@ private fun PermissionCard(
if (adbCommand != null && onCopyCommand != null) {
// ADB command display
Surface(
- color = MaterialTheme.colorScheme.surfaceVariant,
+ color = scheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
SelectionContainer {
@@ -335,7 +341,7 @@ private fun PermissionCard(
Spacer(modifier = Modifier.height(8.dp))
Button(
colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
+ containerColor = scheme.error,
),
onClick = onCopyCommand,
modifier = Modifier.fillMaxWidth()
@@ -345,13 +351,13 @@ private fun PermissionCard(
style = AppTypography.bodyMedium.copy(
fontWeight = FontWeight.Medium
),
- color = MaterialTheme.colorScheme.error
+ color = scheme.errorContainer
)
}
} else if (onGrantClick != null && grantButtonText != null) {
Button(
colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
+ containerColor = scheme.error,
),
onClick = onGrantClick,
modifier = Modifier.fillMaxWidth()
@@ -361,7 +367,7 @@ private fun PermissionCard(
style = AppTypography.bodyMedium.copy(
fontWeight = FontWeight.Medium
),
- color = MaterialTheme.colorScheme.error,
+ color = scheme.errorContainer,
)
}
}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/LegalScreen.kt b/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/LegalScreen.kt
index c750eeb..1bfaeaf 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/LegalScreen.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/screen/settings/LegalScreen.kt
@@ -2,19 +2,30 @@ package com.devbyjonathan.stacklens.screen.settings
import android.webkit.WebView
import android.webkit.WebViewClient
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.viewinterop.AndroidView
+import androidx.webkit.WebSettingsCompat
+import androidx.webkit.WebViewFeature
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,8 +60,32 @@ private fun LegalWebViewScreen(
) {
Scaffold(
topBar = {
- TopAppBar(
- title = { Text(title) },
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = scheme.background
+ ),
+ title = {
+ val muted = scheme.onSurfaceVariant
+ val emphasised = scheme.onSurface
+ val text = buildAnnotatedString {
+ withStyle(SpanStyle(color = muted, fontFamily = GoogleSansCode)) {
+ append("legal")
+ }
+ withStyle(SpanStyle(color = muted, fontFamily = GoogleSansCode)) {
+ append(" / ")
+ }
+ withStyle(
+ SpanStyle(
+ color = emphasised,
+ fontFamily = GoogleSansCode,
+ fontWeight = FontWeight.SemiBold,
+ )
+ ) {
+ append(title)
+ }
+ }
+ Text(text = text, style = typo.titleMedium)
+ },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
@@ -62,6 +97,7 @@ private fun LegalWebViewScreen(
)
}
) { padding ->
+ val isDark = isSystemInDarkTheme()
AndroidView(
modifier = Modifier
.fillMaxSize()
@@ -70,9 +106,19 @@ private fun LegalWebViewScreen(
WebView(context).apply {
webViewClient = WebViewClient()
settings.javaScriptEnabled = true
+ applyDarkening(isDark)
loadUrl(url)
}
+ },
+ update = { webView ->
+ webView.applyDarkening(isDark)
}
)
}
}
+
+private fun WebView.applyDarkening(isDark: Boolean) {
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
+ WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, isDark)
+ }
+}
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..c330b7c 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
@@ -4,8 +4,10 @@ import android.content.res.Configuration
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,22 +20,21 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
-import androidx.compose.material.icons.filled.ColorLens
-import androidx.compose.material.icons.filled.DarkMode
-import androidx.compose.material.icons.filled.Description
-import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.outlined.DarkMode
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Palette
+import androidx.compose.material.icons.outlined.Shield
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.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -43,12 +44,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -56,7 +61,12 @@ 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
+import com.devbyjonathan.uikit.theme.CodeTypography
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+import com.devbyjonathan.uikit.theme.typo
@Composable
fun SettingsScreen(
@@ -66,81 +76,82 @@ fun SettingsScreen(
onThemeChange: (ThemeMode) -> Unit,
onDynamicColorChange: (Boolean) -> Unit,
onTermsClick: () -> Unit,
- onPrivacyClick: () -> Unit
+ onPrivacyClick: () -> Unit,
) {
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
.fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
+ .background(scheme.background)
.verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
) {
- // Appearance Section
- SettingsSectionHeader(title = "Appearance")
+ Spacer(modifier = Modifier.height(8.dp))
+ SettingsHeader()
- SettingsItem(
- icon = Icons.Default.DarkMode,
- title = "Theme",
- subtitle = when (currentThemeMode) {
- ThemeMode.LIGHT -> "Light"
- ThemeMode.DARK -> "Dark"
- ThemeMode.SYSTEM -> "System default"
- },
- onClick = { showThemeDialog = true }
- )
+ Spacer(modifier = Modifier.height(24.dp))
- // Dynamic Color option - only show on Android 12+
- if (isDynamicColorSupported) {
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
-
- SettingsSwitchItem(
- icon = Icons.Default.ColorLens,
- title = "Dynamic Color",
- subtitle = "Use colors from your wallpaper",
- checked = dynamicColorEnabled,
- onCheckedChange = onDynamicColorChange
- )
+ SettingsSection(title = "APPEARANCE") {
+ SettingsCard {
+ SettingsRow(
+ icon = Icons.Outlined.DarkMode,
+ title = "Theme",
+ subtitle = themeSubtitle(currentThemeMode),
+ onClick = { showThemeDialog = true },
+ )
+ if (isDynamicColorSupported) {
+ SettingsCardDivider()
+ SettingsSwitchRow(
+ icon = Icons.Outlined.Palette,
+ iconTileColor = scheme.primaryContainer,
+ iconTint = scheme.onPrimaryContainer,
+ title = "Dynamic color",
+ subtitle = "Use colors from your wallpaper",
+ checked = dynamicColorEnabled,
+ onCheckedChange = onDynamicColorChange,
+ )
+ }
+ }
}
- Spacer(modifier = Modifier.height(16.dp))
-
- // Legal Section
- SettingsSectionHeader(title = "Legal")
-
- SettingsItem(
- icon = Icons.Default.Description,
- title = "Terms & Conditions",
- onClick = onTermsClick
- )
-
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
-
- SettingsItem(
- icon = Icons.Default.Security,
- title = "Privacy Policy",
- onClick = onPrivacyClick
- )
+ Spacer(modifier = Modifier.height(24.dp))
- Spacer(modifier = Modifier.height(16.dp))
+ SettingsSection(title = "LEGAL") {
+ SettingsCard {
+ SettingsRow(
+ icon = Icons.Outlined.Description,
+ title = "Terms & conditions",
+ onClick = onTermsClick,
+ )
+ SettingsCardDivider()
+ SettingsRow(
+ icon = Icons.Outlined.Shield,
+ title = "Privacy policy",
+ onClick = onPrivacyClick,
+ )
+ }
+ }
- // About Section
- SettingsSectionHeader(title = "About")
+ Spacer(modifier = Modifier.height(24.dp))
- SettingsItem(
- icon = Icons.Default.Info,
- title = "App Info",
- subtitle = "Version ${BuildConfig.VERSION_NAME}",
- onClick = { showAppInfoDialog = true }
- )
+ SettingsSection(title = "ABOUT") {
+ SettingsCard {
+ SettingsRow(
+ icon = Icons.Outlined.Info,
+ title = "App info",
+ subtitle = "Version ${BuildConfig.VERSION_NAME} · build ${BuildConfig.VERSION_CODE}",
+ onClick = { showAppInfoDialog = true },
+ )
+ }
+ }
Spacer(modifier = Modifier.height(32.dp))
}
- // Theme Selection Dialog
if (showThemeDialog) {
ThemeSelectionDialog(
currentThemeMode = currentThemeMode,
@@ -148,132 +159,226 @@ fun SettingsScreen(
onThemeChange(theme)
showThemeDialog = false
},
- onDismiss = { showThemeDialog = false }
+ onDismiss = { showThemeDialog = false },
)
}
- // App Info Dialog
if (showAppInfoDialog) {
AppInfoDialog(
currentThemeMode = currentThemeMode,
- onDismiss = { showAppInfoDialog = false }
+ onDismiss = { showAppInfoDialog = false },
)
}
}
@Composable
-private fun SettingsSectionHeader(title: String) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
- )
+private fun SettingsHeader() {
+ Column {
+ Text(
+ text = "Settings",
+ style = AppTypography.displaySmall.copy(
+ fontWeight = FontWeight.Light,
+ fontSize = 40.sp,
+ ),
+ color = scheme.onBackground,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = headerSubtitle(),
+ style = CodeTypography.bodyMedium.copy(fontSize = 13.sp),
+ color = scheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
@Composable
-private fun SettingsItem(
- icon: ImageVector,
+private fun SettingsSection(
title: String,
- subtitle: String? = null,
- onClick: () -> Unit
+ trailing: String? = null,
+ content: @Composable () -> Unit,
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable(onClick = onClick)
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Column(
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
modifier = Modifier
- .weight(1f)
- .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
- style = MaterialTheme.typography.bodyLarge
+ style = AppTypography.labelLarge.copy(
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.4.sp,
+ fontSize = 12.sp,
+ ),
+ color = scheme.onBackground,
+ modifier = Modifier.weight(1f),
)
- if (subtitle != null) {
+ if (trailing != null) {
Text(
- text = subtitle,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ text = trailing,
+ style = CodeTypography.bodySmall,
+ color = scheme.onSurfaceVariant,
)
}
}
+ content()
+ }
+}
- Icon(
- imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
+@Composable
+private fun SettingsCard(content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(scheme.surface)
+ .border(1.dp, scheme.outlineVariant, RoundedCornerShape(16.dp)),
+ ) {
+ content()
}
}
@Composable
-private fun SettingsSwitchItem(
+private fun SettingsCardDivider() {
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = scheme.outlineVariant,
+ )
+}
+
+@Composable
+private fun SettingsRow(
icon: ImageVector,
title: String,
subtitle: String? = null,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit
+ iconTileColor: Color = scheme.surfaceContainer,
+ iconTint: Color = scheme.onSurface,
+ trailing: @Composable (() -> Unit)? = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = scheme.onSurfaceVariant,
+ )
+ },
+ onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable { onCheckedChange(!checked) }
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically
+ .clickable(onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(horizontal = 16.dp)
- ) {
+ SettingsIconTile(icon = icon, tileColor = iconTileColor, tint = iconTint)
+ Spacer(modifier = Modifier.width(14.dp))
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
- style = MaterialTheme.typography.bodyLarge
+ style = typo.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = scheme.onSurface,
)
if (subtitle != null) {
Text(
text = subtitle,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant,
)
}
}
+ if (trailing != null) {
+ Spacer(modifier = Modifier.width(8.dp))
+ trailing()
+ }
+ }
+}
+
+@Composable
+private fun SettingsSwitchRow(
+ icon: ImageVector,
+ title: String,
+ subtitle: String?,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ iconTileColor: Color = scheme.surfaceContainer,
+ iconTint: Color = scheme.onSurface,
+) {
+ SettingsRow(
+ icon = icon,
+ title = title,
+ subtitle = subtitle,
+ iconTileColor = iconTileColor,
+ iconTint = iconTint,
+ onClick = { onCheckedChange(!checked) },
+ trailing = {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = scheme.surface,
+ checkedTrackColor = scheme.inverseSurface,
+ checkedBorderColor = Color.Transparent,
+ uncheckedThumbColor = scheme.outline,
+ uncheckedTrackColor = scheme.surfaceContainer,
+ uncheckedBorderColor = scheme.outlineVariant,
+ ),
+ )
+ },
+ )
+}
- Switch(
- checked = checked,
- onCheckedChange = onCheckedChange
+@Composable
+private fun SettingsIconTile(
+ icon: ImageVector,
+ tileColor: Color,
+ tint: Color,
+) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(tileColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(22.dp),
)
}
}
+private fun themeSubtitle(mode: ThemeMode): String = when (mode) {
+ ThemeMode.LIGHT -> "Light"
+ ThemeMode.DARK -> "Dark"
+ ThemeMode.SYSTEM -> "System default"
+}
+
+private fun headerSubtitle(): String {
+ val version = BuildConfig.VERSION_NAME
+ val device = Build.DEVICE.ifBlank { "device" }
+ val android = "A${Build.VERSION.RELEASE}"
+ return "stacklens v$version · $device · $android"
+}
+
@Composable
private fun ThemeSelectionDialog(
currentThemeMode: ThemeMode,
onThemeSelected: (ThemeMode) -> Unit,
- onDismiss: () -> Unit
+ onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("Choose Theme") },
+ title = {
+ Text(
+ text = "Choose theme",
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp
+ )
+ },
text = {
Column(modifier = Modifier.selectableGroup()) {
ThemeMode.entries.forEach { mode ->
@@ -283,23 +388,20 @@ private fun ThemeSelectionDialog(
.selectable(
selected = currentThemeMode == mode,
onClick = { onThemeSelected(mode) },
- role = Role.RadioButton
+ role = Role.RadioButton,
)
.padding(vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentThemeMode == mode,
- onClick = null
+ onClick = null,
)
Text(
- text = when (mode) {
- ThemeMode.LIGHT -> "Light"
- ThemeMode.DARK -> "Dark"
- ThemeMode.SYSTEM -> "System default"
- },
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 16.dp)
+ text = themeSubtitle(mode),
+ style = typo.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(start = 16.dp),
)
}
}
@@ -307,16 +409,16 @@ private fun ThemeSelectionDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
- Text("Cancel")
+ Text("Cancel", fontWeight = FontWeight.Medium)
}
- }
+ },
)
}
@Composable
private fun AppInfoDialog(
currentThemeMode: ThemeMode,
- onDismiss: () -> Unit
+ onDismiss: () -> Unit,
) {
val context = LocalContext.current
@@ -324,7 +426,7 @@ private fun AppInfoDialog(
onDismissRequest = onDismiss,
title = {
Row(
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier.size(24.dp),
@@ -340,14 +442,14 @@ private fun AppInfoDialog(
text = stringResource(id = R.string.app_name),
style = AppTypography.titleLarge.copy(
fontSize = 18.sp,
- fontWeight = FontWeight.SemiBold
- )
+ fontWeight = FontWeight.SemiBold,
+ ),
)
}
},
text = {
Column(
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoRow("Version", BuildConfig.VERSION_NAME)
InfoRow("Build", BuildConfig.VERSION_CODE.toString())
@@ -358,7 +460,7 @@ private fun AppInfoDialog(
TextButton(onClick = onDismiss) {
Text("Close")
}
- }
+ },
)
}
@@ -366,21 +468,26 @@ private fun AppInfoDialog(
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
+ horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
+ modifier = Modifier.weight(0.3f),
text = label,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ style = typo.bodyMedium,
+ color = scheme.onSurfaceVariant,
+ fontFamily = GoogleSansCode,
)
Text(
+ modifier = Modifier.weight(0.7f),
text = value,
- style = MaterialTheme.typography.bodyMedium
+ style = typo.bodyMedium,
+ fontFamily = GoogleSansCode,
+ textAlign = TextAlign.End
)
}
}
-@Preview
+@Preview(showBackground = true, heightDp = 1100)
@Composable
private fun SettingsScreenPreview() {
StackLensTheme {
@@ -390,12 +497,12 @@ private fun SettingsScreenPreview() {
onThemeChange = {},
onDynamicColorChange = {},
onTermsClick = {},
- onPrivacyClick = {}
+ onPrivacyClick = {},
)
}
}
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(showBackground = true, heightDp = 1100, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SettingsScreenDarkPreview() {
StackLensTheme {
@@ -405,7 +512,7 @@ private fun SettingsScreenDarkPreview() {
onThemeChange = {},
onDynamicColorChange = {},
onTermsClick = {},
- onPrivacyClick = {}
+ onPrivacyClick = {},
)
}
}
diff --git a/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt b/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt
index a8613ab..18a31ce 100644
--- a/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt
+++ b/app/src/main/java/com/devbyjonathan/stacklens/service/CrashSignatureGenerator.kt
@@ -1,6 +1,7 @@
package com.devbyjonathan.stacklens.service
import com.devbyjonathan.stacklens.model.CrashType
+import com.devbyjonathan.stacklens.util.isFrameworkFrame
import javax.inject.Inject
import javax.inject.Singleton
@@ -77,13 +78,7 @@ class CrashSignatureGenerator @Inject constructor() {
val frame = match.groupValues[1]
frame.replace(Regex(":\\d+\\)"), ")")
}
- .filter { frame ->
- // Filter out common framework frames that don't help with grouping
- !frame.startsWith("android.") &&
- !frame.startsWith("java.lang.reflect.") &&
- !frame.startsWith("dalvik.") &&
- !frame.startsWith("com.android.internal.")
- }
+ .filter { frame -> !isFrameworkFrame(frame) }
.take(count)
.toList()
}
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..17ba53a 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,9 +13,10 @@ 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
+import com.devbyjonathan.uikit.theme.StackLensDarkColors
+import com.devbyjonathan.uikit.theme.StackLensLightColors
@Composable
fun StackLensTheme(
@@ -42,13 +42,13 @@ 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)
useDynamicColor && !effectiveDarkTheme -> dynamicLightColorScheme(context)
- effectiveDarkTheme -> darkScheme
- else -> lightScheme
+ effectiveDarkTheme -> StackLensDarkColors
+ else -> StackLensLightColors
}
// Get current view for window modifications
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/CrashMetadataParser.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/CrashMetadataParser.kt
new file mode 100644
index 0000000..2d7a8ec
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/util/CrashMetadataParser.kt
@@ -0,0 +1,32 @@
+package com.devbyjonathan.stacklens.util
+
+data class CrashMetadata(
+ val uid: Int? = null,
+ val systemUptimeMs: Long? = null,
+ val processRuntimeSec: Long? = null,
+ val foreground: Boolean? = null,
+)
+
+private val UID_REGEX = Regex("""(?im)^\s*UID:\s*(\d+)""")
+private val UPTIME_REGEX = Regex("""(?im)^\s*SystemUptimeMs:\s*(\d+)""")
+private val RUNTIME_REGEX = Regex("""(?im)^\s*Process-Runtime:\s*(\d+)""")
+private val FOREGROUND_REGEX = Regex("""(?im)^\s*Foreground:\s*(\w+)""")
+
+fun parseCrashMetadata(content: String): CrashMetadata {
+ val uid = UID_REGEX.find(content)?.groupValues?.get(1)?.toIntOrNull()
+ val uptime = UPTIME_REGEX.find(content)?.groupValues?.get(1)?.toLongOrNull()
+ val runtime = RUNTIME_REGEX.find(content)?.groupValues?.get(1)?.toLongOrNull()
+ val foreground = FOREGROUND_REGEX.find(content)?.groupValues?.get(1)?.let {
+ when (it.lowercase()) {
+ "yes", "true" -> true
+ "no", "false" -> false
+ else -> null
+ }
+ }
+ return CrashMetadata(
+ uid = uid,
+ systemUptimeMs = uptime,
+ processRuntimeSec = runtime,
+ foreground = foreground,
+ )
+}
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..8f81d29
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/util/MarkdownRenderer.kt
@@ -0,0 +1,238 @@
+package com.devbyjonathan.stacklens.util
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+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
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.devbyjonathan.uikit.theme.GoogleSansCode
+import com.devbyjonathan.uikit.theme.scheme
+
+/** Tag used to mark inline-code ranges in the AnnotatedString so they can be drawn manually. */
+const val INLINE_CODE_ANNOTATION_TAG: String = "inline-code"
+
+/**
+ * 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.
+ *
+ * @param inlineCodeBackground background color drawn behind `inline code` runs.
+ * Pass `Color.Unspecified` (default) for no background.
+ * @param inlineCodeColor text color for `inline code` runs. `Color.Unspecified`
+ * (default) inherits from the surrounding Text.
+ * @param inlineCodeFontFamily font family for `inline code`. Defaults to monospace.
+ */
+fun renderInlineMarkdown(
+ source: String,
+ inlineCodeBackground: Color = Color.Unspecified,
+ inlineCodeColor: Color = Color.Unspecified,
+ inlineCodeFontFamily: FontFamily = FontFamily.Monospace,
+): AnnotatedString {
+ val normalized = normalizeBullets(source)
+ val codeStyle = SpanStyle(
+ fontFamily = inlineCodeFontFamily,
+ background = inlineCodeBackground,
+ color = inlineCodeColor,
+ )
+ 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) {
+ // Pad with one space on each side. For the SpanStyle.background path these
+ // spaces give the flat block some breathing room; for MarkdownText they mark
+ // the outer edge of the rounded rectangle that's drawn manually.
+ pushStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "")
+ withStyle(codeStyle) {
+ append(' ')
+ append(normalized.substring(i + 1, end))
+ append(' ')
+ }
+ pop()
+ 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)
+ }
+ }
+}
+
+/**
+ * Composable convenience wrapper around [renderInlineMarkdown] that picks inline-code
+ * colors from the current [scheme] and memoizes the result so recompositions don't
+ * re-parse the same markdown.
+ */
+@Composable
+fun rememberInlineMarkdown(
+ source: String,
+ background: Color = scheme.surfaceContainerHighest,
+ color: Color = Color.Unspecified,
+ fontFamily: FontFamily = GoogleSansCode,
+): AnnotatedString = remember(source, background, color, fontFamily) {
+ renderInlineMarkdown(source, background, color, fontFamily)
+}
+
+/**
+ * Drop-in replacement for `Text(rememberInlineMarkdown(source), ...)` that draws **rounded**
+ * backgrounds behind inline-code runs. `SpanStyle.background` can only produce flat rectangles;
+ * this composable captures the text layout and paints rounded rects via `Modifier.drawBehind`.
+ *
+ * Handles line wrapping — a code span that breaks across lines gets one rounded block per line.
+ *
+ * @param inlineCodeBackground pill color. Defaults to `scheme.surfaceContainerHighest`.
+ * @param cornerRadius radius of the pill. Defaults to 6.dp.
+ * @param inlineCodePadding extra horizontal/vertical padding added around the glyph bounds.
+ * The rendered code already includes a single space on each side, so default is 0.
+ */
+@Composable
+fun MarkdownText(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+ style: TextStyle = LocalTextStyle.current,
+ inlineCodeBackground: Color = scheme.surfaceContainerHighest,
+ inlineCodeColor: Color = Color.Unspecified,
+ inlineCodeFontFamily: FontFamily = GoogleSansCode,
+ cornerRadius: Dp = 6.dp,
+ inlineCodePadding: PaddingValues = PaddingValues(horizontal = 0.dp, vertical = 1.dp),
+) {
+ val annotated = remember(text, inlineCodeColor, inlineCodeFontFamily) {
+ renderInlineMarkdown(
+ source = text,
+ inlineCodeBackground = Color.Unspecified,
+ inlineCodeColor = inlineCodeColor,
+ inlineCodeFontFamily = inlineCodeFontFamily,
+ )
+ }
+ var layout by remember { mutableStateOf(null) }
+ val density = LocalDensity.current
+ val radiusPx = with(density) { cornerRadius.toPx() }
+ val padLeftPx = with(density) {
+ inlineCodePadding.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr).toPx()
+ }
+ val padRightPx = with(density) {
+ inlineCodePadding.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr).toPx()
+ }
+ val padTopPx = with(density) { inlineCodePadding.calculateTopPadding().toPx() }
+ val padBottomPx = with(density) { inlineCodePadding.calculateBottomPadding().toPx() }
+
+ Text(
+ text = annotated,
+ modifier = modifier.drawBehind {
+ val lay = layout ?: return@drawBehind
+ val ranges = annotated.getStringAnnotations(
+ tag = INLINE_CODE_ANNOTATION_TAG,
+ start = 0,
+ end = annotated.length,
+ )
+ for (range in ranges) {
+ val startLine = lay.getLineForOffset(range.start)
+ val endLine = lay.getLineForOffset(range.end - 1)
+ for (lineIdx in startLine..endLine) {
+ val lineStart = maxOf(range.start, lay.getLineStart(lineIdx))
+ val lineEnd = minOf(range.end, lay.getLineEnd(lineIdx))
+ if (lineStart >= lineEnd) continue
+ val startBox = lay.getBoundingBox(lineStart)
+ val endBox = lay.getBoundingBox(lineEnd - 1)
+ val left = startBox.left - padLeftPx
+ val right = endBox.right + padRightPx
+ val top = lay.getLineTop(lineIdx) - padTopPx
+ val bottom = lay.getLineBottom(lineIdx) + padBottomPx
+ drawRoundRect(
+ color = inlineCodeBackground,
+ topLeft = Offset(left, top),
+ size = Size(right - left, bottom - top),
+ cornerRadius = CornerRadius(radiusPx, radiusPx),
+ )
+ }
+ }
+ },
+ onTextLayout = { layout = it },
+ style = style,
+ color = color,
+ )
+}
+
+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
+}
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/StackTraceFrames.kt b/app/src/main/java/com/devbyjonathan/stacklens/util/StackTraceFrames.kt
new file mode 100644
index 0000000..4e30c54
--- /dev/null
+++ b/app/src/main/java/com/devbyjonathan/stacklens/util/StackTraceFrames.kt
@@ -0,0 +1,38 @@
+package com.devbyjonathan.stacklens.util
+
+private val FRAME_REGEX = Regex("""\s+at\s+([\w$.<>]+)\.([\w$<>]+)\(([^)]+)\)""")
+
+fun isFrameworkFrame(classOrFrame: String): Boolean {
+ return classOrFrame.startsWith("android.") ||
+ classOrFrame.startsWith("androidx.") ||
+ classOrFrame.startsWith("java.lang.reflect.") ||
+ classOrFrame.startsWith("dalvik.") ||
+ classOrFrame.startsWith("com.android.internal.") ||
+ classOrFrame.startsWith("kotlin.") ||
+ classOrFrame.startsWith("kotlinx.")
+}
+
+/**
+ * Returns the first non-framework stack frame's `File.kt:line` form, e.g. "MainActivity.kt:29",
+ * or null when no app-owned frame with a line number is present.
+ */
+fun extractLikelyLocation(content: String): String? {
+ for (match in FRAME_REGEX.findAll(content)) {
+ val className = match.groupValues[1]
+ val location = match.groupValues[3]
+ if (isFrameworkFrame(className)) continue
+ val colonIdx = location.lastIndexOf(':')
+ if (colonIdx <= 0) continue
+ val line = location.substring(colonIdx + 1).toIntOrNull() ?: continue
+ val file = location.substring(0, colonIdx)
+ return "$file:$line"
+ }
+ return null
+}
+
+/** Count of non-framework frames in the trace. Used as a confidence signal. */
+fun countAppFrames(content: String): Int {
+ return FRAME_REGEX.findAll(content).count { match ->
+ !isFrameworkFrame(match.groupValues[1])
+ }
+}
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
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 5d81c02..16eef40 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -9,21 +9,21 @@
android:translateY="150.08">
+ android:fillColor="#FF8975" />
+ android:fillColor="#FF8975" />
+ android:fillColor="#6B655A" />
+ android:fillColor="#6B655A" />
+ android:fillColor="#F0EAE1" />
+ android:fillColor="#F0EAE1" />
diff --git a/app/src/main/res/drawable/logo_dark.xml b/app/src/main/res/drawable/logo_dark.xml
index 6b7375a..37ae500 100644
--- a/app/src/main/res/drawable/logo_dark.xml
+++ b/app/src/main/res/drawable/logo_dark.xml
@@ -1,15 +1,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/logo_light.xml b/app/src/main/res/drawable/logo_light.xml
index 2356d7b..11e094a 100644
--- a/app/src/main/res/drawable/logo_light.xml
+++ b/app/src/main/res/drawable/logo_light.xml
@@ -1,15 +1,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/logo_static.xml b/app/src/main/res/drawable/logo_static.xml
index 2e2828c..fa97b2d 100644
--- a/app/src/main/res/drawable/logo_static.xml
+++ b/app/src/main/res/drawable/logo_static.xml
@@ -1,15 +1,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 1084c24..7353dbd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,5 +2,4 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 1084c24..7353dbd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,5 +2,4 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index 086e9c9..31e822b 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 426624a..415b8a8 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index 94629d0..345505f 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 9e6d945..03ec095 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index beccfd9..4d00755 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index d8e8961..bb9cdc3 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 5a386cb..237e183 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index 7ed18ec..0f40bac 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index fc49ffd..57fe0a6 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index 6d2ea61..8b589a1 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..d25ff3d
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #191613
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index da2220f..0454185 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,9 +1,12 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index bb24604..0ec74b5 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,4 +1,7 @@
#FFFFFF
+
+ #F6F3EE
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
index 35efae7..55e67a2 100644
--- a/app/src/main/res/values/ic_launcher_background.xml
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #00184A
+ #191613
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index da2220f..0454185 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,9 +1,12 @@
-
+
\ No newline at end of file
diff --git a/app/src/test/java/com/devbyjonathan/stacklens/repository/EventsTrendTest.kt b/app/src/test/java/com/devbyjonathan/stacklens/repository/EventsTrendTest.kt
new file mode 100644
index 0000000..a01083f
--- /dev/null
+++ b/app/src/test/java/com/devbyjonathan/stacklens/repository/EventsTrendTest.kt
@@ -0,0 +1,40 @@
+package com.devbyjonathan.stacklens.repository
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Simple pure-math tests for the delta computation used by [EventsTrend]. We keep these out of
+ * the repository (which depends on Room and live DAOs) by testing the same arithmetic in
+ * isolation.
+ */
+class EventsTrendTest {
+
+ @Test
+ fun `delta returns 0 when both current and previous are zero`() {
+ assertEquals(0f, computeDelta(0, 0), 0f)
+ }
+
+ @Test
+ fun `delta returns 100 when previous is zero and current is positive`() {
+ assertEquals(100f, computeDelta(5, 0), 0f)
+ }
+
+ @Test
+ fun `delta returns positive percentage when growing`() {
+ assertEquals(50f, computeDelta(15, 10), 0.01f)
+ }
+
+ @Test
+ fun `delta returns negative percentage when shrinking`() {
+ assertEquals(-50f, computeDelta(5, 10), 0.01f)
+ }
+
+ private fun computeDelta(current: Int, previous: Int): Float {
+ return if (previous == 0) {
+ if (current == 0) 0f else 100f
+ } else {
+ (current - previous) / previous.toFloat() * 100f
+ }
+ }
+}
diff --git a/app/src/test/java/com/devbyjonathan/stacklens/util/CrashMetadataParserTest.kt b/app/src/test/java/com/devbyjonathan/stacklens/util/CrashMetadataParserTest.kt
new file mode 100644
index 0000000..709aa76
--- /dev/null
+++ b/app/src/test/java/com/devbyjonathan/stacklens/util/CrashMetadataParserTest.kt
@@ -0,0 +1,54 @@
+package com.devbyjonathan.stacklens.util
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class CrashMetadataParserTest {
+
+ @Test
+ fun `parses all optional fields from a typical DropBox header`() {
+ val content = """
+ Process: com.example.app
+ PID: 18440
+ UID: 10298
+ Flags: 0x29c8be44
+ Package: com.example.app v1 (1.0)
+ Foreground: Yes
+ SystemUptimeMs: 408305
+ Process-Runtime: 1124
+
+ java.lang.RuntimeException: Just Crash
+ at com.example.app.MainActivity.onCreate(MainActivity.kt:29)
+ """.trimIndent()
+
+ val meta = parseCrashMetadata(content)
+
+ assertEquals(10298, meta.uid)
+ assertEquals(408305L, meta.systemUptimeMs)
+ assertEquals(1124L, meta.processRuntimeSec)
+ assertEquals(true, meta.foreground)
+ }
+
+ @Test
+ fun `returns null fields when lines are missing`() {
+ val content = """
+ Process: com.example.app
+
+ java.lang.RuntimeException: Nothing
+ """.trimIndent()
+
+ val meta = parseCrashMetadata(content)
+
+ assertNull(meta.uid)
+ assertNull(meta.systemUptimeMs)
+ assertNull(meta.processRuntimeSec)
+ assertNull(meta.foreground)
+ }
+
+ @Test
+ fun `parses Foreground No as false`() {
+ val meta = parseCrashMetadata("Foreground: No\n")
+ assertEquals(false, meta.foreground)
+ }
+}
diff --git a/app/src/test/java/com/devbyjonathan/stacklens/util/StackTraceFramesTest.kt b/app/src/test/java/com/devbyjonathan/stacklens/util/StackTraceFramesTest.kt
new file mode 100644
index 0000000..23ab029
--- /dev/null
+++ b/app/src/test/java/com/devbyjonathan/stacklens/util/StackTraceFramesTest.kt
@@ -0,0 +1,72 @@
+package com.devbyjonathan.stacklens.util
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StackTraceFramesTest {
+
+ private val sampleWithAppFrames = """
+ FATAL EXCEPTION: main
+ java.lang.RuntimeException: Just Crash
+ at com.example.app.MainActivity.onCreate(MainActivity.kt:29)
+ at com.example.app.MainActivity.access${'$'}onCreate(MainActivity.kt:10)
+ at android.app.Activity.performCreate(Activity.java:8305)
+ at androidx.compose.foundation.ClickablePointerInputNode.invoke(Clickable.kt:987)
+ at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
+ at java.lang.reflect.Method.invoke(Native Method)
+ """.trimIndent()
+
+ private val frameworkOnly = """
+ FATAL EXCEPTION: main
+ at android.app.Activity.performCreate(Activity.java:8305)
+ at androidx.compose.foundation.ClickablePointerInputNode.invoke(Clickable.kt:987)
+ at java.lang.reflect.Method.invoke(Native Method)
+ """.trimIndent()
+
+ @Test
+ fun `isFrameworkFrame identifies android framework frames`() {
+ assertTrue(isFrameworkFrame("android.app.Activity"))
+ assertTrue(isFrameworkFrame("androidx.compose.foundation.Clickable"))
+ assertTrue(isFrameworkFrame("java.lang.reflect.Method"))
+ assertTrue(isFrameworkFrame("dalvik.system.VMStack"))
+ assertTrue(isFrameworkFrame("com.android.internal.os.ZygoteInit"))
+ assertTrue(isFrameworkFrame("kotlin.collections.ArraysKt"))
+ assertTrue(isFrameworkFrame("kotlinx.coroutines.DispatchedTask"))
+ }
+
+ @Test
+ fun `isFrameworkFrame does not match app classes`() {
+ assertFalse(isFrameworkFrame("com.example.app.MainActivity"))
+ assertFalse(isFrameworkFrame("com.devbyjonathan.stacklens.ai.CrashInsight"))
+ }
+
+ @Test
+ fun `extractLikelyLocation returns first app-owned file colon line`() {
+ val location = extractLikelyLocation(sampleWithAppFrames)
+ assertEquals("MainActivity.kt:29", location)
+ }
+
+ @Test
+ fun `extractLikelyLocation returns null when only framework frames exist`() {
+ assertNull(extractLikelyLocation(frameworkOnly))
+ }
+
+ @Test
+ fun `extractLikelyLocation returns null for empty content`() {
+ assertNull(extractLikelyLocation(""))
+ }
+
+ @Test
+ fun `countAppFrames counts only non-framework frames`() {
+ val count = countAppFrames(sampleWithAppFrames)
+ assertEquals(2, count)
+ }
+
+ @Test
+ fun `countAppFrames returns zero for framework-only trace`() {
+ assertEquals(0, countAppFrames(frameworkOnly))
+ }
+}
diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt
index b2fa48f..f5c4595 100644
--- a/buildSrc/src/main/kotlin/ProjectConfig.kt
+++ b/buildSrc/src/main/kotlin/ProjectConfig.kt
@@ -1,8 +1,8 @@
object ProjectConfig {
const val compileSdk = 36
- const val minSdk = 21
+ const val minSdk = 23
const val targetSdk = 36
const val namespace = "com.devbyjonathan.stacklens"
- const val versionCode = 2
- const val versionName = "0.0.3-alpha"
+ const val versionCode = 4
+ const val versionName = "0.1.0-alpha"
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7dffd11..8d74921 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,6 +20,7 @@ lifecycleRuntimeKtx = "2.8.7"
material = "1.12.0"
navigation = "2.8.9"
splashScreen = "1.0.1"
+webkit = "1.12.1"
work = "2.10.0"
hiltWork = "1.2.0"
room = "2.6.1"
@@ -80,6 +81,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" }
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
[bundles]
compose = [
diff --git a/screenshots/readme.png b/screenshots/readme.png
index d0fd600..7cf33b5 100644
Binary files a/screenshots/readme.png and b/screenshots/readme.png differ
diff --git a/uikit/build.gradle.kts b/uikit/build.gradle.kts
index b08b316..a851c66 100644
--- a/uikit/build.gradle.kts
+++ b/uikit/build.gradle.kts
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
}
android {
diff --git a/uikit/src/main/java/com/devbyjonathan/uikit/theme/Color.kt b/uikit/src/main/java/com/devbyjonathan/uikit/theme/Color.kt
index fe38782..43c77cc 100644
--- a/uikit/src/main/java/com/devbyjonathan/uikit/theme/Color.kt
+++ b/uikit/src/main/java/com/devbyjonathan/uikit/theme/Color.kt
@@ -1,5 +1,7 @@
package com.devbyjonathan.uikit.theme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF415F91)
@@ -72,4 +74,102 @@ val surfaceContainerLowestDark = Color(0xFF0C0E13)
val surfaceContainerLowDark = Color(0xFF191C20)
val surfaceContainerDark = Color(0xFF1D2024)
val surfaceContainerHighDark = Color(0xFF282A2F)
-val surfaceContainerHighestDark = Color(0xFF33353A)
\ No newline at end of file
+val surfaceContainerHighestDark = Color(0xFF33353A)
+
+
+val StackLensLightColors = lightColorScheme(
+ // Primary — ink black (app's action color)
+ primary = Color(0xFF1C1A17),
+ onPrimary = Color(0xFFF6F3EE),
+ primaryContainer = Color(0xFFE5DFF7), // AI violet tonal
+ onPrimaryContainer = Color(0xFF3F3176),
+
+ // Secondary — warm neutral
+ secondary = Color(0xFF6B655A),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFECE7DF),
+ onSecondaryContainer = Color(0xFF1C1A17),
+
+ // Tertiary — amber (Native crash)
+ tertiary = Color(0xFF7A5400),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFFFDF9E),
+ onTertiaryContainer = Color(0xFF2A1C00),
+
+ // Error — crash red
+ error = Color(0xFFA8281A),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFFFDAD5),
+ onErrorContainer = Color(0xFF410001),
+
+ // Background / Surface
+ background = Color(0xFFF6F3EE),
+ onBackground = Color(0xFF1C1A17),
+ surface = Color(0xFFFFFFFF),
+ onSurface = Color(0xFF1C1A17),
+ surfaceVariant = Color(0xFFECE7DF),
+ onSurfaceVariant = Color(0xFF6B655A),
+ surfaceTint = Color(0xFF1C1A17),
+ inverseSurface = Color(0xFF1C1A17),
+ inverseOnSurface = Color(0xFFF6F3EE),
+ inversePrimary = Color(0xFFF0EAE1),
+
+ // Surface containers
+ surfaceDim = Color(0xFFE4DDD1),
+ surfaceBright = Color(0xFFFFFFFF),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ surfaceContainerLow = Color(0xFFF6F3EE),
+ surfaceContainer = Color(0xFFECE7DF),
+ surfaceContainerHigh = Color(0xFFE4DDD1),
+ surfaceContainerHighest = Color(0xFFDCD4C6),
+
+ // Outline
+ outline = Color(0xFF9C9589),
+ outlineVariant = Color(0xFFE0DACF),
+ scrim = Color(0xFF000000),
+)
+
+val StackLensDarkColors = darkColorScheme(
+ primary = Color(0xFFF0EAE1),
+ onPrimary = Color(0xFF191613),
+ primaryContainer = Color(0xFF342C6A),
+ onPrimaryContainer = Color(0xFFD9D2F5),
+
+ secondary = Color(0xFFA39A8C),
+ onSecondary = Color(0xFF191613),
+ secondaryContainer = Color(0xFF2C2722),
+ onSecondaryContainer = Color(0xFFF0EAE1),
+
+ tertiary = Color(0xFFFFC676),
+ onTertiary = Color(0xFF3A2C12),
+ tertiaryContainer = Color(0xFF4A3300),
+ onTertiaryContainer = Color(0xFFFFE08A),
+
+ error = Color(0xFFFF8975),
+ onError = Color(0xFF5A1812),
+ errorContainer = Color(0xFF5A1812),
+ onErrorContainer = Color(0xFFFFDAD5),
+
+ background = Color(0xFF191613),
+ onBackground = Color(0xFFF0EAE1),
+ surface = Color(0xFF221E1A),
+ onSurface = Color(0xFFF0EAE1),
+ surfaceVariant = Color(0xFF2C2722),
+ onSurfaceVariant = Color(0xFFA39A8C),
+ surfaceTint = Color(0xFFF0EAE1),
+ inverseSurface = Color(0xFFF0EAE1),
+ inverseOnSurface = Color(0xFF191613),
+ inversePrimary = Color(0xFF1C1A17),
+
+ surfaceDim = Color(0xFF191613),
+ surfaceBright = Color(0xFF38312A),
+ surfaceContainerLowest = Color(0xFF120F0C),
+ surfaceContainerLow = Color(0xFF221E1A),
+ surfaceContainer = Color(0xFF2C2722),
+ surfaceContainerHigh = Color(0xFF38312A),
+ surfaceContainerHighest = Color(0xFF433B32),
+
+ outline = Color(0xFF6A6257),
+ outlineVariant = Color(0xFF39322B),
+ scrim = Color(0xFF000000),
+)
\ No newline at end of file
diff --git a/uikit/src/main/java/com/devbyjonathan/uikit/theme/ThemeShortcuts.kt b/uikit/src/main/java/com/devbyjonathan/uikit/theme/ThemeShortcuts.kt
new file mode 100644
index 0000000..4dca70c
--- /dev/null
+++ b/uikit/src/main/java/com/devbyjonathan/uikit/theme/ThemeShortcuts.kt
@@ -0,0 +1,29 @@
+package com.devbyjonathan.uikit.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+
+/**
+ * Composable-scoped shortcut for `MaterialTheme.colorScheme`.
+ *
+ * Usage: `scheme.surfaceVariant` instead of `MaterialTheme.colorScheme.surfaceVariant`.
+ * Named `scheme` (not `color` / `colors`) to avoid shadowing common Compose parameters
+ * like `color: Color` or `colors: ButtonColors`.
+ */
+val scheme: ColorScheme
+ @Composable
+ @ReadOnlyComposable
+ get() = MaterialTheme.colorScheme
+
+/**
+ * Composable-scoped shortcut for `MaterialTheme.typography`.
+ *
+ * Usage: `typo.bodyMedium` instead of `MaterialTheme.typography.bodyMedium`.
+ */
+val typo: Typography
+ @Composable
+ @ReadOnlyComposable
+ get() = MaterialTheme.typography