# 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, nowHour: Int): Int` and `calcGridEnd(events: List, 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, nowHour: Int): Int { ``` ```kotlin private fun calcGridEnd(events: List, nowHour: Int): Int { ``` to: ```kotlin internal fun calcGridStart(events: List, nowHour: Int): Int { ``` ```kotlin internal fun calcGridEnd(events: List, 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` - [ ] **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, 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, fragments: List, @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( 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, fragments: List, allDayEvents: List, 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.