Files
carry-your-live/carry-your-live/src/services/notifications.ts
T
2026-08-25 16:26:10 +02:00

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);
}