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:
parent
3558171bb9
commit
9c6d2dfef9
12 changed files with 645 additions and 2 deletions
42
app.json
42
app.json
|
|
@ -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
|
||||
|
|
|
|||
BIN
assets/fonts/Inter_400Regular.ttf
Normal file
BIN
assets/fonts/Inter_400Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
assets/fonts/Inter_600SemiBold.ttf
Normal file
Binary file not shown.
6
index.js
Normal file
6
index.js
Normal 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
17
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
112
src/services/widgetSync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
364
src/widgets/TaskListWidget.tsx
Normal file
364
src/widgets/TaskListWidget.tsx
Normal 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} />;
|
||||
}
|
||||
74
src/widgets/widgetTaskHandler.ts
Normal file
74
src/widgets/widgetTaskHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue