268 lines
7.5 KiB
TypeScript
268 lines
7.5 KiB
TypeScript
import { Platform } from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
|
|
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
|
|
|
|
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
|
|
|
|
async function loadTagPreferences(): Promise<TagReminderPreferences> {
|
|
try {
|
|
const raw = await AsyncStorage.getItem(TAG_REMINDER_PREFERENCES_KEY);
|
|
return raw ? (JSON.parse(raw) as TagReminderPreferences) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function readIdMap(): Promise<Record<string, string[]>> {
|
|
try {
|
|
const raw = await AsyncStorage.getItem(ID_MAP_KEY);
|
|
return raw ? (JSON.parse(raw) as Record<string, string[]>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeIdMap(map: Record<string, string[]>): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(ID_MAP_KEY, JSON.stringify(map));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function getNotificationsModule(): any {
|
|
if (Platform.OS === 'web') return null;
|
|
try {
|
|
return import('expo-notifications');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
|
|
switch (pref) {
|
|
case 'none':
|
|
return 'none';
|
|
case 'at_time':
|
|
return 'at_time';
|
|
case '15':
|
|
return '15';
|
|
case '30':
|
|
return '30';
|
|
case '60':
|
|
return '60';
|
|
case '120':
|
|
return '120';
|
|
case '1440':
|
|
return '1440';
|
|
default:
|
|
return 'none';
|
|
}
|
|
}
|
|
|
|
function getRemindersForTask(
|
|
task: any,
|
|
globalPreference: ReminderPreference,
|
|
tagPreferences: TagReminderPreferences = {}
|
|
): Reminder[] {
|
|
const taskReminders: Reminder[] = task.reminders
|
|
? parseReminders(task.reminders)
|
|
: task.reminder && task.reminder !== 'none'
|
|
? [task.reminder as Reminder]
|
|
: [];
|
|
|
|
if (taskReminders.length > 0) {
|
|
return taskReminders;
|
|
}
|
|
|
|
const tagReminders: Reminder[] = [];
|
|
for (const tagId of parseTaskTags(task.tags, task.categoryId)) {
|
|
for (const pref of tagPreferences[tagId] ?? []) {
|
|
if (pref === 'none') continue;
|
|
if (!tagReminders.includes(pref)) tagReminders.push(pref);
|
|
}
|
|
}
|
|
if (tagReminders.length > 0) {
|
|
return tagReminders;
|
|
}
|
|
|
|
const globalReminder = reminderPreferenceToReminder(globalPreference);
|
|
if (globalReminder !== 'none') {
|
|
return [globalReminder];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
|
|
const notifications = await getNotificationsModule();
|
|
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
|
|
|
if (!notifications) return;
|
|
if (!notificationsEnabled) return;
|
|
|
|
const idMap = await readIdMap();
|
|
|
|
// Cancel any existing notifications for this task
|
|
const oldIds = idMap[task.id] || [];
|
|
for (const id of oldIds) {
|
|
try {
|
|
await notifications.cancelScheduledNotificationAsync(id);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
delete idMap[task.id];
|
|
await writeIdMap(idMap);
|
|
|
|
// Determine the set of reminders to schedule
|
|
const tagPreferences = await loadTagPreferences();
|
|
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
|
|
|
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
|
|
|
|
const granted = await requestNotificationPermission();
|
|
if (!granted) return;
|
|
|
|
const [hours, mins] = (task.dueTime as string).split(':').map(Number);
|
|
if (!Number.isNaN(hours)) {
|
|
dueDate.setHours(hours, Number.isNaN(mins) ? 0 : mins, 0, 0);
|
|
}
|
|
|
|
const newIds: string[] = [];
|
|
|
|
for (const reminder of reminders) {
|
|
const minutes = reminderToMinutes(reminder);
|
|
if (minutes === null) continue;
|
|
|
|
const triggerDate = new Date(dueDate.getTime() - minutes * 60000);
|
|
if (triggerDate.getTime() <= Date.now()) continue;
|
|
|
|
try {
|
|
const id = await notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: task.title,
|
|
body: `Due ${task.dueTime}${task.repeat && task.repeat !== 'none' ? ' · repeating' : ''}`,
|
|
sound: 'default',
|
|
},
|
|
trigger: { type: 'date', date: triggerDate },
|
|
});
|
|
newIds.push(id);
|
|
} catch (error) {
|
|
if (__DEV__) console.warn('Failed to schedule reminder:', error);
|
|
}
|
|
}
|
|
|
|
if (newIds.length > 0) {
|
|
idMap[task.id] = newIds;
|
|
await writeIdMap(idMap);
|
|
}
|
|
}
|
|
|
|
export async function cancelTaskReminder(taskId: string): Promise<void> {
|
|
const notifications = await getNotificationsModule();
|
|
if (!notifications) return;
|
|
|
|
const idMap = await readIdMap();
|
|
const ids = idMap[taskId];
|
|
if (!ids) return;
|
|
|
|
for (const id of ids) {
|
|
try {
|
|
await notifications.cancelScheduledNotificationAsync(id);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
delete idMap[taskId];
|
|
await writeIdMap(idMap);
|
|
}
|
|
|
|
export async function requestNotificationPermission(): Promise<boolean> {
|
|
const notifications = await getNotificationsModule();
|
|
if (!notifications) return false;
|
|
try {
|
|
const settings = await notifications.getPermissionsAsync();
|
|
if (!settings.granted) {
|
|
const requested = await notifications.requestPermissionsAsync();
|
|
return requested.granted;
|
|
}
|
|
return settings.granted;
|
|
} catch (error) {
|
|
if (__DEV__) console.warn('Failed to request notification permission:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function cancelAllReminders(): Promise<void> {
|
|
const notifications = await getNotificationsModule();
|
|
if (!notifications) return;
|
|
try {
|
|
await notifications.cancelAllScheduledNotificationsAsync();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
const idMap: Record<string, string[]> = {};
|
|
await writeIdMap(idMap);
|
|
}
|
|
|
|
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
|
|
if (!notificationsEnabled) {
|
|
await cancelAllReminders();
|
|
return;
|
|
}
|
|
|
|
const notifications = await getNotificationsModule();
|
|
if (!notifications) return;
|
|
try {
|
|
await notifications.cancelAllScheduledNotificationsAsync();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
const idMap: Record<string, string[]> = {};
|
|
const tagPreferences = await loadTagPreferences();
|
|
|
|
for (const task of tasks) {
|
|
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
|
|
|
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
|
|
|
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
|
|
|
|
const granted = await requestNotificationPermission();
|
|
if (!granted) continue;
|
|
|
|
const [hours, mins] = (task.dueTime as string).split(':').map(Number);
|
|
if (!Number.isNaN(hours)) dueDate.setHours(hours, Number.isNaN(mins) ? 0 : mins, 0, 0);
|
|
|
|
const newIds: string[] = [];
|
|
|
|
for (const reminder of reminders) {
|
|
const minutes = reminderToMinutes(reminder);
|
|
if (minutes === null) continue;
|
|
|
|
const triggerDate = new Date(dueDate.getTime() - minutes * 60000);
|
|
if (triggerDate.getTime() <= Date.now()) continue;
|
|
|
|
try {
|
|
const id = await notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: task.title,
|
|
body: `Due ${task.dueTime}${task.repeat && task.repeat !== 'none' ? ' · repeating' : ''}`,
|
|
sound: 'default',
|
|
},
|
|
trigger: { type: 'date', date: triggerDate },
|
|
});
|
|
newIds.push(id);
|
|
} catch {
|
|
// skip individual failures
|
|
}
|
|
}
|
|
|
|
if (newIds.length > 0) idMap[task.id] = newIds;
|
|
}
|
|
|
|
await writeIdMap(idMap);
|
|
}
|