feat: add Android widget for task overview (3 sizes)

Adds home screen widgets (Small 2×2, Medium 4×2, Large 4×4) using
react-native-android-widget. Widgets display upcoming tasks sorted by
urgency, support tap-to-complete and deep linking into the app, and
refresh on every task mutation + every 30 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 09:47:15 -05:00
parent 3558171bb9
commit 9c6d2dfef9
12 changed files with 645 additions and 2 deletions

View file

@ -30,7 +30,47 @@
"expo-sqlite",
"expo-font",
"expo-localization",
"@react-native-community/datetimepicker"
"@react-native-community/datetimepicker",
["react-native-android-widget", {
"fonts": [
"./assets/fonts/Inter_400Regular.ttf",
"./assets/fonts/Inter_600SemiBold.ttf"
],
"widgets": [
{
"name": "SimplListeSmall",
"label": "Simpl-Liste",
"description": "Aperçu rapide de vos tâches",
"minWidth": "110dp",
"minHeight": "110dp",
"targetCellWidth": 2,
"targetCellHeight": 2,
"updatePeriodMillis": 1800000
},
{
"name": "SimplListeMedium",
"label": "Simpl-Liste",
"description": "Vos prochaines tâches",
"minWidth": "250dp",
"minHeight": "110dp",
"targetCellWidth": 4,
"targetCellHeight": 2,
"resizeMode": "vertical",
"updatePeriodMillis": 1800000
},
{
"name": "SimplListeLarge",
"label": "Simpl-Liste (grand)",
"description": "Vue détaillée de vos tâches",
"minWidth": "250dp",
"minHeight": "250dp",
"targetCellWidth": 4,
"targetCellHeight": 4,
"resizeMode": "horizontal|vertical",
"updatePeriodMillis": 1800000
}
]
}]
],
"experiments": {
"typedRoutes": true

Binary file not shown.

Binary file not shown.

6
index.js Normal file
View file

@ -0,0 +1,6 @@
import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { widgetTaskHandler } from './src/widgets/widgetTaskHandler';
registerWidgetTaskHandler(widgetTaskHandler);
import 'expo-router/entry';

17
package-lock.json generated
View file

@ -40,6 +40,7 @@
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
@ -10056,6 +10057,22 @@
}
}
},
"node_modules/react-native-android-widget": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/react-native-android-widget/-/react-native-android-widget-0.20.1.tgz",
"integrity": "sha512-m3akQCyoG6XUH8OLHOyL3/Xxx/XXTXf9ZyFYmWvSQrv1YUaZ7kVjmeLIYnaNz+qQ9VrTAS9OygCxZQptqCGzjQ==",
"license": "MIT",
"peerDependencies": {
"expo": ">=54.0.0",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/react-native-css-interop": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.2.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "simpl-liste",
"main": "expo-router/entry",
"main": "index.js",
"version": "1.0.0",
"scripts": {
"start": "expo start",
@ -41,6 +41,7 @@
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-android-widget": "^0.20.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",

View file

@ -8,6 +8,7 @@ import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/st
import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notifications';
import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { syncWidgetData } from '@/src/services/widgetSync';
export interface TaskFilters {
sortBy?: SortBy;
@ -169,6 +170,8 @@ export async function createTask(data: {
}
}
syncWidgetData().catch(() => {});
return id;
}
@ -221,6 +224,8 @@ export async function updateTask(
// Ignore calendar errors
}
}
syncWidgetData().catch(() => {});
}
export async function toggleComplete(id: string) {
@ -262,6 +267,8 @@ export async function reorderTasks(updates: { id: string; position: number }[])
await tx.update(tasks).set({ position, updatedAt: new Date() }).where(eq(tasks.id, id));
}
});
syncWidgetData().catch(() => {});
}
export async function deleteTask(id: string) {
@ -291,4 +298,6 @@ export async function deleteTask(id: string) {
}
await db.delete(taskTags).where(eq(taskTags.taskId, id));
await db.delete(tasks).where(eq(tasks.id, id));
syncWidgetData().catch(() => {});
}

View file

@ -123,5 +123,15 @@
"empty": {
"inbox": "No tasks yet.\nTap + to get started.",
"list": "This list is empty."
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} task",
"taskCount_other": "{{count}} tasks",
"noTasks": "No upcoming tasks",
"overdue": "Overdue",
"today": "Today",
"tomorrow": "Tomorrow",
"noDate": "No date"
}
}

View file

@ -123,5 +123,15 @@
"empty": {
"inbox": "Aucune tâche.\nAppuyez sur + pour commencer.",
"list": "Cette liste est vide."
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} tâche",
"taskCount_other": "{{count}} tâches",
"noTasks": "Aucune tâche à venir",
"overdue": "En retard",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"noDate": "Sans date"
}
}

112
src/services/widgetSync.ts Normal file
View file

