gitshark

Clone repository

git clone https://git.vilip.de/git/vilip/taskflow.git
git clone git@git.vilip.de:vilip/taskflow.git
/**
 * 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;

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog