diff --git a/README.md b/README.md index 21f415b..9b34278 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ [![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://developer.android.com/) [![Kotlin](https://img.shields.io/badge/Language-Kotlin-purple.svg)](https://kotlinlang.org/) [![Jetpack Compose](https://img.shields.io/badge/UI-Jetpack%20Compose-blue.svg)](https://developer.android.com/jetpack/compose) -[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg)](https://android-arsenal.com/api?level=21) +[![API](https://img.shields.io/badge/API-23%2B-brightgreen.svg)](https://android-arsenal.com/api?level=23) +[![Version](https://img.shields.io/badge/Version-0.1.0--alpha-orange.svg)](https://github.com/jonathanlee06/StackLens/releases) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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