feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user