feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+246
View File
@@ -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>
);
}