diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py index 2f0d1d9..e5c3f37 100644 --- a/.github/e2e/gateway.py +++ b/.github/e2e/gateway.py @@ -142,6 +142,8 @@ def __init__(self, name): # Froyo's open items and the chat messages sent to its thread. self.froyo = froyo_items() self.chats = [] + # True while a test wants Froyo's dashboard read to fail (the widget keeps its list). + self.froyo_down = False def now_ts(self): # Strictly increasing timestamps, so two notes pushed back to back never tie. @@ -297,11 +299,14 @@ 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) or empty. + # mode=reset (the made-up list), many (8 items, more than fit), empty, + # or down (reads fail, the list is kept; any other mode brings it back up). + mode = q.get("mode") with state.lock: - mode = q.get("mode") - state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many") - state.chats = [] + state.froyo_down = mode == "down" + if mode != "down": + state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many") + state.chats = [] return self.send(200, "ok") if p == "/test/chats": with state.lock: @@ -312,7 +317,9 @@ def do_GET(self): if q.get("member") != FROYO_MEMBER: return self.send(400, json.dumps({"code": "missing_member"}), "application/json") with state.lock: - items = list(state.froyo) + items, down = list(state.froyo), state.froyo_down + if down: + return self.send(503, json.dumps({"error": "down"}), "application/json") return self.send(200, froyo_dashboard(items), "application/json") if p == "/api/update/check": if not self.signed_in(): diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh index 718197f..91426d2 100755 --- a/.github/e2e/run.sh +++ b/.github/e2e/run.sh @@ -1073,6 +1073,40 @@ f_froyo_widget() { widget_home wait_text "7 open" 30 check "the count goes down to 7" + + # The refresh button reads the list now; the header says when. + curl -s "$GW/test/froyo?mode=reset" >/dev/null + widget_home + tap_now "Refresh" + wait_text "5 open · Updated" 30 + local now shown + now=$(adb shell date +%H:%M | tr -d '\r') + dump + shown=$(grep -oE 'Updated [0-9]{2}:[0-9]{2}' "$OUT/ui.xml" | head -1 | cut -d' ' -f2) + [[ -n $shown ]] || fail "no 'Updated HH:mm' (24 h) in the header" + python3 - "$shown" "$now" <<'PY' || fail "Updated $shown is not the phone's time now ($now)" +import sys +m = lambda t: int(t[:2]) * 60 + int(t[3:]) +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" + 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". + curl -s "$GW/test/froyo?mode=down" >/dev/null + tap_now "Refresh" + wait_text "5 open · as of" 30 + has_text "E2E test parcel" || fail "a failed read hid the rows" + ! has_text "Updated" || fail "a failed read still says Updated" + check "a failed refresh keeps the rows and says 'as of HH:mm'" + curl -s "$GW/test/froyo?mode=reset" >/dev/null + tap_now "Refresh" + wait_text "5 open · Updated" 30 + check "the next good read says Updated again" # Ends on the home screen for the screenshot. } diff --git a/README.md b/README.md index 0320646..2d7c059 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ A home-screen widget, **Froyo · Upcoming**, lists the open items of the crewmat - 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. -- It refreshes about every 30 minutes, when you open the app, and after a Done. Nothing open shows **Nothing coming up**. When the server cannot be reached, the last list stays, with **as of HH:mm**. +- 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 the server cannot be reached, the last list stays, with **as of HH:mm**. - 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. 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` (the `open_items` field of Froyo's dashboard), 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 a58c083..fff8edd 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt @@ -28,12 +28,12 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import io.github.rayvader.kirocrew.core.DueState import io.github.rayvader.kirocrew.core.Profile +import io.github.rayvader.kirocrew.core.SyncNote import io.github.rayvader.kirocrew.core.Upcoming import io.github.rayvader.kirocrew.core.UpcomingItem import java.io.IOException import java.time.LocalDateTime import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import java.util.concurrent.TimeUnit @@ -52,6 +52,14 @@ class FroyoWidgetProvider : AppWidgetProvider() { override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) { FroyoWidgets.render(context) } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == FroyoWidgets.ACTION_REFRESH) { + FroyoWidgets.refreshNow(context) + return + } + super.onReceive(context, intent) + } } /** The widget's saved list, what it shows, and when it refreshes. Android calls only at run time. */ @@ -61,6 +69,10 @@ object FroyoWidgets { private const val SP_ITEMS = "froyo_items" private const val SP_ITEMS_AT = "froyo_items_at" private const val SP_ERROR = "froyo_error" + private const val SP_REFRESHING_AT = "froyo_refreshing_at" + + /** The header's refresh button. Only this app can send it: the button's intent is explicit. */ + const val ACTION_REFRESH = "io.github.rayvader.kirocrew.FROYO_REFRESH" private const val SP_HIDDEN = "froyo_hidden" private const val SP_SLOT = "froyo_slot" private const val SP_MEMBER = "froyo_member" @@ -91,6 +103,21 @@ object FroyoWidgets { WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request) } + /** The refresh button: show "Updating…" at once, then read the list now. */ + fun refreshNow(ctx: Context) { + sp(ctx).edit { putLong(SP_REFRESHING_AT, System.currentTimeMillis()) } + render(ctx) + // No network wait: offline, the read fails at once and the header says "as of HH:mm" + // instead of spinning until the network comes back. + val request = OneTimeWorkRequestBuilder<FroyoRefreshWorker>().build() + WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request) + } + + /** A read finished, good or not: stop showing "Updating…". */ + fun refreshDone(ctx: Context) { + sp(ctx).edit { remove(SP_REFRESHING_AT) } + } + /** From the app's onResume: only when a widget is on the home screen. */ fun refreshIfShown(ctx: Context) { if (hasWidgets(ctx)) refresh(ctx) @@ -228,15 +255,23 @@ object FroyoWidgets { val profile = profile(ctx) v.setOnClickPendingIntent(R.id.froyo_root, openApp(ctx, profile, Upcoming.chatPagePath(slotKey(ctx)), 0)) - // Header: name and count, plus "as of HH:mm" when the last read failed. - var count = ctx.getString(R.string.froyo_open, items.size) - if (error != Error.NONE && loaded && at > 0) { - val time = java.time.Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)) - count = ctx.getString(R.string.froyo_as_of, count, time) + // Header: name and count, then "Updated HH:mm" after a good read, "as of HH:mm" after a + // failed one, or "Updating…" while the refresh button's read runs. It shares the header + // line, so no row is given up for it. + val open = ctx.getString(R.string.froyo_open, items.size) + val refreshing = Upcoming.isRefreshing(sp(ctx).getLong(SP_REFRESHING_AT, 0L), System.currentTimeMillis()) + val line = Upcoming.syncLine(refreshing, error != Error.NONE, loaded, at, ZoneId.systemDefault()) + val count = when (line.note) { + SyncNote.UPDATED -> ctx.getString(R.string.froyo_updated, open, line.time) + SyncNote.AS_OF -> ctx.getString(R.string.froyo_as_of, open, line.time) + SyncNote.UPDATING -> ctx.getString(R.string.froyo_updating_count, open) + SyncNote.NONE -> open } v.setViewVisibility(R.id.froyo_header, if (fit.header) View.VISIBLE else View.GONE) v.setTextViewText(R.id.froyo_count, count) + v.setViewVisibility(R.id.froyo_refresh, if (refreshing) View.GONE else View.VISIBLE) + v.setViewVisibility(R.id.froyo_spinner, if (refreshing) View.VISIBLE else View.GONE) + v.setOnClickPendingIntent(R.id.froyo_refresh, refreshIntent(ctx)) val message = when { loaded && items.isEmpty() -> ctx.getString(R.string.froyo_empty) @@ -336,6 +371,11 @@ object FroyoWidgets { private const val PI_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + private fun refreshIntent(ctx: Context): PendingIntent { + val intent = Intent(ctx, FroyoWidgetProvider::class.java).setAction(ACTION_REFRESH) + return PendingIntent.getBroadcast(ctx, 7200, intent, PI_FLAGS) + } + /** Settings > Add Froyo widget: asks the launcher to place it. False when the launcher cannot. */ fun requestPin(ctx: Context): Boolean { val manager = AppWidgetManager.getInstance(ctx) @@ -347,8 +387,12 @@ object FroyoWidgets { /** Reads Froyo's list and redraws the widgets. */ class FroyoRefreshWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { override fun doWork(): Result { - FroyoWidgets.fetch(applicationContext) - FroyoWidgets.render(applicationContext) + try { + FroyoWidgets.fetch(applicationContext) + } finally { + FroyoWidgets.refreshDone(applicationContext) + FroyoWidgets.render(applicationContext) + } return Result.success() } } 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 617a7f1..9289667 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 @@ -34,6 +34,12 @@ enum class DueState { NONE, LATER, SOON, LATE } */ 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 } + +/** [note] plus the 24 h "HH:mm" it names (empty for [SyncNote.NONE] and [SyncNote.UPDATING]). */ +data class SyncLine(val note: SyncNote, val time: String) + /** Froyo's pinned chat: the slot to send to and the member name the dashboard read needs. */ data class FroyoThread(val slotKey: String, val member: String) @@ -236,6 +242,26 @@ object Upcoming { 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 + + /** True while a refresh the person started is still running: started, and not older than [REFRESH_SPIN_MS]. */ + fun isRefreshing(startedMs: Long, nowMs: Long): Boolean = + startedMs > 0 && nowMs >= startedMs && nowMs - startedMs < REFRESH_SPIN_MS + + /** + * The header's sync note. While a refresh runs: "Updating…". After a good read: "Updated HH:mm". + * After a failed read with a saved list: "as of HH:mm", the time of the list still shown. + * Nothing when no list was ever read. [atMs] is when the shown list was read; the time is + * 24 h in [zone]. + */ + fun syncLine(refreshing: Boolean, failed: Boolean, loaded: Boolean, atMs: Long, zone: ZoneId): SyncLine { + if (refreshing) return SyncLine(SyncNote.UPDATING, "") + if (!loaded || atMs <= 0) return SyncLine(SyncNote.NONE, "") + val time = java.time.Instant.ofEpochMilli(atMs).atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)) + return SyncLine(if (failed) SyncNote.AS_OF else SyncNote.UPDATED, time) + } + /** * Items marked done on the phone stay hidden until Froyo's list drops them, or for * [HIDE_MS] if it never does (then the row comes back, which shows the done did not land). diff --git a/app/src/main/res/layout/widget_froyo.xml b/app/src/main/res/layout/widget_froyo.xml index 8add465..6738110 100644 --- a/app/src/main/res/layout/widget_froyo.xml +++ b/app/src/main/res/layout/widget_froyo.xml @@ -40,6 +40,29 @@ android:maxLines="1" android:textColor="@color/widget_muted" android:textSize="12sp" /> + + <!-- Refresh now. A spinner takes its place while the read runs. --> + <TextView + android:id="@+id/froyo_refresh" + android:layout_width="22sp" + android:layout_height="22sp" + android:layout_marginStart="4dp" + android:contentDescription="@string/froyo_refresh" + android:gravity="center" + android:text="⟳" + android:textColor="@color/widget_text" + android:textSize="15sp" /> + + <ProgressBar + android:id="@+id/froyo_spinner" + style="?android:attr/progressBarStyleSmall" + android:layout_width="16sp" + android:layout_height="16sp" + android:layout_marginStart="7dp" + android:layout_marginEnd="3dp" + android:contentDescription="@string/froyo_updating" + android:indeterminate="true" + android:visibility="gone" /> </LinearLayout> <TextView diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d60d3a6..ffb6fb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,6 +184,10 @@ The app saves the server and signs you in.<br><br> <string name="froyo_widget_description">Froyo\'s open items, soonest first. Tap Done to mark one done.</string> <string name="froyo_open">%1$d open</string> <string name="froyo_as_of">%1$s · as of %2$s</string> + <string name="froyo_updated">%1$s · Updated %2$s</string> + <string name="froyo_updating_count">%1$s · Updating…</string> + <string name="froyo_updating">Updating…</string> + <string name="froyo_refresh">Refresh</string> <string name="froyo_empty">Nothing coming up</string> <string name="froyo_loading">Loading…</string> <string name="froyo_no_server">Add a server in the app first</string> 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 0048b7a..599dd87 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 @@ -105,6 +105,55 @@ class UpcomingTest { assertEquals("•", Upcoming.icon("something-new")) } + @Test + fun aGoodReadSaysUpdatedInLocal24HourTime() { + val at = java.time.ZonedDateTime.of(2026, 11, 5, 14, 7, 0, 0, zone).toInstant().toEpochMilli() + assertEquals(SyncLine(SyncNote.UPDATED, "14:07"), Upcoming.syncLine(false, false, true, at, zone)) + // Another zone, same instant: local time of that zone. + assertEquals(SyncLine(SyncNote.UPDATED, "06:07"), Upcoming.syncLine(false, false, true, at, ZoneId.of("UTC"))) + val night = java.time.ZonedDateTime.of(2026, 11, 5, 23, 5, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("23:05", Upcoming.syncLine(false, false, true, night, zone).time) + } + + @Test + fun aFailedReadKeepsTheListAndSaysAsOf() { + val at = java.time.ZonedDateTime.of(2026, 11, 5, 9, 30, 0, 0, zone).toInstant().toEpochMilli() + assertEquals(SyncLine(SyncNote.AS_OF, "09:30"), Upcoming.syncLine(false, true, true, at, zone)) + } + + @Test + fun nothingReadYetSaysNothing() { + assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, false, 0L, zone).note) + assertEquals(SyncNote.NONE, Upcoming.syncLine(false, true, false, 0L, zone).note) + assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, true, 0L, zone).note) + } + + @Test + fun aRunningRefreshSaysUpdating() { + assertEquals(SyncLine(SyncNote.UPDATING, ""), Upcoming.syncLine(true, false, true, 1_000L, zone)) + assertEquals(SyncNote.UPDATING, Upcoming.syncLine(true, true, false, 0L, zone).note) + } + + @Test + fun refreshSpinnerStopsWhenDoneOrStale() { + val t = 10_000_000L + assertTrue(Upcoming.isRefreshing(t, t)) + assertTrue(Upcoming.isRefreshing(t, t + 30_000)) + // A run that never reported back stops spinning after two minutes. + assertFalse(Upcoming.isRefreshing(t, t + Upcoming.REFRESH_SPIN_MS)) + // Cleared (0) or a clock that went back: not refreshing. + assertFalse(Upcoming.isRefreshing(0L, t)) + 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.