@ -0,0 +1,112 @@
import { Platform } from 'react-native';
import { requestWidgetUpdate } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { db } from '../db/client';
import { tasks } from '../db/schema';
import { eq, and, isNull, gte, lte, lt, asc } from 'drizzle-orm';
import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_DATA_KEY = 'widget:tasks';
export interface WidgetTask {
id: string;
title: string;
priority: number;
dueDate: string | null;
completed: boolean;
}
export async function syncWidgetData(): Promise<void> {
if (Platform.OS !== 'android') return;
try {
const now = new Date();
const todayStart = startOfDay(now);
const twoWeeksEnd = endOfDay(addWeeks(now, 2));
// Fetch tasks with due date in the next 2 weeks
const upcomingTasks = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.completed, false),
isNull(tasks.parentId),
gte(tasks.dueDate, todayStart),
lte(tasks.dueDate, twoWeeksEnd)
)
)
.orderBy(asc(tasks.dueDate));
// Fetch overdue tasks
const overdueTasks = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.completed, false),
isNull(tasks.parentId),
lt(tasks.dueDate, todayStart)
)
)
.orderBy(asc(tasks.dueDate));
// Fetch tasks without a due date
const noDateTasks = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.completed, false),
isNull(tasks.parentId),
isNull(tasks.dueDate)
)
)
.orderBy(asc(tasks.position));
// Combine: overdue first, then upcoming, then no date
const allTasks: WidgetTask[] = [
...overdueTasks.map((t) => ({
id: t.id,
title: t.title,
priority: t.priority,
dueDate: t.dueDate ? new Date(t.dueDate).toISOString() : null,
completed: t.completed,
})),
...upcomingTasks.map((t) => ({
id: t.id,
title: t.title,
priority: t.priority,
dueDate: t.dueDate ? new Date(t.dueDate).toISOString() : null,
completed: t.completed,
})),
...noDateTasks.map((t) => ({
id: t.id,
title: t.title,
priority: t.priority,
dueDate: null,
completed: t.completed,
})),
];
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks));
// Request widget update for all 3 sizes
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
for (const widgetName of widgetNames) {
try {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks: allTasks }),
widgetNotFound: () => {},
});
} catch {
// Widget not placed on home screen
}
}
} catch {
// Ignore sync errors — widget is non-critical
}
}

View file

