From 5b7971e7e22cb7bebeefc411874cc4a1e5561e89 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 16:04:44 +0000
Subject: [PATCH 1/8] feat: Froyo widget in Google Calendar style, with day
headers
One scrolling list: a header per day (Today in blue and bold, Tomorrow,
then dates; the year only when not this year; No date last), then that
day's cards. Cards are rounded and colored by type, white text at 4.5:1
or better, with a 24 h time / all day / late line, the icon and the whole
title wrapped. Late items go under Today. All-day before timed.
RemoteCollectionItems on API 31+, the RemoteViewsService below it.
---
.github/e2e/gateway.py | 26 ++-
.github/e2e/run.sh | 134 +++++++++++--
.github/e2e/ui.py | 70 ++++++-
README.md | 11 +-
.../github/rayvader/kirocrew/FroyoWidget.kt | 157 +++++++++------
.../github/rayvader/kirocrew/core/Upcoming.kt | 34 ----
.../rayvader/kirocrew/core/UpcomingDays.kt | 142 +++++++++++++
.../main/res/drawable/widget_card_bill.xml | 5 +
.../main/res/drawable/widget_card_chip.xml | 5 +
.../res/drawable/widget_card_deadline.xml | 5 +
.../main/res/drawable/widget_card_event.xml | 5 +
.../main/res/drawable/widget_card_flight.xml | 5 +
.../main/res/drawable/widget_card_late.xml | 5 +
.../main/res/drawable/widget_card_other.xml | 5 +
app/src/main/res/layout/widget_froyo.xml | 9 +-
app/src/main/res/layout/widget_froyo_day.xml | 11 +
app/src/main/res/layout/widget_froyo_item.xml | 124 ++++++------
.../main/res/values-night/widget_tones.xml | 13 ++
app/src/main/res/values/widget_tones.xml | 14 ++
app/src/main/res/xml/froyo_widget_info.xml | 4 +-
.../kirocrew/core/UpcomingDaysTest.kt | 189 ++++++++++++++++++
.../rayvader/kirocrew/core/UpcomingTest.kt | 36 ----
22 files changed, 797 insertions(+), 212 deletions(-)
create mode 100644 app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
create mode 100644 app/src/main/res/drawable/widget_card_bill.xml
create mode 100644 app/src/main/res/drawable/widget_card_chip.xml
create mode 100644 app/src/main/res/drawable/widget_card_deadline.xml
create mode 100644 app/src/main/res/drawable/widget_card_event.xml
create mode 100644 app/src/main/res/drawable/widget_card_flight.xml
create mode 100644 app/src/main/res/drawable/widget_card_late.xml
create mode 100644 app/src/main/res/drawable/widget_card_other.xml
create mode 100644 app/src/main/res/layout/widget_froyo_day.xml
create mode 100644 app/src/main/res/values-night/widget_tones.xml
create mode 100644 app/src/main/res/values/widget_tones.xml
create mode 100644 app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt
diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py
index da5a62b..565efb3 100644
--- a/.github/e2e/gateway.py
+++ b/.github/e2e/gateway.py
@@ -169,6 +169,26 @@ def push(self, note):
FROYO_MEMBER = "Froyo E2E"
+# 60 characters, made up: the widget must wrap it fully, never cut it.
+LONG_TITLE = "E2E made-up title that is sixty characters long, to wrap ok."
+
+
+def froyo_days():
+ """Made-up items across days, for the day-header layout: late, today (all day and timed),
+ tomorrow, two later days, next year, and one with no date."""
+ today = date.today()
+ d = lambda n: (today + timedelta(days=n)).isoformat()
+ return [
+ {"n": 1, "title": "E2E late parcel", "due": d(-2), "kind": "delivery", "flag": None},
+ {"n": 2, "title": "E2E all-day errand", "due": d(0), "kind": "booking", "flag": None},
+ {"n": 3, "title": "E2E flight home", "due": d(1) + " 14:10", "kind": "flight", "flag": None},
+ {"n": 4, "title": LONG_TITLE, "due": d(2), "kind": "bill", "flag": None},
+ {"n": 5, "title": "E2E form deadline", "due": d(3) + " 09:30", "kind": "deadline", "flag": None},
+ {"n": 6, "title": "E2E next year trip", "due": date(today.year + 1, 1, 15).isoformat(), "kind": "flight", "flag": None},
+ {"n": 7, "title": "E2E renewal no date", "due": None, "kind": "bill", "flag": None},
+ ]
+
+
def froyo_items(many=False):
"""Made-up items, dated from today: one late, one today, two later, one with no date.
many=True adds three more, so the widget's list has to scroll."""
@@ -308,7 +328,8 @@ def do_GET(self):
state.gw_update = {"latest": q.get("latest", ""), "status": q.get("status", "succeeded")}
return self.send(200, "ok")
if p == "/test/froyo":
- # mode=reset (the made-up list), many (8 items, more than fit), empty,
+ # mode=reset (the made-up list), many (8 items, more than fit), days (spread over
+ # days, with a 60-character title), empty,
# down (503) or bad (200, no list): reads fail and the list is kept; any other
# mode brings it back up.
mode = q.get("mode")
@@ -316,7 +337,8 @@ def do_GET(self):
state.froyo_down = mode == "down"
state.froyo_bad = mode == "bad"
if mode not in ("down", "bad"):
- state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
+ state.froyo = ([] if mode == "empty" else froyo_days() if mode == "days"
+ else froyo_items(many=mode == "many"))
state.chats = []
return self.send(200, "ok")
if p == "/test/chats":
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index e6c25fa..64a0607 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -936,6 +936,70 @@ widget_rows() {
}
top_of() { dump && python3 "$HERE/ui.py" top "$OUT/ui.xml" "$1"; }
+# Swipe the widget's list by about half its height: up (to later items) or down.
+list_swipe() {
+ local x1 y1 x2 y2 x
+ read -r x1 y1 x2 y2 < <(dump && python3 "$HERE/ui.py" bounds "$OUT/ui.xml" froyo_list) || return 1
+ x=$(((x1 + x2) / 2))
+ if [[ $1 == up ]]; then
+ adb shell input swipe "$x" $((y2 - 15)) "$x" $(((y1 + y2) / 2)) 500
+ else
+ adb shell input swipe "$x" $((y1 + 15)) "$x" $((y2 - 15)) 300
+ fi
+ sleep 1.5
+}
+list_top() { local i; for i in 1 2 3 4 5 6; do list_swipe down || return 1; done; }
+# Scroll the list until TEXT is on screen.
+list_show() {
+ local i
+ has_text "$1" && return 0
+ for i in $(seq 1 10); do list_swipe up || break; has_text "$1" && return 0; done
+ list_top
+ has_text "$1" || fail "not in the widget's list, even after scrolling: $1"
+}
+# Walk the whole list from the top: check every card in view (title and Done chip whole), and
+# print each line once, in list order, to $OUT/list.txt.
+list_walk() {
+ local i before after n
+ list_top
+ : > "$OUT/list.txt"
+ for i in $(seq 1 14); do
+ dump || return 1
+ n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || { cp "$OUT/ui.xml" "$OUT/logs/$FEATURE_ID-cut-ui.xml"; fail "a card is cut: $n"; return 1; }
+ before=$(wc -l < "$OUT/list.txt")
+ python3 "$HERE/ui.py" rows "$OUT/ui.xml" - | cut -d' ' -f2- | while read -r line; do
+ grep -qxF "$line" "$OUT/list.txt" || echo "$line" >> "$OUT/list.txt"
+ done
+ after=$(wc -l < "$OUT/list.txt")
+ ((i > 1 && after == before)) && break
+ list_swipe up || return 1
+ done
+ list_top
+}
+# The line number of the first list line containing TEXT, from list_walk.
+line_of() { grep -nF -- "$1" "$OUT/list.txt" | head -1 | cut -d: -f1; }
+
+# The day-header checks, at the screen size and text size in force.
+days_check() {
+ local where=$1 a b c d e f long short
+ list_walk
+ cp "$OUT/list.txt" "$OUT/logs/$FEATURE_ID-list-$where.txt"
+ a=$(line_of "day Today ·"); b=$(line_of "day Tomorrow ·"); c=$(line_of "$((THIS_YEAR + 1))")
+ d=$(line_of "day No date"); e=$(line_of "card E2E renewal no date"); f=$(line_of "card E2E late parcel")
+ [[ -n $a && -n $b && -n $c && -n $d && -n $e && -n $f ]] || fail "$where: a header or the last item is missing: $(tr '\n' '|' < "$OUT/list.txt")"
+ ((a < f && f < b && b < c && c < d && d < e)) || fail "$where: headers out of order ($a $f $b $c $d $e)"
+ check "$where: headers in order (Today with the late item, Tomorrow, later, next year, No date last); every card whole, Done visible; scrolling reaches the last item"
+ list_show "$LONG"
+ long=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
+ short=$(python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home" 2>/dev/null || true)
+ [[ -z $short ]] && { list_show "E2E flight home"; short=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home"); }
+ ((long * 10 >= short * 17)) || fail "$where: the 60-character title is not wrapped onto more lines ($long px vs $short px)"
+ list_show "$LONG"
+ n=$(dump && python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: the long title's card is cut: $n"
+ check "$where: the 60-character title wraps fully and is not cut"
+ list_top
+}
+
# Home, then the page that holds the widget. Home pressed on the launcher jumps to its
# first page, and the launcher may have put the widget on another one.
widget_home() {
@@ -975,25 +1039,29 @@ f_froyo_widget() {
has_text "late" || fail "the overdue item is not marked late"
check "an overdue item says late"
local a b c rows
- a=$(top_of "E2E test parcel") && b=$(top_of "E2E test dinner") && c=$(top_of "E2E test flight") \
- || fail "the first three items are not all shown"
+ list_walk
+ a=$(line_of "E2E test parcel"); b=$(line_of "E2E test dinner"); c=$(line_of "E2E test flight")
+ [[ -n $a && -n $b && -n $c ]] || fail "the first three items are not all in the list"
((a < b && b < c)) || fail "items are not soonest first ($a $b $c)"
check "items are soonest first"
- rows=$(widget_rows) || fail "widget rows overlap: $rows"
- # The emulator's 4x2 is tall enough for all 5.
- ((rows == 5)) || fail "the widget shows $rows of 5 items with room to spare"
- has_text "E2E test renewal" || fail "the fifth item is not shown"
+ grep -q "day Today ·" "$OUT/list.txt" || fail "no Today header"
+ check "the list has day headers"
+ rows=$(widget_rows) || fail "widget cards overlap: $rows"
+ ((rows >= 2)) || fail "only $rows cards show at the default size"
+ grep -qF "card E2E test renewal" "$OUT/list.txt" || fail "the fifth item is not in the list"
! has_text "more" || fail "a '+N more' line shows; the list should hold every item"
- check "every item shows when the height has room (5 of 5), none overlapping"
+ check "every item is in the list, $rows cards in view at once, none overlapping"
adb exec-out screencap -p > "$OUT/shots/froyo-widget.png" 2>/dev/null || true
- # Tap a row: the app opens on Froyo's chat.
+ # Tap a card: the app opens on Froyo's chat.
+ list_show "E2E test flight"
tap_now "E2E test flight"
page_path /chat 30
check "a row opens the app on Froyo's chat"
widget_home
# Done asks first; Cancel changes nothing.
+ list_top
wait_text "E2E test parcel" 20
tap_now "Mark E2E test parcel done"
wait_text 'Mark "E2E test parcel" done?' 15
@@ -1091,12 +1159,13 @@ d = (m(sys.argv[2]) - m(sys.argv[1])) % 1440
sys.exit(0 if d <= 2 else 1)
PY
check "the refresh button reads the list now and says 'Updated HH:mm' (24 h)"
- rows=$(widget_rows) || fail "rows overlap with the Updated note: $rows"
- ((rows == 5)) || fail "the Updated note cost a row: $rows of 5 show"
- check "the note shares the header: all 5 rows still show"
+ rows=$(widget_rows) || fail "cards overlap with the Updated note: $rows"
+ ((rows >= 2)) || fail "only $rows cards show with the Updated note"
+ check "the note shares the header: $rows cards still in view"
adb exec-out screencap -p > "$OUT/shots/froyo-widget-refresh.png" 2>/dev/null || true
# A failed read keeps the rows and says "as of HH:mm".
+ list_top
curl -s "$GW/test/froyo?mode=down" >/dev/null
tap_now "Refresh"
wait_text "5 open · as of" 30
@@ -1117,8 +1186,49 @@ PY
wait_text "5 open · Updated" 30
! has_text "· network" || fail "the reason stayed after a good read"
check "the next good read says Updated again"
- ! has_text "E2E stale row" || fail "the lagging open_items field was shown instead of open_items_live"
+ list_walk
+ ! grep -qF "E2E stale row" "$OUT/list.txt" || fail "the lagging open_items field was shown instead of open_items_live"
check "the live list (open_items_live) wins over the lagging open_items"
+
+ # Day headers, Google Calendar style, at 115% text: phone size, then unfolded size.
+ THIS_YEAR=$(adb shell date +%Y | tr -d '\r')
+ LONG="E2E made-up title that is sixty characters long, to wrap ok."
+ # Later features need the normal screen: put it back even when a check here fails.
+ trap 'adb shell settings put system font_scale 1.0; adb shell wm size reset; adb shell cmd uimode night no >/dev/null' EXIT
+ adb shell settings put system font_scale 1.15
+ curl -s "$GW/test/froyo?mode=days" >/dev/null
+ launch
+ widget_home
+ tap_now "Refresh"
+ wait_text "7 open · Updated" 30
+ sleep 3
+ days_check "phone, 115% text"
+ adb exec-out screencap -p > "$OUT/shots/froyo-days.png" 2>/dev/null || true
+ adb shell cmd uimode night yes >/dev/null
+ sleep 4
+ launch
+ widget_home
+ sleep 4
+ adb exec-out screencap -p > "$OUT/shots/froyo-days-dark.png" 2>/dev/null || true
+ adb shell cmd uimode night no >/dev/null
+ sleep 3
+
+ # Unfolded: a wide screen (smallest width over 600 dp) with the same 115% text.
+ adb shell wm size 1840x2208
+ sleep 6
+ launch
+ widget_home
+ tap_now "Refresh"
+ wait_text "7 open · Updated" 30
+ sleep 3
+ days_check "unfolded size, 115% text"
+ adb exec-out screencap -p > "$OUT/shots/froyo-days-unfolded.png" 2>/dev/null || true
+ adb shell wm size reset
+ adb shell settings put system font_scale 1.0
+ sleep 4
+ curl -s "$GW/test/froyo?mode=days" >/dev/null
+ launch
+ widget_home
# Ends on the home screen for the screenshot.
}
diff --git a/.github/e2e/ui.py b/.github/e2e/ui.py
index 2389c2b..e6dbe9f 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -11,6 +11,9 @@
overlap or stick out
ui.py top DUMP TEXT top y of the node whose text equals TEXT
ui.py bounds DUMP ID "x1 y1 x2 y2" of the node with resource id ID
+ ui.py rows DUMP - Froyo list lines on screen, top to bottom: "y day|card TEXT"
+ ui.py cards DUMP - cards fully in view; exit 1 when a title or Done chip is cut
+ ui.py height DUMP TEXT height in px of the node whose text equals TEXT
"""
import re
import sys
@@ -130,6 +133,70 @@ def widget(path, _arg):
return 0
+def _rid(node, name):
+ return node.get("resource-id", "").endswith(":id/" + name)
+
+
+def _inside(inner, outer, slack=1):
+ return (inner[0] >= outer[0] - slack and inner[1] >= outer[1] - slack
+ and inner[2] <= outer[2] + slack and inner[3] <= outer[3] + slack)
+
+
+def rows(path, _arg):
+ """The Froyo list's lines on screen, top to bottom: "y day|card TEXT"."""
+ out = []
+ for node in nodes(path):
+ b = box(node)
+ if not b or b[3] <= b[1]:
+ continue
+ if _rid(node, "froyo_day"):
+ out.append((b[1], "day", node.get("text", "")))
+ elif _rid(node, "froyo_title"):
+ out.append((b[1], "card", node.get("text", "")))
+ for y, kind, text in sorted(out):
+ print(y, kind, text)
+ return 0
+
+
+def cards(path, _arg):
+ """Check every card fully inside the list: its title and Done chip sit wholly inside the
+ card. Prints how many cards were checked; exit 1 on the first one cut or covered."""
+ root = ET.parse(path).getroot()
+ lst = next((box(n) for n in root.iter("node") if _rid(n, "froyo_list")), None)
+ if not lst:
+ print("no list on screen")
+ return 1
+ checked = 0
+ for card in root.iter("node"):
+ if not _rid(card, "froyo_item"):
+ continue
+ cb = box(card)
+ if not cb or cb[1] <= lst[1] + 1 or cb[3] >= lst[3] - 1:
+ continue # partly scrolled out of view: checked when it is in view
+ title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
+ done = next((n for n in card.iter("node") if _rid(n, "froyo_done")), None)
+ if title is None or not box(title) or not _inside(box(title), cb):
+ print("title cut or missing in card", cb, title.get("text", "") if title is not None else "")
+ return 1
+ if done is None or not box(done) or box(done)[2] - box(done)[0] < 10 or not _inside(box(done), cb):
+ print("Done chip hidden or cut in card", cb, title.get("text", ""))
+ return 1
+ checked += 1
+ print(checked)
+ return 0
+
+
+def height(path, text):
+ """Height in px of the node whose text equals TEXT."""
+ for node in nodes(path):
+ if node.get("text", "") == text:
+ b = box(node)
+ if b:
+ print(b[3] - b[1])
+ return 0
+ return 1
+
+
def bounds(path, rid):
""""x1 y1 x2 y2" of the first node whose resource id ends with :id/RID."""
for node in nodes(path):
@@ -162,4 +229,5 @@ def jobs(path, pkg):
if __name__ == "__main__":
cmd, path, arg = sys.argv[1], sys.argv[2], sys.argv[3]
sys.exit({"find": find, "menu": menu, "has": has, "switch": switch, "window": window, "jobs": jobs,
- "widget": widget, "top": top, "bounds": bounds}[cmd](path, arg))
+ "widget": widget, "top": top, "bounds": bounds, "rows": rows, "cards": cards,
+ "height": height}[cmd](path, arg))
diff --git a/README.md b/README.md
index 87e5b8e..dbf5cfa 100644
--- a/README.md
+++ b/README.md
@@ -92,12 +92,13 @@ How it works: the app reads `GET /api/notifications` on your gateway with the ap
A home-screen widget, **Froyo · Upcoming**, lists the open items of the crewmate Froyo (`froyo-personal-butler`): bookings, flights, deliveries and deadlines. Add it from **Settings → Add Froyo widget**, or long-press the home screen and pick it under Widgets.
-- The header shows how many items are open and stays at the top. Below it, every open item is in a list you scroll with a finger, soonest first: kind icon (📅 booking, ✈ flight, 📦 delivery, ⏰ deadline), due day and time in 24-hour time (the day only when there is no time), and the title.
-- Due within 24 hours is orange. Overdue is red and says **late**.
-- Tap a row to open Froyo's chat in the app.
-- Tap **Done** on a row. The app asks first. Say yes and the row goes, with **Undo** for 5 seconds. Then the app sends `Done:
` to Froyo's chat, and Froyo marks it done. Undo after that sends `Undo done: `. With app lock on, Done asks you to unlock first.
+- The header shows how many items are open and stays at the top. Below it, every open item is in one list you scroll with a finger, laid out like Google Calendar: a header per day (**Today · Fri 6 Nov** in blue, **Tomorrow · Sat 7 Nov**, then **Sun 8 Nov**; the year only when it is not this year), then that day's cards. Items with no date come last, under **No date**.
+- Each card is colored by type: booking blue, flight purple, deadline amber, bill green, other teal. It shows the time (24-hour, or **all day**), the kind icon and the whole title, wrapped onto as many lines as it needs. All-day items come before timed ones.
+- Late items go under Today, in red, with **late · was Wed 4 Nov**.
+- Tap a card to open Froyo's chat in the app.
+- Tap **Done** on a card. The app asks first. Say yes and the row goes, with **Undo** for 5 seconds. Then the app sends `Done: ` to Froyo's chat, and Froyo marks it done. Undo after that sends `Undo done: `. With app lock on, Done asks you to unlock first.
- It refreshes about every 30 minutes, when you open the app, after a Done, and when you tap **⟳** in the header (it shows **Updating…** while it reads). The header says **Updated HH:mm** after a good read. Nothing open shows **Nothing coming up**. When a read fails, the last list stays, with **as of HH:mm** and why: **sign-in** (open the app to sign in), **network**, **timeout**, **server** or **format** (the page held no list the widget can read).
-- Resize it from 4×1 to 4×4. A taller widget shows more rows at once; the list shows whole rows and scrolls to the rest. Rows are never squeezed or overlapped.
+- Resize it from 4×2 to 4×4. A taller widget shows more at once; the list scrolls to the rest. Text follows the system font size and the app's **Auto text size**. Cards are never squeezed or cut.
How it works: the app reads Froyo's thread with `POST /api/members/froyo-personal-butler/thread` and its open items from `GET /api/members/froyo-personal-butler/dashboard` (Froyo's `open_items_live` field, kept current by its sync job, else `open_items`), with the app's own login cookie. Done is a chat message sent with `POST /api/chat`, the same call the dashboard's chat box makes.
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
index b61a899..e0c3707 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
@@ -6,10 +6,13 @@ import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
-import android.content.res.Configuration
+import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.StyleSpan
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
@@ -26,10 +29,13 @@ import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
-import io.github.rayvader.kirocrew.core.DueState
+import io.github.rayvader.kirocrew.core.AutoText
+import io.github.rayvader.kirocrew.core.DayRow
import io.github.rayvader.kirocrew.core.FailReason
import io.github.rayvader.kirocrew.core.Profile
import io.github.rayvader.kirocrew.core.SyncNote
+import io.github.rayvader.kirocrew.core.Tone
+import io.github.rayvader.kirocrew.core.UpcomingDays
import io.github.rayvader.kirocrew.core.Upcoming
import io.github.rayvader.kirocrew.core.UpcomingItem
import android.util.Log
@@ -296,10 +302,6 @@ object FroyoWidgets {
manager.notifyAppWidgetViewDataChanged(ids, R.id.froyo_list)
}
- /** True when the widget [id] is tall enough for the header (not 4x1). */
- fun headerShown(ctx: Context, id: Int): Boolean =
- Upcoming.fit(heightDp(ctx, AppWidgetManager.getInstance(ctx).getAppWidgetOptions(id)), ctx.resources.configuration.fontScale).header
-
private fun views(
ctx: Context,
id: Int,
@@ -310,8 +312,11 @@ object FroyoWidgets {
at: Long,
): RemoteViews {
val v = RemoteViews(ctx.packageName, R.layout.widget_froyo)
- val fit = Upcoming.fit(heightDp(ctx, options), ctx.resources.configuration.fontScale)
val profile = profile(ctx)
+ val scale = textScale(ctx)
+ v.setTextViewTextSize(R.id.froyo_name, TypedValue.COMPLEX_UNIT_SP, 14f * scale)
+ v.setTextViewTextSize(R.id.froyo_count, TypedValue.COMPLEX_UNIT_SP, 12f * scale)
+ v.setTextViewTextSize(R.id.froyo_message, TypedValue.COMPLEX_UNIT_SP, 13f * scale)
v.setOnClickPendingIntent(R.id.froyo_root, openApp(ctx, profile, Upcoming.chatPagePath(slotKey(ctx)), 0))
// Header: name and count, then "Updated HH:mm" after a good read, "as of HH:mm" after a
@@ -327,7 +332,6 @@ object FroyoWidgets {
SyncNote.UPDATING -> ctx.getString(R.string.froyo_updating_count, open)
SyncNote.NONE -> open
}
- v.setViewVisibility(R.id.froyo_header, if (fit.header) View.VISIBLE else View.GONE)
v.setTextViewText(R.id.froyo_count, count)
v.setViewVisibility(R.id.froyo_refresh, if (refreshing) View.GONE else View.VISIBLE)
v.setViewVisibility(R.id.froyo_spinner, if (refreshing) View.VISIBLE else View.GONE)
@@ -345,47 +349,75 @@ object FroyoWidgets {
v.setViewVisibility(R.id.froyo_message, if (message != null) View.VISIBLE else View.GONE)
v.setTextViewText(R.id.froyo_message, message ?: "")
- // Every item, in a list that scrolls. Rows come from FroyoWidgetService; their taps
- // fill in this one template (Android allows one per list).
- val adapter = Intent(ctx, FroyoWidgetService::class.java)
- .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
- adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME))
- @Suppress("DEPRECATION")
- v.setRemoteAdapter(R.id.froyo_list, adapter)
+ // Every item, in one list that scrolls: a header per day, then that day's cards. Taps
+ // fill in this one template (Android allows one per list): a card opens the app, its
+ // Done chip asks to mark it done.
v.setPendingIntentTemplate(R.id.froyo_list, tapTemplate(ctx))
v.setViewVisibility(R.id.froyo_list, if (items.isEmpty()) View.GONE else View.VISIBLE)
if (Build.VERSION.SDK_INT >= 31) {
- // Snap the list to whole rows, so no row is cut at rest. Older Android fills the space.
- v.setViewLayoutHeight(R.id.froyo_list, fit.listHeightDp, TypedValue.COMPLEX_UNIT_DIP)
+ val rows = dayRows(items)
+ val builder = RemoteViews.RemoteCollectionItems.Builder()
+ .setViewTypeCount(VIEW_TYPES)
+ .setHasStableIds(false)
+ rows.forEachIndexed { i, row -> builder.addItem(i.toLong(), rowViews(ctx, row, scale)) }
+ v.setRemoteAdapter(R.id.froyo_list, builder.build())
+ } else {
+ val adapter = Intent(ctx, FroyoWidgetService::class.java)
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
+ adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME))
+ @Suppress("DEPRECATION")
+ v.setRemoteAdapter(R.id.froyo_list, adapter)
}
return v
}
- /** One row, for the list. */
- fun rowViews(ctx: Context, item: UpcomingItem, position: Int, total: Int, header: Boolean): RemoteViews {
+ /** Day headers and cards for [items], in the phone's time and language. */
+ fun dayRows(items: List): List =
+ UpcomingDays.rows(items, LocalDateTime.now(), Locale.getDefault())
+
+ /** A day header and an item card: the two kinds of line in the list. */
+ const val VIEW_TYPES = 2
+
+ /**
+ * The widget follows the app's Auto text size (bigger when folded) on top of the system
+ * font size, which sp already includes.
+ */
+ fun textScale(ctx: Context): Float {
+ if (!Prefs(ctx).autoTextSize) return 1f
+ return AutoText.percent(ctx.resources.configuration.smallestScreenWidthDp) / 100f
+ }
+
+ /** One line of the list: a day header, or an item card. */
+ fun rowViews(ctx: Context, row: DayRow, scale: Float): RemoteViews = when (row) {
+ is DayRow.Header -> {
+ val v = RemoteViews(ctx.packageName, R.layout.widget_froyo_day)
+ val text = SpannableString(row.label)
+ if (row.today) text.setSpan(StyleSpan(Typeface.BOLD), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ v.setTextViewText(R.id.froyo_day, text)
+ v.setTextColor(
+ R.id.froyo_day,
+ ContextCompat.getColor(ctx, if (row.today) R.color.widget_day_today else R.color.widget_day_other),
+ )
+ v.setTextViewTextSize(R.id.froyo_day, TypedValue.COMPLEX_UNIT_SP, 12f * scale)
+ v
+ }
+ is DayRow.Card -> cardViews(ctx, row, scale)
+ }
+
+ private fun cardViews(ctx: Context, row: DayRow.Card, scale: Float): RemoteViews {
val v = RemoteViews(ctx.packageName, R.layout.widget_froyo_item)
- val now = LocalDateTime.now()
+ val item = row.item
+ v.setInt(R.id.froyo_item, "setBackgroundResource", cardBackground(row.tone))
+ v.setTextColor(R.id.froyo_done, ContextCompat.getColor(ctx, toneColor(row.tone)))
+ v.setTextViewText(R.id.froyo_time, row.timeLine)
+ v.setViewVisibility(R.id.froyo_time, if (row.timeLine.isEmpty()) View.GONE else View.VISIBLE)
v.setTextViewText(R.id.froyo_icon, Upcoming.icon(item.kind))
- val due = Upcoming.dueText(item, now, Locale.getDefault())
- v.setTextViewText(R.id.froyo_due, due)
- v.setViewVisibility(R.id.froyo_due, if (due.isEmpty()) View.GONE else View.VISIBLE)
- v.setTextColor(
- R.id.froyo_due,
- ContextCompat.getColor(
- ctx,
- when (Upcoming.state(item, now)) {
- DueState.LATE -> R.color.widget_late
- DueState.SOON -> R.color.widget_soon
- else -> R.color.widget_muted
- },
- ),
- )
v.setTextViewText(R.id.froyo_title, item.title)
- // No header (4x1): the count goes on the first row so it is not lost.
- val showCount = !header && position == 0
- v.setViewVisibility(R.id.froyo_row_count, if (showCount) View.VISIBLE else View.GONE)
- v.setTextViewText(R.id.froyo_row_count, ctx.getString(R.string.froyo_open, total))
- v.setContentDescription(R.id.froyo_item, ctx.getString(R.string.froyo_row_desc, item.title, due))
+ v.setTextViewTextSize(R.id.froyo_time, TypedValue.COMPLEX_UNIT_SP, 11f * scale)
+ v.setTextViewTextSize(R.id.froyo_icon, TypedValue.COMPLEX_UNIT_SP, 14f * scale)
+ v.setTextViewTextSize(R.id.froyo_title, TypedValue.COMPLEX_UNIT_SP, 14f * scale)
+ v.setTextViewTextSize(R.id.froyo_done, TypedValue.COMPLEX_UNIT_SP, 12f * scale)
+ v.setContentDescription(R.id.froyo_item, ctx.getString(R.string.froyo_row_desc, item.title, row.timeLine))
v.setOnClickFillInIntent(
R.id.froyo_item,
Intent().putExtra(FroyoDoneActivity.EXTRA_OPEN_PATH, Upcoming.chatPagePath(slotKey(ctx)))
@@ -396,16 +428,24 @@ object FroyoWidgets {
return v
}
- /** The widget's height in dp: the portrait height on a phone held upright, else the landscape one. */
- private fun heightDp(ctx: Context, options: Bundle): Float {
- val portrait = ctx.resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE
- val max = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
- val min = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)
- val h = if (portrait) max else min
- return (if (h > 0) h else if (min > 0) min else DEFAULT_HEIGHT_DP).toFloat()
+ private fun cardBackground(tone: Tone): Int = when (tone) {
+ Tone.LATE -> R.drawable.widget_card_late
+ Tone.EVENT -> R.drawable.widget_card_event
+ Tone.FLIGHT -> R.drawable.widget_card_flight
+ Tone.DEADLINE -> R.drawable.widget_card_deadline
+ Tone.BILL -> R.drawable.widget_card_bill
+ Tone.OTHER -> R.drawable.widget_card_other
+ }
+
+ private fun toneColor(tone: Tone): Int = when (tone) {
+ Tone.LATE -> R.color.widget_tone_late
+ Tone.EVENT -> R.color.widget_tone_event
+ Tone.FLIGHT -> R.color.widget_tone_flight
+ Tone.DEADLINE -> R.color.widget_tone_deadline
+ Tone.BILL -> R.color.widget_tone_bill
+ Tone.OTHER -> R.color.widget_tone_other
}
- private const val DEFAULT_HEIGHT_DP = 110
private fun openApp(ctx: Context, profile: Profile?, path: String, code: Int): PendingIntent {
val intent = Intent(ctx, MainActivity::class.java)
@@ -550,33 +590,32 @@ class FroyoWidgetService : RemoteViewsService() {
}
class FroyoListFactory(private val ctx: Context, private val widgetId: Int) : RemoteViewsService.RemoteViewsFactory {
- @Volatile private var items: List = emptyList()
- @Volatile private var header = true
+ @Volatile private var rows: List = emptyList()
+ @Volatile private var scale = 1f
override fun onCreate() = Unit
override fun onDataSetChanged() {
- items = FroyoWidgets.visibleItems(ctx).second
- header = FroyoWidgets.headerShown(ctx, widgetId)
+ rows = FroyoWidgets.dayRows(FroyoWidgets.visibleItems(ctx).second)
+ scale = FroyoWidgets.textScale(ctx)
}
override fun onDestroy() {
- items = emptyList()
+ rows = emptyList()
}
- override fun getCount(): Int = items.size
+ override fun getCount(): Int = rows.size
override fun getViewAt(position: Int): RemoteViews {
- val list = items
- val item = list.getOrNull(position) ?: return RemoteViews(ctx.packageName, R.layout.widget_froyo_item)
- return FroyoWidgets.rowViews(ctx, item, position, list.size, header)
+ val row = rows.getOrNull(position) ?: return RemoteViews(ctx.packageName, R.layout.widget_froyo_day)
+ return FroyoWidgets.rowViews(ctx, row, scale)
}
override fun getLoadingView(): RemoteViews? = null
- override fun getViewTypeCount(): Int = 1
+ override fun getViewTypeCount(): Int = FroyoWidgets.VIEW_TYPES
- override fun getItemId(position: Int): Long = items.getOrNull(position)?.title?.hashCode()?.toLong() ?: position.toLong()
+ override fun getItemId(position: Int): Long = position.toLong()
- override fun hasStableIds(): Boolean = true
+ override fun hasStableIds(): Boolean = false
}
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
index 8e332f4..1f4e776 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
@@ -26,13 +26,6 @@ data class UpcomingItem(
/** How soon an item is due. */
enum class DueState { NONE, LATER, SOON, LATE }
-/**
- * How the widget lays out at one size. Every item is in the scrolling list; [rows] is how
- * many whole rows the list shows at rest, and [listHeightDp] is its height, snapped to whole
- * rows so none is cut at rest. [header] is false when only one row fits with no room for the
- * header (4x1); the count then goes on the first row so it is not lost.
- */
-data class WidgetFit(val rows: Int, val header: Boolean, val listHeightDp: Float)
/** What the header says after the count: when the list was read, or that it is being read. */
enum class SyncNote { NONE, UPDATED, AS_OF, UPDATING }
@@ -242,33 +235,6 @@ object Upcoming {
else -> "•"
}
- // Fixed sizes from widget_froyo.xml and widget_froyo_item.xml. The header and each row
- // have a fixed height in sp, so they grow with the font scale exactly; the padding is dp.
- // Change both together.
- private const val PAD_DP = 12f
- const val HEADER_SP = 22f
- const val ROW_SP = 28f
-
- fun headerHeight(fontScale: Float): Float = HEADER_SP * fontScale
-
- fun rowHeight(fontScale: Float): Float = ROW_SP * fontScale
-
- /**
- * The layout at [heightDp] and [fontScale]: the header, then a list of as many whole rows
- * as fit. The list scrolls through every item, so none is left out. Too short for the
- * header and a row: one row, no header.
- */
- fun fit(heightDp: Float, fontScale: Float): WidgetFit {
- val scale = if (fontScale > 0f) fontScale else 1f
- val withHeader = ((heightDp - PAD_DP - headerHeight(scale)) / rowHeight(scale)).toInt()
- if (withHeader >= 1) return WidgetFit(withHeader, header = true, listHeightDp = withHeader * rowHeight(scale))
- return WidgetFit(1, header = false, listHeightDp = rowHeight(scale))
- }
-
- /** Height the header and the list take, for checks that nothing sticks out. */
- fun used(fit: WidgetFit, fontScale: Float): Float =
- PAD_DP + (if (fit.header) headerHeight(fontScale) else 0f) + fit.listHeightDp
-
/** A tap on the refresh button counts as running for at most this long, so a lost run never spins forever. */
const val REFRESH_SPIN_MS = 2L * 60 * 1000
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
new file mode 100644
index 0000000..9ff889e
--- /dev/null
+++ b/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
@@ -0,0 +1,142 @@
+package io.github.rayvader.kirocrew.core
+
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+/**
+ * The card color of a Froyo item. [light] and [dark] are the card fills for the light and the
+ * dark theme (ARGB); both carry white text at 4.5:1 or better. The widget's color resources
+ * (`widget_tone_*` in values/ and values-night/) hold the same values.
+ */
+enum class Tone(val light: Int, val dark: Int) {
+ LATE(0xFFD93025.toInt(), 0xFFC03027.toInt()),
+ EVENT(0xFF1A73E8.toInt(), 0xFF1D69CC.toInt()),
+ FLIGHT(0xFF8E24AA.toInt(), 0xFF802698.toInt()),
+ // Google's amber #E37400 is only 3.1:1 with white text: darkened to pass.
+ DEADLINE(0xFFB65D00.toInt(), 0xFFA25607.toInt()),
+ BILL(0xFF188038.toInt(), 0xFF1C7437.toInt()),
+ // Teal #00897B is 4.3:1 with white text: darkened a little to pass.
+ OTHER(0xFF008476.toInt(), 0xFF07776C.toInt()),
+}
+
+/** One line of the widget's list: a day header, or an item's card under it. */
+sealed interface DayRow {
+ /** "Today · Fri 6 Nov", "Tomorrow · Sat 7 Nov", "Sun 8 Nov", or "No date". */
+ data class Header(val label: String, val today: Boolean) : DayRow
+
+ /** [timeLine] is "14:10", "all day", the late line, or "" for an item with no date. */
+ data class Card(val item: UpcomingItem, val tone: Tone, val timeLine: String, val late: Boolean) : DayRow
+}
+
+/**
+ * The widget's list in the Google Calendar style: a header per day, then that day's items.
+ * Late items go under Today. Items with no date go under "No date", last. Plain Kotlin.
+ */
+object UpcomingDays {
+ const val TODAY = "Today"
+ const val TOMORROW = "Tomorrow"
+ const val NO_DATE = "No date"
+ const val ALL_DAY = "all day"
+
+ /** The card color: late wins; else by kind; an item with no date is teal. */
+ fun tone(item: UpcomingItem, now: LocalDateTime): Tone {
+ if (Upcoming.state(item, now) == DueState.LATE) return Tone.LATE
+ if (item.due == null) return Tone.OTHER
+ return when (item.kind) {
+ "booking", "dinner", "event", "meeting", "reservation" -> Tone.EVENT
+ "flight" -> Tone.FLIGHT
+ "deadline" -> Tone.DEADLINE
+ "bill", "payment" -> Tone.BILL
+ else -> Tone.OTHER
+ }
+ }
+
+ /** "Fri 6 Nov", with the year only when it is not [now]'s year. */
+ fun dayText(day: LocalDate, now: LocalDateTime, locale: Locale): String {
+ val pattern = if (day.year == now.year) "EEE d MMM" else "EEE d MMM yyyy"
+ return day.format(DateTimeFormatter.ofPattern(pattern, locale))
+ }
+
+ fun headerLabel(day: LocalDate, now: LocalDateTime, locale: Locale): String {
+ val text = dayText(day, now, locale)
+ return when (day) {
+ now.toLocalDate() -> "$TODAY · $text"
+ now.toLocalDate().plusDays(1) -> "$TOMORROW · $text"
+ else -> text
+ }
+ }
+
+ /** "late · was Wed 4 Nov", plus the time when the item had one. */
+ fun lateLine(item: UpcomingItem, now: LocalDateTime, locale: Locale): String {
+ val due = item.due ?: return ""
+ val day = dayText(due.toLocalDate(), now, locale)
+ val time = if (item.hasTime) " " + due.format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)) else ""
+ return "late · was $day$time"
+ }
+
+ fun timeLine(item: UpcomingItem, now: LocalDateTime, locale: Locale): String {
+ val due = item.due ?: return ""
+ if (Upcoming.state(item, now) == DueState.LATE) return lateLine(item, now, locale)
+ return if (item.hasTime) due.format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)) else ALL_DAY
+ }
+
+ /**
+ * Headers and cards, in order: Today (late items first, oldest first; then all-day; then by
+ * time), then each later day (all-day before timed), then "No date". Within a slot, by title.
+ */
+ fun rows(items: List, now: LocalDateTime, locale: Locale): List {
+ val today = now.toLocalDate()
+ val dated = items.filter { it.due != null }
+ val undated = items.filter { it.due == null }
+ // The day each item is shown under: late ones move to today.
+ fun shownDay(i: UpcomingItem): LocalDate {
+ val late = Upcoming.state(i, now) == DueState.LATE
+ return if (late) today else i.due!!.toLocalDate()
+ }
+ val byDay = dated.groupBy(::shownDay).toSortedMap()
+ val out = ArrayList()
+ for ((day, list) in byDay) {
+ out.add(DayRow.Header(headerLabel(day, now, locale), today = day == today))
+ val sorted = list.sortedWith(
+ compareBy(
+ { if (Upcoming.state(it, now) == DueState.LATE) 0 else if (!it.hasTime) 1 else 2 },
+ { it.due },
+ { it.title.lowercase(Locale.ROOT) },
+ ),
+ )
+ sorted.forEach { out.add(card(it, now, locale)) }
+ }
+ if (undated.isNotEmpty()) {
+ out.add(DayRow.Header(NO_DATE, today = false))
+ undated.sortedBy { it.title.lowercase(Locale.ROOT) }.forEach { out.add(card(it, now, locale)) }
+ }
+ return out
+ }
+
+ private fun card(item: UpcomingItem, now: LocalDateTime, locale: Locale): DayRow.Card =
+ DayRow.Card(
+ item = item,
+ tone = tone(item, now),
+ timeLine = timeLine(item, now, locale),
+ late = Upcoming.state(item, now) == DueState.LATE,
+ )
+
+ /** WCAG contrast ratio of two opaque ARGB colors, 1.0 to 21.0. */
+ fun contrast(a: Int, b: Int): Double {
+ val la = luminance(a)
+ val lb = luminance(b)
+ return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
+ }
+
+ private fun luminance(argb: Int): Double {
+ fun ch(shift: Int): Double {
+ val c = ((argb shr shift) and 0xFF) / 255.0
+ return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
+ }
+ return 0.2126 * ch(16) + 0.7152 * ch(8) + 0.0722 * ch(0)
+ }
+
+ const val WHITE = 0xFFFFFFFF.toInt()
+}
diff --git a/app/src/main/res/drawable/widget_card_bill.xml b/app/src/main/res/drawable/widget_card_bill.xml
new file mode 100644
index 0000000..219e8dd
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_bill.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_chip.xml b/app/src/main/res/drawable/widget_card_chip.xml
new file mode 100644
index 0000000..2f215a2
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_chip.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_deadline.xml b/app/src/main/res/drawable/widget_card_deadline.xml
new file mode 100644
index 0000000..66973d7
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_deadline.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_event.xml b/app/src/main/res/drawable/widget_card_event.xml
new file mode 100644
index 0000000..dc886bf
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_event.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_flight.xml b/app/src/main/res/drawable/widget_card_flight.xml
new file mode 100644
index 0000000..550ae21
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_flight.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_late.xml b/app/src/main/res/drawable/widget_card_late.xml
new file mode 100644
index 0000000..93e06a5
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_late.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_card_other.xml b/app/src/main/res/drawable/widget_card_other.xml
new file mode 100644
index 0000000..254cf1a
--- /dev/null
+++ b/app/src/main/res/drawable/widget_card_other.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_froyo.xml b/app/src/main/res/layout/widget_froyo.xml
index 6738110..09b7dd0 100644
--- a/app/src/main/res/layout/widget_froyo.xml
+++ b/app/src/main/res/layout/widget_froyo.xml
@@ -1,7 +1,6 @@
-
+
diff --git a/app/src/main/res/layout/widget_froyo_day.xml b/app/src/main/res/layout/widget_froyo_day.xml
new file mode 100644
index 0000000..4e305c6
--- /dev/null
+++ b/app/src/main/res/layout/widget_froyo_day.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/layout/widget_froyo_item.xml b/app/src/main/res/layout/widget_froyo_item.xml
index 82ba166..bd777a0 100644
--- a/app/src/main/res/layout/widget_froyo_item.xml
+++ b/app/src/main/res/layout/widget_froyo_item.xml
@@ -1,65 +1,75 @@
-
-
+
+ android:layout_height="wrap_content"
+ android:paddingBottom="5dp">
-
+ android:background="@drawable/widget_card_event"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingStart="10dp"
+ android:paddingTop="6dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="7dp">
-
+
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/widget_tones.xml b/app/src/main/res/values-night/widget_tones.xml
new file mode 100644
index 0000000..02fbc2a
--- /dev/null
+++ b/app/src/main/res/values-night/widget_tones.xml
@@ -0,0 +1,13 @@
+
+
+
+ #C03027
+ #1D69CC
+ #802698
+ #A25607
+ #1C7437
+ #07776C
+ #8AB4F8
+ #BDC1C6
+ #FFFFFF
+
diff --git a/app/src/main/res/values/widget_tones.xml b/app/src/main/res/values/widget_tones.xml
new file mode 100644
index 0000000..b088be4
--- /dev/null
+++ b/app/src/main/res/values/widget_tones.xml
@@ -0,0 +1,14 @@
+
+
+
+ #D93025
+ #1A73E8
+ #8E24AA
+ #B65D00
+ #188038
+ #008476
+
+ #1A73E8
+ #5F6368
+ #FFFFFF
+
diff --git a/app/src/main/res/xml/froyo_widget_info.xml b/app/src/main/res/xml/froyo_widget_info.xml
index d840ee9..2863483 100644
--- a/app/src/main/res/xml/froyo_widget_info.xml
+++ b/app/src/main/res/xml/froyo_widget_info.xml
@@ -1,12 +1,12 @@
-
+
) = rows.map {
+ when (it) {
+ is DayRow.Header -> "# ${it.label}"
+ is DayRow.Card -> it.item.title
+ }
+ }
+
+ @Test
+ fun groupsByDayInOrderWithLateUnderTodayAndNoDateLast() {
+ val items = listOf(
+ item("Later dinner", "2026-11-08 19:30"),
+ item("No date form", null, "deadline"),
+ item("Next year trip", "2027-01-03", "flight"),
+ item("Today timed", "2026-11-06 14:10"),
+ item("Tomorrow bill", "2026-11-07", "bill"),
+ item("Late parcel", "2026-11-04", "delivery"),
+ item("Today all day", "2026-11-06"),
+ item("Late earlier", "2026-11-02 10:00"),
+ )
+ assertEquals(
+ listOf(
+ "# Today · Fri 6 Nov",
+ "Late earlier", "Late parcel", // late first, oldest first
+ "Today all day", // all day before timed
+ "Today timed",
+ "# Tomorrow · Sat 7 Nov",
+ "Tomorrow bill",
+ "# Sun 8 Nov",
+ "Later dinner",
+ "# Sun 3 Jan 2027", // the year only when it is not this year
+ "Next year trip",
+ "# No date",
+ "No date form",
+ ),
+ labels(UpcomingDays.rows(items, now, uk)),
+ )
+ }
+
+ @Test
+ fun onlyTodaysHeaderIsMarkedToday() {
+ val rows = UpcomingDays.rows(
+ listOf(item("A", "2026-11-06"), item("B", "2026-11-07"), item("C", null)),
+ now,
+ uk,
+ )
+ val headers = rows.filterIsInstance()
+ assertEquals(listOf(true, false, false), headers.map { it.today })
+ }
+
+ @Test
+ fun lateWithNothingDueTodayStillMakesATodayHeader() {
+ val rows = UpcomingDays.rows(listOf(item("Late", "2026-11-04")), now, uk)
+ assertEquals(listOf("# Today · Fri 6 Nov", "Late"), labels(rows))
+ }
+
+ @Test
+ fun allDayComesBeforeTimedOnTheSameDay() {
+ val rows = UpcomingDays.rows(
+ listOf(item("At 08:00", "2026-11-09 08:00"), item("All day", "2026-11-09"), item("At 07:00", "2026-11-09 07:00")),
+ now,
+ uk,
+ )
+ assertEquals(listOf("# Mon 9 Nov", "All day", "At 07:00", "At 08:00"), labels(rows))
+ }
+
+ @Test
+ fun timeLines() {
+ val cards = UpcomingDays.rows(
+ listOf(
+ item("Timed", "2026-11-06 14:10"),
+ item("All day", "2026-11-07"),
+ item("Late day", "2026-11-04"),
+ item("Late timed", "2026-11-06 08:30"),
+ item("Late last year", "2025-12-30"),
+ item("Undated", null),
+ ),
+ now,
+ uk,
+ ).filterIsInstance().associate { it.item.title to it.timeLine }
+ assertEquals("14:10", cards["Timed"])
+ assertEquals("all day", cards["All day"])
+ assertEquals("late · was Wed 4 Nov", cards["Late day"])
+ assertEquals("late · was Fri 6 Nov 08:30", cards["Late timed"])
+ assertEquals("late · was Tue 30 Dec 2025", cards["Late last year"])
+ assertEquals("", cards["Undated"])
+ }
+
+ @Test
+ fun aDateOnlyItemDueTodayIsNotLate() {
+ val card = UpcomingDays.rows(listOf(item("Today", "2026-11-06")), now, uk)[1] as DayRow.Card
+ assertFalse(card.late)
+ assertEquals(Tone.EVENT, card.tone)
+ }
+
+ @Test
+ fun colorsByType() {
+ val soon = "2026-11-10"
+ assertEquals(Tone.EVENT, UpcomingDays.tone(item("x", soon, "booking"), now))
+ assertEquals(Tone.EVENT, UpcomingDays.tone(item("x", soon, "dinner"), now))
+ assertEquals(Tone.EVENT, UpcomingDays.tone(item("x", soon, "event"), now))
+ assertEquals(Tone.FLIGHT, UpcomingDays.tone(item("x", soon, "flight"), now))
+ assertEquals(Tone.DEADLINE, UpcomingDays.tone(item("x", soon, "deadline"), now))
+ assertEquals(Tone.BILL, UpcomingDays.tone(item("x", soon, "bill"), now))
+ assertEquals(Tone.BILL, UpcomingDays.tone(item("x", soon, "payment"), now))
+ assertEquals(Tone.OTHER, UpcomingDays.tone(item("x", soon, "delivery"), now))
+ assertEquals(Tone.OTHER, UpcomingDays.tone(item("x", soon, "something-new"), now))
+ // No date: teal, whatever the kind.
+ assertEquals(Tone.OTHER, UpcomingDays.tone(item("x", null, "flight"), now))
+ // Late wins over the kind.
+ assertEquals(Tone.LATE, UpcomingDays.tone(item("x", "2026-11-01", "flight"), now))
+ }
+
+ @Test
+ fun theSpecColorsPassOrAreDarkenedToPass() {
+ assertEquals(0xFFD93025.toInt(), Tone.LATE.light)
+ assertEquals(0xFF1A73E8.toInt(), Tone.EVENT.light)
+ assertEquals(0xFF8E24AA.toInt(), Tone.FLIGHT.light)
+ assertEquals(0xFF188038.toInt(), Tone.BILL.light)
+ // These two fail with white text as given, so they are darker.
+ assertTrue(UpcomingDays.contrast(0xFFE37400.toInt(), UpcomingDays.WHITE) < 4.5)
+ assertTrue(UpcomingDays.contrast(0xFF00897B.toInt(), UpcomingDays.WHITE) < 4.5)
+ }
+
+ @Test
+ fun everyCardColorHasWhiteTextAt45OrBetterInBothThemes() {
+ for (tone in Tone.values()) {
+ val light = UpcomingDays.contrast(tone.light, UpcomingDays.WHITE)
+ val dark = UpcomingDays.contrast(tone.dark, UpcomingDays.WHITE)
+ assertTrue("$tone light $light", light >= 4.5)
+ assertTrue("$tone dark $dark", dark >= 4.5)
+ }
+ }
+
+ @Test
+ fun contrastMath() {
+ assertEquals(21.0, UpcomingDays.contrast(0xFF000000.toInt(), UpcomingDays.WHITE), 0.01)
+ assertEquals(1.0, UpcomingDays.contrast(UpcomingDays.WHITE, UpcomingDays.WHITE), 0.001)
+ }
+
+ /** The widget's color resources must hold the same colors this test checks. */
+ @Test
+ fun theColorResourcesMatchTheTones() {
+ fun colors(path: String): Map {
+ val f = listOf(File(path), File("app/$path")).first { it.exists() }
+ return Regex("""#([0-9A-Fa-f]{6})""")
+ .findAll(f.readText())
+ .associate { it.groupValues[1] to (0xFF000000.toInt() or it.groupValues[2].toInt(16)) }
+ }
+ val light = colors("src/main/res/values/widget_tones.xml")
+ val dark = colors("src/main/res/values-night/widget_tones.xml")
+ for (tone in Tone.values()) {
+ val key = tone.name.lowercase(Locale.ROOT)
+ assertEquals("light $key", tone.light, light[key])
+ assertEquals("dark $key", tone.dark, dark[key])
+ }
+ }
+
+ @Test
+ fun aLongTitleIsKeptWhole() {
+ val long = "E2E made-up title that is sixty characters long, to wrap ok."
+ assertEquals(60, long.length)
+ val card = UpcomingDays.rows(listOf(item(long, "2026-11-07")), now, uk)[1] as DayRow.Card
+ assertEquals(long, card.item.title)
+ }
+}
diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
index 362383e..9d87a4a 100644
--- a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
+++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
@@ -259,42 +259,6 @@ class UpcomingTest {
assertFalse(Upcoming.isRefreshing(t, t - 1))
}
- @Test
- fun theSyncNoteSitsInTheHeaderSoNoRowIsLost() {
- // Rows per height are unchanged by the note: it shares the header line.
- assertEquals(3, Upcoming.fit(139f, 1f).rows)
- assertEquals(3, Upcoming.fit(139f, 1.15f).rows)
- assertEquals(2, Upcoming.fit(110f, 1.15f).rows)
- }
-
- @Test
- fun listShowsWholeRowsAndScrollsTheRest() {
- // Raymond's Wide on Smart Launcher is about 139 dp; the emulator's 4x2 is about 223 dp.
- assertEquals(WidgetFit(3, true, 84f), Upcoming.fit(139f, 1f))
- assertEquals(3, Upcoming.fit(139f, 1.15f).rows)
- assertEquals(2, Upcoming.fit(110f, 1f).rows)
- assertEquals(2, Upcoming.fit(110f, 1.15f).rows)
- assertEquals(6, Upcoming.fit(223f, 1f).rows)
- assertEquals(5, Upcoming.fit(223f, 1.15f).rows)
- assertEquals(9, Upcoming.fit(300f, 1f).rows)
- // 4x1: one row, no header.
- assertEquals(WidgetFit(1, false, 28f), Upcoming.fit(50f, 1f))
- // The list is snapped to whole rows, so no row is cut at rest.
- assertEquals(Upcoming.fit(139f, 1.15f).rows * Upcoming.rowHeight(1.15f), Upcoming.fit(139f, 1.15f).listHeightDp, 0.001f)
- }
-
- @Test
- fun listNeverSticksOut() {
- val heights = (110..150).map { it.toFloat() } + listOf(139f, 223f, 300f, 600f)
- for (scale in listOf(0.85f, 1f, 1.15f, 1.3f)) {
- for (h in heights) {
- val fit = Upcoming.fit(h, scale)
- assertTrue("h=$h scale=$scale fit=$fit", Upcoming.used(fit, scale) <= h)
- assertTrue("h=$h scale=$scale", fit.rows >= 1 && fit.header)
- }
- }
- }
-
@Test
fun noMoreLineEveryItemIsInTheList() {
// The list holds every open item; nothing is counted away as "+N more".
From 2eb39f8af55223efa329751d49779e640ae88d0c Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 16:56:29 +0000
Subject: [PATCH 2/8] test: scroll the widget list until a card is wholly in
view
A card cut by the list's edge matched by its description while its title
was out of sight, so the long-title check read a node that was not there.
---
.github/e2e/run.sh | 15 +++++++++------
.github/e2e/ui.py | 19 ++++++++++++++++++-
2 files changed, 27 insertions(+), 7 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index 64a0607..7d99c72 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -949,13 +949,16 @@ list_swipe() {
sleep 1.5
}
list_top() { local i; for i in 1 2 3 4 5 6; do list_swipe down || return 1; done; }
-# Scroll the list until TEXT is on screen.
+# Scroll the list until the card titled TEXT is wholly in view (a card cut by the list's
+# edge does not count: its title may be out of sight).
+card_shown() { dump && python3 "$HERE/ui.py" shown "$OUT/ui.xml" "$1"; }
list_show() {
local i
- has_text "$1" && return 0
- for i in $(seq 1 10); do list_swipe up || break; has_text "$1" && return 0; done
+ card_shown "$1" && return 0
list_top
- has_text "$1" || fail "not in the widget's list, even after scrolling: $1"
+ card_shown "$1" && return 0
+ for i in $(seq 1 12); do list_swipe up || break; card_shown "$1" && return 0; done
+ fail "not wholly in view in the widget's list, even after scrolling: $1"
}
# Walk the whole list from the top: check every card in view (title and Done chip whole), and
# print each line once, in list order, to $OUT/list.txt.
@@ -991,8 +994,8 @@ days_check() {
check "$where: headers in order (Today with the late item, Tomorrow, later, next year, No date last); every card whole, Done visible; scrolling reaches the last item"
list_show "$LONG"
long=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
- short=$(python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home" 2>/dev/null || true)
- [[ -z $short ]] && { list_show "E2E flight home"; short=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home"); }
+ list_show "E2E flight home"
+ short=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home") || fail "$where: the short title is not shown"
((long * 10 >= short * 17)) || fail "$where: the 60-character title is not wrapped onto more lines ($long px vs $short px)"
list_show "$LONG"
n=$(dump && python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: the long title's card is cut: $n"
diff --git a/.github/e2e/ui.py b/.github/e2e/ui.py
index e6dbe9f..3d8c464 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -14,6 +14,7 @@
ui.py rows DUMP - Froyo list lines on screen, top to bottom: "y day|card TEXT"
ui.py cards DUMP - cards fully in view; exit 1 when a title or Done chip is cut
ui.py height DUMP TEXT height in px of the node whose text equals TEXT
+ ui.py shown DUMP TEXT exit 0 when the card titled TEXT is wholly inside the list
"""
import re
import sys
@@ -186,6 +187,22 @@ def cards(path, _arg):
return 0
+def shown(path, text):
+ """Exit 0 when the card titled TEXT is wholly inside the widget's list (not cut by its edge)."""
+ root = ET.parse(path).getroot()
+ lst = next((box(n) for n in root.iter("node") if _rid(n, "froyo_list")), None)
+ if not lst:
+ return 1
+ for card in root.iter("node"):
+ if not _rid(card, "froyo_item"):
+ continue
+ cb = box(card)
+ title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
+ if cb and title is not None and title.get("text", "") == text and _inside(cb, lst):
+ return 0
+ return 1
+
+
def height(path, text):
"""Height in px of the node whose text equals TEXT."""
for node in nodes(path):
@@ -230,4 +247,4 @@ def jobs(path, pkg):
cmd, path, arg = sys.argv[1], sys.argv[2], sys.argv[3]
sys.exit({"find": find, "menu": menu, "has": has, "switch": switch, "window": window, "jobs": jobs,
"widget": widget, "top": top, "bounds": bounds, "rows": rows, "cards": cards,
- "height": height}[cmd](path, arg))
+ "height": height, "shown": shown}[cmd](path, arg))
From b083409e1c64e4a130aaadbab64c8e456e224007 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 17:45:10 +0000
Subject: [PATCH 3/8] fix: Froyo widget header never squeezes its title on a
narrow widget
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On an unfolded screen the launcher made the widget narrow and the count
pushed 'Froyo · Upcoming' down to '..'. Title and count now share the
header's width and wrap. The e2e list helpers retry the list's bounds and
fail with a message instead of stopping silently.
---
.github/e2e/run.sh | 25 +++++++++++++++++-------
app/src/main/res/layout/widget_froyo.xml | 13 ++++++------
2 files changed, 24 insertions(+), 14 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index 7d99c72..c414823 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -938,8 +938,15 @@ top_of() { dump && python3 "$HERE/ui.py" top "$OUT/ui.xml" "$1"; }
# Swipe the widget's list by about half its height: up (to later items) or down.
list_swipe() {
- local x1 y1 x2 y2 x
- read -r x1 y1 x2 y2 < <(dump && python3 "$HERE/ui.py" bounds "$OUT/ui.xml" froyo_list) || return 1
+ local x1="" y1 y2 x2 x i
+ # A big screen's dump can come back without the list for a moment: try a few times.
+ for i in 1 2 3 4 5; do
+ read -r x1 y1 x2 y2 < <(dump && python3 "$HERE/ui.py" bounds "$OUT/ui.xml" froyo_list) && break
+ x1=""
+ dismiss_anr
+ sleep 1.5
+ done
+ [[ -n $x1 ]] || { cp "$OUT/ui.xml" "$OUT/logs/$FEATURE_ID-nolist-ui.xml" 2>/dev/null; fail "the widget's list is not on screen"; return 1; }
x=$(((x1 + x2) / 2))
if [[ $1 == up ]]; then
adb shell input swipe "$x" $((y2 - 15)) "$x" $(((y1 + y2) / 2)) 500
@@ -948,7 +955,7 @@ list_swipe() {
fi
sleep 1.5
}
-list_top() { local i; for i in 1 2 3 4 5 6; do list_swipe down || return 1; done; }
+list_top() { local i; for i in 1 2 3 4 5 6; do list_swipe down || fail "cannot scroll the widget's list"; done; }
# Scroll the list until the card titled TEXT is wholly in view (a card cut by the list's
# edge does not count: its title may be out of sight).
card_shown() { dump && python3 "$HERE/ui.py" shown "$OUT/ui.xml" "$1"; }
@@ -967,15 +974,15 @@ list_walk() {
list_top
: > "$OUT/list.txt"
for i in $(seq 1 14); do
- dump || return 1
+ dump || fail "no screen dump"
n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || { cp "$OUT/ui.xml" "$OUT/logs/$FEATURE_ID-cut-ui.xml"; fail "a card is cut: $n"; return 1; }
before=$(wc -l < "$OUT/list.txt")
python3 "$HERE/ui.py" rows "$OUT/ui.xml" - | cut -d' ' -f2- | while read -r line; do
grep -qxF "$line" "$OUT/list.txt" || echo "$line" >> "$OUT/list.txt"
done
after=$(wc -l < "$OUT/list.txt")
- ((i > 1 && after == before)) && break
- list_swipe up || return 1
+ if ((i > 1 && after == before)); then break; fi
+ list_swipe up || fail "cannot scroll the widget's list"
done
list_top
}
@@ -984,7 +991,11 @@ line_of() { grep -nF -- "$1" "$OUT/list.txt" | head -1 | cut -d: -f1; }
# The day-header checks, at the screen size and text size in force.
days_check() {
- local where=$1 a b c d e f long short
+ local where=$1 a b c d e f long short name
+ name=$(dump && python3 "$HERE/ui.py" bounds "$OUT/ui.xml" froyo_name) || fail "$where: no header title"
+ read -r a b c d <<<"$name"
+ ((c - a >= 150)) || fail "$where: the header title is squeezed to $((c - a)) px"
+ check "$where: the header title 'Froyo · Upcoming' is not squeezed ($((c - a)) px wide)"
list_walk
cp "$OUT/list.txt" "$OUT/logs/$FEATURE_ID-list-$where.txt"
a=$(line_of "day Today ·"); b=$(line_of "day Tomorrow ·"); c=$(line_of "$((THIS_YEAR + 1))")
diff --git a/app/src/main/res/layout/widget_froyo.xml b/app/src/main/res/layout/widget_froyo.xml
index 09b7dd0..74c7cbe 100644
--- a/app/src/main/res/layout/widget_froyo.xml
+++ b/app/src/main/res/layout/widget_froyo.xml
@@ -1,5 +1,6 @@
-
@@ -70,8 +71,6 @@
android:id="@+id/froyo_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:ellipsize="end"
- android:maxLines="2"
android:textColor="@color/widget_muted"
android:textSize="13sp"
android:visibility="gone" />
From 12f91abe0c61cff7b5a1ee8c17187ce82f1e47bb Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 19:21:57 +0000
Subject: [PATCH 4/8] fix: narrow Froyo widget puts the count under the title
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sharing one line broke 'Froyo · Upcoming' mid-word on a narrow widget.
Below 290 dp (divided by the text scale) the count now gets its own line.
The e2e list walk stops only when the screen stops changing, since one
tall card can fill a whole swipe.
---
.github/e2e/run.sh | 19 +++++++++++--------
.../github/rayvader/kirocrew/FroyoWidget.kt | 9 ++++++++-
.../rayvader/kirocrew/core/UpcomingDays.kt | 13 +++++++++++++
app/src/main/res/layout/widget_froyo.xml | 17 +++++++++++++----
.../kirocrew/core/UpcomingDaysTest.kt | 12 ++++++++++++
5 files changed, 57 insertions(+), 13 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index c414823..bddb99d 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -970,24 +970,27 @@ list_show() {
# Walk the whole list from the top: check every card in view (title and Done chip whole), and
# print each line once, in list order, to $OUT/list.txt.
list_walk() {
- local i before after n
+ local i n view last=""
list_top
: > "$OUT/list.txt"
- for i in $(seq 1 14); do
+ for i in $(seq 1 30); do
dump || fail "no screen dump"
n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || { cp "$OUT/ui.xml" "$OUT/logs/$FEATURE_ID-cut-ui.xml"; fail "a card is cut: $n"; return 1; }
- before=$(wc -l < "$OUT/list.txt")
- python3 "$HERE/ui.py" rows "$OUT/ui.xml" - | cut -d' ' -f2- | while read -r line; do
- grep -qxF "$line" "$OUT/list.txt" || echo "$line" >> "$OUT/list.txt"
+ # What is on screen, with positions: the same twice means the list cannot scroll further.
+ # (A tall card can fill a whole swipe, so "no new line" alone is not the end.)
+ view=$(python3 "$HERE/ui.py" rows "$OUT/ui.xml" -)
+ printf '%s\n' "$view" | cut -d' ' -f2- | while read -r line; do
+ [[ -z $line ]] && continue
+ grep -qxF -- "$line" "$OUT/list.txt" || echo "$line" >> "$OUT/list.txt"
done
- after=$(wc -l < "$OUT/list.txt")
- if ((i > 1 && after == before)); then break; fi
+ [[ $view == "$last" ]] && break
+ last=$view
list_swipe up || fail "cannot scroll the widget's list"
done
list_top
}
# The line number of the first list line containing TEXT, from list_walk.
-line_of() { grep -nF -- "$1" "$OUT/list.txt" | head -1 | cut -d: -f1; }
+line_of() { { grep -nF -- "$1" "$OUT/list.txt" || true; } | head -1 | cut -d: -f1; }
# The day-header checks, at the screen size and text size in force.
days_check() {
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
index e0c3707..5344609 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
@@ -332,7 +332,14 @@ object FroyoWidgets {
SyncNote.UPDATING -> ctx.getString(R.string.froyo_updating_count, open)
SyncNote.NONE -> open
}
- v.setTextViewText(R.id.froyo_count, count)
+ // Narrow widget (an unfolded screen gives it fewer dp): the count under the title.
+ val widthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0).toFloat()
+ val stacked = UpcomingDays.stackHeader(widthDp, scale * ctx.resources.configuration.fontScale)
+ v.setTextViewText(R.id.froyo_count, if (stacked) "" else count)
+ v.setViewVisibility(R.id.froyo_count, if (stacked) View.GONE else View.VISIBLE)
+ v.setTextViewText(R.id.froyo_count_below, if (stacked) count else "")
+ v.setViewVisibility(R.id.froyo_count_below, if (stacked) View.VISIBLE else View.GONE)
+ v.setTextViewTextSize(R.id.froyo_count_below, TypedValue.COMPLEX_UNIT_SP, 12f * scale)
v.setViewVisibility(R.id.froyo_refresh, if (refreshing) View.GONE else View.VISIBLE)
v.setViewVisibility(R.id.froyo_spinner, if (refreshing) View.VISIBLE else View.GONE)
v.setOnClickPendingIntent(R.id.froyo_refresh, refreshIntent(ctx))
diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
index 9ff889e..e613167 100644
--- a/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
+++ b/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
@@ -123,6 +123,19 @@ object UpcomingDays {
late = Upcoming.state(item, now) == DueState.LATE,
)
+ /**
+ * Below this width (dp, divided by the text scale) "Froyo · Upcoming" and the count do not
+ * both fit on one line, so the count moves to its own line under the title.
+ */
+ const val HEADER_ONE_LINE_DP = 290f
+
+ /** True when the header should put the count under the title. Unknown width: one line. */
+ fun stackHeader(widthDp: Float, textScale: Float): Boolean {
+ if (widthDp <= 0f) return false
+ val scale = if (textScale > 0f) textScale else 1f
+ return widthDp / scale < HEADER_ONE_LINE_DP
+ }
+
/** WCAG contrast ratio of two opaque ARGB colors, 1.0 to 21.0. */
fun contrast(a: Int, b: Int): Double {
val la = luminance(a)
diff --git a/app/src/main/res/layout/widget_froyo.xml b/app/src/main/res/layout/widget_froyo.xml
index 74c7cbe..4e3f35a 100644
--- a/app/src/main/res/layout/widget_froyo.xml
+++ b/app/src/main/res/layout/widget_froyo.xml
@@ -1,6 +1,6 @@
-
@@ -67,6 +66,16 @@
android:visibility="gone" />
+
+
+
Date: Fri, 6 Nov 2026 20:09:11 +0000
Subject: [PATCH 5/8] test: correct the narrow-header width cases
---
.../io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt
index 8465580..ef7f426 100644
--- a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt
+++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt
@@ -181,9 +181,11 @@ class UpcomingDaysTest {
@Test
fun aNarrowWidgetPutsTheCountUnderTheTitle() {
- // Phone 4x2 (~348 dp) at 115 % text: one line. Unfolded 2-cell (~180 dp): stacked.
+ // Phone 4x2 (~348 dp) at 115 % text (348/1.15 = 302 >= 290): one line.
assertFalse(UpcomingDays.stackHeader(348f, 1.15f))
- assertFalse(UpcomingDays.stackHeader(362f, 1.1f * 1.15f))
+ assertFalse(UpcomingDays.stackHeader(362f, 1f))
+ // Unfolded, the launcher gives the widget few dp, or the app's folded 110 % piles on: stacked.
+ assertTrue(UpcomingDays.stackHeader(362f, 1.1f * 1.15f)) // 362/1.265 = 286 < 290
assertTrue(UpcomingDays.stackHeader(180f, 1.15f))
assertTrue(UpcomingDays.stackHeader(250f, 1f))
// Bigger text needs more width for one line.
From 99e557ddbe9f4a19397b1173cf8e39a3964a2e68 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 21:11:01 +0000
Subject: [PATCH 6/8] test: check the long title only when its card is wholly
in view
uiautomator crops a half-hidden card, so a card touching the list's edge
no longer counts as shown. The wrap check compares the title with its own
card's one-line time, since a short title also wraps on a narrow widget.
---
.github/e2e/run.sh | 10 ++++------
.github/e2e/ui.py | 27 +++++++++++++++++++++++++--
2 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index bddb99d..bdb99d5 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -1007,12 +1007,10 @@ days_check() {
((a < f && f < b && b < c && c < d && d < e)) || fail "$where: headers out of order ($a $f $b $c $d $e)"
check "$where: headers in order (Today with the late item, Tomorrow, later, next year, No date last); every card whole, Done visible; scrolling reaches the last item"
list_show "$LONG"
- long=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
- list_show "E2E flight home"
- short=$(dump && python3 "$HERE/ui.py" height "$OUT/ui.xml" "E2E flight home") || fail "$where: the short title is not shown"
- ((long * 10 >= short * 17)) || fail "$where: the 60-character title is not wrapped onto more lines ($long px vs $short px)"
- list_show "$LONG"
- n=$(dump && python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: the long title's card is cut: $n"
+ long=$(dump && python3 "$HERE/ui.py" lines "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
+ # Two or more lines of title text (each a bit taller than the small time line).
+ ((long >= 2)) || fail "$where: the 60-character title is not wrapped (about $long line of time-line height)"
+ n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: the long title's card is cut: $n"
check "$where: the 60-character title wraps fully and is not cut"
list_top
}
diff --git a/.github/e2e/ui.py b/.github/e2e/ui.py
index 3d8c464..3bdf220 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -15,6 +15,7 @@
ui.py cards DUMP - cards fully in view; exit 1 when a title or Done chip is cut
ui.py height DUMP TEXT height in px of the node whose text equals TEXT
ui.py shown DUMP TEXT exit 0 when the card titled TEXT is wholly inside the list
+ ui.py lines DUMP TEXT about how many lines the card titled TEXT takes
"""
import re
import sys
@@ -198,7 +199,28 @@ def shown(path, text):
continue
cb = box(card)
title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
- if cb and title is not None and title.get("text", "") == text and _inside(cb, lst):
+ if not cb or title is None or title.get("text", "") != text:
+ continue
+ # uiautomator crops a half-hidden card to what shows, so a card touching the list's
+ # top or bottom edge may be cut there: it does not count as wholly in view.
+ if _inside(cb, lst) and cb[1] > lst[1] + 1 and cb[3] < lst[3] - 1:
+ return 0
+ return 1
+
+
+def lines(path, text):
+ """How many lines the card titled TEXT takes: its title's height over its time line's
+ height (one line, smaller text), rounded down. Exit 1 when not found."""
+ for card in nodes(path):
+ if not _rid(card, "froyo_item"):
+ continue
+ title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
+ time = next((n for n in card.iter("node") if _rid(n, "froyo_time")), None)
+ if title is None or time is None or title.get("text", "") != text:
+ continue
+ tb, mb = box(title), box(time)
+ if tb and mb and mb[3] > mb[1]:
+ print(int((tb[3] - tb[1]) / (mb[3] - mb[1])))
return 0
return 1
@@ -247,4 +269,5 @@ def jobs(path, pkg):
cmd, path, arg = sys.argv[1], sys.argv[2], sys.argv[3]
sys.exit({"find": find, "menu": menu, "has": has, "switch": switch, "window": window, "jobs": jobs,
"widget": widget, "top": top, "bounds": bounds, "rows": rows, "cards": cards,
- "height": height, "shown": shown}[cmd](path, arg))
+ "height": height, "shown": shown,
+ "lines": lines}[cmd](path, arg))
From 902f44e174bcfdeda589026c2e570f78754ffb22 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 22:04:36 +0000
Subject: [PATCH 7/8] test: check a card taller than the widget's list from its
top
On a narrow unfolded widget the 60-character card is taller than the list,
so it is never wholly in view; the list scrolls through it. The check now
brings the card's top into view and checks the title wraps.
---
.github/e2e/run.sh | 17 ++++++++++++++---
.github/e2e/ui.py | 23 ++++++++++++++++++++++-
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index bdb99d5..3973b8e 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -959,6 +959,16 @@ list_top() { local i; for i in 1 2 3 4 5 6; do list_swipe down || fail "cannot s
# Scroll the list until the card titled TEXT is wholly in view (a card cut by the list's
# edge does not count: its title may be out of sight).
card_shown() { dump && python3 "$HERE/ui.py" shown "$OUT/ui.xml" "$1"; }
+# Scroll the list until the top of the card titled TEXT is in view.
+card_top() {
+ local i
+ list_top
+ for i in $(seq 1 12); do
+ dump && python3 "$HERE/ui.py" top_shown "$OUT/ui.xml" "$1" && return 0
+ list_swipe up || break
+ done
+ fail "the top of this card never comes into view: $1"
+}
list_show() {
local i
card_shown "$1" && return 0
@@ -1006,11 +1016,12 @@ days_check() {
[[ -n $a && -n $b && -n $c && -n $d && -n $e && -n $f ]] || fail "$where: a header or the last item is missing: $(tr '\n' '|' < "$OUT/list.txt")"
((a < f && f < b && b < c && c < d && d < e)) || fail "$where: headers out of order ($a $f $b $c $d $e)"
check "$where: headers in order (Today with the late item, Tomorrow, later, next year, No date last); every card whole, Done visible; scrolling reaches the last item"
- list_show "$LONG"
+ # Its card's top in view. On a narrow widget the card can be taller than the list: the list
+ # then scrolls through it (list_walk above reached the items after it), and nothing is cut.
+ card_top "$LONG"
long=$(dump && python3 "$HERE/ui.py" lines "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
- # Two or more lines of title text (each a bit taller than the small time line).
((long >= 2)) || fail "$where: the 60-character title is not wrapped (about $long line of time-line height)"
- n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: the long title's card is cut: $n"
+ n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: a card is cut: $n"
check "$where: the 60-character title wraps fully and is not cut"
list_top
}
diff --git a/.github/e2e/ui.py b/.github/e2e/ui.py
index 3bdf220..33a0961 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -16,6 +16,7 @@
ui.py height DUMP TEXT height in px of the node whose text equals TEXT
ui.py shown DUMP TEXT exit 0 when the card titled TEXT is wholly inside the list
ui.py lines DUMP TEXT about how many lines the card titled TEXT takes
+ ui.py top_shown DUMP TEXT exit 0 when the top of the card titled TEXT is inside the list
"""
import re
import sys
@@ -208,6 +209,26 @@ def shown(path, text):
return 1
+def top_shown(path, text):
+ """Exit 0 when the top of the card titled TEXT (its time line) is wholly inside the list.
+ For a card taller than the list, that is the most of it one screen can show."""
+ root = ET.parse(path).getroot()
+ lst = next((box(n) for n in root.iter("node") if _rid(n, "froyo_list")), None)
+ if not lst:
+ return 1
+ for card in root.iter("node"):
+ if not _rid(card, "froyo_item"):
+ continue
+ title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
+ time = next((n for n in card.iter("node") if _rid(n, "froyo_time")), None)
+ if title is None or time is None or title.get("text", "") != text:
+ continue
+ cb, tb = box(card), box(time)
+ if cb and tb and cb[1] > lst[1] + 1 and tb[3] - tb[1] > 5 and _inside(tb, lst):
+ return 0
+ return 1
+
+
def lines(path, text):
"""How many lines the card titled TEXT takes: its title's height over its time line's
height (one line, smaller text), rounded down. Exit 1 when not found."""
@@ -270,4 +291,4 @@ def jobs(path, pkg):
sys.exit({"find": find, "menu": menu, "has": has, "switch": switch, "window": window, "jobs": jobs,
"widget": widget, "top": top, "bounds": bounds, "rows": rows, "cards": cards,
"height": height, "shown": shown,
- "lines": lines}[cmd](path, arg))
+ "lines": lines, "top_shown": top_shown}[cmd](path, arg))
From dc348b35c00d9df4fb41cbf9e8c5d875b3d069d8 Mon Sep 17 00:00:00 2001
From: rayvader <42436531+rayvader@users.noreply.github.com>
Date: Fri, 6 Nov 2026 22:57:26 +0000
Subject: [PATCH 8/8] test: measure the long title's wrap across a scroll of
the list
Count its lines against the header title (same text size, always on
screen) and keep the most seen while scrolling, instead of catching one
scroll position.
---
.github/e2e/run.sh | 17 ++++++++++++-----
.github/e2e/ui.py | 27 +++++++++++++--------------
2 files changed, 25 insertions(+), 19 deletions(-)
diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh
index 3973b8e..b7129aa 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -1016,11 +1016,18 @@ days_check() {
[[ -n $a && -n $b && -n $c && -n $d && -n $e && -n $f ]] || fail "$where: a header or the last item is missing: $(tr '\n' '|' < "$OUT/list.txt")"
((a < f && f < b && b < c && c < d && d < e)) || fail "$where: headers out of order ($a $f $b $c $d $e)"
check "$where: headers in order (Today with the late item, Tomorrow, later, next year, No date last); every card whole, Done visible; scrolling reaches the last item"
- # Its card's top in view. On a narrow widget the card can be taller than the list: the list
- # then scrolls through it (list_walk above reached the items after it), and nothing is cut.
- card_top "$LONG"
- long=$(dump && python3 "$HERE/ui.py" lines "$OUT/ui.xml" "$LONG") || fail "$where: the long title is not shown"
- ((long >= 2)) || fail "$where: the 60-character title is not wrapped (about $long line of time-line height)"
+ # The most of the title on screen at any point of a scroll through the list. On a narrow
+ # widget its card can be taller than the list; the list then scrolls through it (list_walk
+ # above reached the items after it), and nothing is cut.
+ local most=0 here i
+ list_top
+ for i in $(seq 1 14); do
+ here=$(dump && python3 "$HERE/ui.py" lines "$OUT/ui.xml" "$LONG") || here=0
+ ((here > most)) && most=$here
+ ((most >= 2)) && break
+ list_swipe up || break
+ done
+ ((most >= 2)) || fail "$where: the 60-character title is not wrapped (at most $most line seen)"
n=$(python3 "$HERE/ui.py" cards "$OUT/ui.xml" -) || fail "$where: a card is cut: $n"
check "$where: the 60-character title wraps fully and is not cut"
list_top
diff --git a/.github/e2e/ui.py b/.github/e2e/ui.py
index 33a0961..ef61e7f 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -230,20 +230,19 @@ def top_shown(path, text):
def lines(path, text):
- """How many lines the card titled TEXT takes: its title's height over its time line's
- height (one line, smaller text), rounded down. Exit 1 when not found."""
- for card in nodes(path):
- if not _rid(card, "froyo_item"):
- continue
- title = next((n for n in card.iter("node") if _rid(n, "froyo_title")), None)
- time = next((n for n in card.iter("node") if _rid(n, "froyo_time")), None)
- if title is None or time is None or title.get("text", "") != text:
- continue
- tb, mb = box(title), box(time)
- if tb and mb and mb[3] > mb[1]:
- print(int((tb[3] - tb[1]) / (mb[3] - mb[1])))
- return 0
- return 1
+ """About how many lines of the title TEXT are on screen: its visible height over the
+ header title's height (same 14sp text, one line, always on screen). 0 when not shown."""
+ ref = next((box(n) for n in nodes(path) if _rid(n, "froyo_name")), None)
+ if not ref or ref[3] <= ref[1]:
+ return 1
+ for n in nodes(path):
+ if _rid(n, "froyo_title") and n.get("text", "") == text:
+ b = box(n)
+ if b:
+ print(round((b[3] - b[1]) / (ref[3] - ref[1])))
+ return 0
+ print(0)
+ return 0
def height(path, text):