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:
parent
f8523b8a6c
commit
47f698d86b
16 changed files with 1910 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1051
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
1
src/db/migrations/0002_majestic_wendell_rand.sql
Normal file
1
src/db/migrations/0002_majestic_wendell_rand.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `tasks` ADD `calendar_event_id` text;
|
||||
309
src/db/migrations/meta/0002_snapshot.json
Normal file
309
src/db/migrations/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
87
src/services/calendar.ts
Normal 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
112
src/services/icsExport.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
84
src/services/notifications.ts
Normal file
84
src/services/notifications.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue