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:
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.marcus.gtd.widget;
|
||||||
|
|
||||||
|
import com.reactnativeandroidwidget.RNWidgetProvider;
|
||||||
|
|
||||||
|
public class NextUp extends RNWidgetProvider {
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
21
app/android/app/src/main/res/xml/widgetprovider_nextup.xml
Normal file
21
app/android/app/src/main/res/xml/widgetprovider_nextup.xml
Normal 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>
|
||||||
18
app/app.json
18
app/app.json
@@ -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
10
app/index.js
Normal 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
4027
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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