summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 09:15:16 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:39:57 +0000
commit99b9e252eaaadac7790788bdf9e1efb52afab53a (patch)
treee35a84752b8d149843b10f6e4d5e226f71dfc951
parent9d4b4ead6ee139fd0f2ba1508bc513e1a5fafc57 (diff)
docs: add widget text-size + tomorrow-section fix implementation plan
-rw-r--r--docs/superpowers/plans/2026-07-13-widget-text-size-and-tomorrow-fix.md1064
1 files changed, 1064 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-13-widget-text-size-and-tomorrow-fix.md b/docs/superpowers/plans/2026-07-13-widget-text-size-and-tomorrow-fix.md
new file mode 100644
index 0000000..1a8b6e9
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-13-widget-text-size-and-tomorrow-fix.md
@@ -0,0 +1,1064 @@
+# Widget Text Size + Tomorrow-Section Fix Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix the Android widget's TOMORROW section (currently rendered but always pushed off-screen) and add a configurable text size/weight setting (Small/Normal/Large) for widget text.
+
+**Architecture:** A pure-Kotlin `WidgetTextSize` enum with independent header/content scale+weight fields, read once per widget render and threaded as an explicit parameter through the existing `DootWidget.kt` composable chain. The tomorrow-section fix is an isolated one-line-of-logic change to the grid-bounds calculation, unrelated to the text-size work but shipped in the same file/session.
+
+**Tech Stack:** Kotlin, Jetpack Glance (AppWidget), JUnit 4, Gradle.
+
+## Global Constraints
+
+- Kotlin 1.9.20 / Gradle 8.6 / JDK 17 (per `android/gradle.properties` and installed toolchain — no version changes needed).
+- No new third-party dependencies.
+- `SMALL` must reproduce today's exact rendered output (sizes and weights unchanged) — it is the escape hatch for anyone who liked the current look.
+- Default preference (including for existing installs with no saved value) is `NORMAL`, not `SMALL`.
+- Only text scales — icon/tap-target sizes (`ic_refresh`, `ic_add`, `ic_checkbox_empty`, all currently `14.dp`/`24.dp`) are untouched.
+- No `CompositionLocal` — this file's existing style is explicit composable parameters; keep it that way.
+
+---
+
+### Task 1: Fix the tomorrow-section grid-bounds bug
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt:472,481` (visibility) and `:96-97` (grid bounds call)
+- Test: `android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt` (new)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `calcGridStart(events: List<WidgetItem>, nowHour: Int): Int` and `calcGridEnd(events: List<WidgetItem>, nowHour: Int): Int`, changed from `private` to `internal` so the test module can call them directly. Signatures unchanged.
+
+**Root cause (already confirmed against live data):** `calcGridStart`/`calcGridEnd` receive `scheduledEvents`, which mixes today's and tomorrow's items, and only look at `.hour` on the parsed `Instant` — the date is discarded. A tomorrow event at an early hour pulls `gridStart` down (e.g. to 9am) even when nothing is scheduled *today* until much later, manufacturing empty hour rows that push the non-scrolling `TomorrowSection` below the widget's visible area.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.doot.widget.data.WidgetItem
+
+class DootWidgetGridTest {
+
+ private fun tomorrowEvent(startIso: String, endIso: String): WidgetItem = WidgetItem(
+ id = "tmrw", title = "Tomorrow Event", source = "calendar", type = "event",
+ start = startIso, end = endIso
+ )
+
+ @Test
+ fun `calcGridStart on an unfiltered mixed list is skewed by tomorrow's event hour`() {
+ // Documents why WidgetRoot MUST pre-filter to today-only events before calling
+ // this function: it has no date awareness, only hour-of-day. "now" is 22:00
+ // today (2026-07-12); the only item is tomorrow's (2026-07-13) 10:00 event.
+ val mixedList = listOf(tomorrowEvent("2026-07-13T10:00:00-10:00", "2026-07-13T14:30:00-10:00"))
+ assertEquals(9, calcGridStart(mixedList, nowHour = 22))
+ }
+
+ @Test
+ fun `calcGridStart on the correctly today-filtered (empty) list falls back to nowHour`() {
+ val gridStart = calcGridStart(emptyList(), nowHour = 22)
+ assertEquals(21, gridStart)
+ }
+
+ @Test
+ fun `calcGridEnd falls back to nowHour plus eight when no today events, capped at 23`() {
+ val gridEnd = calcGridEnd(emptyList(), nowHour = 22)
+ assertEquals(23, gridEnd)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.DootWidgetGridTest"`
+Expected: FAILS to compile — `calcGridStart`/`calcGridEnd` are `private` in `DootWidget.kt`, unresolved reference from the test module.
+
+- [ ] **Step 3: Change visibility and fix the grid-bounds call site**
+
+In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, change the two function signatures (currently lines 472 and 481):
+
+```kotlin
+private fun calcGridStart(events: List<WidgetItem>, nowHour: Int): Int {
+```
+```kotlin
+private fun calcGridEnd(events: List<WidgetItem>, nowHour: Int): Int {
+```
+
+to:
+
+```kotlin
+internal fun calcGridStart(events: List<WidgetItem>, nowHour: Int): Int {
+```
+```kotlin
+internal fun calcGridEnd(events: List<WidgetItem>, nowHour: Int): Int {
+```
+
+Then, in `WidgetRoot` (currently lines 95-97):
+
+```kotlin
+ val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now)
+ val gridStart = calcGridStart(scheduledEvents, nowZoned.hour)
+ val gridEnd = calcGridEnd(scheduledEvents, nowZoned.hour)
+```
+
+replace with:
+
+```kotlin
+ val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now)
+ // Grid bounds must only reflect TODAY's events -- a tomorrow event's hour-of-day
+ // would otherwise stretch today's grid (see calcGridStart/calcGridEnd), manufacturing
+ // empty hour rows that push the non-scrolling TomorrowSection below the widget's
+ // visible area.
+ val todayScheduledEvents = scheduledEvents.filter { Instant.parse(it.start!!) < tomorrowStart }
+ val gridStart = calcGridStart(todayScheduledEvents, nowZoned.hour)
+ val gridEnd = calcGridEnd(todayScheduledEvents, nowZoned.hour)
+```
+
+(The `for (hour in gridStart..gridEnd) { HourRow(hour, nowZoned, scheduledEvents, fragments, zone) }` loop below is unchanged — it keeps receiving the full `scheduledEvents`, which is correct since `HourRow` already filters by real `Instant`, not hour-of-day.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.DootWidgetGridTest"`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Run the full existing unit test suite to confirm no regressions**
+
+Run: `cd android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests pass (including `SlotPackerTest`, `WidgetRepositoryTest`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetGridTest.kt
+git commit -m "fix(widget): stop tomorrow's events from stretching today's grid bounds
+
+calcGridStart/calcGridEnd only looked at hour-of-day, so a tomorrow
+event's early or late hour could inflate today's grid range with empty
+rows, pushing the non-scrolling TomorrowSection below the widget's
+visible area. Filter to today-only events before computing bounds."
+```
+
+---
+
+### Task 2: Add `WidgetTextSize` enum, scaling helpers, and preference key
+
+**Files:**
+- Create: `android/app/src/main/java/org/terst/doot/widget/ui/WidgetTextSize.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt`
+- Test: `android/app/src/test/java/org/terst/doot/widget/ui/WidgetTextSizeTest.kt` (new)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces (used by Tasks 3 and 4):
+ - `enum class WidgetTextSize(val headerScale: Float, val headerWeightBump: Int, val contentScale: Float, val contentWeightBump: Int) { SMALL, NORMAL, LARGE }`
+ - `WidgetTextSize.Companion.fromPref(raw: String?): WidgetTextSize`
+ - `fun WidgetTextSize.scaledHeaderSize(baseSp: Int): TextUnit`
+ - `fun WidgetTextSize.scaledHeaderWeight(base: FontWeight): FontWeight`
+ - `fun WidgetTextSize.scaledContentSize(baseSp: Int): TextUnit`
+ - `fun WidgetTextSize.scaledContentWeight(base: FontWeight): FontWeight`
+ - `Keys.TEXT_SIZE: Preferences.Key<String>`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `android/app/src/test/java/org/terst/doot/widget/ui/WidgetTextSizeTest.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import androidx.glance.text.FontWeight
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class WidgetTextSizeTest {
+
+ @Test
+ fun `SMALL reproduces base size and weight unchanged`() {
+ assertEquals(14f, WidgetTextSize.SMALL.scaledContentSize(14).value, 0.001f)
+ assertEquals(FontWeight.Normal, WidgetTextSize.SMALL.scaledContentWeight(FontWeight.Normal))
+ assertEquals(FontWeight.Bold, WidgetTextSize.SMALL.scaledHeaderWeight(FontWeight.Bold))
+ }
+
+ @Test
+ fun `NORMAL scales size by 1_15x and bumps weight one step`() {
+ assertEquals(16.1f, WidgetTextSize.NORMAL.scaledContentSize(14).value, 0.001f)
+ assertEquals(FontWeight.Medium, WidgetTextSize.NORMAL.scaledContentWeight(FontWeight.Normal))
+ assertEquals(FontWeight.Bold, WidgetTextSize.NORMAL.scaledHeaderWeight(FontWeight.Medium))
+ }
+
+ @Test
+ fun `LARGE scales size by 1_3x and bumps weight two steps`() {
+ assertEquals(18.2f, WidgetTextSize.LARGE.scaledContentSize(14).value, 0.001f)
+ assertEquals(FontWeight.Bold, WidgetTextSize.LARGE.scaledContentWeight(FontWeight.Normal))
+ }
+
+ @Test
+ fun `weight bump caps at Bold instead of overflowing`() {
+ assertEquals(FontWeight.Bold, WidgetTextSize.LARGE.scaledContentWeight(FontWeight.Bold))
+ assertEquals(FontWeight.Bold, WidgetTextSize.LARGE.scaledHeaderWeight(FontWeight.Medium))
+ }
+
+ @Test
+ fun `fromPref falls back to NORMAL for missing or invalid values, else parses by name`() {
+ assertEquals(WidgetTextSize.NORMAL, WidgetTextSize.fromPref(null))
+ assertEquals(WidgetTextSize.NORMAL, WidgetTextSize.fromPref("bogus"))
+ assertEquals(WidgetTextSize.SMALL, WidgetTextSize.fromPref("SMALL"))
+ assertEquals(WidgetTextSize.LARGE, WidgetTextSize.fromPref("LARGE"))
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.WidgetTextSizeTest"`
+Expected: FAILS to compile — `WidgetTextSize` does not exist yet.
+
+- [ ] **Step 3: Implement `WidgetTextSize.kt`**
+
+Create `android/app/src/main/java/org/terst/doot/widget/ui/WidgetTextSize.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+import androidx.glance.text.FontWeight
+
+/**
+ * Header and content are independent knobs (currently seeded with the same
+ * numbers) so either can be retuned later without affecting the other.
+ * SMALL reproduces the widget's original, pre-setting appearance exactly.
+ */
+enum class WidgetTextSize(
+ val headerScale: Float,
+ val headerWeightBump: Int,
+ val contentScale: Float,
+ val contentWeightBump: Int
+) {
+ SMALL(1.0f, 0, 1.0f, 0),
+ NORMAL(1.15f, 1, 1.15f, 1),
+ LARGE(1.3f, 2, 1.3f, 2);
+
+ companion object {
+ fun fromPref(raw: String?): WidgetTextSize =
+ raw?.let { name -> entries.find { it.name == name } } ?: NORMAL
+ }
+}
+
+private val weightLadder = listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.Bold)
+
+private fun bumpWeight(base: FontWeight, bump: Int): FontWeight {
+ val idx = weightLadder.indexOf(base).coerceAtLeast(0)
+ return weightLadder[(idx + bump).coerceAtMost(weightLadder.lastIndex)]
+}
+
+fun WidgetTextSize.scaledHeaderSize(baseSp: Int): TextUnit = (baseSp * headerScale).sp
+fun WidgetTextSize.scaledHeaderWeight(base: FontWeight): FontWeight = bumpWeight(base, headerWeightBump)
+fun WidgetTextSize.scaledContentSize(baseSp: Int): TextUnit = (baseSp * contentScale).sp
+fun WidgetTextSize.scaledContentWeight(base: FontWeight): FontWeight = bumpWeight(base, contentWeightBump)
+```
+
+- [ ] **Step 4: Add the preference key**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt`, replace:
+
+```kotlin
+object Keys {
+ val SERVER_URL = stringPreferencesKey("server_url")
+ val TOKEN = stringPreferencesKey("token")
+ val ITEMS_JSON = stringPreferencesKey("items_json")
+ val NOW = stringPreferencesKey("now")
+ val LAST_UPDATED = longPreferencesKey("last_updated")
+ val IS_REFRESHING = booleanPreferencesKey("is_refreshing")
+}
+```
+
+with:
+
+```kotlin
+object Keys {
+ val SERVER_URL = stringPreferencesKey("server_url")
+ val TOKEN = stringPreferencesKey("token")
+ val ITEMS_JSON = stringPreferencesKey("items_json")
+ val NOW = stringPreferencesKey("now")
+ val LAST_UPDATED = longPreferencesKey("last_updated")
+ val IS_REFRESHING = booleanPreferencesKey("is_refreshing")
+ val TEXT_SIZE = stringPreferencesKey("text_size")
+}
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `cd android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.WidgetTextSizeTest"`
+Expected: PASS (5 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/WidgetTextSize.kt android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt android/app/src/test/java/org/terst/doot/widget/ui/WidgetTextSizeTest.kt
+git commit -m "feat(widget): add WidgetTextSize enum with independent header/content scaling"
+```
+
+---
+
+### Task 3: Thread `WidgetTextSize` through the widget's composables
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`
+
+**Interfaces:**
+- Consumes: `WidgetTextSize`, `WidgetTextSize.fromPref`, `scaledHeaderSize`, `scaledHeaderWeight`, `scaledContentSize`, `scaledContentWeight` (Task 2), `Keys.TEXT_SIZE` (Task 2).
+- Produces: every composable in this file that renders `Text` now takes a `textSize: WidgetTextSize` parameter (used by Task 4's manual verification and any future call sites).
+
+This task only changes: `provideGlance`, and the signatures/`Text` calls in `WidgetRoot`, `AllDayRow`, `HourRow`, `EventBlock`, `TaskFragmentBlock`, `TaskRow`, `TomorrowSection`, `TomorrowEventRow`. No unit test — these are Glance/RemoteViews composables, consistent with how the rest of this file is (not) unit tested; verification is the compile + full-suite run in Step 2, plus the on-device check in Task 5.
+
+- [ ] **Step 1: Apply all composable changes**
+
+Replace the `DootWidget` class's `provideGlance`:
+
+```kotlin
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val prefs = context.dataStore.data.first()
+ val items = parseItems(prefs)
+ val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ ?: Instant.now()
+ val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false
+
+ provideContent {
+ WidgetRoot(items, now, isRefreshing)
+ }
+ }
+```
+
+with:
+
+```kotlin
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val prefs = context.dataStore.data.first()
+ val items = parseItems(prefs)
+ val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ ?: Instant.now()
+ val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false
+ val textSize = WidgetTextSize.fromPref(prefs[Keys.TEXT_SIZE])
+
+ provideContent {
+ WidgetRoot(items, now, isRefreshing, textSize)
+ }
+ }
+```
+
+Replace `WidgetRoot` in full (this already includes Task 1's `todayScheduledEvents` fix):
+
+```kotlin
+@Composable
+fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean, textSize: WidgetTextSize) {
+ val zone = ZoneId.systemDefault()
+ val nowZoned: ZonedDateTime = now.atZone(zone)
+ val todayStart = nowZoned.toLocalDate().atStartOfDay(zone).toInstant()
+ val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant()
+ val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS)
+
+ // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see
+ // TimelineItemToWidgetItem's doc comment for why undated doot/gtask
+ // tasks, which are also flagged isAllDay, are deliberately excluded
+ // here) are pinned to the top of their day's section and never compete
+ // for hourly grid slots or floating-task packing. Previously they had
+ // no Start at all and fell into the same floating-task queue as
+ // ordinary untimed tasks, where enough tasks ahead of them in the queue
+ // could push their assigned slot past the visible grid range entirely
+ // -- not merely unpinned, actually invisible.
+ val allDayEvents = items.filter { it.isAllDay && it.type == "event" }
+ val rest = items.filter { !(it.isAllDay && it.type == "event") }
+ val todayAllDay = allDayEvents.filter { item ->
+ val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ s == null || (s >= todayStart && s < tomorrowStart)
+ }
+ val tomorrowAllDay = allDayEvents.filter { item ->
+ val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() }
+ s != null && s >= tomorrowStart && s < tomorrowEnd
+ }
+
+ val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) }
+ // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha
+ val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now }
+ val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now }
+ val floating = rest.filter { it.start == null }
+ val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now)
+ // Grid bounds must only reflect TODAY's events -- a tomorrow event's hour-of-day
+ // would otherwise stretch today's grid (see calcGridStart/calcGridEnd), manufacturing
+ // empty hour rows that push the non-scrolling TomorrowSection below the widget's
+ // visible area.
+ val todayScheduledEvents = scheduledEvents.filter { Instant.parse(it.start!!) < tomorrowStart }
+ val gridStart = calcGridStart(todayScheduledEvents, nowZoned.hour)
+ val gridEnd = calcGridEnd(todayScheduledEvents, nowZoned.hour)
+
+ val tomorrowItems = scheduledEvents
+ .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd }
+ val tomorrowFrags = fragments
+ .filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd }
+
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(Color.Transparent)
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x66FFFFFF)),
+ fontSize = textSize.scaledHeaderSize(11),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
+ ),
+ modifier = GlanceModifier.defaultWeight()
+ )
+ QuickAddButton()
+ Spacer(modifier = GlanceModifier.width(4.dp))
+ RefreshButton(isRefreshing)
+ }
+
+ todayAllDay.forEach { AllDayRow(it, textSize) }
+
+ for (hour in gridStart..gridEnd) {
+ HourRow(hour, nowZoned, scheduledEvents, fragments, zone, textSize)
+ }
+
+ if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty()) {
+ TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone, textSize)
+ }
+ }
+}
+```
+
+Replace `AllDayRow` in full:
+
+```kotlin
+@Composable
+fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
+ Text(
+ text = event.title,
+ style = TextStyle(
+ color = ColorProvider(Color.White.copy(alpha = 0.9f)),
+ fontSize = textSize.scaledContentSize(13),
+ fontWeight = textSize.scaledContentWeight(FontWeight.Medium)
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+Replace `HourRow` in full:
+
+```kotlin
+@Composable
+fun HourRow(
+ hour: Int,
+ nowZoned: ZonedDateTime,
+ scheduled: List<WidgetItem>,
+ fragments: List<TaskFragment>,
+ @Suppress("UNUSED_PARAMETER") zone: ZoneId,
+ textSize: WidgetTextSize
+) {
+ val hourStart = nowZoned.withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant()
+ val hourEnd = hourStart.plus(1, ChronoUnit.HOURS)
+ val isNowHour = nowZoned.hour == hour
+
+ val eventsHere = scheduled.filter { event ->
+ event.start?.let { s ->
+ val start = Instant.parse(s)
+ start >= hourStart && start < hourEnd
+ } ?: false
+ }
+ val fragsHere = fragments.filter { frag ->
+ frag.startTime >= hourStart && frag.startTime < hourEnd
+ }
+
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().wrapContentHeight(),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = hourLabel(hour),
+ style = TextStyle(
+ color = ColorProvider(Color(0x4DFFFFFF)),
+ fontSize = textSize.scaledHeaderSize(10),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal)
+ ),
+ modifier = GlanceModifier.width(32.dp).padding(top = 2.dp)
+ )
+
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ // Hour divider line
+ Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(Color(0x1AFFFFFF))) {}
+
+ // NOW indicator
+ if (isNowHour) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Color(0xBFFFFFFF.toInt()))
+ .padding(vertical = 2.dp)
+ ) {}
+ }
+
+ eventsHere.forEach { event ->
+ val isPast = event.end?.let { Instant.parse(it) < nowZoned.toInstant() } ?: false
+ EventBlock(event, isPast, textSize)
+ }
+
+ fragsHere.forEach { frag ->
+ TaskFragmentBlock(frag, textSize)
+ }
+ }
+ }
+}
+```
+
+Replace `EventBlock` in full:
+
+```kotlin
+@Composable
+fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ val alpha = if (isPast) 0.5f else 1f
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .width(3.dp)
+ .height(20.dp)
+ .background(color.copy(alpha = alpha))
+ ) {}
+ Text(
+ text = event.title,
+ style = TextStyle(
+ color = ColorProvider(Color.White.copy(alpha = alpha)),
+ fontSize = textSize.scaledContentSize(14),
+ fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+Replace `TaskFragmentBlock` in full:
+
+```kotlin
+@Composable
+fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ ) {
+ Text(
+ "TODAY",
+ style = TextStyle(
+ color = ColorProvider(Color(0x73FFFFFF)),
+ fontSize = textSize.scaledHeaderSize(9),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp, top = 3.dp, bottom = 1.dp)
+ )
+ fragment.slots.forEach { slot ->
+ TaskRow(slot.task, textSize)
+ }
+ }
+}
+```
+
+Replace `TaskRow` in full:
+
+```kotlin
+@Composable
+fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, TaskDetailActivity::class.java).apply {
+ putExtra(TaskDetailActivity.EXTRA_ID, task.id)
+ putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source)
+ putExtra(TaskDetailActivity.EXTRA_TITLE, task.title)
+ putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable)
+ putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ // Split into two sibling clickable regions to avoid the Glance/RemoteViews limitation
+ // where a parent clickable silently overrides a nested child clickable. The checkbox
+ // gets its own non-overlapping Box; the title region handles the detail-open action.
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (task.completable) {
+ Box(
+ modifier = GlanceModifier
+ .size(24.dp)
+ .clickable(
+ actionRunCallback<CompleteTaskAction>(
+ actionParametersOf(
+ CompleteTaskAction.idKey to task.id,
+ CompleteTaskAction.sourceKey to task.source
+ )
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_checkbox_empty),
+ contentDescription = "Complete ${task.title}",
+ colorFilter = ColorFilter.tint(ColorProvider(sourceColor(task.source))),
+ modifier = GlanceModifier.size(14.dp)
+ )
+ }
+ } else {
+ Box(
+ modifier = GlanceModifier
+ .size(8.dp)
+ .background(sourceColor(task.source).copy(alpha = 0.6f))
+ ) {}
+ }
+
+ Box(
+ modifier = GlanceModifier
+ .defaultWeight()
+ .clickable(actionStartActivity(detailIntent))
+ .padding(start = 8.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = task.title,
+ style = TextStyle(
+ color = ColorProvider(
+ if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt())
+ ),
+ fontSize = textSize.scaledContentSize(14),
+ fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
+ ),
+ maxLines = 1
+ )
+ }
+ }
+}
+```
+
+Replace `TomorrowSection` in full:
+
+```kotlin
+@Composable
+fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allDayEvents: List<WidgetItem>, zone: ZoneId, textSize: WidgetTextSize) {
+ Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {}
+
+ Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) {
+ Text(
+ "TOMORROW",
+ style = TextStyle(
+ color = ColorProvider(Color(0x66FFFFFF)),
+ fontSize = textSize.scaledHeaderSize(11),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold)
+ )
+ )
+ }
+
+ allDayEvents.forEach { AllDayRow(it, textSize) }
+
+ items.forEach { item ->
+ val isPast = false
+ if (item.type == "event") {
+ TomorrowEventRow(item, zone, textSize)
+ } else {
+ TaskRow(item, textSize)
+ }
+ }
+
+ fragments.forEach { frag ->
+ frag.slots.forEach { slot -> TaskRow(slot.task, textSize) }
+ }
+}
+```
+
+Replace `TomorrowEventRow` in full:
+
+```kotlin
+@Composable
+fun TomorrowEventRow(event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ val timeLabel = event.start?.let {
+ val t = Instant.parse(it).atZone(zone)
+ hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else ""
+ } ?: ""
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = timeLabel,
+ style = TextStyle(
+ color = ColorProvider(Color(0x4DFFFFFF)),
+ fontSize = textSize.scaledHeaderSize(10),
+ fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal)
+ ),
+ modifier = GlanceModifier.width(32.dp)
+ )
+ Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
+ Text(
+ text = event.title,
+ style = TextStyle(
+ color = ColorProvider(Color.White.copy(alpha = 0.75f)),
+ fontSize = textSize.scaledContentSize(13),
+ fontWeight = textSize.scaledContentWeight(FontWeight.Normal)
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+- [ ] **Step 2: Compile and run the full unit test suite**
+
+Run: `cd android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL — this file now compiles with every `Text` call site reading through `WidgetTextSize`, and all existing tests (`SlotPackerTest`, `WidgetRepositoryTest`, plus Tasks 1-2's new tests) still pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt
+git commit -m "feat(widget): thread WidgetTextSize through all widget text rendering"
+```
+
+---
+
+### Task 4: Add the text-size picker to `SettingsActivity`
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt`
+
+**Interfaces:**
+- Consumes: `WidgetTextSize`, `WidgetTextSize.fromPref` (Task 2), `Keys.TEXT_SIZE` (Task 2).
+- Produces: nothing consumed by later tasks — this is the final UI surface.
+
+No dedicated unit test: this project has no Compose UI test harness configured (no `androidTest` Compose testing dependency, no Robolectric), consistent with `SettingsScreen` having no existing tests today. Verified by compiling and by the manual on-device check in Task 5.
+
+- [ ] **Step 1: Add the picker to `SettingsActivity`**
+
+In `android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt`, replace the `onCreate` body's state/loading block and the `SettingsScreen` call:
+
+```kotlin
+ setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ val scope = rememberCoroutineScope()
+ var savedUrl by remember { mutableStateOf("") }
+ var savedToken by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) {
+ val prefs = dataStore.data.first()
+ savedUrl = prefs[Keys.SERVER_URL] ?: ""
+ savedToken = prefs[Keys.TOKEN] ?: ""
+ }
+
+ SettingsScreen(
+ initialUrl = savedUrl,
+ initialToken = savedToken,
+ onSave = { url, token ->
+ lifecycleScope.launch {
+ saveAndFinish(url, token, appWidgetId)
+ }
+ },
+ onTest = { url, token, onResult ->
+ scope.launch {
+ val repo = WidgetRepository(OkHttpClient(), url, token)
+ repo.fetchRaw().fold(
+ onSuccess = { onResult("✓ Connected (${it.items.size} items)") },
+ onFailure = { onResult("✗ ${it.message}") }
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+
+ private suspend fun saveAndFinish(url: String, token: String, appWidgetId: Int) {
+ dataStore.edit { prefs ->
+ prefs[Keys.SERVER_URL] = url.trimEnd('/')
+ prefs[Keys.TOKEN] = token
+ }
+ RefreshWorker.schedule(this)
+ RefreshWorker.runOnce(this)
+
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
+ } else {
+ setResult(RESULT_OK)
+ }
+ finish()
+ }
+```
+
+with:
+
+```kotlin
+ setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ val scope = rememberCoroutineScope()
+ var savedUrl by remember { mutableStateOf("") }
+ var savedToken by remember { mutableStateOf("") }
+ var savedTextSize by remember { mutableStateOf(WidgetTextSize.NORMAL) }
+
+ LaunchedEffect(Unit) {
+ val prefs = dataStore.data.first()
+ savedUrl = prefs[Keys.SERVER_URL] ?: ""
+ savedToken = prefs[Keys.TOKEN] ?: ""
+ savedTextSize = WidgetTextSize.fromPref(prefs[Keys.TEXT_SIZE])
+ }
+
+ SettingsScreen(
+ initialUrl = savedUrl,
+ initialToken = savedToken,
+ initialTextSize = savedTextSize,
+ onSave = { url, token, textSize ->
+ lifecycleScope.launch {
+ saveAndFinish(url, token, textSize, appWidgetId)
+ }
+ },
+ onTest = { url, token, onResult ->
+ scope.launch {
+ val repo = WidgetRepository(OkHttpClient(), url, token)
+ repo.fetchRaw().fold(
+ onSuccess = { onResult("✓ Connected (${it.items.size} items)") },
+ onFailure = { onResult("✗ ${it.message}") }
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+
+ private suspend fun saveAndFinish(url: String, token: String, textSize: WidgetTextSize, appWidgetId: Int) {
+ dataStore.edit { prefs ->
+ prefs[Keys.SERVER_URL] = url.trimEnd('/')
+ prefs[Keys.TOKEN] = token
+ prefs[Keys.TEXT_SIZE] = textSize.name
+ }
+ RefreshWorker.schedule(this)
+ RefreshWorker.runOnce(this)
+
+ if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
+ setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
+ } else {
+ setResult(RESULT_OK)
+ }
+ finish()
+ }
+```
+
+Then replace the `SettingsScreen` composable in full:
+
+```kotlin
+@Composable
+fun SettingsScreen(
+ initialUrl: String = "",
+ initialToken: String = "",
+ initialTextSize: WidgetTextSize = WidgetTextSize.NORMAL,
+ onSave: (url: String, token: String, textSize: WidgetTextSize) -> Unit,
+ onTest: (url: String, token: String, onResult: (String) -> Unit) -> Unit
+) {
+ var url by remember(initialUrl) { mutableStateOf(initialUrl) }
+ var token by remember(initialToken) { mutableStateOf(initialToken) }
+ var textSize by remember(initialTextSize) { mutableStateOf(initialTextSize) }
+ var status by remember { mutableStateOf("") }
+ var testing by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
+ ) {
+ Text("Doot Widget", style = MaterialTheme.typography.headlineMedium)
+ Text(
+ "Enter your doot server URL and the WIDGET_TOKEN from your .env file.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Server URL") },
+ placeholder = { Text("https://doot.example.com") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ singleLine = true
+ )
+
+ OutlinedTextField(
+ value = token,
+ onValueChange = { token = it },
+ label = { Text("Widget Token") },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ singleLine = true
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("Text Size", style = MaterialTheme.typography.labelLarge)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ WidgetTextSize.entries.forEach { option ->
+ FilterChip(
+ selected = textSize == option,
+ onClick = { textSize = option },
+ label = { Text(option.name.lowercase().replaceFirstChar { it.uppercase() }) }
+ )
+ }
+ }
+ }
+
+ if (status.isNotEmpty()) {
+ Text(
+ status,
+ color = if (status.startsWith("✓"))
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedButton(
+ onClick = {
+ testing = true
+ status = "Testing..."
+ onTest(url, token) { result ->
+ status = result
+ testing = false
+ }
+ },
+ enabled = url.isNotBlank() && token.isNotBlank() && !testing
+ ) {
+ Text("Test")
+ }
+
+ Button(
+ onClick = { onSave(url, token, textSize) },
+ enabled = url.isNotBlank() && token.isNotBlank()
+ ) {
+ Text("Save")
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Compile**
+
+Run: `cd android && ./gradlew compileDebugKotlin`
+Expected: BUILD SUCCESSFUL. (`FilterChip` is part of `androidx.compose.material3.*`, already wildcard-imported at the top of this file — no new import needed.)
+
+- [ ] **Step 3: Run the full unit test suite**
+
+Run: `cd android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests still pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/SettingsActivity.kt
+git commit -m "feat(widget): add Small/Normal/Large text size picker to widget settings"
+```
+
+---
+
+### Task 5: Build, deploy, and verify
+
+**Files:** none (build/deploy only).
+
+- [ ] **Step 1: Run the full unit test suite one more time**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL.
+
+- [ ] **Step 2: Build the release APK**
+
+Run: `cd /workspace/doot/android && ./gradlew assembleRelease`
+Expected: BUILD SUCCESSFUL. Output at `android/app/build/outputs/apk/release/app-release.apk`.
+
+- [ ] **Step 3: Confirm the build actually changed**
+
+Run: `md5sum /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk`
+Expected: the two checksums differ (the currently-deployed APK predates this work).
+
+- [ ] **Step 4: Deploy the new APK**
+
+Run: `cp /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk`
+
+- [ ] **Step 5: Update the project worklog**
+
+Per `.agent/config.md`'s Worklog Integrity mandate, append a short entry to `/workspace/doot/.agent/worklog.md`'s "Recently Completed" section describing the tomorrow-grid-bounds fix and the new Small/Normal/Large widget text-size setting.
+
+- [ ] **Step 6: Manual on-device verification**
+
+No `adb`/emulator is available in this environment, so this step is for the user after reinstalling the APK from `doot-widget.apk`:
+- Confirm the TOMORROW section now appears when something is scheduled tomorrow.
+- Open widget settings (long-press widget → Configure), confirm the Small/Normal/Large picker shows, and confirm selecting each option visibly changes the widget's text size/weight after Save.