@ -0,0 +1,364 @@
import React from 'react';
import { FlexWidget, TextWidget } from 'react-native-android-widget';
import type { WidgetInfo } from 'react-native-android-widget';
type HexColor = `#${string}`;
type ColorProp = HexColor;
import type { WidgetTask } from '../services/widgetSync';
import {
isToday,
isTomorrow,
isBefore,
startOfDay,
format,
} from 'date-fns';
import { fr } from 'date-fns/locale';
const FONT_REGULAR = 'Inter_400Regular';
const FONT_SEMIBOLD = 'Inter_600SemiBold';
const BG_COLOR = '#FFF8F0' as const;
const TEXT_COLOR = '#1A1A1A' as const;
const TEXT_SECONDARY = '#6B6B6B' as const;
const BORDER_COLOR = '#E5E7EB' as const;
const OVERDUE_COLOR = '#C17767' as const;
const TODAY_COLOR = '#4A90A4' as const;
const CHECKBOX_UNCHECKED = '#D1D5DB' as const;
const PRIORITY_COLORS = {
3: '#C17767' as const,
2: '#4A90A4' as const,
1: '#8BA889' as const,
} as const;
function getPriorityDotColor(priority: number): ColorProp | null {
return PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] ?? null;
}
function getDateLabel(dueDate: string | null): { text: string; color: ColorProp } {
if (!dueDate) {
return { text: 'Sans date', color: TEXT_SECONDARY };
}
const date = new Date(dueDate);
const now = new Date();
if (isBefore(date, startOfDay(now))) {
return { text: 'En retard', color: OVERDUE_COLOR };
}
if (isToday(date)) {
return { text: "Aujourd'hui", color: TODAY_COLOR };
}
if (isTomorrow(date)) {
return { text: 'Demain', color: TEXT_COLOR };
}
return {
text: format(date, 'EEE d MMM', { locale: fr }),
color: TEXT_SECONDARY,
};
}
interface TaskListWidgetProps extends WidgetInfo {
widgetName: string;
tasks?: WidgetTask[];
}
function TaskItemRow({ task }: { task: WidgetTask }) {
const dateInfo = getDateLabel(task.dueDate);
const priorityColor = getPriorityDotColor(task.priority);
return (
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
width: 'match_parent',
borderBottomWidth: 1,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: `simplliste:///task/${task.id}` }}
>
{/* Checkbox */}
<FlexWidget
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: CHECKBOX_UNCHECKED,
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
}}
clickAction="TOGGLE_COMPLETE"
clickActionData={{ taskId: task.id }}
/>
{/* Priority dot + title */}
<FlexWidget
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
}}
>
{priorityColor != null ? (
<FlexWidget
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: priorityColor,
marginRight: 6,
}}
/>
) : null}
<FlexWidget style={{ flex: 1 }}>
<TextWidget
text={task.title}
maxLines={1}
truncate="END"
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: TEXT_COLOR,
}}
/>
</FlexWidget>
</FlexWidget>
{/* Date label */}
<TextWidget
text={dateInfo.text}
style={{
fontSize: 11,
fontFamily: FONT_REGULAR,
color: dateInfo.color,
marginLeft: 8,
}}
/>
</FlexWidget>
);
}
function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
return (
<FlexWidget
style={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BG_COLOR,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
padding: 12,
}}
clickAction="OPEN_APP"
>
<TextWidget
text="Simpl-Liste"
style={{
fontSize: 14,
fontFamily: FONT_SEMIBOLD,
color: TEXT_COLOR,
marginBottom: 4,
}}
/>
<TextWidget
text={`${tasks.length}`}
style={{
fontSize: 36,
fontFamily: FONT_SEMIBOLD,
color: TODAY_COLOR,
marginBottom: 2,
}}
/>
<TextWidget
text={tasks.length === 1 ? 'tâche' : 'tâches'}
style={{
fontSize: 12,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
marginBottom: 8,
}}
/>
{/* Add button */}
<FlexWidget
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: TODAY_COLOR,
alignItems: 'center',
justifyContent: 'center',
}}
clickAction="OPEN_URI"
clickActionData={{ uri: 'simplliste:///task/new' }}
>
<TextWidget
text="+"
style={{
fontSize: 22,
fontFamily: FONT_SEMIBOLD,
color: '#FFFFFF',
}}
/>
</FlexWidget>
</FlexWidget>
);
}
function ListWidgetContent({
tasks,
maxItems,
}: {
tasks: WidgetTask[];
maxItems: number;
}) {
const displayTasks = tasks.slice(0, maxItems);
return (
<FlexWidget
style={{
flexDirection: 'column',
backgroundColor: BG_COLOR,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
overflow: 'hidden',
}}
>
{/* Header */}
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 10,
width: 'match_parent',
borderBottomWidth: 1,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_APP"
>
<TextWidget
text="Simpl-Liste"
style={{
fontSize: 16,
fontFamily: FONT_SEMIBOLD,
color: TEXT_COLOR,
}}
/>
<FlexWidget
style={{
flexDirection: 'row',
alignItems: 'center',
}}
>
<TextWidget
text={`${tasks.length}`}
style={{
fontSize: 13,
fontFamily: FONT_SEMIBOLD,
color: TODAY_COLOR,
marginRight: 4,
}}
/>
<TextWidget
text={tasks.length === 1 ? 'tâche' : 'tâches'}
style={{
fontSize: 13,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
}}
/>
</FlexWidget>
</FlexWidget>
{/* Task list */}
{displayTasks.length > 0 ? (
<FlexWidget
style={{
flex: 1,
flexDirection: 'column',
width: 'match_parent',
}}
>
{displayTasks.map((task) => (
<TaskItemRow key={task.id} task={task} />
))}
</FlexWidget>
) : (
<FlexWidget
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: 'match_parent',
}}
>
<TextWidget
text="Aucune tâche à venir"
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
}}
/>
</FlexWidget>
)}
{/* Add button footer */}
<FlexWidget
style={{
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 8,
width: 'match_parent',
borderTopWidth: 1,
borderColor: BORDER_COLOR,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: 'simplliste:///task/new' }}
>
<FlexWidget
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: TODAY_COLOR,
alignItems: 'center',
justifyContent: 'center',
}}
>
<TextWidget
text="+"
style={{
fontSize: 18,
fontFamily: FONT_SEMIBOLD,
color: '#FFFFFF',
}}
/>
</FlexWidget>
</FlexWidget>
</FlexWidget>
);
}
export function TaskListWidget(props: TaskListWidgetProps) {
const widgetTasks = props.tasks ?? [];
const widgetName = props.widgetName;
if (widgetName === 'SimplListeSmall') {
return <SmallWidget tasks={widgetTasks} />;
}
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} />;
}

View file

@ -0,0 +1,74 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { TaskListWidget } from './TaskListWidget';
import { WIDGET_DATA_KEY, type WidgetTask } from '../services/widgetSync';
async function getWidgetTasks(): Promise<WidgetTask[]> {
try {
const data = await AsyncStorage.getItem(WIDGET_DATA_KEY);
if (!data) return [];
return JSON.parse(data) as WidgetTask[];
} catch {
return [];
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
const { widgetAction, widgetInfo, renderWidget } = props;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const tasks = await getWidgetTasks();
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks,
})
);
break;
}
case 'WIDGET_DELETED':
break;
case 'WIDGET_CLICK': {
if (props.clickAction === 'TOGGLE_COMPLETE') {
const taskId = props.clickActionData?.taskId as string;
if (!taskId) break;
// Update the cached data to remove the completed task immediately
const tasks = await getWidgetTasks();
const updatedTasks = tasks.filter((t) => t.id !== taskId);
await AsyncStorage.setItem(
WIDGET_DATA_KEY,
JSON.stringify(updatedTasks)
);
// Re-render the widget with updated data
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks: updatedTasks,
})
);
// Toggle in the actual database (async, will re-sync on next app open)
try {
const { toggleComplete } = await import(
'../db/repository/tasks'
);
await toggleComplete(taskId);
} catch {
// DB might not be available in headless mode — sync on next app open
}
}
break;
}
}
}