diff --git a/app.json b/app.json index 4555060..22c4d04 100644 --- a/app.json +++ b/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 diff --git a/assets/fonts/Inter_400Regular.ttf b/assets/fonts/Inter_400Regular.ttf new file mode 100644 index 0000000..9a53a64 Binary files /dev/null and b/assets/fonts/Inter_400Regular.ttf differ diff --git a/assets/fonts/Inter_600SemiBold.ttf b/assets/fonts/Inter_600SemiBold.ttf new file mode 100644 index 0000000..61f1cc6 Binary files /dev/null and b/assets/fonts/Inter_600SemiBold.ttf differ diff --git a/index.js b/index.js new file mode 100644 index 0000000..25597fb --- /dev/null +++ b/index.js @@ -0,0 +1,6 @@ +import { registerWidgetTaskHandler } from 'react-native-android-widget'; +import { widgetTaskHandler } from './src/widgets/widgetTaskHandler'; + +registerWidgetTaskHandler(widgetTaskHandler); + +import 'expo-router/entry'; diff --git a/package-lock.json b/package-lock.json index 540533c..7a24f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ed63438..db01f52 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 8127441..37687ef 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -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(() => {}); } diff --git a/src/i18n/en.json b/src/i18n/en.json index 97df818..8d3970e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 2d2e675..1afc52a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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" } } diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts new file mode 100644 index 0000000..fc13d20 --- /dev/null +++ b/src/services/widgetSync.ts @@ -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 { + 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 + } +} diff --git a/src/widgets/TaskListWidget.tsx b/src/widgets/TaskListWidget.tsx new file mode 100644 index 0000000..8788520 --- /dev/null +++ b/src/widgets/TaskListWidget.tsx @@ -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 ( + + {/* Checkbox */} + + + {/* Priority dot + title */} + + {priorityColor != null ? ( + + ) : null} + + + + + + {/* Date label */} + + + ); +} + +function SmallWidget({ tasks }: { tasks: WidgetTask[] }) { + return ( + + + + + {/* Add button */} + + + + + ); +} + +function ListWidgetContent({ + tasks, + maxItems, +}: { + tasks: WidgetTask[]; + maxItems: number; +}) { + const displayTasks = tasks.slice(0, maxItems); + + return ( + + {/* Header */} + + + + + + + + + {/* Task list */} + {displayTasks.length > 0 ? ( + + {displayTasks.map((task) => ( + + ))} + + ) : ( + + + + )} + + {/* Add button footer */} + + + + + + + ); +} + +export function TaskListWidget(props: TaskListWidgetProps) { + const widgetTasks = props.tasks ?? []; + const widgetName = props.widgetName; + + if (widgetName === 'SimplListeSmall') { + return ; + } + + const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4; + return ; +} diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts new file mode 100644 index 0000000..5eaa7c3 --- /dev/null +++ b/src/widgets/widgetTaskHandler.ts @@ -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 { + 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 { + 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; + } + } +}