feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
||||
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
||||
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 { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCountdown } from '@/hooks/useCountdown';
|
||||
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
|
||||
import {
|
||||
clearActiveSchedule,
|
||||
rescheduleActivePlan,
|
||||
@@ -21,31 +20,24 @@ import {
|
||||
getFastingPhase,
|
||||
getPhaseLabel,
|
||||
loadPreferredPlanId,
|
||||
loadStoredFastingNotificationIds,
|
||||
savePreferredPlanId,
|
||||
savePreferredPlanId
|
||||
} from '@/utils/fasting';
|
||||
import type { FastingNotificationIds } from '@/utils/fasting';
|
||||
import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications';
|
||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FastingTabScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||
const activePlan = useAppSelector(selectActiveFastingPlan);
|
||||
|
||||
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
|
||||
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined);
|
||||
const [notificationsReady, setNotificationsReady] = useState(false);
|
||||
const notificationsLoadedRef = useRef(false);
|
||||
const notificationIdsRef = useRef<FastingNotificationIds>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePlan?.id) return;
|
||||
@@ -75,34 +67,31 @@ export default function FastingTabScreen() {
|
||||
};
|
||||
}, [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(() => {
|
||||
if (activePlan) return activePlan;
|
||||
if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan;
|
||||
return 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(() => {
|
||||
if (activeSchedule) return new Date(activeSchedule.startISO);
|
||||
if (currentPlan) return getRecommendedStart(currentPlan);
|
||||
@@ -137,59 +126,33 @@ export default function FastingTabScreen() {
|
||||
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
// 显示通知错误(如果有)
|
||||
useEffect(() => {
|
||||
if (!notificationsReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
const verifyPreference = async () => {
|
||||
const enabled = await getNotificationEnabled();
|
||||
if (!cancelled && !enabled) {
|
||||
setNotificationsReady(false);
|
||||
notificationsLoadedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
verifyPreference();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [notificationsReady]);
|
||||
if (notificationError) {
|
||||
console.warn('断食通知错误:', notificationError);
|
||||
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
|
||||
}
|
||||
}, [notificationError]);
|
||||
|
||||
const recommendedDate = useMemo(() => {
|
||||
if (!currentPlan) return undefined;
|
||||
return getRecommendedStart(currentPlan);
|
||||
}, [currentPlan]);
|
||||
|
||||
// 调试信息(开发环境)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const syncNotifications = async () => {
|
||||
if (!notificationsLoadedRef.current) {
|
||||
const storedIds = await loadStoredFastingNotificationIds();
|
||||
if (cancelled) return;
|
||||
notificationIdsRef.current = storedIds;
|
||||
notificationsLoadedRef.current = true;
|
||||
}
|
||||
|
||||
const nextIds = await resyncFastingNotifications({
|
||||
schedule: activeSchedule ?? null,
|
||||
plan: notificationsReady ? currentPlan : undefined,
|
||||
previousIds: notificationIdsRef.current,
|
||||
enabled: notificationsReady,
|
||||
if (__DEV__ && lastSyncTime) {
|
||||
console.log('断食通知状态:', {
|
||||
ready: notificationsReady,
|
||||
loading: notificationsLoading,
|
||||
error: notificationError,
|
||||
notificationIds,
|
||||
lastSyncTime,
|
||||
schedule: activeSchedule?.startISO,
|
||||
plan: currentPlan?.id,
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
notificationIdsRef.current = nextIds;
|
||||
}
|
||||
};
|
||||
|
||||
syncNotifications();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
|
||||
}
|
||||
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
|
||||
|
||||
const handleAdjustStart = () => {
|
||||
if (!currentPlan) return;
|
||||
@@ -199,9 +162,9 @@ export default function FastingTabScreen() {
|
||||
const handleConfirmStart = (date: Date) => {
|
||||
if (!currentPlan) return;
|
||||
if (activeSchedule) {
|
||||
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
||||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||
} 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 (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}>
|
||||
<View style={[styles.safeArea]}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]}
|
||||
contentContainerStyle={[styles.scrollContainer, {
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 120
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
@@ -228,6 +194,13 @@ export default function FastingTabScreen() {
|
||||
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||||
</View>
|
||||
|
||||
{/* 通知错误提示 */}
|
||||
<NotificationErrorAlert
|
||||
error={notificationError}
|
||||
onRetry={forceSync}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
|
||||
{currentPlan && (
|
||||
<FastingOverviewCard
|
||||
plan={currentPlan}
|
||||
@@ -281,17 +254,19 @@ export default function FastingTabScreen() {
|
||||
recommendedDate={recommendedDate}
|
||||
onConfirm={handleConfirmStart}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white'
|
||||
},
|
||||
scrollContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
|
||||
},
|
||||
headerRow: {
|
||||
marginBottom: 20,
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function PersonalScreen() {
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<GlassView style={styles.editButtonGlass}>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
@@ -511,6 +511,11 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
editButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
editButtonTextGlass: {
|
||||
color: 'rgba(147, 112, 219, 1)',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
|
||||
@@ -15,9 +15,11 @@ import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||
import { WaterRecordSource } from '@/services/waterRecords';
|
||||
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||
import { store } from '@/store';
|
||||
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||
import { loadActiveFastingSchedule } from '@/utils/fasting';
|
||||
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React, { useEffect } from 'react';
|
||||
@@ -35,12 +37,42 @@ import { Provider } from 'react-redux';
|
||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { profile } = useAppSelector((state) => state.user);
|
||||
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
const fastingHydrationRequestedRef = React.useRef(false);
|
||||
|
||||
// 初始化快捷动作处理
|
||||
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(() => {
|
||||
if (isLoggedIn) {
|
||||
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 { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -19,6 +20,7 @@ export default function LogsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showTestPanel, setShowTestPanel] = useState(false);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
@@ -68,6 +70,10 @@ export default function LogsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotifications = () => {
|
||||
setShowTestPanel(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
// 添加测试日志
|
||||
@@ -148,6 +154,9 @@ export default function LogsScreen() {
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>日志 ({logs.length})</Text>
|
||||
<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}>
|
||||
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
||||
</TouchableOpacity>
|
||||
@@ -186,6 +195,13 @@ export default function LogsScreen() {
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 断食通知测试面板 */}
|
||||
{showTestPanel && (
|
||||
<FastingNotificationTestPanel
|
||||
onClose={() => setShowTestPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function FastingPlanDetailScreen() {
|
||||
const displayWindow = buildDisplayWindow(window.start, window.end);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -65,9 +65,9 @@ export default function FastingPlanDetailScreen() {
|
||||
|
||||
const handleConfirmPicker = (date: Date) => {
|
||||
if (activeSchedule?.planId === plan.id) {
|
||||
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
||||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||
} else {
|
||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' }));
|
||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' }));
|
||||
}
|
||||
setShowPicker(false);
|
||||
router.replace(ROUTES.TAB_FASTING);
|
||||
|
||||
Reference in New Issue
Block a user