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..b7129aa 100755
--- a/.github/e2e/run.sh
+++ b/.github/e2e/run.sh
@@ -936,6 +936,103 @@ 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 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
+ 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 || 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"; }
+# 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
+ list_top
+ 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.
+list_walk() {
+ local i n view last=""
+ list_top
+ : > "$OUT/list.txt"
+ 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; }
+ # 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
+ [[ $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" || true; } | 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 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))")
+ 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"
+ # 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
+}
+
# 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 +1072,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 +1192,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 +1219,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..ef61e7f 100644
--- a/.github/e2e/ui.py
+++ b/.github/e2e/ui.py
@@ -11,6 +11,12 @@
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
+ 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
@@ -130,6 +136,126 @@ 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 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 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 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):
+ """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):
+ """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 +288,6 @@ 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, "shown": shown,
+ "lines": lines, "top_shown": top_shown}[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..5344609 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,8 +332,14 @@ 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)
+ // 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))
@@ -345,47 +356,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 +435,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 +597,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..e613167
--- /dev/null
+++ b/app/src/main/java/io/github/rayvader/kirocrew/core/UpcomingDays.kt
@@ -0,0 +1,155 @@
+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,
+ )
+
+ /**
+ * 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)
+ 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..4e3f35a 100644
--- a/app/src/main/res/layout/widget_froyo.xml
+++ b/app/src/main/res/layout/widget_froyo.xml
@@ -1,7 +1,7 @@
-
+
@@ -25,8 +28,6 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:ellipsize="end"
- android:maxLines="1"
android:text="@string/froyo_widget_title"
android:textColor="@color/widget_text"
android:textSize="14sp"
@@ -37,7 +38,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
- android:maxLines="1"
+ android:gravity="end"
android:textColor="@color/widget_muted"
android:textSize="12sp" />
@@ -65,12 +66,20 @@
android:visibility="gone" />
+
+
+
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 aNarrowWidgetPutsTheCountUnderTheTitle() {
+ // Phone 4x2 (~348 dp) at 115 % text (348/1.15 = 302 >= 290): one line.
+ assertFalse(UpcomingDays.stackHeader(348f, 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.
+ assertTrue(UpcomingDays.stackHeader(320f, 1.3f))
+ assertFalse(UpcomingDays.stackHeader(0f, 1f))
+ }
+
+ @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".