Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)

/**
Expand Down Expand Up @@ -65,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
*/
Expand Down Expand Up @@ -108,17 +115,20 @@ object CategoryTimeWidgetUpdater {
views.setTextViewText(R.id.widget_minutes, minutes.toString())

// Draw and set the bar chart
Comment on lines 116 to 117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Category dots keep defaults

When a user selects a non-default category color, these configured colors are applied only to the bar bitmap. The row binding updates text and visibility but never colors the dot views, whose drawables still use fixed default colors. The displayed dot therefore disagrees with its corresponding bar segment, leaving the widget color fix incomplete.

Knowledge Base Used: Category time home screen widget

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
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])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — The new appDotIds array is used to set a color filter on the dot ImageViews. The layout widget_category_time.xml must have app_dot_1, app_dot_2, app_dot_3 defined as ImageViews. If these IDs do not exist in the layout, views.setInt(appDotIds[i], "setColorFilter", dotColors[i]) will throw an IllegalArgumentException ("View id not found") at runtime, which is caught by the outer try-catch and results in the widget showing zeros instead of the app list. The PR does not modify the layout file, so it is unclear whether these IDs exist. If they do not exist, the widget update fails entirely for all users.

views.setViewVisibility(appRowIds[i], View.VISIBLE)
} else {
views.setViewVisibility(appRowIds[i], View.GONE)
Expand Down Expand Up @@ -168,16 +178,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<String, String> {
val result = mutableMapOf<String, String>()
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — The classes setting in aw-webui is a JSON array of category objects, but the name field is not always an array. In aw-webui's actual datastore, the classes setting is stored as a JSON array where each element has a name that is a string (e.g. "Work") or an array of strings for subcategories? Let me check. The PR's own test helper builds "name":["Work"] as an array. But the real aw-webui classes setting format: In aw-webui source, the classes setting is a list of objects with name being a string (the full path like "Work") and data containing color. Actually I need to verify. The PR description says "extracts each top-level category's data.color field" and the code checks nameArr.length() != 1 to only include top-level categories. If the real format has name as a string, then optJSONArray("name") returns null and the entry is skipped, so no colors are ever read. This would be a contract bug. But I'm not sure about the real format. The test helper uses arrays, but that may be invented. I should check the aw-webui code. Since I can't access it, I should be cautious. The PR description says "the same datastore key aw-webui persists to". The aw-webui classes setting is actually a JSON array of objects like {"name": "Work", "rule": {...}, "data": {"color": "#00BFA5"}}? I recall from ActivityWatch web UI, the categories are defined with a name that is a string, not an array. The $category field in events is an array, but the classes setting uses a string name. Let me think. In aw-webui's src/settings.js, the default classes are something like [{name: 'Work', rule: {...}, data: {color: '#...'}}]. The name is a string. So this code's assumption that name is an array is wrong. That would mean parseCategoryColors always returns an empty map, and the feature silently does nothing. This is a real bug. I should report it. The line is 195: val nameArr = obj.optJSONArray("name") ?: continue. The fix is to handle both string and array: if name is a string, use it directly; if it's an array, use the first element if length==1. But the PR's intent is to only include top-level categories. In aw-webui, subcategories are separate entries with names like "Work > Programming"? Actually the classes setting has a flat list of category definitions, each with a full name string. There is no hierarchy in the setting; the hierarchy comes from the $category array in events. So the check for nameArr.length() != 1 is based on a wrong assumption. The correct behavior is to read the name string directly. This is a P1 bug because the feature won't work at all. I'll report it.

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.
*/
internal fun resolveBarColors(
context: Context,
categoryNames: List<String>,
configuredColors: Map<String, String>
): 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
}

/**
Expand All @@ -186,9 +243,11 @@ object CategoryTimeWidgetUpdater {
private fun createBarChartBitmap(
context: Context,
categoryData: List<Pair<String, Long>>,
totalMillis: Long
totalMillis: Long,
configuredColors: Map<String, String> = 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -117,4 +119,57 @@ class CategoryTimeWidgetUpdaterTest {
fun parseCategories_returnsEmptyForEmptyResult() {
assertEquals(emptyList<Pair<String, Long>>(), CategoryTimeWidgetUpdater.parseCategories("[]"))
}

// ── parseCategoryColors ────────────────────────────────────────────────────

private fun classesJson(vararg entries: Pair<List<String>, 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())
}
}
Loading