Files
digital-pilates/app/(tabs)/fasting.tsx
richarjiang d39a32c0d8 feat(fasting): add auto-renewal and reset functionality for fasting plans
- 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
2025-10-15 19:06:18 +08:00

381 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
});