/**
* Notification Service
* Handles daily task notifications
*/
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { NotificationRequest } from 'expo-notifications';
import taskService from './task.service';
import {
DEFAULT_REMINDER_SCHEDULE,
REMINDER_WINDOW_DAYS,
ReminderSchedule,
ReminderTime,
buildReminderBody,
buildReminderOccurrences,
createUniformSchedule,
isValidReminderSchedule,
isValidReminderTime,
withReminderTimeForWeekday,
} from '../utils/reminder';
const REMINDER_SCHEDULE_KEY = '@taskflow_reminder_schedule';
/** Pre-per-weekday key, holding a single time for all days. */
const LEGACY_REMINDER_TIME_KEY = '@taskflow_reminder_time';
const REMINDER_ENABLED_KEY = '@taskflow_reminder_enabled';
/** Background task that recounts today's tasks and rewrites pending reminders. */
export const REMINDER_REFRESH_TASK = 'taskflow-reminder-refresh';
/** How often the OS is asked to run the refresh task, in minutes. */
const REFRESH_INTERVAL_MINUTES = 15;
// Only import notifications on native platforms
let Notifications: any;
let TaskManager: any;
let BackgroundTask: any;
if (Platform.OS !== 'web') {
Notifications = require('expo-notifications');
TaskManager = require('expo-task-manager');
BackgroundTask = require('expo-background-task');
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
}
class NotificationService {
private scheduling: Promise<unknown> = Promise.resolve();
/**
* Request notification permissions
*/
async requestPermissions(): Promise<boolean> {
if (Platform.OS === 'web') {
return false;
}
try {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Notification permission not granted');
return false;
}
// For Android, create notification channel
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('daily-tasks', {
name: 'Daily Task Reminders',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
sound: 'default',
});
}
return true;
} catch (error) {
console.error('Failed to request notification permissions:', error);
return false;
}
}
/**
* Get the reminder time of every weekday. A schedule stored before reminder
* times were per-weekday is carried over to all seven days.
*/
async getReminderSchedule(): Promise<ReminderSchedule> {
try {
const json = await AsyncStorage.getItem(REMINDER_SCHEDULE_KEY);
if (json) {
const parsed = JSON.parse(json) as ReminderSchedule;
if (isValidReminderSchedule(parsed)) {
return parsed;
}
}
const legacyJson = await AsyncStorage.getItem(LEGACY_REMINDER_TIME_KEY);
if (legacyJson) {
const legacy = JSON.parse(legacyJson) as ReminderTime;
if (isValidReminderTime(legacy)) {
return createUniformSchedule(legacy);
}
}
} catch (error) {
console.error('Failed to load reminder schedule:', error);
}
return DEFAULT_REMINDER_SCHEDULE;
}
/**
* Persist a full reminder schedule. If the daily reminder is enabled, the
* pending reminders are rewritten to the new times.
*/
async setReminderSchedule(schedule: ReminderSchedule): Promise<void> {
if (!isValidReminderSchedule(schedule)) {
throw new Error('Invalid reminder schedule');
}
await AsyncStorage.setItem(REMINDER_SCHEDULE_KEY, JSON.stringify(schedule));
await this.refreshDailyNotificationIfEnabled();
}
/**
* Persist the reminder time of a single weekday (0 = Sunday), leaving the
* other days as they are.
*/
async setReminderTimeForWeekday(weekday: number, time: ReminderTime): Promise<ReminderSchedule> {
const schedule = withReminderTimeForWeekday(await this.getReminderSchedule(), weekday, time);
await this.setReminderSchedule(schedule);
return schedule;
}
/**
* Whether the daily reminder is switched on. Installs from before the flag
* existed are recognised by their already-scheduled notifications.
*/
async isDailyReminderEnabled(): Promise<boolean> {
if (Platform.OS === 'web') {
return false;
}
try {
const stored = await AsyncStorage.getItem(REMINDER_ENABLED_KEY);
if (stored !== null) {
return stored === 'true';
}
const scheduled = await this.getScheduledNotifications();
const enabled = scheduled.length > 0;
await AsyncStorage.setItem(REMINDER_ENABLED_KEY, String(enabled));
return enabled;
} catch (error) {
console.error('Failed to read reminder state:', error);
return false;
}
}
/**
* Switch the daily reminder on and schedule the upcoming reminders.
*/
async enableDailyReminder(): Promise<boolean> {
if (Platform.OS === 'web') {
return false;
}
const hasPermission = await this.requestPermissions();
if (!hasPermission) {
return false;
}
await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'true');
const scheduled = await this.scheduleReminderWindow();
if (!scheduled) {
await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'false');
return false;
}
await this.registerRefreshTask();
return true;
}
/**
* Switch the daily reminder off and drop everything pending.
*/
async disableDailyReminder(): Promise<void> {
if (Platform.OS === 'web') {
return;
}
await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'false');
await this.unregisterRefreshTask();
await this.cancelAllNotifications();
}
/**
* Recount and rewrite the pending reminders if the reminder is enabled.
*
* Called on app start, whenever the app goes to the background and from the
* background refresh task, so the count a reminder shows is as close to its
* delivery time as the OS allows.
*/
async refreshDailyNotificationIfEnabled(): Promise<void> {
if (Platform.OS === 'web') {
return;
}
try {
if (!(await this.isDailyReminderEnabled())) {
return;
}
await this.scheduleReminderWindow();
await this.registerRefreshTask();
} catch (error) {
console.error('Failed to refresh daily notification:', error);
}
}
/**
* Replace all pending reminders with one notification per upcoming day, each
* carrying the task count of the day it is delivered on.
*
* Runs are queued: cancelling and re-scheduling is not atomic, so two
* overlapping refreshes could otherwise leave duplicate reminders behind.
*/
private scheduleReminderWindow(): Promise<boolean> {
const run = this.scheduling.catch(() => undefined).then(() => this.writeReminderWindow());
this.scheduling = run;
return run;
}
private async writeReminderWindow(): Promise<boolean> {
try {
// Cancel all (rather than by id) so duplicates can't pile up across app
// restarts, where in-memory notification ids are lost.
await Notifications.cancelAllScheduledNotificationsAsync();
const tasks = await taskService.getAllTasks();
const schedule = await this.getReminderSchedule();
const occurrences = buildReminderOccurrences(tasks, schedule, {
now: new Date(),
days: REMINDER_WINDOW_DAYS,
});
for (const occurrence of occurrences) {
await Notifications.scheduleNotificationAsync({
content: {
title: '📋 Daily Task Reminder',
body: occurrence.body,
sound: true,
priority: Notifications.AndroidNotificationPriority.HIGH,
data: { screen: '/(tabs)/today' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: occurrence.triggerAt,
} as any,
});
}
return true;
} catch (error) {
console.error('Failed to schedule reminder window:', error);
return false;
}
}
private async registerRefreshTask(): Promise<void> {
try {
if (await TaskManager.isTaskRegisteredAsync(REMINDER_REFRESH_TASK)) {
return;
}
await BackgroundTask.registerTaskAsync(REMINDER_REFRESH_TASK, {
minimumInterval: REFRESH_INTERVAL_MINUTES,
});
} catch (error) {
console.error('Failed to register reminder refresh task:', error);
}
}
private async unregisterRefreshTask(): Promise<void> {
try {
if (await TaskManager.isTaskRegisteredAsync(REMINDER_REFRESH_TASK)) {
await BackgroundTask.unregisterTaskAsync(REMINDER_REFRESH_TASK);
}
} catch (error) {
console.error('Failed to unregister reminder refresh task:', error);
}
}
/**
* Check if notifications are enabled
*/
async isEnabled(): Promise<boolean> {
if (Platform.OS === 'web') {
return false;
}
try {
const { status } = await Notifications.getPermissionsAsync();
return status === 'granted';
} catch (error) {
return false;
}
}
/**
* Get all scheduled notifications
*/
async getScheduledNotifications(): Promise<NotificationRequest[]> {
try {
return await Notifications.getAllScheduledNotificationsAsync();
} catch (error) {
console.error('Failed to get scheduled notifications:', error);
return [];
}
}
/**
* Cancel all notifications
*/
async cancelAllNotifications(): Promise<void> {
try {
await Notifications.cancelAllScheduledNotificationsAsync();
} catch (error) {
console.error('Failed to cancel all notifications:', error);
}
}
/**
* Send immediate test notification
*/
async sendTestNotification(): Promise<void> {
try {
const todayTasks = await taskService.getTodayTasks();
const body = buildReminderBody(todayTasks.length);
await Notifications.scheduleNotificationAsync({
content: {
title: '📋 Daily Task Reminder (Test)',
body,
sound: true,
priority: Notifications.AndroidNotificationPriority.HIGH,
data: { screen: '/(tabs)/today' },
},
trigger: null, // Send immediately
});
} catch (error) {
console.error('Failed to send test notification:', error);
}
}
}
const notificationService = new NotificationService();
if (Platform.OS !== 'web') {
try {
TaskManager.defineTask(REMINDER_REFRESH_TASK, async () => {
try {
await notificationService.refreshDailyNotificationIfEnabled();
return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) {
console.error('Reminder refresh task failed:', error);
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
} catch (error) {
console.error('Failed to define reminder refresh task:', error);
}
}
export default notificationService;