247 lines
7.7 KiB
TypeScript
247 lines
7.7 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { NavigationContainer } from '@react-navigation/native';
|
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
import { StatusBar, Alert, AppState, AppStateStatus } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { theme } from './theme';
|
|
import TodayScreen from './screens/TodayScreen';
|
|
import CalendarScreen from './screens/CalendarScreen';
|
|
import * as Notifications from 'expo-notifications';
|
|
import { getEvents } from './api';
|
|
import { computeUpcomingReminders, EventForReminder } from './utils/recurrence';
|
|
|
|
const Tab = createBottomTabNavigator();
|
|
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
|
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowAlert: true,
|
|
shouldShowBanner: true,
|
|
shouldShowList: true,
|
|
shouldPlaySound: true,
|
|
shouldSetBadge: false,
|
|
}),
|
|
});
|
|
|
|
export default function App() {
|
|
const notifiedKeysRef = useRef<Set<string>>(new Set());
|
|
const scheduledNotificationIdsRef = useRef<Set<string>>(new Set());
|
|
const heartbeatTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
|
|
|
useEffect(() => {
|
|
requestNotificationPermissions();
|
|
initializeNotifications();
|
|
|
|
// 监听应用状态变化
|
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
|
|
|
return () => {
|
|
subscription.remove();
|
|
cleanup();
|
|
};
|
|
}, []);
|
|
|
|
const requestNotificationPermissions = async () => {
|
|
const { status } = await Notifications.requestPermissionsAsync();
|
|
if (status !== 'granted') {
|
|
Alert.alert('提示', '需要通知权限才能发送提醒');
|
|
}
|
|
};
|
|
|
|
const initializeNotifications = async () => {
|
|
await loadAndScheduleReminders();
|
|
startReminderHeartbeat();
|
|
};
|
|
|
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
|
|
// 应用从后台回到前台,重新检查提醒
|
|
loadAndScheduleReminders();
|
|
}
|
|
appStateRef.current = nextAppState;
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (heartbeatTimerRef.current) {
|
|
clearInterval(heartbeatTimerRef.current);
|
|
}
|
|
// 取消所有已安排的通知
|
|
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
|
try {
|
|
await Notifications.cancelScheduledNotificationAsync(id);
|
|
} catch (e) {
|
|
console.error('取消通知失败:', e);
|
|
}
|
|
});
|
|
};
|
|
|
|
const loadAndScheduleReminders = async () => {
|
|
try {
|
|
const now = Date.now();
|
|
const next24h = now + 24 * 3600 * 1000;
|
|
|
|
// 加载事件(覆盖当前月和前后各一个月)
|
|
const currentDate = new Date();
|
|
const events = await getEvents(currentDate);
|
|
|
|
// 取消之前的所有通知
|
|
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
|
try {
|
|
await Notifications.cancelScheduledNotificationAsync(id);
|
|
} catch (e) {
|
|
console.error('取消通知失败:', e);
|
|
}
|
|
});
|
|
scheduledNotificationIdsRef.current.clear();
|
|
|
|
// 补偿通知:检查过去 2 分钟内到期的提醒
|
|
catchUpNotifications(events, now);
|
|
|
|
// 安排未来 24 小时的通知
|
|
scheduleNotifications(events, now, next24h);
|
|
} catch (e) {
|
|
console.error('加载提醒失败:', e);
|
|
}
|
|
};
|
|
|
|
const catchUpNotifications = (events: EventForReminder[], now: number) => {
|
|
const since = now - WINDOW_MS;
|
|
events.forEach((e) => {
|
|
if (e.completed || e.deleted) return;
|
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
|
windowReminders.forEach((remindIso) => {
|
|
const key = `${e.id}|${remindIso}`;
|
|
if (!notifiedKeysRef.current.has(key)) {
|
|
triggerNotify(e, remindIso);
|
|
notifiedKeysRef.current.add(key);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const scheduleNotifications = async (
|
|
events: EventForReminder[],
|
|
now: number,
|
|
next24h: number
|
|
) => {
|
|
for (const e of events) {
|
|
if (e.completed || e.deleted) continue;
|
|
const upcoming = computeUpcomingReminders(e, now, next24h);
|
|
for (const remindIso of upcoming) {
|
|
const ts = new Date(remindIso).getTime();
|
|
const delay = ts - now;
|
|
if (delay > 0) {
|
|
const key = `${e.id}|${remindIso}`;
|
|
if (notifiedKeysRef.current.has(key)) continue;
|
|
|
|
try {
|
|
const notificationId = await Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: '提醒',
|
|
body: e.title,
|
|
sound: true,
|
|
},
|
|
trigger: {
|
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
|
seconds: delay / 1000,
|
|
} as any,
|
|
});
|
|
scheduledNotificationIdsRef.current.add(notificationId);
|
|
console.log('已安排通知:', e.title, '在', new Date(ts).toLocaleString());
|
|
} catch (e) {
|
|
console.error('安排通知失败:', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const triggerNotify = (e: EventForReminder, remindIso: string) => {
|
|
Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: '提醒',
|
|
body: e.title,
|
|
sound: true,
|
|
},
|
|
trigger: null, // 立即触发
|
|
});
|
|
};
|
|
|
|
const startReminderHeartbeat = () => {
|
|
if (heartbeatTimerRef.current) {
|
|
clearInterval(heartbeatTimerRef.current);
|
|
}
|
|
|
|
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
|
heartbeatTimerRef.current = setInterval(async () => {
|
|
const now = Date.now();
|
|
const since = now - WINDOW_MS;
|
|
|
|
try {
|
|
const events = await getEvents(new Date());
|
|
events.forEach((e: EventForReminder) => {
|
|
if (e.completed || e.deleted) return;
|
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
|
windowReminders.forEach((remindIso) => {
|
|
const key = `${e.id}|${remindIso}`;
|
|
if (!notifiedKeysRef.current.has(key)) {
|
|
triggerNotify(e, remindIso);
|
|
notifiedKeysRef.current.add(key);
|
|
}
|
|
});
|
|
});
|
|
} catch (e) {
|
|
console.error('心跳检查失败:', e);
|
|
}
|
|
}, 60 * 1000);
|
|
};
|
|
|
|
return (
|
|
<NavigationContainer>
|
|
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
|
<Tab.Navigator
|
|
screenOptions={{
|
|
tabBarActiveTintColor: theme.colors.primary,
|
|
tabBarInactiveTintColor: theme.colors.textSecondary,
|
|
tabBarStyle: {
|
|
backgroundColor: theme.colors.surface,
|
|
borderTopColor: theme.colors.border,
|
|
paddingTop: 8,
|
|
paddingBottom: 24,
|
|
height: 70,
|
|
},
|
|
headerStyle: {
|
|
backgroundColor: theme.colors.background,
|
|
},
|
|
headerTintColor: theme.colors.text,
|
|
headerTitleStyle: {
|
|
fontWeight: 'bold',
|
|
},
|
|
}}
|
|
>
|
|
<Tab.Screen
|
|
name="Today"
|
|
component={TodayScreen}
|
|
options={{
|
|
title: '今日',
|
|
tabBarIcon: ({ color, size }) => (
|
|
<Ionicons name="today" size={size} color={color} />
|
|
),
|
|
}}
|
|
/>
|
|
<Tab.Screen
|
|
name="Calendar"
|
|
component={CalendarScreen}
|
|
options={{
|
|
title: '日历',
|
|
tabBarIcon: ({ color, size }) => (
|
|
<Ionicons name="calendar" size={size} color={color} />
|
|
),
|
|
}}
|
|
/>
|
|
</Tab.Navigator>
|
|
</NavigationContainer>
|
|
);
|
|
}
|