diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-07-13-widget-text-size-and-tomorrow-fix.md | 1064 |
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. |
