Android home-screen widget: top-3 next actions (4x2 'Next up' card) — tap the priority ring to complete via the offline queue, rows deep-link to Clarify, + opens capture; redraws on every store change plus a 30-min self-refresh
Some checks failed
Build & Release APK / build (push) Failing after 24s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:06:45 -07:00
parent 4ef3f8c63c
commit d54d5b32f1
14 changed files with 4248 additions and 3 deletions

View File

@@ -16,6 +16,7 @@
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/> <meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@@ -28,5 +29,12 @@
<data android:scheme="gtd"/> <data android:scheme="gtd"/>
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name=".widget.NextUp" android:exported="false" android:label="GTD — Next up">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.marcus.gtd.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nextup"/>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,6 @@
package com.marcus.gtd.widget;
import com.reactnativeandroidwidget.RNWidgetProvider;
public class NextUp extends RNWidgetProvider {
}

View File

@@ -2,4 +2,5 @@
<string name="app_name">GTD</string> <string name="app_name">GTD</string>
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string> <string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="widget_nextup_description" translatable="false">Your top three next actions</string>
</resources> </resources>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widget_nextup_description"
android:initialLayout="@layout/rn_widget"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

@@ -26,6 +26,24 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
[
"react-native-android-widget",
{
"widgets": [
{
"name": "NextUp",
"label": "GTD — Next up",
"description": "Your top three next actions",
"minWidth": "250dp",
"minHeight": "110dp",
"targetCellWidth": 4,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"updatePeriodMillis": 1800000
}
]
}
],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {

10
app/index.js Normal file
View File

@@ -0,0 +1,10 @@
// Custom entry: register the widget's headless task handler before the app
// starts, so widget events work even when the app UI never mounts.
import "expo-router/entry";
import { Platform } from "react-native";
import { registerWidgetTaskHandler } from "react-native-android-widget";
import { widgetTaskHandler } from "./src/widget/widget-task-handler";
if (Platform.OS === "android") {
registerWidgetTaskHandler(widgetTaskHandler);
}

4027
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "app", "name": "app",
"main": "expo-router/entry", "main": "index.js",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/ui": "~57.0.9", "@expo/ui": "~57.0.9",
@@ -24,6 +24,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-android-widget": "^0.22.1",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",

View File

@@ -4,8 +4,9 @@ import { StatusBar } from "expo-status-bar";
import { useEffect } from "react"; import { useEffect } from "react";
import { AppState } from "react-native"; import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, sync } from "../lib/store"; import { getState, hydrate, subscribe, sync } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() { export default function RootLayout() {
useEffect(() => { useEffect(() => {
@@ -18,6 +19,8 @@ export default function RootLayout() {
const net = NetInfo.addEventListener((s) => { const net = NetInfo.addEventListener((s) => {
if (s.isConnected) void sync(); if (s.isConnected) void sync();
}); });
// Keep the home-screen widget in step with every mutation and sync.
const widget = subscribe(refreshNextUpWidget);
// Slow heartbeat as a belt-and-braces retry while changes are queued. // Slow heartbeat as a belt-and-braces retry while changes are queued.
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
if (getState().pending > 0 || getState().offline) void sync(); if (getState().pending > 0 || getState().offline) void sync();
@@ -25,6 +28,7 @@ export default function RootLayout() {
return () => { return () => {
appState.remove(); appState.remove();
net(); net();
widget();
clearInterval(heartbeat); clearInterval(heartbeat);
}; };
}, []); }, []);

View File

@@ -9,7 +9,7 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
4: "Low", 4: "Low",
}; };
export const PRIORITY_COLOR: Record<Task["priority"], string> = { export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
1: "#E05B5B", 1: "#E05B5B",
2: "#E0A93E", 2: "#E0A93E",
3: "#5B8DEF", 3: "#5B8DEF",

View File

@@ -0,0 +1,98 @@
"use no memo"; // widget trees are built by calling the component as a plain function — the React Compiler must not transform it
// Home-screen widget: the same top-three "Next up" list as the home screen.
// Built only from react-native-android-widget primitives (no RN views, no
// hooks) — it renders to Android RemoteViews in a headless task.
//
// Interactions mirror the app: tap the circle to complete, tap the row to
// open Clarify, tap + to capture, tap anywhere else to open the app.
import { FlexWidget, TextWidget } from "react-native-android-widget";
import { dueMeta, type DueTone } from "../lib/dates";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { C } from "../lib/theme";
import type { Task } from "../lib/types";
const DUE_COLOR: Record<DueTone, `#${string}`> = { overdue: C.danger, today: C.warn, later: C.muted };
function Row({ task }: { task: Task }) {
const due = task.due_date ? dueMeta(task.due_date) : null;
// One line per row so three rows fit the 2-cell height: right-side meta is
// the due label when there is one, otherwise context/estimate.
const side = due ? due.label : [task.context, formatEstimate(task.estimate_min)].filter(Boolean).join(" · ");
return (
<FlexWidget
style={{ width: "match_parent", flex: 1, flexDirection: "row", alignItems: "center" }}
clickAction="OPEN_URI"
clickActionData={{ uri: `gtd://task/${task.id}` }}
>
<FlexWidget
clickAction="COMPLETE_TASK"
clickActionData={{ taskId: task.id }}
accessibilityLabel={`Complete ${task.title}`}
style={{ width: 32, height: 32, alignItems: "center", justifyContent: "center" }}
>
<FlexWidget
style={{ width: 19, height: 19, borderRadius: 10, borderWidth: 2, borderColor: PRIORITY_COLOR[task.priority] }}
/>
</FlexWidget>
<FlexWidget style={{ flex: 1, marginLeft: 6 }}>
<TextWidget text={task.title} truncate="END" maxLines={1} style={{ fontSize: 15, color: C.text }} />
</FlexWidget>
{!!side && <TextWidget text={side} truncate="END" maxLines={1} style={{ fontSize: 11, color: due ? DUE_COLOR[due.tone] : C.muted, marginLeft: 8 }} />}
</FlexWidget>
);
}
export function NextUpWidget({ tasks, totalNext, overdueCount }: { tasks: Task[]; totalNext: number; overdueCount: number }) {
return (
<FlexWidget
clickAction="OPEN_APP"
style={{
width: "match_parent",
height: "match_parent",
flexDirection: "column",
backgroundColor: C.surface,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
<FlexWidget
style={{ width: "match_parent", flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}
>
<FlexWidget style={{ flexDirection: "row", alignItems: "center" }}>
<TextWidget text="NEXT UP" style={{ fontSize: 12, letterSpacing: 1, color: C.muted }} />
{totalNext > tasks.length && (
<TextWidget text={` +${totalNext - tasks.length} more`} style={{ fontSize: 12, color: C.muted }} />
)}
{overdueCount > 0 && (
<TextWidget
text={` · ${overdueCount} overdue`}
clickAction="OPEN_URI"
clickActionData={{ uri: "gtd://today" }}
style={{ fontSize: 12, color: C.danger }}
/>
)}
</FlexWidget>
<FlexWidget
clickAction="OPEN_URI"
clickActionData={{ uri: "gtd://add" }}
accessibilityLabel="Add task"
style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: C.accent, alignItems: "center", justifyContent: "center" }}
>
<TextWidget text="+" style={{ fontSize: 16, color: "#FFFFFF" }} />
</FlexWidget>
</FlexWidget>
{tasks.length === 0 ? (
<FlexWidget style={{ width: "match_parent", flex: 1, alignItems: "center", justifyContent: "center" }}>
<TextWidget text="Nothing next. Tap + to add." style={{ fontSize: 14, color: C.muted }} />
</FlexWidget>
) : (
<FlexWidget style={{ width: "match_parent", flex: 1, flexDirection: "column" }}>
{tasks.map((t) => (
<Row key={t.id} task={t} />
))}
</FlexWidget>
)}
</FlexWidget>
);
}

18
app/src/widget/refresh.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Platform } from "react-native";
import { requestWidgetUpdate } from "react-native-android-widget";
import { renderNextUpWidget, WIDGET_NAME } from "./render";
let timer: ReturnType<typeof setTimeout> | undefined;
/**
* Redraw the home-screen widget from current store state. Debounced because
* it's driven by the store listener, which fires on every mutation and sync
* step; drawing RemoteViews once per burst is plenty.
*/
export function refreshNextUpWidget() {
if (Platform.OS !== "android") return;
clearTimeout(timer);
timer = setTimeout(() => {
void requestWidgetUpdate({ widgetName: WIDGET_NAME, renderWidget: renderNextUpWidget });
}, 300);
}

14
app/src/widget/render.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use no memo";
// One source of truth for what the widget shows: the same selectors the home
// screen uses, so widget and app always agree on the top three.
import { getState, todayTasks, visibleTasksByStatus } from "../lib/store";
import { NextUpWidget } from "./NextUpWidget";
/** Must match the widget `name` in app.json's react-native-android-widget plugin config. */
export const WIDGET_NAME = "NextUp";
export function renderNextUpWidget() {
const s = getState();
const next = visibleTasksByStatus(s, "next");
return <NextUpWidget tasks={next.slice(0, 3)} totalNext={next.length} overdueCount={todayTasks(s).overdue.length} />;
}

View File

@@ -0,0 +1,19 @@
// Headless entry point for widget events. When the app's JS context is
// alive this shares the in-memory store; when launched cold, hydrate() loads
// AsyncStorage — so completing from the widget goes through the exact same
// local-first mutation path (instant local write, op queued, synced later).
import type { WidgetTaskHandlerProps } from "react-native-android-widget";
import { hydrate, updateTask } from "../lib/store";
import { renderNextUpWidget } from "./render";
export async function widgetTaskHandler(props: WidgetTaskHandlerProps) {
if (props.widgetAction === "WIDGET_DELETED") return;
await hydrate();
if (props.widgetAction === "WIDGET_CLICK") {
// OPEN_APP / OPEN_URI are handled natively; only completion reaches JS.
if (props.clickAction !== "COMPLETE_TASK") return;
const taskId = props.clickActionData?.taskId;
if (typeof taskId === "string") updateTask(taskId, { status: "done" });
}
props.renderWidget(renderNextUpWidget());
}