feat: add notifications, calendar sync, and ICS export

- Scheduled task reminders via expo-notifications with configurable
  offset (at time, 1h, 3h, 1 day before)
- Optional calendar sync via expo-calendar (creates/updates/removes
  events in a dedicated Simpl-Liste calendar)
- ICS export with RRULE support for inbox, lists, and individual tasks
- New migration adding calendar_event_id to tasks table
- Settings UI for notifications toggle, reminder offset, and calendar sync
- Export buttons in inbox toolbar, list header, and task detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 08:09:57 -05:00
parent f8523b8a6c
commit 47f698d86b
16 changed files with 1910 additions and 7 deletions

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, Pressable, useColorScheme, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
@ -14,11 +14,13 @@ import { getTagsForTask } from '@/src/db/repository/tags';
import TaskItem from '@/src/components/task/TaskItem';
import SortMenu from '@/src/components/SortMenu';
import FilterMenu from '@/src/components/FilterMenu';
import { exportAndShareICS } from '@/src/services/icsExport';
type Tag = { id: string; name: string; color: string };
type Task = {
id: string;
title: string;
notes: string | null;
completed: boolean;
priority: number;
dueDate: Date | null;
@ -83,12 +85,27 @@ export default function InboxScreen() {
]);
};
const handleExportICS = async () => {
if (tasks.length === 0) {
Alert.alert(t('export.noTasks'));
return;
}
try {
await exportAndShareICS(tasks, t('nav.inbox'));
} catch {
// User cancelled sharing
}
};
const filtersActive = hasActiveFilters();
return (
<View className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Toolbar */}
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={handleExportICS} className="mr-3 p-1">
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
<Pressable onPress={() => setShowSort(true)} className="mr-3 p-1">
<ArrowUpDown size={20} color={sortBy !== 'position' ? colors.bleu.DEFAULT : isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -1,12 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform } from 'react-native';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags';
import { initCalendar } from '@/src/services/calendar';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
@ -16,7 +17,12 @@ const TAG_COLORS = ['#4A90A4', '#C17767', '#8BA889', '#D4A574', '#7B68EE', '#E57
export default function SettingsScreen() {
const { t } = useTranslation();
const systemScheme = useColorScheme();
const { theme, locale, setTheme, setLocale } = useSettingsStore();
const {
theme, locale, setTheme, setLocale,
notificationsEnabled, setNotificationsEnabled,
reminderOffset, setReminderOffset,
calendarSyncEnabled, setCalendarSyncEnabled,
} = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const [tagsList, setTagsList] = useState<{ id: string; name: string; color: string }[]>([]);
@ -146,6 +152,112 @@ export default function SettingsScreen() {
</View>
</View>
{/* Notifications Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('notifications.title')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className={`flex-row items-center justify-between border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<View className="flex-row items-center">
<Bell size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('notifications.enabled')}
</Text>
</View>
<Switch
value={notificationsEnabled}
onValueChange={setNotificationsEnabled}
trackColor={{ false: isDark ? '#3A3A3A' : '#E5E7EB', true: colors.bleu.DEFAULT }}
thumbColor="#FFFFFF"
/>
</View>
{notificationsEnabled && (
<View className="px-4 py-3.5">
<Text
className={`mb-2 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('notifications.offset')}
</Text>
<View className="flex-row flex-wrap gap-2">
{[
{ value: 0, label: t('notifications.atTime') },
{ value: 1, label: t('notifications.hoursBefore', { count: 1 }) },
{ value: 3, label: t('notifications.hoursBefore', { count: 3 }) },
{ value: 24, label: t('notifications.dayBefore') },
].map((opt) => {
const isActive = reminderOffset === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => setReminderOffset(opt.value)}
className={`rounded-full px-3 py-1.5 ${isActive ? 'bg-bleu' : isDark ? 'bg-[#3A3A3A]' : 'bg-[#E5E7EB]'}`}
>
<Text
className={`text-sm ${isActive ? 'text-white' : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
)}
</View>
</View>
{/* Calendar Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('calendar.title')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className="flex-row items-center justify-between px-4 py-3.5">
<View className="mr-4 flex-1 flex-row items-center">
<CalendarDays size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<View className="ml-3 flex-1">
<Text
className={`text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('calendar.syncEnabled')}
</Text>
<Text
className={`mt-0.5 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('calendar.syncDescription')}
</Text>
</View>
</View>
<Switch
value={calendarSyncEnabled}
onValueChange={async (value) => {
if (value) {
const granted = await initCalendar();
if (!granted) return;
}
setCalendarSyncEnabled(value);
}}
trackColor={{ false: isDark ? '#3A3A3A' : '#E5E7EB', true: colors.bleu.DEFAULT }}
thumbColor="#FFFFFF"
/>
</View>
</View>
</View>
{/* Tags Section */}
<View className="px-4 pt-6">
<View className="mb-3 flex-row items-center justify-between">

View file

@ -11,6 +11,7 @@ import { db } from '@/src/db/client';
import migrations from '@/src/db/migrations/migrations';
import { ensureInbox } from '@/src/db/repository/lists';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { initNotifications } from '@/src/services/notifications';
import '@/src/i18n';
import '@/src/global.css';
@ -63,7 +64,8 @@ export default function RootLayout() {
useEffect(() => {
if (fontsLoaded && migrationsReady) {
ensureInbox().then(() => {
ensureInbox().then(async () => {
await initNotifications();
SplashScreen.hideAsync();
});
}

View file

@ -10,7 +10,7 @@ import {
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { ArrowLeft, Plus, Trash2, Calendar, X, Repeat } from 'lucide-react-native';
import { ArrowLeft, Plus, Trash2, Calendar, X, Repeat, Download } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
@ -29,6 +29,7 @@ import {
} from '@/src/db/repository/tasks';
import { getAllTags, getTagsForTask, setTagsForTask } from '@/src/db/repository/tags';
import TagChip from '@/src/components/task/TagChip';
import { exportAndShareICS } from '@/src/services/icsExport';
type TaskData = {
id: string;
@ -159,6 +160,17 @@ export default function TaskDetailScreen() {
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<View className="flex-row items-center">
{dueDate && (
<Pressable
onPress={() => exportAndShareICS(
[{ id: id!, title, notes: notes || null, dueDate, priority, completed: task.completed, recurrence }],
title
)}
className="mr-3 p-1"
>
<Download size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
)}
<Pressable onPress={handleDelete} className="mr-3 p-1">
<Trash2 size={20} color={colors.terracotta.DEFAULT} />
</Pressable>

1051
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
@ -17,13 +18,18 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-intent-launcher": "~13.0.8",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",

View file

@ -0,0 +1 @@
ALTER TABLE `tasks` ADD `calendar_event_id` text;

View file

@ -0,0 +1,309 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3b2c3545-d1aa-4879-9654-4c6b58c73dc2",
"prevId": "0d7c6471-bc3c-4111-8166-9729923db06b",
"tables": {
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_tags": {
"name": "task_tags",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"task_tags_task_id_tasks_id_fk": {
"name": "task_tags_task_id_tasks_id_fk",
"tableFrom": "task_tags",
"tableTo": "tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_tags_tag_id_tags_id_fk": {
"name": "task_tags_tag_id_tags_id_fk",
"tableFrom": "task_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"task_tags_task_id_tag_id_pk": {
"columns": [
"task_id",
"tag_id"
],
"name": "task_tags_task_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calendar_event_id": {
"name": "calendar_event_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tasks_list_id_lists_id_fk": {
"name": "tasks_list_id_lists_id_fk",
"tableFrom": "tasks",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -15,6 +15,13 @@
"when": 1771637151512,
"tag": "0001_sticky_arachne",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1771639773448,
"tag": "0002_majestic_wendell_rand",
"breakpoints": true
}
]
}

View file

@ -3,12 +3,14 @@
import journal from './meta/_journal.json';
import m0000 from './0000_bitter_phalanx.sql';
import m0001 from './0001_sticky_arachne.sql';
import m0002 from './0002_majestic_wendell_rand.sql';
export default {
journal,
migrations: {
m0000,
m0001
m0001,
m0002
}
}

View file

@ -5,6 +5,9 @@ import { randomUUID } from '@/src/lib/uuid';
import { getNextOccurrence, type RecurrenceType } from '@/src/lib/recurrence';
import { startOfDay, endOfDay, endOfWeek, startOfWeek } from 'date-fns';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore';
import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notifications';
import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
export interface TaskFilters {
sortBy?: SortBy;
@ -124,6 +127,23 @@ export async function createTask(data: {
: await getTasksByList(data.listId);
const maxPosition = siblings.reduce((max, t) => Math.max(max, t.position), 0);
let calendarEventId: string | null = null;
// Calendar sync
if (data.dueDate && useSettingsStore.getState().calendarSyncEnabled) {
try {
calendarEventId = await addTaskToCalendar({
id,
title: data.title,
notes: data.notes ?? null,
dueDate: data.dueDate,
priority: data.priority ?? 0,
});
} catch {
// Calendar permission may not be granted
}
}
await db.insert(tasks).values({
id,
title: data.title,
@ -135,9 +155,20 @@ export async function createTask(data: {
completed: false,
position: maxPosition + 1,
recurrence: data.recurrence ?? null,
calendarEventId,
createdAt: now,
updatedAt: now,
});
// Schedule notification
if (data.dueDate) {
try {
await scheduleTaskReminder({ id, title: data.title, dueDate: data.dueDate });
} catch {
// Notifications permission may not be granted
}
}
return id;
}
@ -160,6 +191,36 @@ export async function updateTask(
updates.completedAt = null;
}
await db.update(tasks).set(updates).where(eq(tasks.id, id));
// Re-schedule notification if dueDate changed
if (data.dueDate !== undefined) {
try {
await cancelTaskReminder(id);
if (data.dueDate) {
const task = await getTaskById(id);
if (task) {
await scheduleTaskReminder({ id, title: task.title, dueDate: data.dueDate });
}
}
} catch {
// Ignore notification errors
}
}
// Update calendar event if exists
const task = await getTaskById(id);
if (task?.calendarEventId && task.dueDate) {
try {
await updateCalendarEvent(task.calendarEventId, {
title: task.title,
notes: task.notes,
dueDate: new Date(task.dueDate),
completed: task.completed,
});
} catch {
// Ignore calendar errors
}
}
}
export async function toggleComplete(id: string) {
@ -169,6 +230,15 @@ export async function toggleComplete(id: string) {
const nowCompleting = !task.completed;
await updateTask(id, { completed: nowCompleting });
// Cancel notification when completing
if (nowCompleting) {
try {
await cancelTaskReminder(id);
} catch {
// Ignore
}
}
// If completing a recurring task, create the next occurrence
if (nowCompleting && task.recurrence && task.dueDate) {
const nextDate = getNextOccurrence(
@ -187,6 +257,24 @@ export async function toggleComplete(id: string) {
}
export async function deleteTask(id: string) {
const task = await getTaskById(id);
// Cancel notification
try {
await cancelTaskReminder(id);
} catch {
// Ignore
}
// Remove calendar event
if (task?.calendarEventId) {
try {
await removeCalendarEvent(task.calendarEventId);
} catch {
// Ignore
}
}
// Delete subtasks first
const subtasks = await getSubtasks(id);
for (const sub of subtasks) {

View file

@ -23,6 +23,7 @@ export const tasks = sqliteTable('tasks', {
parentId: text('parent_id'),
position: integer('position').notNull().default(0),
recurrence: text('recurrence'), // 'daily' | 'weekly' | 'monthly' | 'yearly' | null
calendarEventId: text('calendar_event_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

87
src/services/calendar.ts Normal file
View file

@ -0,0 +1,87 @@
import * as Calendar from 'expo-calendar';
import { Platform } from 'react-native';
const CALENDAR_NAME = 'Simpl-Liste';
async function getOrCreateCalendarId(): Promise<string | null> {
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
const existing = calendars.find((c) => c.title === CALENDAR_NAME);
if (existing) return existing.id;
// Create a new calendar
const defaultCalendarSource =
Platform.OS === 'android'
? { isLocalAccount: true, name: CALENDAR_NAME, type: Calendar.CalendarType.LOCAL }
: calendars.find((c) => c.source?.type === 'caldav')?.source ??
calendars[0]?.source;
if (!defaultCalendarSource) return null;
const newCalendarId = await Calendar.createCalendarAsync({
title: CALENDAR_NAME,
color: '#4A90A4',
entityType: Calendar.EntityTypes.EVENT,
source: defaultCalendarSource as Calendar.Source,
name: CALENDAR_NAME,
ownerAccount: 'Simpl-Liste',
accessLevel: Calendar.CalendarAccessLevel.OWNER,
});
return newCalendarId;
}
export async function initCalendar(): Promise<boolean> {
const { status } = await Calendar.requestCalendarPermissionsAsync();
return status === 'granted';
}
export async function addTaskToCalendar(task: {
id: string;
title: string;
notes: string | null;
dueDate: Date;
priority: number;
}): Promise<string | null> {
const calendarId = await getOrCreateCalendarId();
if (!calendarId) return null;
const eventId = await Calendar.createEventAsync(calendarId, {
title: task.title,
notes: task.notes ?? undefined,
startDate: task.dueDate,
endDate: new Date(task.dueDate.getTime() + 30 * 60 * 1000), // 30min duration
allDay: false,
alarms: [{ relativeOffset: 0 }],
});
return eventId;
}
export async function updateCalendarEvent(
eventId: string,
task: {
title: string;
notes: string | null;
dueDate: Date;
completed: boolean;
}
): Promise<void> {
try {
await Calendar.updateEventAsync(eventId, {
title: task.completed ? `${task.title}` : task.title,
notes: task.notes ?? undefined,
startDate: task.dueDate,
endDate: new Date(task.dueDate.getTime() + 30 * 60 * 1000),
});
} catch {
// Event may have been deleted externally
}
}
export async function removeCalendarEvent(eventId: string): Promise<void> {
try {
await Calendar.deleteEventAsync(eventId);
} catch {
// Event may have been deleted externally
}
}

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

@ -0,0 +1,112 @@
import { File, Paths } from 'expo-file-system';
import { getContentUriAsync } from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { Platform } from 'react-native';
import { format } from 'date-fns';
interface ICSTask {
id: string;
title: string;
notes: string | null;
dueDate: Date | null;
priority: number;
completed: boolean;
recurrence: string | null;
}
function formatICSDate(date: Date): string {
return format(date, "yyyyMMdd'T'HHmmss");
}
function escapeICS(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
function mapPriority(priority: number): number {
switch (priority) {
case 3: return 1; // high
case 2: return 5; // medium
case 1: return 9; // low
default: return 0; // none
}
}
function mapRecurrence(recurrence: string | null): string | null {
if (!recurrence) return null;
const freqMap: Record<string, string> = {
daily: 'DAILY',
weekly: 'WEEKLY',
monthly: 'MONTHLY',
yearly: 'YEARLY',
};
const freq = freqMap[recurrence];
return freq ? `RRULE:FREQ=${freq}` : null;
}
export function generateICS(tasks: ICSTask[], listName: string): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Simpl-Liste//FR',
`X-WR-CALNAME:${escapeICS(listName)}`,
];
const now = formatICSDate(new Date());
for (const task of tasks) {
// Use VEVENT instead of VTODO — most calendar apps (Google, Proton) ignore VTODO
lines.push('BEGIN:VEVENT');
lines.push(`UID:${task.id}@simpl-liste`);
lines.push(`DTSTAMP:${now}`);
lines.push(`SUMMARY:${task.completed ? '\u2713 ' : ''}${escapeICS(task.title)}`);
if (task.notes) {
lines.push(`DESCRIPTION:${escapeICS(task.notes)}`);
}
if (task.dueDate) {
const dueStr = formatICSDate(new Date(task.dueDate));
lines.push(`DTSTART:${dueStr}`);
lines.push(`DTEND:${dueStr}`);
}
if (task.priority > 0) {
lines.push(`PRIORITY:${mapPriority(task.priority)}`);
}
const rrule = mapRecurrence(task.recurrence);
if (rrule) {
lines.push(rrule);
}
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
export async function exportAndShareICS(tasks: ICSTask[], listName: string): Promise<void> {
const icsContent = generateICS(tasks, listName);
const sanitizedName = listName.replace(/[^a-zA-Z0-9À-ÿ_-]/g, '_');
const file = new File(Paths.cache, `${sanitizedName}.ics`);
file.write(icsContent);
if (Platform.OS === 'android') {
// Convert file:// URI to content:// URI for Android intent
const contentUri = await getContentUriAsync(file.uri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW' as any, {
data: contentUri,
type: 'text/calendar',
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
});
} else {
await Sharing.shareAsync(file.uri, {
mimeType: 'text/calendar',
dialogTitle: listName,
UTI: 'com.apple.ical.ics',
});
}
}

View file

@ -0,0 +1,84 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
export async function initNotifications() {
try {
const { status: existing } = await Notifications.getPermissionsAsync();
let finalStatus = existing;
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('task-reminders', {
name: 'Rappels de tâches',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
}
// Show notifications even when app is in foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
return finalStatus === 'granted';
} catch {
// expo-notifications may not be fully supported in Expo Go
return false;
}
}
export async function scheduleTaskReminder(task: {
id: string;
title: string;
dueDate: Date | null;
}) {
if (!task.dueDate) return;
const { notificationsEnabled, reminderOffset } = useSettingsStore.getState();
if (!notificationsEnabled) return;
// Cancel any existing reminder for this task
await cancelTaskReminder(task.id);
const triggerDate = new Date(task.dueDate.getTime() - reminderOffset * 60 * 60 * 1000);
// Don't schedule if the trigger time is in the past
if (triggerDate <= new Date()) return;
try {
await Notifications.scheduleNotificationAsync({
identifier: task.id,
content: {
title: 'Simpl-Liste',
body: task.title,
sound: 'default',
...(Platform.OS === 'android' && { channelId: 'task-reminders' }),
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: triggerDate,
},
});
} catch {
// May fail in Expo Go
}
}
export async function cancelTaskReminder(taskId: string) {
try {
await Notifications.cancelScheduledNotificationAsync(taskId);
} catch {
// May fail in Expo Go
}
}

View file

@ -7,8 +7,14 @@ type ThemeMode = 'light' | 'dark' | 'system';
interface SettingsState {
theme: ThemeMode;
locale: 'fr' | 'en';
notificationsEnabled: boolean;
reminderOffset: number; // hours before due date (0 = at time)
calendarSyncEnabled: boolean;
setTheme: (theme: ThemeMode) => void;
setLocale: (locale: 'fr' | 'en') => void;
setNotificationsEnabled: (enabled: boolean) => void;
setReminderOffset: (offset: number) => void;
setCalendarSyncEnabled: (enabled: boolean) => void;
}
export const useSettingsStore = create<SettingsState>()(
@ -16,8 +22,14 @@ export const useSettingsStore = create<SettingsState>()(
(set) => ({
theme: 'system',
locale: 'fr',
notificationsEnabled: true,
reminderOffset: 0,
calendarSyncEnabled: false,
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }),
setReminderOffset: (reminderOffset) => set({ reminderOffset }),
setCalendarSyncEnabled: (calendarSyncEnabled) => set({ calendarSyncEnabled }),
}),
{
name: 'simpl-liste-settings',