- Implement auto-renewal logic for completed fasting cycles using dayjs - Add reset button with information modal in FastingOverviewCard - Configure iOS push notifications for production environment - Add expo-media-library and react-native-view-shot dependencies - Update FastingScheduleOrigin type to include 'auto' origin
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
||
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
||
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||
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 { useCountdown } from '@/hooks/useCountdown';
|
||
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
|
||
import {
|
||
clearActiveSchedule,
|
||
rescheduleActivePlan,
|
||
scheduleFastingPlan,
|
||
selectActiveFastingPlan,
|
||
selectActiveFastingSchedule,
|
||
} from '@/store/fastingSlice';
|
||
import {
|
||
buildDisplayWindow,
|
||
calculateFastingWindow,
|
||
getFastingPhase,
|
||
getPhaseLabel,
|
||
loadPreferredPlanId,
|
||
savePreferredPlanId
|
||
} from '@/utils/fasting';
|
||
import { useFocusEffect } from '@react-navigation/native';
|
||
import { useRouter } from 'expo-router';
|
||
import dayjs from 'dayjs';
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
export default function FastingTabScreen() {
|
||
const router = useRouter();
|
||
const dispatch = useAppDispatch();
|
||
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);
|
||
|
||
useEffect(() => {
|
||
if (!activePlan?.id) return;
|
||
setPreferredPlanId(activePlan.id);
|
||
void savePreferredPlanId(activePlan.id);
|
||
}, [activePlan?.id]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const hydratePreferredPlan = async () => {
|
||
try {
|
||
const savedPlanId = await loadPreferredPlanId();
|
||
if (cancelled) return;
|
||
if (activePlan?.id) return;
|
||
if (savedPlanId && getPlanById(savedPlanId)) {
|
||
setPreferredPlanId(savedPlanId);
|
||
}
|
||
} catch (error) {
|
||
console.warn('读取断食首选计划失败', error);
|
||
}
|
||
};
|
||
|
||
hydratePreferredPlan();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [activePlan?.id]);
|
||
|
||
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);
|
||
return undefined;
|
||
}, [activeSchedule, currentPlan]);
|
||
|
||
const scheduleEnd = useMemo(() => {
|
||
if (activeSchedule && currentPlan) {
|
||
return new Date(activeSchedule.endISO);
|
||
}
|
||
if (currentPlan && scheduleStart) {
|
||
return calculateFastingWindow(scheduleStart, currentPlan.fastingHours).end;
|
||
}
|
||
return undefined;
|
||
}, [activeSchedule, currentPlan, scheduleStart]);
|
||
|
||
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
|
||
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
|
||
const { formatted: countdownValue } = useCountdown({ target: countdownTarget ?? null });
|
||
|
||
const progress = useMemo(() => {
|
||
if (!scheduleStart || !scheduleEnd) return 0;
|
||
const total = scheduleEnd.getTime() - scheduleStart.getTime();
|
||
if (total <= 0) return 0;
|
||
const now = Date.now();
|
||
if (now <= scheduleStart.getTime()) return 0;
|
||
if (now >= scheduleEnd.getTime()) return 1;
|
||
return (now - scheduleStart.getTime()) / total;
|
||
}, [scheduleStart, scheduleEnd]);
|
||
|
||
const displayWindow = buildDisplayWindow(scheduleStart ?? null, scheduleEnd ?? null);
|
||
|
||
const [showPicker, setShowPicker] = useState(false);
|
||
|
||
// 显示通知错误(如果有)
|
||
useEffect(() => {
|
||
if (notificationError) {
|
||
console.warn('断食通知错误:', notificationError);
|
||
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
|
||
}
|
||
}, [notificationError]);
|
||
|
||
const recommendedDate = useMemo(() => {
|
||
if (!currentPlan) return undefined;
|
||
return getRecommendedStart(currentPlan);
|
||
}, [currentPlan]);
|
||
|
||
// 调试信息(开发环境)
|
||
useEffect(() => {
|
||
if (__DEV__ && lastSyncTime) {
|
||
console.log('断食通知状态:', {
|
||
ready: notificationsReady,
|
||
loading: notificationsLoading,
|
||
error: notificationError,
|
||
notificationIds,
|
||
lastSyncTime,
|
||
schedule: activeSchedule?.startISO,
|
||
plan: currentPlan?.id,
|
||
});
|
||
}
|
||
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSchedule || !currentPlan) return;
|
||
if (phase !== 'completed') return;
|
||
|
||
const start = dayjs(activeSchedule.startISO);
|
||
const end = dayjs(activeSchedule.endISO);
|
||
if (!start.isValid() || !end.isValid()) return;
|
||
|
||
const now = dayjs();
|
||
if (now.isBefore(end)) return;
|
||
|
||
const fastingHours = currentPlan.fastingHours;
|
||
const eatingHours = currentPlan.eatingHours;
|
||
const cycleHours = fastingHours + eatingHours;
|
||
|
||
if (fastingHours <= 0 || cycleHours <= 0) return;
|
||
|
||
let nextStart = start;
|
||
let nextEnd = end;
|
||
let iterations = 0;
|
||
const maxIterations = 60;
|
||
|
||
while (!now.isBefore(nextEnd)) {
|
||
nextStart = nextStart.add(cycleHours, 'hour');
|
||
nextEnd = nextStart.add(fastingHours, 'hour');
|
||
iterations += 1;
|
||
|
||
if (iterations >= maxIterations) {
|
||
if (__DEV__) {
|
||
console.warn('自动续订断食周期失败: 超出最大迭代次数', {
|
||
start: activeSchedule.startISO,
|
||
end: activeSchedule.endISO,
|
||
planId: currentPlan.id,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (iterations === 0) return;
|
||
|
||
dispatch(rescheduleActivePlan({
|
||
start: nextStart.toDate().toISOString(),
|
||
origin: 'auto',
|
||
}));
|
||
}, [dispatch, activeSchedule, currentPlan, phase]);
|
||
|
||
const handleAdjustStart = () => {
|
||
if (!currentPlan) return;
|
||
setShowPicker(true);
|
||
};
|
||
|
||
const handleConfirmStart = (date: Date) => {
|
||
if (!currentPlan) return;
|
||
if (activeSchedule) {
|
||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||
} else {
|
||
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date.toISOString(), origin: 'manual' }));
|
||
}
|
||
};
|
||
|
||
const handleSelectPlan = (plan: FastingPlan) => {
|
||
router.push(`${ROUTES.FASTING_PLAN_DETAIL}/${plan.id}`);
|
||
};
|
||
|
||
const handleViewMeal = () => {
|
||
router.push(ROUTES.FOOD_LIBRARY);
|
||
};
|
||
|
||
const handleResetPlan = () => {
|
||
dispatch(clearActiveSchedule());
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.safeArea]}>
|
||
<ScrollView
|
||
contentContainerStyle={[styles.scrollContainer, {
|
||
paddingTop: insets.top,
|
||
paddingBottom: 120
|
||
}]}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View style={styles.headerRow}>
|
||
<Text style={styles.screenTitle}>轻断食</Text>
|
||
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||
</View>
|
||
|
||
{/* 通知错误提示 */}
|
||
<NotificationErrorAlert
|
||
error={notificationError}
|
||
onRetry={forceSync}
|
||
onDismiss={clearError}
|
||
/>
|
||
|
||
{currentPlan && (
|
||
<FastingOverviewCard
|
||
plan={currentPlan}
|
||
phaseLabel={getPhaseLabel(phase)}
|
||
countdownLabel={phase === 'fasting' ? '距离进食还有' : '距离断食还有'}
|
||
countdownValue={countdownValue}
|
||
startDayLabel={displayWindow.startDayLabel}
|
||
startTimeLabel={displayWindow.startTimeLabel}
|
||
endDayLabel={displayWindow.endDayLabel}
|
||
endTimeLabel={displayWindow.endTimeLabel}
|
||
onAdjustStartPress={handleAdjustStart}
|
||
onViewMealsPress={handleViewMeal}
|
||
onResetPress={handleResetPlan}
|
||
progress={progress}
|
||
/>
|
||
)}
|
||
|
||
{currentPlan && (
|
||
<View style={styles.highlightCard}>
|
||
<View style={styles.highlightHeader}>
|
||
<Text style={styles.highlightTitle}>计划亮点</Text>
|
||
<Text style={styles.highlightSubtitle}>{currentPlan.subtitle}</Text>
|
||
</View>
|
||
{currentPlan.highlights.map((highlight) => (
|
||
<View key={highlight} style={styles.highlightItem}>
|
||
<View style={[styles.highlightDot, { backgroundColor: currentPlan.theme.accent }]} />
|
||
<Text style={styles.highlightText}>{highlight}</Text>
|
||
</View>
|
||
))}
|
||
<View style={styles.resetRow}>
|
||
<Text style={styles.resetHint}>
|
||
如果计划与作息不符,可重新选择方案或调整开始时间。
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
<FastingPlanList
|
||
plans={FASTING_PLANS}
|
||
activePlanId={activePlan?.id ?? currentPlan?.id}
|
||
onSelectPlan={handleSelectPlan}
|
||
/>
|
||
</ScrollView>
|
||
|
||
<FastingStartPickerModal
|
||
visible={showPicker}
|
||
onClose={() => setShowPicker(false)}
|
||
initialDate={scheduleStart}
|
||
recommendedDate={recommendedDate}
|
||
onConfirm={handleConfirmStart}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
safeArea: {
|
||
flex: 1,
|
||
backgroundColor: 'white'
|
||
},
|
||
scrollContainer: {
|
||
paddingHorizontal: 20,
|
||
paddingTop: 12,
|
||
|
||
},
|
||
headerRow: {
|
||
marginBottom: 20,
|
||
},
|
||
screenTitle: {
|
||
fontSize: 28,
|
||
fontWeight: '800',
|
||
color: '#2E3142',
|
||
marginBottom: 6,
|
||
},
|
||
screenSubtitle: {
|
||
fontSize: 14,
|
||
color: '#6F7D87',
|
||
fontWeight: '500',
|
||
},
|
||
highlightCard: {
|
||
marginTop: 28,
|
||
padding: 20,
|
||
borderRadius: 24,
|
||
backgroundColor: '#FFFFFF',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 12 },
|
||
shadowOpacity: 0.06,
|
||
shadowRadius: 20,
|
||
elevation: 4,
|
||
},
|
||
highlightHeader: {
|
||
marginBottom: 12,
|
||
},
|
||
highlightTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
color: '#2E3142',
|
||
},
|
||
highlightSubtitle: {
|
||
fontSize: 13,
|
||
color: '#6F7D87',
|
||
marginTop: 6,
|
||
},
|
||
highlightItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
marginBottom: 10,
|
||
},
|
||
highlightDot: {
|
||
width: 6,
|
||
height: 6,
|
||
borderRadius: 3,
|
||
marginTop: 7,
|
||
marginRight: 10,
|
||
},
|
||
highlightText: {
|
||
flex: 1,
|
||
fontSize: 14,
|
||
color: '#4A5460',
|
||
lineHeight: 20,
|
||
},
|
||
resetRow: {
|
||
marginTop: 16,
|
||
},
|
||
resetHint: {
|
||
fontSize: 12,
|
||
color: '#8A96A3',
|
||
},
|
||
});
|