From 8bf676ce4cfff60174bd2120e800031ada31b4fc Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 20:40:21 +0000 Subject: [PATCH 1/2] fix(widget): read category colors from aw-webui settings instead of hardcoded values The bar chart and category dots in the home-screen widget used three hardcoded accent colors (#00BFA5, #7986CB, #42A5F5) regardless of what colors the user configured for their categories in the web UI. Read the `classes` setting (same datastore key aw-webui persists to) and extract the configured color from each top-level category's `data.color` field. Fall back to the hardcoded defaults for categories that have no color configured. Closes ActivityWatch/aw-android#288 Git-Session-Id: fd77e3bc-ee8c-51e6-a794-7065e7599bd3 --- .../widget/CategoryTimeWidgetUpdater.kt | 83 +++++++++++++++---- .../widget/CategoryTimeWidgetUpdaterTest.kt | 55 ++++++++++++ 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt index ead4d34c..84de9aec 100644 --- a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt +++ b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt @@ -19,6 +19,7 @@ import net.activitywatch.android.R import net.activitywatch.android.RustInterface import com.jakewharton.threetenabp.AndroidThreeTen import org.json.JSONArray +import org.json.JSONObject import org.threeten.bp.LocalDate import org.threeten.bp.LocalDateTime import org.threeten.bp.LocalTime @@ -32,11 +33,11 @@ private const val BAR_WIDTH = 400 private const val BAR_HEIGHT = 24 private const val BAR_CORNER_RADIUS = 12f -// Category accent colors (matching the dots) - these stay constant in both themes -private val CATEGORY_ACCENT_COLORS = intArrayOf( - Color.parseColor("#00BFA5"), // Teal - category 1 - Color.parseColor("#7986CB"), // Purple - category 2 - Color.parseColor("#42A5F5") // Blue - category 3 +// Fallback accent colors used when a category has no configured color in aw-webui +private val FALLBACK_ACCENT_COLORS = intArrayOf( + Color.parseColor("#00BFA5"), // Teal + Color.parseColor("#7986CB"), // Purple + Color.parseColor("#42A5F5") // Blue ) /** @@ -108,7 +109,8 @@ object CategoryTimeWidgetUpdater { views.setTextViewText(R.id.widget_minutes, minutes.toString()) // Draw and set the bar chart - val barChartBitmap = createBarChartBitmap(context, categoryData, totalMillis) + val configuredColors = parseCategoryColors(ri.getSetting("classes")) + val barChartBitmap = createBarChartBitmap(context, categoryData, totalMillis, configuredColors) views.setImageViewBitmap(R.id.widget_bar_chart, barChartBitmap) // Update top 3 apps @@ -168,16 +170,63 @@ object CategoryTimeWidgetUpdater { } /** - * Get the category colors array, with the "others" color resolved from theme resources + * Parse the aw-webui `classes` setting JSON into a map of top-level category + * name → CSS hex color string (e.g. "#00BFA5"). Only top-level categories + * (name array length == 1) are included. Returns an empty map on any error. + * + * This function is kept free of Android APIs so it can be tested on the JVM. */ - private fun getCategoryColors(context: Context): IntArray { + internal fun parseCategoryColors(settingsJson: String): Map { + val result = mutableMapOf() + val v = settingsJson.trim() + if (v == "null" || !v.startsWith("[")) return result + try { + val array = JSONArray(v) + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val nameArr = obj.optJSONArray("name") ?: continue + if (nameArr.length() != 1) continue // only top-level categories + val name = nameArr.optString(0) ?: continue + val color = obj.optJSONObject("data")?.optString("color") ?: continue + if (color.isBlank()) continue + result[name] = color + } + } catch (e: Exception) { + Log.w(TAG, "Could not parse classes setting for colors", e) + } + return result + } + + /** + * Resolve the 4-element color array for the bar chart: top-3 category colors + * (from configured settings, falling back to hardcoded defaults) + others color. + * Silently skips invalid hex strings and uses the fallback. + */ + private fun resolveBarColors( + context: Context, + categoryNames: List, + configuredColors: Map + ): IntArray { val othersColor = ContextCompat.getColor(context, R.color.widget_bar_bg) - return intArrayOf( - CATEGORY_ACCENT_COLORS[0], - CATEGORY_ACCENT_COLORS[1], - CATEGORY_ACCENT_COLORS[2], - othersColor - ) + val colors = IntArray(4) { i -> + if (i < 3) { + val name = categoryNames.getOrNull(i) + val hex = if (name != null) configuredColors[name] else null + if (hex != null) { + try { + Color.parseColor(hex) + } catch (_: IllegalArgumentException) { + Log.w(TAG, "Invalid color '$hex' for category '$name', using fallback") + FALLBACK_ACCENT_COLORS[i] + } + } else { + FALLBACK_ACCENT_COLORS[i] + } + } else { + othersColor + } + } + return colors } /** @@ -186,9 +235,11 @@ object CategoryTimeWidgetUpdater { private fun createBarChartBitmap( context: Context, categoryData: List>, - totalMillis: Long + totalMillis: Long, + configuredColors: Map = emptyMap() ): Bitmap { - val categoryColors = getCategoryColors(context) + val categoryNames = categoryData.take(3).map { it.first } + val categoryColors = resolveBarColors(context, categoryNames, configuredColors) val bitmap = Bitmap.createBitmap(BAR_WIDTH, BAR_HEIGHT, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val paint = Paint().apply { diff --git a/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt b/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt index 02e49460..143f94cb 100644 --- a/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdaterTest.kt @@ -1,6 +1,8 @@ package net.activitywatch.android.widget import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.threeten.bp.LocalTime @@ -117,4 +119,57 @@ class CategoryTimeWidgetUpdaterTest { fun parseCategories_returnsEmptyForEmptyResult() { assertEquals(emptyList>(), CategoryTimeWidgetUpdater.parseCategories("[]")) } + + // ── parseCategoryColors ──────────────────────────────────────────────────── + + private fun classesJson(vararg entries: Pair, String?>): String { + val objs = entries.joinToString(",") { (name, color) -> + val nameArr = name.joinToString(",") { "\"$it\"" } + val dataBlock = if (color != null) ""","data":{"color":"$color"}""" else "" + """{"name":[$nameArr],"rule":{"type":"none"}$dataBlock}""" + } + return "[$objs]" + } + + @Test + fun parseCategoryColors_extractsTopLevelColors() { + val json = classesJson( + listOf("Work") to "#00BFA5", + listOf("Media") to "#FF0000", + listOf("Work", "Programming") to "#123456" // subcategory — excluded + ) + val result = CategoryTimeWidgetUpdater.parseCategoryColors(json) + assertEquals("#00BFA5", result["Work"]) + assertEquals("#FF0000", result["Media"]) + assertFalse("subcategory keys must not appear", result.containsKey("Work>Programming")) + assertEquals(2, result.size) + } + + @Test + fun parseCategoryColors_returnsEmptyForNullSetting() { + assertTrue(CategoryTimeWidgetUpdater.parseCategoryColors("null").isEmpty()) + } + + @Test + fun parseCategoryColors_skipsEntriesWithNoColor() { + val json = classesJson( + listOf("Work") to "#00BFA5", + listOf("Media") to null + ) + val result = CategoryTimeWidgetUpdater.parseCategoryColors(json) + assertEquals(1, result.size) + assertEquals("#00BFA5", result["Work"]) + } + + @Test + fun parseCategoryColors_handlesShortHexColors() { + val json = classesJson(listOf("Work") to "#0F0") + val result = CategoryTimeWidgetUpdater.parseCategoryColors(json) + assertEquals("#0F0", result["Work"]) + } + + @Test + fun parseCategoryColors_returnsEmptyForMalformedJson() { + assertTrue(CategoryTimeWidgetUpdater.parseCategoryColors("not-json").isEmpty()) + } } From ad6a91400df03e54010fee746dc6e88feb9d3841 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 20:55:19 +0000 Subject: [PATCH 2/2] fix(widget): apply configured colors to category dot indicators The row-binding loop updated text and visibility but never updated the dot ImageViews, so dots kept their hardcoded drawable colors regardless of what the user configured. Now resolveBarColors() is called once before the loop (same inputs already used for the bar bitmap) and the result is applied via setColorFilter so bar segments and their corresponding legend dots always match. Git-Session-Id: 3aced3c8-97ab-51ae-8ad1-cf9a4d27cdd9 --- .../android/widget/CategoryTimeWidgetUpdater.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt index 84de9aec..02ee0419 100644 --- a/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt +++ b/mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetUpdater.kt @@ -66,6 +66,12 @@ object CategoryTimeWidgetUpdater { R.id.app_time_3 ) + private val appDotIds = intArrayOf( + R.id.app_dot_1, + R.id.app_dot_2, + R.id.app_dot_3 + ) + /** * Update all instances of the widget */ @@ -115,12 +121,14 @@ object CategoryTimeWidgetUpdater { // Update top 3 apps val topApps = categoryData.take(3) - + val dotColors = resolveBarColors(context, topApps.map { it.first }, configuredColors) + for (i in 0 until 3) { if (i < topApps.size) { val (name, duration) = topApps[i] views.setTextViewText(appNameIds[i], name) views.setTextViewText(appTimeIds[i], formatDurationShort(duration)) + views.setInt(appDotIds[i], "setColorFilter", dotColors[i]) views.setViewVisibility(appRowIds[i], View.VISIBLE) } else { views.setViewVisibility(appRowIds[i], View.GONE) @@ -202,7 +210,7 @@ object CategoryTimeWidgetUpdater { * (from configured settings, falling back to hardcoded defaults) + others color. * Silently skips invalid hex strings and uses the fallback. */ - private fun resolveBarColors( + internal fun resolveBarColors( context: Context, categoryNames: List, configuredColors: Map