feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
This commit is contained in:
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.17",
|
"version": "1.0.18",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
||||||
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
||||||
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { NotificationErrorAlert } from '@/components/ui/NotificationErrorAlert';
|
||||||
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
|
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { useCountdown } from '@/hooks/useCountdown';
|
import { useCountdown } from '@/hooks/useCountdown';
|
||||||
|
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
|
||||||
import {
|
import {
|
||||||
clearActiveSchedule,
|
clearActiveSchedule,
|
||||||
rescheduleActivePlan,
|
rescheduleActivePlan,
|
||||||
@@ -21,31 +20,24 @@ import {
|
|||||||
getFastingPhase,
|
getFastingPhase,
|
||||||
getPhaseLabel,
|
getPhaseLabel,
|
||||||
loadPreferredPlanId,
|
loadPreferredPlanId,
|
||||||
loadStoredFastingNotificationIds,
|
savePreferredPlanId
|
||||||
savePreferredPlanId,
|
|
||||||
} from '@/utils/fasting';
|
} from '@/utils/fasting';
|
||||||
import type { FastingNotificationIds } from '@/utils/fasting';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications';
|
|
||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function FastingTabScreen() {
|
export default function FastingTabScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const theme = useColorScheme() ?? 'light';
|
const insets = useSafeAreaInsets();
|
||||||
const colorTokens = Colors[theme];
|
|
||||||
|
|
||||||
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||||
const activePlan = useAppSelector(selectActiveFastingPlan);
|
const activePlan = useAppSelector(selectActiveFastingPlan);
|
||||||
|
|
||||||
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
|
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
|
||||||
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined);
|
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined);
|
||||||
const [notificationsReady, setNotificationsReady] = useState(false);
|
|
||||||
const notificationsLoadedRef = useRef(false);
|
|
||||||
const notificationIdsRef = useRef<FastingNotificationIds>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePlan?.id) return;
|
if (!activePlan?.id) return;
|
||||||
@@ -75,34 +67,31 @@ export default function FastingTabScreen() {
|
|||||||
};
|
};
|
||||||
}, [activePlan?.id]);
|
}, [activePlan?.id]);
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const checkNotifications = async () => {
|
|
||||||
const ready = await ensureFastingNotificationsReady();
|
|
||||||
if (!cancelled) {
|
|
||||||
setNotificationsReady(ready);
|
|
||||||
if (!ready) {
|
|
||||||
notificationsLoadedRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkNotifications();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const currentPlan: FastingPlan | undefined = useMemo(() => {
|
const currentPlan: FastingPlan | undefined = useMemo(() => {
|
||||||
if (activePlan) return activePlan;
|
if (activePlan) return activePlan;
|
||||||
if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan;
|
if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan;
|
||||||
return defaultPlan;
|
return defaultPlan;
|
||||||
}, [activePlan, preferredPlanId, defaultPlan]);
|
}, [activePlan, preferredPlanId, defaultPlan]);
|
||||||
|
|
||||||
|
// 使用新的通知管理 hook
|
||||||
|
const {
|
||||||
|
isReady: notificationsReady,
|
||||||
|
isLoading: notificationsLoading,
|
||||||
|
error: notificationError,
|
||||||
|
notificationIds,
|
||||||
|
lastSyncTime,
|
||||||
|
verifyAndSync,
|
||||||
|
forceSync,
|
||||||
|
clearError,
|
||||||
|
} = useFastingNotifications(activeSchedule, currentPlan);
|
||||||
|
|
||||||
|
// 每次进入页面时验证通知
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
verifyAndSync();
|
||||||
|
}, [verifyAndSync])
|
||||||
|
);
|
||||||
|
|
||||||
const scheduleStart = useMemo(() => {
|
const scheduleStart = useMemo(() => {
|
||||||
if (activeSchedule) return new Date(activeSchedule.startISO);
|
if (activeSchedule) return new Date(activeSchedule.startISO);
|
||||||
if (currentPlan) return getRecommendedStart(currentPlan);
|
if (currentPlan) return getRecommendedStart(currentPlan);
|
||||||
@@ -137,59 +126,33 @@ export default function FastingTabScreen() {
|
|||||||
|
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
|
||||||
|
// 显示通知错误(如果有)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notificationsReady) return;
|
if (notificationError) {
|
||||||
|
console.warn('断食通知错误:', notificationError);
|
||||||
let cancelled = false;
|
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
|
||||||
const verifyPreference = async () => {
|
|
||||||
const enabled = await getNotificationEnabled();
|
|
||||||
if (!cancelled && !enabled) {
|
|
||||||
setNotificationsReady(false);
|
|
||||||
notificationsLoadedRef.current = false;
|
|
||||||
}
|
}
|
||||||
};
|
}, [notificationError]);
|
||||||
|
|
||||||
verifyPreference();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [notificationsReady]);
|
|
||||||
|
|
||||||
const recommendedDate = useMemo(() => {
|
const recommendedDate = useMemo(() => {
|
||||||
if (!currentPlan) return undefined;
|
if (!currentPlan) return undefined;
|
||||||
return getRecommendedStart(currentPlan);
|
return getRecommendedStart(currentPlan);
|
||||||
}, [currentPlan]);
|
}, [currentPlan]);
|
||||||
|
|
||||||
|
// 调试信息(开发环境)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (__DEV__ && lastSyncTime) {
|
||||||
|
console.log('断食通知状态:', {
|
||||||
const syncNotifications = async () => {
|
ready: notificationsReady,
|
||||||
if (!notificationsLoadedRef.current) {
|
loading: notificationsLoading,
|
||||||
const storedIds = await loadStoredFastingNotificationIds();
|
error: notificationError,
|
||||||
if (cancelled) return;
|
notificationIds,
|
||||||
notificationIdsRef.current = storedIds;
|
lastSyncTime,
|
||||||
notificationsLoadedRef.current = true;
|
schedule: activeSchedule?.startISO,
|
||||||
}
|
plan: currentPlan?.id,
|
||||||
|
|
||||||
const nextIds = await resyncFastingNotifications({
|
|
||||||
schedule: activeSchedule ?? null,
|
|
||||||
plan: notificationsReady ? currentPlan : undefined,
|
|
||||||
previousIds: notificationIdsRef.current,
|
|
||||||
enabled: notificationsReady,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
notificationIdsRef.current = nextIds;
|
|
||||||
}
|
}
|
||||||
};
|
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
|
||||||
|
|
||||||
syncNotifications();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
|
|
||||||
|
|
||||||
const handleAdjustStart = () => {
|
const handleAdjustStart = () => {
|
||||||
if (!currentPlan) return;
|
if (!currentPlan) return;
|
||||||
@@ -199,9 +162,9 @@ export default function FastingTabScreen() {
|
|||||||
const handleConfirmStart = (date: Date) => {
|
const handleConfirmStart = (date: Date) => {
|
||||||
if (!currentPlan) return;
|
if (!currentPlan) return;
|
||||||
if (activeSchedule) {
|
if (activeSchedule) {
|
||||||
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date, origin: 'manual' }));
|
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date.toISOString(), origin: 'manual' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,9 +181,12 @@ export default function FastingTabScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}>
|
<View style={[styles.safeArea]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]}
|
contentContainerStyle={[styles.scrollContainer, {
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: 120
|
||||||
|
}]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
@@ -228,6 +194,13 @@ export default function FastingTabScreen() {
|
|||||||
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 通知错误提示 */}
|
||||||
|
<NotificationErrorAlert
|
||||||
|
error={notificationError}
|
||||||
|
onRetry={forceSync}
|
||||||
|
onDismiss={clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
{currentPlan && (
|
{currentPlan && (
|
||||||
<FastingOverviewCard
|
<FastingOverviewCard
|
||||||
plan={currentPlan}
|
plan={currentPlan}
|
||||||
@@ -281,17 +254,19 @@ export default function FastingTabScreen() {
|
|||||||
recommendedDate={recommendedDate}
|
recommendedDate={recommendedDate}
|
||||||
onConfirm={handleConfirmStart}
|
onConfirm={handleConfirmStart}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
backgroundColor: 'white'
|
||||||
},
|
},
|
||||||
scrollContainer: {
|
scrollContainer: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 12,
|
paddingTop: 12,
|
||||||
|
|
||||||
},
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default function PersonalScreen() {
|
|||||||
{isLgAvaliable ? (
|
{isLgAvaliable ? (
|
||||||
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
<GlassView style={styles.editButtonGlass}>
|
<GlassView style={styles.editButtonGlass}>
|
||||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
</GlassView>
|
</GlassView>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
@@ -511,6 +511,11 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
editButtonText: {
|
editButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
editButtonTextGlass: {
|
||||||
color: 'rgba(147, 112, 219, 1)',
|
color: 'rgba(147, 112, 219, 1)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
|||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { workoutMonitorService } from '@/services/workoutMonitor';
|
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
|
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||||
|
import { loadActiveFastingSchedule } from '@/utils/fasting';
|
||||||
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
@@ -35,12 +37,42 @@ import { Provider } from 'react-redux';
|
|||||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { profile } = useAppSelector((state) => state.user);
|
const { profile } = useAppSelector((state) => state.user);
|
||||||
|
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||||
const { isLoggedIn } = useAuthGuard()
|
const { isLoggedIn } = useAuthGuard()
|
||||||
|
const fastingHydrationRequestedRef = React.useRef(false);
|
||||||
|
|
||||||
// 初始化快捷动作处理
|
// 初始化快捷动作处理
|
||||||
useQuickActions();
|
useQuickActions();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (fastingHydrationRequestedRef.current) return;
|
||||||
|
if (activeFastingSchedule) {
|
||||||
|
fastingHydrationRequestedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fastingHydrationRequestedRef.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const hydrate = async () => {
|
||||||
|
try {
|
||||||
|
const stored = await loadActiveFastingSchedule();
|
||||||
|
if (cancelled || !stored) return;
|
||||||
|
if (store.getState().fasting.activeSchedule) return;
|
||||||
|
dispatch(hydrateActiveSchedule(stored));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('恢复断食计划失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hydrate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dispatch, activeFastingSchedule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
dispatch(fetchChallenges());
|
dispatch(fetchChallenges());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { log, logger, LogEntry } from '@/utils/logger';
|
import { FastingNotificationTestPanel } from '@/components/developer/FastingNotificationTestPanel';
|
||||||
|
import { log, LogEntry, logger } from '@/utils/logger';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -19,6 +20,7 @@ export default function LogsScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [showTestPanel, setShowTestPanel] = useState(false);
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +70,10 @@ export default function LogsScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTestNotifications = () => {
|
||||||
|
setShowTestPanel(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
// 添加测试日志
|
// 添加测试日志
|
||||||
@@ -148,6 +154,9 @@ export default function LogsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.title}>日志 ({logs.length})</Text>
|
<Text style={styles.title}>日志 ({logs.length})</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
|
<TouchableOpacity onPress={handleTestNotifications} style={styles.actionButton}>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color="#FF8800" />
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
|
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
|
||||||
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -186,6 +195,13 @@ export default function LogsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 断食通知测试面板 */}
|
||||||
|
{showTestPanel && (
|
||||||
|
<FastingNotificationTestPanel
|
||||||
|
onClose={() => setShowTestPanel(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function FastingPlanDetailScreen() {
|
|||||||
const displayWindow = buildDisplayWindow(window.start, window.end);
|
const displayWindow = buildDisplayWindow(window.start, window.end);
|
||||||
|
|
||||||
const handleStartWithRecommended = () => {
|
const handleStartWithRecommended = () => {
|
||||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart, origin: 'recommended' }));
|
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart.toISOString(), origin: 'recommended' }));
|
||||||
router.replace(ROUTES.TAB_FASTING);
|
router.replace(ROUTES.TAB_FASTING);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,9 +65,9 @@ export default function FastingPlanDetailScreen() {
|
|||||||
|
|
||||||
const handleConfirmPicker = (date: Date) => {
|
const handleConfirmPicker = (date: Date) => {
|
||||||
if (activeSchedule?.planId === plan.id) {
|
if (activeSchedule?.planId === plan.id) {
|
||||||
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' }));
|
dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' }));
|
||||||
}
|
}
|
||||||
setShowPicker(false);
|
setShowPicker(false);
|
||||||
router.replace(ROUTES.TAB_FASTING);
|
router.replace(ROUTES.TAB_FASTING);
|
||||||
|
|||||||
229
components/developer/FastingNotificationTestPanel.tsx
Normal file
229
components/developer/FastingNotificationTestPanel.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { FASTING_PLANS, FastingPlan } from '@/constants/Fasting';
|
||||||
|
import { fastingNotificationTester } from '@/utils/fastingNotificationTest';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface FastingNotificationTestPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FastingNotificationTestPanel: React.FC<FastingNotificationTestPanelProps> = ({
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [testResults, setTestResults] = useState<Array<{
|
||||||
|
testName: string;
|
||||||
|
passed: boolean;
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
const runTest = async (plan: FastingPlan) => {
|
||||||
|
setIsRunning(true);
|
||||||
|
setTestResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await fastingNotificationTester.runFullTestSuite(plan);
|
||||||
|
setTestResults(results);
|
||||||
|
|
||||||
|
const passedCount = results.filter(r => r.passed).length;
|
||||||
|
const totalCount = results.length;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'测试完成',
|
||||||
|
`测试结果: ${passedCount}/${totalCount} 通过`,
|
||||||
|
[{ text: '确定', onPress: () => { } }]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
'测试失败',
|
||||||
|
`测试过程中发生错误: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
[{ text: '确定', onPress: () => { } }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearResults = () => {
|
||||||
|
setTestResults([]);
|
||||||
|
fastingNotificationTester.clearResults();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>断食通知测试</Text>
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Text style={styles.closeButtonText}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>选择测试方案</Text>
|
||||||
|
{FASTING_PLANS.map((plan) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={plan.id}
|
||||||
|
style={[styles.planButton, isRunning && styles.disabledButton]}
|
||||||
|
onPress={() => runTest(plan)}
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
<Text style={styles.planButtonText}>{plan.title}</Text>
|
||||||
|
<Text style={styles.planSubtitle}>{plan.subtitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{testResults.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>测试结果</Text>
|
||||||
|
<TouchableOpacity style={styles.clearButton} onPress={clearResults}>
|
||||||
|
<Text style={styles.clearButtonText}>清除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{testResults.map((result, index) => (
|
||||||
|
<View key={index} style={[
|
||||||
|
styles.resultItem,
|
||||||
|
result.passed ? styles.passedResult : styles.failedResult
|
||||||
|
]}>
|
||||||
|
<Text style={styles.resultTestName}>{result.testName}</Text>
|
||||||
|
<Text style={styles.resultMessage}>{result.message}</Text>
|
||||||
|
<Text style={styles.resultTime}>
|
||||||
|
{result.timestamp.toLocaleTimeString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.runningText}>正在运行测试...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E0E0E0',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333333',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
planButton: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
planButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
planSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
clearButtonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
resultItem: {
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
},
|
||||||
|
passedResult: {
|
||||||
|
backgroundColor: '#F0F9F0',
|
||||||
|
borderLeftColor: '#4CAF50',
|
||||||
|
},
|
||||||
|
failedResult: {
|
||||||
|
backgroundColor: '#FFF3F3',
|
||||||
|
borderLeftColor: '#F44336',
|
||||||
|
},
|
||||||
|
resultTestName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
resultMessage: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
resultTime: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#999999',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
runningText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
});
|
||||||
81
components/ui/NotificationErrorAlert.tsx
Normal file
81
components/ui/NotificationErrorAlert.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface NotificationErrorAlertProps {
|
||||||
|
error: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationErrorAlert: React.FC<NotificationErrorAlertProps> = ({
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
|
}) => {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>通知提醒</Text>
|
||||||
|
<Text style={styles.message}>{error}</Text>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{onRetry && (
|
||||||
|
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
||||||
|
<Text style={styles.buttonText}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{onDismiss && (
|
||||||
|
<TouchableOpacity style={[styles.button, styles.dismissButton]} onPress={onDismiss}>
|
||||||
|
<Text style={styles.buttonText}>忽略</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#FEF2F2',
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: '#EF4444',
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#DC2626',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#7F1D1D',
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#DC2626',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
dismissButton: {
|
||||||
|
backgroundColor: '#9CA3AF',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -29,8 +29,11 @@ export type FastingPlan = {
|
|||||||
export const FASTING_STORAGE_KEYS = {
|
export const FASTING_STORAGE_KEYS = {
|
||||||
preferredPlanId: '@fasting_preferred_plan',
|
preferredPlanId: '@fasting_preferred_plan',
|
||||||
notificationsRegistered: '@fasting_notifications_registered',
|
notificationsRegistered: '@fasting_notifications_registered',
|
||||||
|
preStartNotificationId: '@fasting_notification_pre_start_id',
|
||||||
startNotificationId: '@fasting_notification_start_id',
|
startNotificationId: '@fasting_notification_start_id',
|
||||||
|
preEndNotificationId: '@fasting_notification_pre_end_id',
|
||||||
endNotificationId: '@fasting_notification_end_id',
|
endNotificationId: '@fasting_notification_end_id',
|
||||||
|
activeSchedule: '@fasting_active_schedule',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const FASTING_PLANS: FastingPlan[] = [
|
export const FASTING_PLANS: FastingPlan[] = [
|
||||||
|
|||||||
184
hooks/useFastingNotifications.ts
Normal file
184
hooks/useFastingNotifications.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { FastingPlan } from '@/constants/Fasting';
|
||||||
|
import {
|
||||||
|
ensureFastingNotificationsReady,
|
||||||
|
resyncFastingNotifications,
|
||||||
|
verifyFastingNotifications,
|
||||||
|
} from '@/services/fastingNotifications';
|
||||||
|
import { FastingSchedule } from '@/store/fastingSlice';
|
||||||
|
import { FastingNotificationIds, loadStoredFastingNotificationIds } from '@/utils/fasting';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface UseFastingNotificationsState {
|
||||||
|
isReady: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
notificationIds: FastingNotificationIds;
|
||||||
|
lastSyncTime: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFastingNotificationsActions {
|
||||||
|
verifyAndSync: () => Promise<void>;
|
||||||
|
forceSync: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFastingNotifications = (
|
||||||
|
schedule: FastingSchedule | null,
|
||||||
|
plan: FastingPlan | undefined
|
||||||
|
): UseFastingNotificationsState & UseFastingNotificationsActions => {
|
||||||
|
const [state, setState] = useState<UseFastingNotificationsState>({
|
||||||
|
isReady: false,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
notificationIds: {},
|
||||||
|
lastSyncTime: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isInitializedRef = useRef(false);
|
||||||
|
const notificationIdsRef = useRef<FastingNotificationIds>({});
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
|
||||||
|
// 初始化通知系统
|
||||||
|
const initialize = useCallback(async () => {
|
||||||
|
if (isInitializedRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
// 1. 检查通知权限
|
||||||
|
const ready = await ensureFastingNotificationsReady();
|
||||||
|
if (!ready) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isReady: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: '通知权限未授予',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 加载存储的通知ID
|
||||||
|
const storedIds = await loadStoredFastingNotificationIds();
|
||||||
|
notificationIdsRef.current = storedIds;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isReady: true,
|
||||||
|
isLoading: false,
|
||||||
|
notificationIds: storedIds,
|
||||||
|
}));
|
||||||
|
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化断食通知失败', error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isReady: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : '初始化失败',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 验证和同步通知
|
||||||
|
const verifyAndSync = useCallback(async () => {
|
||||||
|
if (!state.isReady || isSyncingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
setState(prev => ({ ...prev, error: null }));
|
||||||
|
|
||||||
|
const { isValid, updatedIds } = await verifyFastingNotifications({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
storedIds: notificationIdsRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationIdsRef.current = updatedIds;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
notificationIds: updatedIds,
|
||||||
|
lastSyncTime: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.log('断食通知已重新同步');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证断食通知失败', error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: error instanceof Error ? error.message : '验证失败',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 验证失败时不立即强制同步,避免重复调用
|
||||||
|
// forceSync 会在用户点击重试按钮时调用
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [state.isReady, schedule, plan]);
|
||||||
|
|
||||||
|
// 强制同步通知
|
||||||
|
const forceSync = useCallback(async () => {
|
||||||
|
if (!state.isReady || isSyncingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
setState(prev => ({ ...prev, error: null }));
|
||||||
|
|
||||||
|
const nextIds = await resyncFastingNotifications({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
previousIds: notificationIdsRef.current,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationIdsRef.current = nextIds;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
notificationIds: nextIds,
|
||||||
|
lastSyncTime: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('断食通知已强制同步', {
|
||||||
|
schedule: schedule?.startISO,
|
||||||
|
plan: plan?.id,
|
||||||
|
notificationIds: nextIds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('强制同步断食通知失败', error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: error instanceof Error ? error.message : '同步失败',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [state.isReady, schedule, plan]);
|
||||||
|
|
||||||
|
// 清除错误
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
// 当计划或方案变化时验证和同步
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.isReady) {
|
||||||
|
verifyAndSync();
|
||||||
|
}
|
||||||
|
}, [state.isReady, schedule?.startISO, schedule?.endISO, plan?.id, verifyAndSync]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
verifyAndSync,
|
||||||
|
forceSync,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -135,7 +135,6 @@
|
|||||||
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
|
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
|
||||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
|
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.17</string>
|
<string>1.0.18</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -55,10 +55,12 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
|
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
|
||||||
|
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
|
||||||
|
<string>Read and understand clinical health data.</string>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
<string>应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。</string>
|
<string>Read and understand health data.</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
<string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string>
|
<string>Share workout data with other apps.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
|
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
@@ -98,12 +100,5 @@
|
|||||||
<string>Light</string>
|
<string>Light</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
|
||||||
<string>Read and understand health data.</string>
|
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
|
||||||
<string>Share workout data with other apps.</string>
|
|
||||||
<!-- Below is only required if requesting clinical health data -->
|
|
||||||
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
|
|
||||||
<string>Read and understand clinical health data.</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -14,5 +14,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>health-records</string>
|
<string>health-records</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,37 +1,59 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
|
||||||
import { FastingPlan } from '@/constants/Fasting';
|
import { FastingPlan } from '@/constants/Fasting';
|
||||||
import { FastingSchedule } from '@/store/fastingSlice';
|
import { FastingSchedule } from '@/store/fastingSlice';
|
||||||
import {
|
import {
|
||||||
clearFastingNotificationIds,
|
clearFastingNotificationIds,
|
||||||
|
FastingNotificationIds,
|
||||||
getFastingNotificationsRegistered,
|
getFastingNotificationsRegistered,
|
||||||
loadStoredFastingNotificationIds,
|
loadStoredFastingNotificationIds,
|
||||||
saveFastingNotificationIds,
|
saveFastingNotificationIds,
|
||||||
setFastingNotificationsRegistered,
|
setFastingNotificationsRegistered,
|
||||||
FastingNotificationIds,
|
|
||||||
} from '@/utils/fasting';
|
} from '@/utils/fasting';
|
||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import { notificationService, NotificationTypes } from './notifications';
|
import { notificationService, NotificationTypes } from './notifications';
|
||||||
|
|
||||||
const REMINDER_OFFSET_MINUTES = 10;
|
const REMINDER_OFFSET_MINUTES = 30; // 改为30分钟提醒
|
||||||
|
|
||||||
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
|
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
|
||||||
if (!ids) return;
|
if (!ids) return;
|
||||||
const { startId, endId } = ids;
|
const { preStartId, startId, preEndId, endId } = ids;
|
||||||
|
|
||||||
|
// 取消开始前30分钟通知
|
||||||
|
try {
|
||||||
|
if (preStartId) {
|
||||||
|
await notificationService.cancelNotification(preStartId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('取消断食开始前30分钟提醒失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消开始时通知
|
||||||
try {
|
try {
|
||||||
if (startId) {
|
if (startId) {
|
||||||
await notificationService.cancelNotification(startId);
|
await notificationService.cancelNotification(startId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('取消断食开始提醒失败', error);
|
console.warn('取消断食开始时提醒失败', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消结束前30分钟通知
|
||||||
|
try {
|
||||||
|
if (preEndId) {
|
||||||
|
await notificationService.cancelNotification(preEndId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('取消断食结束前30分钟提醒失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消结束时通知
|
||||||
try {
|
try {
|
||||||
if (endId) {
|
if (endId) {
|
||||||
await notificationService.cancelNotification(endId);
|
await notificationService.cancelNotification(endId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('取消断食结束提醒失败', error);
|
console.warn('取消断食结束时提醒失败', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,33 +130,83 @@ export const resyncFastingNotifications = async ({
|
|||||||
|
|
||||||
const notificationIds: FastingNotificationIds = {};
|
const notificationIds: FastingNotificationIds = {};
|
||||||
|
|
||||||
|
// 1. 安排开始前30分钟通知
|
||||||
if (start.isAfter(now)) {
|
if (start.isAfter(now)) {
|
||||||
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||||
const triggerMoment = preStart.isAfter(now) ? preStart : start;
|
|
||||||
|
|
||||||
|
// 只有当开始前30分钟还在未来时才安排这个通知
|
||||||
|
if (preStart.isAfter(now)) {
|
||||||
try {
|
try {
|
||||||
const startId = await notificationService.scheduleNotificationAtDate(
|
const preStartId = await notificationService.scheduleNotificationAtDate(
|
||||||
{
|
{
|
||||||
title: `${plan.title} 即将开始`,
|
title: `${plan.title} 即将开始`,
|
||||||
body: preStart.isAfter(now)
|
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||||
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
|
|
||||||
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
|
||||||
data: {
|
data: {
|
||||||
type: NotificationTypes.FASTING_START,
|
type: NotificationTypes.FASTING_START,
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
|
subtype: 'pre_start',
|
||||||
},
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
},
|
},
|
||||||
triggerMoment.toDate()
|
preStart.toDate()
|
||||||
);
|
);
|
||||||
notificationIds.startId = startId;
|
notificationIds.preStartId = preStartId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('安排断食开始通知失败', error);
|
console.error('安排断食开始前30分钟通知失败', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 安排开始时通知
|
||||||
|
try {
|
||||||
|
const startId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: `${plan.title} 开始了`,
|
||||||
|
body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_START,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'start',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
start.toDate()
|
||||||
|
);
|
||||||
|
notificationIds.startId = startId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食开始时通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 安排结束前30分钟通知
|
||||||
if (end.isAfter(now)) {
|
if (end.isAfter(now)) {
|
||||||
|
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||||
|
|
||||||
|
// 只有当结束前30分钟还在未来时才安排这个通知
|
||||||
|
if (preEnd.isAfter(now)) {
|
||||||
|
try {
|
||||||
|
const preEndId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: '即将结束断食',
|
||||||
|
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_END,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'pre_end',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
preEnd.toDate()
|
||||||
|
);
|
||||||
|
notificationIds.preEndId = preEndId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食结束前30分钟通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 安排结束时通知
|
||||||
try {
|
try {
|
||||||
const endId = await notificationService.scheduleNotificationAtDate(
|
const endId = await notificationService.scheduleNotificationAtDate(
|
||||||
{
|
{
|
||||||
@@ -143,6 +215,7 @@ export const resyncFastingNotifications = async ({
|
|||||||
data: {
|
data: {
|
||||||
type: NotificationTypes.FASTING_END,
|
type: NotificationTypes.FASTING_END,
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
|
subtype: 'end',
|
||||||
},
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
@@ -151,7 +224,7 @@ export const resyncFastingNotifications = async ({
|
|||||||
);
|
);
|
||||||
notificationIds.endId = endId;
|
notificationIds.endId = endId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('安排断食结束通知失败', error);
|
console.error('安排断食结束时通知失败', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,4 +232,255 @@ export const resyncFastingNotifications = async ({
|
|||||||
return notificationIds;
|
return notificationIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择性同步通知,只更新需要更新的通知
|
||||||
|
* @param schedule 断食计划
|
||||||
|
* @param plan 断食方案
|
||||||
|
* @param storedIds 存储的通知ID
|
||||||
|
* @param validIds 有效的通知ID
|
||||||
|
* @param enabled 是否启用通知
|
||||||
|
* @returns 更新后的通知ID
|
||||||
|
*/
|
||||||
|
const selectiveSyncNotifications = async ({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
storedIds,
|
||||||
|
validIds,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
schedule: FastingSchedule | null;
|
||||||
|
plan?: FastingPlan;
|
||||||
|
storedIds: FastingNotificationIds;
|
||||||
|
validIds: FastingNotificationIds;
|
||||||
|
enabled: boolean;
|
||||||
|
}): Promise<FastingNotificationIds> => {
|
||||||
|
if (!enabled || !schedule || !plan) {
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
const start = dayjs(schedule.startISO);
|
||||||
|
const end = dayjs(schedule.endISO);
|
||||||
|
|
||||||
|
if (end.isBefore(now)) {
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedIds: FastingNotificationIds = { ...validIds };
|
||||||
|
|
||||||
|
// 1. 检查开始前30分钟通知
|
||||||
|
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||||
|
if (preStart.isAfter(now) && !validIds.preStartId) {
|
||||||
|
try {
|
||||||
|
const preStartId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: `${plan.title} 即将开始`,
|
||||||
|
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_START,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'pre_start',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
preStart.toDate()
|
||||||
|
);
|
||||||
|
updatedIds.preStartId = preStartId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食开始前30分钟通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查开始时通知
|
||||||
|
if (start.isAfter(now) && !validIds.startId) {
|
||||||
|
try {
|
||||||
|
const startId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: `${plan.title} 开始了`,
|
||||||
|
body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_START,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'start',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
start.toDate()
|
||||||
|
);
|
||||||
|
updatedIds.startId = startId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食开始时通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查结束前30分钟通知
|
||||||
|
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||||
|
if (preEnd.isAfter(now) && !validIds.preEndId) {
|
||||||
|
try {
|
||||||
|
const preEndId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: '即将结束断食',
|
||||||
|
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_END,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'pre_end',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
preEnd.toDate()
|
||||||
|
);
|
||||||
|
updatedIds.preEndId = preEndId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食结束前30分钟通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查结束时通知
|
||||||
|
if (end.isAfter(now) && !validIds.endId) {
|
||||||
|
try {
|
||||||
|
const endId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: '补能时刻到啦',
|
||||||
|
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_END,
|
||||||
|
planId: plan.id,
|
||||||
|
subtype: 'end',
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
end.toDate()
|
||||||
|
);
|
||||||
|
updatedIds.endId = endId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食结束时通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveFastingNotificationIds(updatedIds);
|
||||||
|
return updatedIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证现有通知是否正确设置
|
||||||
|
* @param schedule 当前断食计划
|
||||||
|
* @param plan 当前断食方案
|
||||||
|
* @param storedIds 存储的通知ID
|
||||||
|
* @returns 验证结果和需要更新的通知ID
|
||||||
|
*/
|
||||||
|
export const verifyFastingNotifications = async ({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
storedIds,
|
||||||
|
}: {
|
||||||
|
schedule: FastingSchedule | null;
|
||||||
|
plan?: FastingPlan;
|
||||||
|
storedIds: FastingNotificationIds;
|
||||||
|
}): Promise<{ isValid: boolean; updatedIds: FastingNotificationIds }> => {
|
||||||
|
if (!schedule || !plan) {
|
||||||
|
// 如果没有计划或方案,应该清空所有通知
|
||||||
|
if (Object.values(storedIds).some(id => id)) {
|
||||||
|
await cancelNotificationIds(storedIds);
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return { isValid: false, updatedIds: {} };
|
||||||
|
}
|
||||||
|
return { isValid: true, updatedIds: storedIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
const start = dayjs(schedule.startISO);
|
||||||
|
const end = dayjs(schedule.endISO);
|
||||||
|
|
||||||
|
// 如果断食期已结束,应该清空所有通知
|
||||||
|
if (end.isBefore(now)) {
|
||||||
|
if (Object.values(storedIds).some(id => id)) {
|
||||||
|
await cancelNotificationIds(storedIds);
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return { isValid: false, updatedIds: {} };
|
||||||
|
}
|
||||||
|
return { isValid: true, updatedIds: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前已安排的通知
|
||||||
|
let scheduledNotifications: Notifications.NotificationRequest[] = [];
|
||||||
|
try {
|
||||||
|
scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取已安排通知失败', error);
|
||||||
|
// 如果获取通知失败,不要立即重新同步,而是返回当前存储的ID
|
||||||
|
// 这样可以避免可能的重复通知问题
|
||||||
|
return { isValid: false, updatedIds: storedIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个通知是否存在且正确
|
||||||
|
const expectedNotifications = [
|
||||||
|
{ id: storedIds.preStartId, type: 'pre_start', time: start.subtract(REMINDER_OFFSET_MINUTES, 'minute') },
|
||||||
|
{ id: storedIds.startId, type: 'start', time: start },
|
||||||
|
{ id: storedIds.preEndId, type: 'pre_end', time: end.subtract(REMINDER_OFFSET_MINUTES, 'minute') },
|
||||||
|
{ id: storedIds.endId, type: 'end', time: end },
|
||||||
|
];
|
||||||
|
|
||||||
|
let needsResync = false;
|
||||||
|
const validIds: FastingNotificationIds = {};
|
||||||
|
|
||||||
|
for (const expected of expectedNotifications) {
|
||||||
|
// 跳过已过期的通知
|
||||||
|
if (expected.time.isBefore(now)) {
|
||||||
|
if (expected.id) {
|
||||||
|
needsResync = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expected.id) {
|
||||||
|
needsResync = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查通知是否还存在
|
||||||
|
const exists = scheduledNotifications.some(n => n.identifier === expected.id);
|
||||||
|
if (!exists) {
|
||||||
|
needsResync = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知存在且有效
|
||||||
|
switch (expected.type) {
|
||||||
|
case 'pre_start':
|
||||||
|
validIds.preStartId = expected.id;
|
||||||
|
break;
|
||||||
|
case 'start':
|
||||||
|
validIds.startId = expected.id;
|
||||||
|
break;
|
||||||
|
case 'pre_end':
|
||||||
|
validIds.preEndId = expected.id;
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
validIds.endId = expected.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsResync) {
|
||||||
|
// 只同步需要更新的通知,而不是全部重新同步
|
||||||
|
const updatedIds = await selectiveSyncNotifications({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
storedIds,
|
||||||
|
validIds,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
return { isValid: false, updatedIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, updatedIds: validIds };
|
||||||
|
};
|
||||||
|
|
||||||
export type { FastingNotificationIds };
|
export type { FastingNotificationIds };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting';
|
import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting';
|
||||||
import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting';
|
import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting';
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { RootState } from './index';
|
import type { RootState } from './index';
|
||||||
|
|
||||||
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start';
|
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start';
|
||||||
@@ -29,14 +29,35 @@ const fastingSlice = createSlice({
|
|||||||
name: 'fasting',
|
name: 'fasting',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
hydrateActiveSchedule: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<FastingSchedule | null>
|
||||||
|
) => {
|
||||||
|
const incoming = action.payload;
|
||||||
|
if (!incoming) {
|
||||||
|
state.activeSchedule = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeSchedule) {
|
||||||
|
const currentUpdated = dayjs(state.activeSchedule.updatedAtISO ?? state.activeSchedule.startISO);
|
||||||
|
const incomingUpdated = dayjs(incoming.updatedAtISO ?? incoming.startISO);
|
||||||
|
if (currentUpdated.isSame(incomingUpdated) || currentUpdated.isAfter(incomingUpdated)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activeSchedule = incoming;
|
||||||
|
},
|
||||||
scheduleFastingPlan: (
|
scheduleFastingPlan: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ planId: string; start: Date; origin?: FastingScheduleOrigin }>
|
action: PayloadAction<{ planId: string; start: string; origin?: FastingScheduleOrigin }>
|
||||||
) => {
|
) => {
|
||||||
const plan = getPlanById(action.payload.planId);
|
const plan = getPlanById(action.payload.planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
|
|
||||||
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
|
const startDate = new Date(action.payload.start);
|
||||||
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
||||||
const nowISO = new Date().toISOString();
|
const nowISO = new Date().toISOString();
|
||||||
state.activeSchedule = {
|
state.activeSchedule = {
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
@@ -49,13 +70,14 @@ const fastingSlice = createSlice({
|
|||||||
},
|
},
|
||||||
rescheduleActivePlan: (
|
rescheduleActivePlan: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ start: Date; origin?: FastingScheduleOrigin }>
|
action: PayloadAction<{ start: string; origin?: FastingScheduleOrigin }>
|
||||||
) => {
|
) => {
|
||||||
if (!state.activeSchedule) return;
|
if (!state.activeSchedule) return;
|
||||||
const plan = getPlanById(state.activeSchedule.planId);
|
const plan = getPlanById(state.activeSchedule.planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
|
|
||||||
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
|
const startDate = new Date(action.payload.start);
|
||||||
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
||||||
state.activeSchedule = {
|
state.activeSchedule = {
|
||||||
...state.activeSchedule,
|
...state.activeSchedule,
|
||||||
startISO: start.toISOString(),
|
startISO: start.toISOString(),
|
||||||
@@ -66,11 +88,12 @@ const fastingSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setRecommendedSchedule: (
|
setRecommendedSchedule: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ planId: string; recommendedStart: Date }>
|
action: PayloadAction<{ planId: string; recommendedStart: string }>
|
||||||
) => {
|
) => {
|
||||||
const plan = getPlanById(action.payload.planId);
|
const plan = getPlanById(action.payload.planId);
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
const { start, end } = calculateFastingWindow(action.payload.recommendedStart, plan.fastingHours);
|
const startDate = new Date(action.payload.recommendedStart);
|
||||||
|
const { start, end } = calculateFastingWindow(startDate, plan.fastingHours);
|
||||||
const nowISO = new Date().toISOString();
|
const nowISO = new Date().toISOString();
|
||||||
state.activeSchedule = {
|
state.activeSchedule = {
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
@@ -107,6 +130,7 @@ const fastingSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
hydrateActiveSchedule,
|
||||||
scheduleFastingPlan,
|
scheduleFastingPlan,
|
||||||
rescheduleActivePlan,
|
rescheduleActivePlan,
|
||||||
setRecommendedSchedule,
|
setRecommendedSchedule,
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import foodLibraryReducer from './foodLibrarySlice';
|
|||||||
import foodRecognitionReducer from './foodRecognitionSlice';
|
import foodRecognitionReducer from './foodRecognitionSlice';
|
||||||
import goalsReducer from './goalsSlice';
|
import goalsReducer from './goalsSlice';
|
||||||
import healthReducer from './healthSlice';
|
import healthReducer from './healthSlice';
|
||||||
import fastingReducer from './fastingSlice';
|
import fastingReducer, {
|
||||||
|
clearActiveSchedule,
|
||||||
|
completeActiveSchedule,
|
||||||
|
rescheduleActivePlan,
|
||||||
|
scheduleFastingPlan,
|
||||||
|
setRecommendedSchedule,
|
||||||
|
} from './fastingSlice';
|
||||||
import moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import nutritionReducer from './nutritionSlice';
|
import nutritionReducer from './nutritionSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
@@ -16,6 +22,7 @@ import trainingPlanReducer from './trainingPlanSlice';
|
|||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
import waterReducer from './waterSlice';
|
import waterReducer from './waterSlice';
|
||||||
import workoutReducer from './workoutSlice';
|
import workoutReducer from './workoutSlice';
|
||||||
|
import { persistActiveFastingSchedule } from '@/utils/fasting';
|
||||||
|
|
||||||
// 创建监听器中间件来处理自动同步
|
// 创建监听器中间件来处理自动同步
|
||||||
const listenerMiddleware = createListenerMiddleware();
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
@@ -45,6 +52,46 @@ syncActions.forEach(action => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistFastingState = async (listenerApi: any) => {
|
||||||
|
const state = listenerApi.getState() as { fasting?: { activeSchedule?: any } };
|
||||||
|
await persistActiveFastingSchedule(state?.fasting?.activeSchedule ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: scheduleFastingPlan,
|
||||||
|
effect: async (_, listenerApi) => {
|
||||||
|
await persistFastingState(listenerApi);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: rescheduleActivePlan,
|
||||||
|
effect: async (_, listenerApi) => {
|
||||||
|
await persistFastingState(listenerApi);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: completeActiveSchedule,
|
||||||
|
effect: async (_, listenerApi) => {
|
||||||
|
await persistFastingState(listenerApi);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: setRecommendedSchedule,
|
||||||
|
effect: async (_, listenerApi) => {
|
||||||
|
await persistFastingState(listenerApi);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: clearActiveSchedule,
|
||||||
|
effect: async () => {
|
||||||
|
await persistActiveFastingSchedule(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
|
||||||
|
import type { FastingSchedule } from '@/store/fastingSlice';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
|
||||||
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
|
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
dayjs.extend(isSameOrAfter);
|
dayjs.extend(isSameOrAfter);
|
||||||
@@ -100,6 +101,47 @@ export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadActiveFastingSchedule = async (): Promise<FastingSchedule | null> => {
|
||||||
|
try {
|
||||||
|
const stored = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.activeSchedule);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored) as Partial<FastingSchedule>;
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed.planId !== 'string' ||
|
||||||
|
typeof parsed.startISO !== 'string' ||
|
||||||
|
typeof parsed.endISO !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
planId: parsed.planId,
|
||||||
|
startISO: parsed.startISO,
|
||||||
|
endISO: parsed.endISO,
|
||||||
|
createdAtISO: parsed.createdAtISO ?? parsed.startISO,
|
||||||
|
updatedAtISO: parsed.updatedAtISO ?? parsed.endISO,
|
||||||
|
origin: parsed.origin ?? 'manual',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取断食计划失败', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistActiveFastingSchedule = async (schedule: FastingSchedule | null) => {
|
||||||
|
try {
|
||||||
|
if (schedule) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.activeSchedule, JSON.stringify(schedule));
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.activeSchedule);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存断食计划失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const loadPreferredPlanId = async (): Promise<string | null> => {
|
export const loadPreferredPlanId = async (): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
|
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
|
||||||
@@ -118,8 +160,10 @@ export const savePreferredPlanId = async (planId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FastingNotificationIds = {
|
export type FastingNotificationIds = {
|
||||||
startId?: string | null;
|
preStartId?: string | null; // 开始前30分钟
|
||||||
endId?: string | null;
|
startId?: string | null; // 开始时
|
||||||
|
preEndId?: string | null; // 结束前30分钟
|
||||||
|
endId?: string | null; // 结束时
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
|
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
|
||||||
@@ -146,13 +190,17 @@ export const setFastingNotificationsRegistered = async (registered: boolean) =>
|
|||||||
|
|
||||||
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
|
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
|
||||||
try {
|
try {
|
||||||
const [startId, endId] = await Promise.all([
|
const [preStartId, startId, preEndId, endId] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preStartNotificationId),
|
||||||
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
|
||||||
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preEndNotificationId),
|
||||||
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
preStartId: preStartId ?? undefined,
|
||||||
startId: startId ?? undefined,
|
startId: startId ?? undefined,
|
||||||
|
preEndId: preEndId ?? undefined,
|
||||||
endId: endId ?? undefined,
|
endId: endId ?? undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -163,12 +211,28 @@ export const loadStoredFastingNotificationIds = async (): Promise<FastingNotific
|
|||||||
|
|
||||||
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
|
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
|
||||||
try {
|
try {
|
||||||
|
// 保存开始前30分钟通知ID
|
||||||
|
if (ids.preStartId) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preStartNotificationId, ids.preStartId);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存开始时通知ID
|
||||||
if (ids.startId) {
|
if (ids.startId) {
|
||||||
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
|
||||||
} else {
|
} else {
|
||||||
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存结束前30分钟通知ID
|
||||||
|
if (ids.preEndId) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preEndNotificationId, ids.preEndId);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存结束时通知ID
|
||||||
if (ids.endId) {
|
if (ids.endId) {
|
||||||
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
|
||||||
} else {
|
} else {
|
||||||
@@ -182,7 +246,9 @@ export const saveFastingNotificationIds = async (ids: FastingNotificationIds) =>
|
|||||||
export const clearFastingNotificationIds = async () => {
|
export const clearFastingNotificationIds = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId),
|
||||||
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
|
||||||
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId),
|
||||||
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
341
utils/fastingNotificationTest.ts
Normal file
341
utils/fastingNotificationTest.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { FastingPlan } from '@/constants/Fasting';
|
||||||
|
import {
|
||||||
|
ensureFastingNotificationsReady,
|
||||||
|
resyncFastingNotifications,
|
||||||
|
verifyFastingNotifications
|
||||||
|
} from '@/services/fastingNotifications';
|
||||||
|
import { notificationService } from '@/services/notifications';
|
||||||
|
import { FastingSchedule } from '@/store/fastingSlice';
|
||||||
|
import { FastingNotificationIds } from '@/utils/fasting';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断食通知系统测试工具
|
||||||
|
* 用于验证通知系统在各种场景下的可靠性
|
||||||
|
*/
|
||||||
|
export class FastingNotificationTester {
|
||||||
|
private testResults: Array<{
|
||||||
|
testName: string;
|
||||||
|
passed: boolean;
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
private logResult(testName: string, passed: boolean, message: string) {
|
||||||
|
const result = {
|
||||||
|
testName,
|
||||||
|
passed,
|
||||||
|
message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
this.testResults.push(result);
|
||||||
|
|
||||||
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${testName}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取测试结果
|
||||||
|
*/
|
||||||
|
getTestResults() {
|
||||||
|
return this.testResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除测试结果
|
||||||
|
*/
|
||||||
|
clearResults() {
|
||||||
|
this.testResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知权限检查
|
||||||
|
*/
|
||||||
|
async testNotificationPermissions() {
|
||||||
|
try {
|
||||||
|
const ready = await ensureFastingNotificationsReady();
|
||||||
|
this.logResult(
|
||||||
|
'通知权限检查',
|
||||||
|
ready,
|
||||||
|
ready ? '通知权限已授予' : '通知权限未授予'
|
||||||
|
);
|
||||||
|
return ready;
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'通知权限检查',
|
||||||
|
false,
|
||||||
|
`权限检查失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知安排
|
||||||
|
*/
|
||||||
|
async testNotificationScheduling(plan: FastingPlan) {
|
||||||
|
const now = dayjs();
|
||||||
|
const start = now.add(1, 'hour');
|
||||||
|
const end = start.add(plan.fastingHours, 'hour');
|
||||||
|
|
||||||
|
const schedule: FastingSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: start.toISOString(),
|
||||||
|
endISO: end.toISOString(),
|
||||||
|
createdAtISO: now.toISOString(),
|
||||||
|
updatedAtISO: now.toISOString(),
|
||||||
|
origin: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notificationIds = await resyncFastingNotifications({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
previousIds: {},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAllIds = !!(notificationIds.preStartId && notificationIds.startId &&
|
||||||
|
notificationIds.preEndId && notificationIds.endId);
|
||||||
|
|
||||||
|
this.logResult(
|
||||||
|
'通知安排测试',
|
||||||
|
hasAllIds,
|
||||||
|
hasAllIds
|
||||||
|
? '成功安排所有四个通知点'
|
||||||
|
: `缺少通知ID: ${JSON.stringify(notificationIds)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: hasAllIds, notificationIds };
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'通知安排测试',
|
||||||
|
false,
|
||||||
|
`通知安排失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
return { success: false, notificationIds: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知验证
|
||||||
|
*/
|
||||||
|
async testNotificationVerification(
|
||||||
|
schedule: FastingSchedule,
|
||||||
|
plan: FastingPlan,
|
||||||
|
notificationIds: FastingNotificationIds
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { isValid, updatedIds } = await verifyFastingNotifications({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
storedIds: notificationIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logResult(
|
||||||
|
'通知验证测试',
|
||||||
|
isValid,
|
||||||
|
isValid ? '通知验证通过' : '通知验证失败,已重新同步'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isValid, updatedIds };
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'通知验证测试',
|
||||||
|
false,
|
||||||
|
`通知验证失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
return { isValid: false, updatedIds: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试通知取消
|
||||||
|
*/
|
||||||
|
async testNotificationCancellation(notificationIds: FastingNotificationIds) {
|
||||||
|
try {
|
||||||
|
// 取消所有通知
|
||||||
|
const cancelPromises = [];
|
||||||
|
if (notificationIds.preStartId) {
|
||||||
|
cancelPromises.push(notificationService.cancelNotification(notificationIds.preStartId));
|
||||||
|
}
|
||||||
|
if (notificationIds.startId) {
|
||||||
|
cancelPromises.push(notificationService.cancelNotification(notificationIds.startId));
|
||||||
|
}
|
||||||
|
if (notificationIds.preEndId) {
|
||||||
|
cancelPromises.push(notificationService.cancelNotification(notificationIds.preEndId));
|
||||||
|
}
|
||||||
|
if (notificationIds.endId) {
|
||||||
|
cancelPromises.push(notificationService.cancelNotification(notificationIds.endId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(cancelPromises);
|
||||||
|
|
||||||
|
// 验证通知是否已取消
|
||||||
|
const scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||||
|
const remainingIds = scheduledNotifications.map(n => n.identifier);
|
||||||
|
const cancelledIds = Object.values(notificationIds).filter(id =>
|
||||||
|
id && !remainingIds.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allCancelled = cancelledIds.length === Object.values(notificationIds).filter(id => id).length;
|
||||||
|
|
||||||
|
this.logResult(
|
||||||
|
'通知取消测试',
|
||||||
|
allCancelled,
|
||||||
|
allCancelled
|
||||||
|
? '所有通知已成功取消'
|
||||||
|
: `部分通知未取消: ${JSON.stringify(cancelledIds)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return allCancelled;
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'通知取消测试',
|
||||||
|
false,
|
||||||
|
`通知取消失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试边界情况
|
||||||
|
*/
|
||||||
|
async testEdgeCases(plan: FastingPlan) {
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
// 测试1: 已经过期的断食期
|
||||||
|
const pastStart = now.subtract(2, 'hour');
|
||||||
|
const pastEnd = pastStart.add(plan.fastingHours, 'hour');
|
||||||
|
|
||||||
|
const pastSchedule: FastingSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: pastStart.toISOString(),
|
||||||
|
endISO: pastEnd.toISOString(),
|
||||||
|
createdAtISO: now.toISOString(),
|
||||||
|
updatedAtISO: now.toISOString(),
|
||||||
|
origin: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pastNotificationIds = await resyncFastingNotifications({
|
||||||
|
schedule: pastSchedule,
|
||||||
|
plan,
|
||||||
|
previousIds: {},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNoIds = Object.values(pastNotificationIds).every(id => !id);
|
||||||
|
|
||||||
|
this.logResult(
|
||||||
|
'过期断食期测试',
|
||||||
|
hasNoIds,
|
||||||
|
hasNoIds ? '正确处理过期断食期,未安排通知' : '错误地为过期断食期安排了通知'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'过期断食期测试',
|
||||||
|
false,
|
||||||
|
`过期断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试2: 即将开始的断食期(少于30分钟)
|
||||||
|
const imminentStart = now.add(15, 'minute');
|
||||||
|
const imminentEnd = imminentStart.add(plan.fastingHours, 'hour');
|
||||||
|
|
||||||
|
const imminentSchedule: FastingSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: imminentStart.toISOString(),
|
||||||
|
endISO: imminentEnd.toISOString(),
|
||||||
|
createdAtISO: now.toISOString(),
|
||||||
|
updatedAtISO: now.toISOString(),
|
||||||
|
origin: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imminentNotificationIds = await resyncFastingNotifications({
|
||||||
|
schedule: imminentSchedule,
|
||||||
|
plan,
|
||||||
|
previousIds: {},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应该只有开始时、结束前30分钟和结束时的通知
|
||||||
|
const hasCorrectIds = !imminentNotificationIds.preStartId &&
|
||||||
|
!!imminentNotificationIds.startId &&
|
||||||
|
!!imminentNotificationIds.preEndId &&
|
||||||
|
!!imminentNotificationIds.endId;
|
||||||
|
|
||||||
|
this.logResult(
|
||||||
|
'即将开始断食期测试',
|
||||||
|
hasCorrectIds,
|
||||||
|
hasCorrectIds
|
||||||
|
? '正确处理即将开始的断食期,只安排了必要的通知'
|
||||||
|
: '通知安排不正确'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logResult(
|
||||||
|
'即将开始断食期测试',
|
||||||
|
false,
|
||||||
|
`即将开始断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行完整的测试套件
|
||||||
|
*/
|
||||||
|
async runFullTestSuite(plan: FastingPlan) {
|
||||||
|
console.log('开始运行断食通知系统测试套件...');
|
||||||
|
this.clearResults();
|
||||||
|
|
||||||
|
// 1. 测试通知权限
|
||||||
|
const hasPermission = await this.testNotificationPermissions();
|
||||||
|
if (!hasPermission) {
|
||||||
|
console.log('通知权限未授予,跳过其他测试');
|
||||||
|
return this.getTestResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 测试通知安排
|
||||||
|
const { success: schedulingSuccess, notificationIds } = await this.testNotificationScheduling(plan);
|
||||||
|
if (!schedulingSuccess) {
|
||||||
|
console.log('通知安排失败,跳过后续测试');
|
||||||
|
return this.getTestResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 创建测试用的断食计划
|
||||||
|
const now = dayjs();
|
||||||
|
const start = now.add(1, 'hour');
|
||||||
|
const end = start.add(plan.fastingHours, 'hour');
|
||||||
|
|
||||||
|
const schedule: FastingSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: start.toISOString(),
|
||||||
|
endISO: end.toISOString(),
|
||||||
|
createdAtISO: now.toISOString(),
|
||||||
|
updatedAtISO: now.toISOString(),
|
||||||
|
origin: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 测试通知验证
|
||||||
|
await this.testNotificationVerification(schedule, plan, notificationIds);
|
||||||
|
|
||||||
|
// 5. 测试边界情况
|
||||||
|
await this.testEdgeCases(plan);
|
||||||
|
|
||||||
|
// 6. 测试通知取消
|
||||||
|
await this.testNotificationCancellation(notificationIds);
|
||||||
|
|
||||||
|
const results = this.getTestResults();
|
||||||
|
const passedCount = results.filter(r => r.passed).length;
|
||||||
|
const totalCount = results.length;
|
||||||
|
|
||||||
|
console.log(`测试完成: ${passedCount}/${totalCount} 通过`);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出测试实例
|
||||||
|
export const fastingNotificationTester = new FastingNotificationTester();
|
||||||
Reference in New Issue
Block a user