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
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:
@@ -4,8 +4,9 @@ import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
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 { refreshNextUpWidget } from "../widget/refresh";
|
||||
|
||||
export default function RootLayout() {
|
||||
useEffect(() => {
|
||||
@@ -18,6 +19,8 @@ export default function RootLayout() {
|
||||
const net = NetInfo.addEventListener((s) => {
|
||||
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.
|
||||
const heartbeat = setInterval(() => {
|
||||
if (getState().pending > 0 || getState().offline) void sync();
|
||||
@@ -25,6 +28,7 @@ export default function RootLayout() {
|
||||
return () => {
|
||||
appState.remove();
|
||||
net();
|
||||
widget();
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
|
||||
4: "Low",
|
||||
};
|
||||
|
||||
export const PRIORITY_COLOR: Record<Task["priority"], string> = {
|
||||
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
|
||||
1: "#E05B5B",
|
||||
2: "#E0A93E",
|
||||
3: "#5B8DEF",
|
||||
|
||||
98
app/src/widget/NextUpWidget.tsx
Normal file
98
app/src/widget/NextUpWidget.tsx
Normal 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
18
app/src/widget/refresh.ts
Normal 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
14
app/src/widget/render.tsx
Normal 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} />;
|
||||
}
|
||||
19
app/src/widget/widget-task-handler.tsx
Normal file
19
app/src/widget/widget-task-handler.tsx
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user