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: <title>`. 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: <title>` to Froyo's chat, and Froyo marks it done. Undo after that sends `Undo done: <title>`. 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<UpcomingItem>): List<DayRow> = + 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<UpcomingItem> = emptyList() - @Volatile private var header = true + @Volatile private var rows: List<DayRow> = 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<UpcomingItem>, now: LocalDateTime, locale: Locale): List<DayRow> { + 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<DayRow>() + for ((day, list) in byDay) { + out.add(DayRow.Header(headerLabel(day, now, locale), today = day == today)) + val sorted = list.sortedWith( + compareBy<UpcomingItem>( + { 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_bill" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_chip_card" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_deadline" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_event" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_flight" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_late" /> + <corners android:radius="10dp" /> +</shape> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <solid android:color="@color/widget_tone_other" /> + <corners android:radius="10dp" /> +</shape> 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 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- "Froyo · Upcoming": a fixed header and a scrolling list of every open item - (rows: widget_froyo_item.xml). Sizes here are the ones core/Upcoming.fit counts with: - 6 dp padding top and bottom, a 22sp header, 28sp rows. Change both together. --> +<!-- "Froyo · Upcoming": a fixed header (on a narrow widget the count moves under the title, + so neither is cut), then one scrolling list of day headers + (widget_froyo_day.xml) and item cards (widget_froyo_item.xml), in the Google Calendar style. --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/froyo_root" android:layout_width="match_parent" @@ -15,8 +15,11 @@ <LinearLayout android:id="@+id/froyo_header" + android:baselineAligned="false" android:layout_width="match_parent" - android:layout_height="22sp" + android:layout_height="wrap_content" + android:minHeight="22sp" + android:layout_marginBottom="2dp" android:gravity="center_vertical" android:orientation="horizontal"> @@ -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" /> </LinearLayout> + <!-- On a narrow widget the count sits here, under the title (core/UpcomingDays.stackHeader). --> + <TextView + android:id="@+id/froyo_count_below" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="2dp" + android:textColor="@color/widget_muted" + android:textSize="12sp" + android:visibility="gone" /> + <TextView 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" /> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- A day header in the Froyo list: "Today · Fri 6 Nov". Color and bold are set from code. --> +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/froyo_day" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingStart="2dp" + android:paddingTop="6dp" + android:paddingBottom="4dp" + android:textColor="@color/widget_day_other" + android:textSize="12sp" /> 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 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- One Froyo item. The fixed 28sp height is core/Upcoming.ROW_SP: it grows with the font - scale, so a row's text is never cut, and the list can snap to whole rows. --> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/froyo_item" +<!-- One Froyo item: a rounded card in its type's color (set from code), full width. + The title wraps to as many lines as it needs: never cut or ellipsized. --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" - android:layout_height="28sp" - android:gravity="center_vertical" - android:orientation="horizontal"> + android:layout_height="wrap_content" + android:paddingBottom="5dp"> - <TextView - android:id="@+id/froyo_icon" - android:layout_width="wrap_content" + <LinearLayout + android:id="@+id/froyo_item" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:importantForAccessibility="no" - android:maxLines="1" - android:textColor="@color/widget_text" - android:textSize="13sp" /> + 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"> - <TextView - android:id="@+id/froyo_due" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="6dp" - android:maxLines="1" - android:textColor="@color/widget_muted" - android:textSize="12sp" - android:textStyle="bold" /> + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical"> - <TextView - android:id="@+id/froyo_title" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="6dp" - android:layout_weight="1" - android:ellipsize="end" - android:maxLines="1" - android:textColor="@color/widget_text" - android:textSize="13sp" /> + <TextView + android:id="@+id/froyo_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="#FFFFFF" + android:textSize="11sp" + android:textStyle="bold" /> - <TextView - android:id="@+id/froyo_row_count" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:maxLines="1" - android:textColor="@color/widget_muted" - android:textSize="12sp" - android:visibility="gone" /> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> - <TextView - android:id="@+id/froyo_done" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="6dp" - android:background="@drawable/widget_chip_bg" - android:maxLines="1" - android:paddingStart="8dp" - android:paddingTop="2dp" - android:paddingEnd="8dp" - android:paddingBottom="2dp" - android:text="@string/froyo_done" - android:textColor="@color/widget_text" - android:textSize="12sp" /> -</LinearLayout> + <TextView + android:id="@+id/froyo_icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="6dp" + android:importantForAccessibility="no" + android:textColor="#FFFFFF" + android:textSize="14sp" /> + + <TextView + android:id="@+id/froyo_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:textColor="#FFFFFF" + android:textSize="14sp" /> + </LinearLayout> + </LinearLayout> + + <TextView + android:id="@+id/froyo_done" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:background="@drawable/widget_card_chip" + android:maxLines="1" + android:paddingStart="10dp" + android:paddingTop="3dp" + android:paddingEnd="10dp" + android:paddingBottom="3dp" + android:text="@string/froyo_done" + android:textColor="@color/widget_tone_event" + android:textSize="12sp" + android:textStyle="bold" /> + </LinearLayout> +</FrameLayout> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Froyo widget card fills, white text on each at 4.5:1 or better (core/Tone, UpcomingDaysTest). --> + <color name="widget_tone_late">#C03027</color> + <color name="widget_tone_event">#1D69CC</color> + <color name="widget_tone_flight">#802698</color> + <color name="widget_tone_deadline">#A25607</color> + <color name="widget_tone_bill">#1C7437</color> + <color name="widget_tone_other">#07776C</color> + <color name="widget_day_today">#8AB4F8</color> + <color name="widget_day_other">#BDC1C6</color> + <color name="widget_chip_card">#FFFFFF</color> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Froyo widget card fills, white text on each at 4.5:1 or better (core/Tone, UpcomingDaysTest). --> + <color name="widget_tone_late">#D93025</color> + <color name="widget_tone_event">#1A73E8</color> + <color name="widget_tone_flight">#8E24AA</color> + <color name="widget_tone_deadline">#B65D00</color> + <color name="widget_tone_bill">#188038</color> + <color name="widget_tone_other">#008476</color> + <!-- Day headers: today in Google blue, others grey. --> + <color name="widget_day_today">#1A73E8</color> + <color name="widget_day_other">#5F6368</color> + <color name="widget_chip_card">#FFFFFF</color> +</resources> 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 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- 4x2 by default, resizable from 4x1 to 4x4. --> +<!-- 4x2 by default, resizable from 4x2 to 4x4; the list scrolls inside. --> <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" android:description="@string/froyo_widget_description" android:initialLayout="@layout/widget_froyo" android:minWidth="250dp" android:minHeight="110dp" android:minResizeWidth="180dp" - android:minResizeHeight="40dp" + android:minResizeHeight="110dp" android:previewLayout="@layout/widget_froyo" android:resizeMode="horizontal|vertical" android:targetCellWidth="4" 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 new file mode 100644 index 0000000..ef7f426 --- /dev/null +++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingDaysTest.kt @@ -0,0 +1,203 @@ +package io.github.rayvader.kirocrew.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Locale + +class UpcomingDaysTest { + + private val zone = ZoneId.of("Asia/Singapore") + private val uk = Locale.UK + + /** Friday 6 Nov 2026, 09:00. */ + private val now = LocalDateTime.of(2026, 11, 6, 9, 0) + + private fun item(title: String, due: String?, kind: String = "booking"): UpcomingItem { + val (at, hasTime) = if (due == null) null to false else Upcoming.parseDue(due, zone) + return UpcomingItem(title, kind, at, hasTime) + } + + private fun labels(rows: List<DayRow>) = 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<DayRow.Header>() + 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<DayRow.Card>().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<String, Int> { + val f = listOf(File(path), File("app/$path")).first { it.exists() } + return Regex("""<color name="widget_tone_(\w+)">#([0-9A-Fa-f]{6})</color>""") + .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".