feat(fasting): 新增轻断食功能模块
新增完整的轻断食功能,包括: - 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划 - 断食状态实时追踪和倒计时显示 - 自定义开始时间选择器 - 断食通知提醒功能 - Redux状态管理和数据持久化 - 新增tab导航入口和路由配置
This commit is contained in:
@@ -21,6 +21,7 @@ type TabConfig = {
|
|||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
|
fasting: { icon: 'timer', title: '断食' },
|
||||||
goals: { icon: 'flag.fill', title: '习惯' },
|
goals: { icon: 'flag.fill', title: '习惯' },
|
||||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
personal: { icon: 'person.fill', title: '个人' },
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
@@ -36,6 +37,7 @@ export default function TabLayout() {
|
|||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
statistics: ROUTES.TAB_STATISTICS,
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
fasting: ROUTES.TAB_FASTING,
|
||||||
goals: ROUTES.TAB_GOALS,
|
goals: ROUTES.TAB_GOALS,
|
||||||
challenges: ROUTES.TAB_CHALLENGES,
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
personal: ROUTES.TAB_PERSONAL,
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
@@ -176,6 +178,10 @@ export default function TabLayout() {
|
|||||||
<Label>健康</Label>
|
<Label>健康</Label>
|
||||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="fasting">
|
||||||
|
<Icon sf="timer" drawable="custom_android_drawable" />
|
||||||
|
<Label>断食</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="goals">
|
<NativeTabs.Trigger name="goals">
|
||||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||||
<Label>习惯</Label>
|
<Label>习惯</Label>
|
||||||
@@ -198,6 +204,7 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||||
|
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
|
||||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
|
|||||||
369
app/(tabs)/fasting.tsx
Normal file
369
app/(tabs)/fasting.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
||||||
|
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
||||||
|
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
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 {
|
||||||
|
clearActiveSchedule,
|
||||||
|
rescheduleActivePlan,
|
||||||
|
scheduleFastingPlan,
|
||||||
|
selectActiveFastingPlan,
|
||||||
|
selectActiveFastingSchedule,
|
||||||
|
} from '@/store/fastingSlice';
|
||||||
|
import {
|
||||||
|
buildDisplayWindow,
|
||||||
|
calculateFastingWindow,
|
||||||
|
getFastingPhase,
|
||||||
|
getPhaseLabel,
|
||||||
|
loadPreferredPlanId,
|
||||||
|
loadStoredFastingNotificationIds,
|
||||||
|
savePreferredPlanId,
|
||||||
|
} from '@/utils/fasting';
|
||||||
|
import type { FastingNotificationIds } from '@/utils/fasting';
|
||||||
|
import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications';
|
||||||
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } 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 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;
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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 (!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]);
|
||||||
|
|
||||||
|
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 (!cancelled) {
|
||||||
|
notificationIdsRef.current = nextIds;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncNotifications();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
|
||||||
|
|
||||||
|
const handleAdjustStart = () => {
|
||||||
|
if (!currentPlan) return;
|
||||||
|
setShowPicker(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmStart = (date: Date) => {
|
||||||
|
if (!currentPlan) return;
|
||||||
|
if (activeSchedule) {
|
||||||
|
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
||||||
|
} else {
|
||||||
|
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date, 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 (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.screenTitle}>轻断食</Text>
|
||||||
|
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
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>
|
||||||
|
<Text style={styles.resetAction} onPress={handleResetPlan}>
|
||||||
|
重置
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
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: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
resetHint: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8A96A3',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
resetAction: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6366F1',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -209,6 +209,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="profile/edit" />
|
<Stack.Screen name="profile/edit" />
|
||||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||||
|
|
||||||
<Stack.Screen name="ai-posture-assessment" />
|
<Stack.Screen name="ai-posture-assessment" />
|
||||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
|
|||||||
528
app/fasting/[planId].tsx
Normal file
528
app/fasting/[planId].tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import { CircularRing } from '@/components/CircularRing';
|
||||||
|
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
rescheduleActivePlan,
|
||||||
|
scheduleFastingPlan,
|
||||||
|
selectActiveFastingSchedule,
|
||||||
|
} from '@/store/fastingSlice';
|
||||||
|
import { buildDisplayWindow, calculateFastingWindow, savePreferredPlanId } from '@/utils/fasting';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
type InfoTab = 'fit' | 'avoid' | 'intro';
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<InfoTab, string> = {
|
||||||
|
fit: '适合人群',
|
||||||
|
avoid: '不适合人群',
|
||||||
|
intro: '计划介绍',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FastingPlanDetailScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||||
|
|
||||||
|
const { planId } = useLocalSearchParams<{ planId: string }>();
|
||||||
|
const fallbackPlan = FASTING_PLANS[0];
|
||||||
|
const plan: FastingPlan = useMemo(
|
||||||
|
() => (planId ? getPlanById(planId) ?? fallbackPlan : fallbackPlan),
|
||||||
|
[planId, fallbackPlan]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void savePreferredPlanId(plan.id);
|
||||||
|
}, [plan.id]);
|
||||||
|
|
||||||
|
const [infoTab, setInfoTab] = useState<InfoTab>('fit');
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const glassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
|
const recommendedStart = useMemo(() => getRecommendedStart(plan), [plan]);
|
||||||
|
const window = calculateFastingWindow(recommendedStart, plan.fastingHours);
|
||||||
|
const displayWindow = buildDisplayWindow(window.start, window.end);
|
||||||
|
|
||||||
|
const handleStartWithRecommended = () => {
|
||||||
|
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart, origin: 'recommended' }));
|
||||||
|
router.replace(ROUTES.TAB_FASTING);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenPicker = () => {
|
||||||
|
setShowPicker(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPicker = (date: Date) => {
|
||||||
|
if (activeSchedule?.planId === plan.id) {
|
||||||
|
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
|
||||||
|
} else {
|
||||||
|
dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' }));
|
||||||
|
}
|
||||||
|
setShowPicker(false);
|
||||||
|
router.replace(ROUTES.TAB_FASTING);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInfoList = () => {
|
||||||
|
let items: string[] = [];
|
||||||
|
if (infoTab === 'fit') items = plan.audienceFit;
|
||||||
|
if (infoTab === 'avoid') items = plan.audienceAvoid;
|
||||||
|
if (infoTab === 'intro') items = [plan.description, ...plan.nutritionTips];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoList}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<View key={item} style={styles.infoItem}>
|
||||||
|
<View style={[styles.infoDot, { backgroundColor: plan.theme.accent }]} />
|
||||||
|
<Text style={styles.infoText}>{item}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fastingRatio = plan.fastingHours / 24;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
|
||||||
|
{/* 固定悬浮的返回按钮 */}
|
||||||
|
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={router.back} activeOpacity={0.8}>
|
||||||
|
{glassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.backButtonGlass}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(255,255,255,0.4)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.backButtonFallback}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[plan.theme.accentSecondary, plan.theme.backdrop]}
|
||||||
|
style={[styles.hero, { paddingTop: insets.top + 12 }]}
|
||||||
|
>
|
||||||
|
<View style={{
|
||||||
|
paddingTop: insets.top + 12
|
||||||
|
}}>
|
||||||
|
<View style={styles.heroHeader}>
|
||||||
|
<Text style={styles.planId}>{plan.id}</Text>
|
||||||
|
{plan.badge && (
|
||||||
|
<View style={[styles.heroBadge, { backgroundColor: `${plan.theme.accent}2B` }]}>
|
||||||
|
<Text style={[styles.heroBadgeText, { color: plan.theme.accent }]}>{plan.badge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.heroTitle}>{plan.title}</Text>
|
||||||
|
<Text style={styles.heroSubtitle}>{plan.subtitle}</Text>
|
||||||
|
|
||||||
|
<View style={styles.tagRow}>
|
||||||
|
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
|
||||||
|
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
|
||||||
|
断食 {plan.fastingHours} 小时
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
|
||||||
|
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
|
||||||
|
进食 {plan.eatingHours} 小时
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.chartCard}>
|
||||||
|
<CircularRing
|
||||||
|
size={190}
|
||||||
|
strokeWidth={18}
|
||||||
|
progress={fastingRatio}
|
||||||
|
progressColor={plan.theme.accent}
|
||||||
|
trackColor={plan.theme.ringTrack}
|
||||||
|
showCenterText={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.chartContent}>
|
||||||
|
<Text style={styles.chartTitle}>每日节奏</Text>
|
||||||
|
<Text style={styles.chartValue}>{plan.fastingHours} h 断食</Text>
|
||||||
|
<Text style={styles.chartSubtitle}>进食窗口 {plan.eatingHours} h</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: plan.theme.accent }]} />
|
||||||
|
<Text style={styles.legendText}>断食期</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: plan.theme.ringTrack }]} />
|
||||||
|
<Text style={styles.legendText}>进食期</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.windowCard}>
|
||||||
|
<Text style={styles.windowLabel}>推荐开始时间</Text>
|
||||||
|
<View style={styles.windowRow}>
|
||||||
|
<View style={styles.windowCell}>
|
||||||
|
<Text style={styles.windowTitle}>开始</Text>
|
||||||
|
<Text style={styles.windowDay}>{displayWindow.startDayLabel}</Text>
|
||||||
|
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
|
||||||
|
{displayWindow.startTimeLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.windowDivider} />
|
||||||
|
<View style={styles.windowCell}>
|
||||||
|
<Text style={styles.windowTitle}>结束</Text>
|
||||||
|
<Text style={styles.windowDay}>{displayWindow.endDayLabel}</Text>
|
||||||
|
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
|
||||||
|
{displayWindow.endTimeLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.windowHint}>
|
||||||
|
推荐在晚餐后约 2 小时开始,保证进食期覆盖早餐至午后。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
{(Object.keys(TAB_LABELS) as InfoTab[]).map((tabKey) => {
|
||||||
|
const isActive = infoTab === tabKey;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tabKey}
|
||||||
|
style={[
|
||||||
|
styles.tabButton,
|
||||||
|
isActive && { backgroundColor: plan.theme.accent },
|
||||||
|
]}
|
||||||
|
onPress={() => setInfoTab(tabKey)}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabButtonText,
|
||||||
|
{ color: isActive ? '#fff' : colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[tabKey]}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{renderInfoList()}
|
||||||
|
|
||||||
|
<View style={styles.actionBlock}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.secondaryAction, { borderColor: plan.theme.accent }]}
|
||||||
|
onPress={handleOpenPicker}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={[styles.secondaryActionText, { color: plan.theme.accent }]}>
|
||||||
|
自定义开始时间
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryAction, { backgroundColor: plan.theme.accent }]}
|
||||||
|
onPress={handleStartWithRecommended}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryActionText}>
|
||||||
|
开始轻断食
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<FastingStartPickerModal
|
||||||
|
visible={showPicker}
|
||||||
|
onClose={() => setShowPicker(false)}
|
||||||
|
initialDate={recommendedStart}
|
||||||
|
recommendedDate={recommendedStart}
|
||||||
|
onConfirm={handleConfirmPicker}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 32,
|
||||||
|
borderBottomLeftRadius: 32,
|
||||||
|
borderBottomRightRadius: 32,
|
||||||
|
},
|
||||||
|
backButtonContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 24,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
backButtonGlass: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
backButtonFallback: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.5)',
|
||||||
|
},
|
||||||
|
heroContent: {
|
||||||
|
},
|
||||||
|
heroHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
planId: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
heroBadge: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
heroBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
heroSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5B6572',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tagRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
tagChip: {
|
||||||
|
marginRight: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
},
|
||||||
|
tagChipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 28,
|
||||||
|
},
|
||||||
|
chartCard: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
chartContent: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 70,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6F7D87',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
chartValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
},
|
||||||
|
chartSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6F7D87',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 12,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5B6572',
|
||||||
|
},
|
||||||
|
windowCard: {
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
windowLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
windowRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
windowCell: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
windowTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#778290',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
windowDay: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#2E3142',
|
||||||
|
},
|
||||||
|
windowTime: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
windowDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 60,
|
||||||
|
backgroundColor: 'rgba(95,105,116,0.2)',
|
||||||
|
},
|
||||||
|
windowHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6F7D87',
|
||||||
|
marginTop: 16,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F2F3F5',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tabButtonText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
infoList: {
|
||||||
|
marginBottom: 28,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
infoDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
marginRight: 10,
|
||||||
|
marginTop: 7,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#4A5460',
|
||||||
|
lineHeight: 21,
|
||||||
|
},
|
||||||
|
actionBlock: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 50,
|
||||||
|
},
|
||||||
|
secondaryAction: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1.4,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
secondaryActionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
primaryAction: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
primaryActionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
274
components/fasting/FastingOverviewCard.tsx
Normal file
274
components/fasting/FastingOverviewCard.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { CircularRing } from '@/components/CircularRing';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import type { FastingPlan } from '@/constants/Fasting';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
type FastingOverviewCardProps = {
|
||||||
|
plan?: FastingPlan;
|
||||||
|
phaseLabel: string;
|
||||||
|
countdownLabel: string;
|
||||||
|
countdownValue: string;
|
||||||
|
startDayLabel: string;
|
||||||
|
startTimeLabel: string;
|
||||||
|
endDayLabel: string;
|
||||||
|
endTimeLabel: string;
|
||||||
|
onAdjustStartPress: () => void;
|
||||||
|
onViewMealsPress: () => void;
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FastingOverviewCard({
|
||||||
|
plan,
|
||||||
|
phaseLabel,
|
||||||
|
countdownLabel,
|
||||||
|
countdownValue,
|
||||||
|
startDayLabel,
|
||||||
|
startTimeLabel,
|
||||||
|
endDayLabel,
|
||||||
|
endTimeLabel,
|
||||||
|
onAdjustStartPress,
|
||||||
|
onViewMealsPress,
|
||||||
|
progress,
|
||||||
|
}: FastingOverviewCardProps) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
const themeColors = plan?.theme;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
themeColors?.accentSecondary ?? colors.heroSurfaceTint,
|
||||||
|
themeColors?.backdrop ?? colors.pageBackgroundEmphasis,
|
||||||
|
]}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.planLabel}>轻断食计划</Text>
|
||||||
|
{plan?.id && (
|
||||||
|
<View style={styles.planTag}>
|
||||||
|
<Text style={[styles.planTagText, { color: themeColors?.accent ?? colors.primary }]}>
|
||||||
|
{plan.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{plan?.badge && (
|
||||||
|
<View style={[styles.badge, { backgroundColor: `${themeColors?.accent ?? colors.primary}20` }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: themeColors?.accent ?? colors.primary }]}>
|
||||||
|
{plan.badge}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.scheduleRow}>
|
||||||
|
<View style={styles.scheduleCell}>
|
||||||
|
<Text style={styles.scheduleLabel}>断食开始时间</Text>
|
||||||
|
<Text style={styles.scheduleDay}>{startDayLabel}</Text>
|
||||||
|
<Text style={styles.scheduleTime}>{startTimeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<View style={styles.scheduleCell}>
|
||||||
|
<Text style={styles.scheduleLabel}>断食结束时间</Text>
|
||||||
|
<Text style={styles.scheduleDay}>{endDayLabel}</Text>
|
||||||
|
<Text style={styles.scheduleTime}>{endTimeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<View style={styles.ringContainer}>
|
||||||
|
<CircularRing
|
||||||
|
size={168}
|
||||||
|
strokeWidth={14}
|
||||||
|
progress={progress}
|
||||||
|
progressColor={themeColors?.ringProgress ?? colors.primary}
|
||||||
|
trackColor={themeColors?.ringTrack ?? 'rgba(0,0,0,0.05)'}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
resetToken={phaseLabel}
|
||||||
|
/>
|
||||||
|
<View style={styles.ringContent}>
|
||||||
|
<Text style={styles.phaseText}>{phaseLabel}</Text>
|
||||||
|
<Text style={styles.countdownLabel}>{countdownLabel}</Text>
|
||||||
|
<Text style={styles.countdownValue}>{countdownValue}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.secondaryButton, { borderColor: themeColors?.accent ?? colors.primary }]}
|
||||||
|
onPress={onAdjustStartPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={[styles.secondaryButtonText, { color: themeColors?.accent ?? colors.primary }]}>
|
||||||
|
提前开始断食
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* <TouchableOpacity
|
||||||
|
style={[styles.primaryButton, { backgroundColor: themeColors?.accent ?? colors.primary }]}
|
||||||
|
onPress={onViewMealsPress}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>查看食谱</Text>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: 28,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 16 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
planLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
planTag: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
planTagText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 18,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scheduleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scheduleCell: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scheduleLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#70808E',
|
||||||
|
marginBottom: 6,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
scheduleDay: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#2E3142',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scheduleTime: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
width: 1,
|
||||||
|
height: 52,
|
||||||
|
backgroundColor: 'rgba(112,128,142,0.22)',
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
marginTop: 26,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
ringContainer: {
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
ringContent: {
|
||||||
|
position: 'absolute',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
phaseText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
countdownLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6F7D87',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
countdownValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
actionsRow: {
|
||||||
|
marginTop: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderWidth: 1.2,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#2E3142',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
});
|
||||||
185
components/fasting/FastingPlanList.tsx
Normal file
185
components/fasting/FastingPlanList.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type { FastingPlan } from '@/constants/Fasting';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
type FastingPlanListProps = {
|
||||||
|
plans: FastingPlan[];
|
||||||
|
activePlanId?: string | null;
|
||||||
|
onSelectPlan: (plan: FastingPlan) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const difficultyLabel: Record<FastingPlan['difficulty'], string> = {
|
||||||
|
新手: '适合入门',
|
||||||
|
进阶: '脂代提升',
|
||||||
|
强化: '平台突破',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FastingPlanList({ plans, activePlanId, onSelectPlan }: FastingPlanListProps) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
|
||||||
|
const sortedPlans = useMemo(
|
||||||
|
() => plans.slice().sort((a, b) => a.fastingHours - b.fastingHours),
|
||||||
|
[plans]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.headerTitle}>单日计划</Text>
|
||||||
|
<View style={styles.headerBadge}>
|
||||||
|
<Text style={styles.headerBadgeText}>精选</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{sortedPlans.map((plan) => {
|
||||||
|
const isActive = plan.id === activePlanId;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={plan.id}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: plan.theme.backdrop,
|
||||||
|
borderColor: isActive ? plan.theme.accent : 'transparent',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={() => onSelectPlan(plan)}
|
||||||
|
>
|
||||||
|
<View style={styles.cardTopRow}>
|
||||||
|
<View style={[styles.difficultyPill, { backgroundColor: `${plan.theme.accent}1A` }]}>
|
||||||
|
<Text style={[styles.difficultyText, { color: plan.theme.accent }]}>
|
||||||
|
{plan.difficulty}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{plan.badge && (
|
||||||
|
<View style={[styles.badgePill, { backgroundColor: `${plan.theme.accent}26` }]}>
|
||||||
|
<Text style={[styles.badgeText, { color: plan.theme.accent }]}>
|
||||||
|
{plan.badge}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardTitle}>{plan.title}</Text>
|
||||||
|
<Text style={styles.cardSubtitle}>{plan.subtitle}</Text>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<IconSymbol name="chart.pie.fill" color={colors.icon} size={16} />
|
||||||
|
<Text style={styles.metaText}>{plan.fastingHours} 小时断食</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<IconSymbol name="flag.fill" color={colors.icon} size={16} />
|
||||||
|
<Text style={styles.metaText}>{difficultyLabel[plan.difficulty]}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
marginTop: 32,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
},
|
||||||
|
headerBadge: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: 'rgba(53, 52, 69, 0.08)',
|
||||||
|
},
|
||||||
|
headerBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#353445',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingRight: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: 220,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
marginRight: 16,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
cardTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
difficultyPill: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
difficultyText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
badgePill: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#2E3142',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#5B6572',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
metaText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5B6572',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
242
components/fasting/FastingStartPickerModal.tsx
Normal file
242
components/fasting/FastingStartPickerModal.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import WheelPickerExpo from 'react-native-wheel-picker-expo';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { FloatingSelectionCard } from '@/components/ui/FloatingSelectionCard';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
type FastingStartPickerModalProps = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialDate?: Date | null;
|
||||||
|
recommendedDate?: Date | null;
|
||||||
|
onConfirm: (date: Date) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DayOption = {
|
||||||
|
label: string;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDayOptions = (now: dayjs.Dayjs): DayOption[] => ([
|
||||||
|
{ offset: -1, label: '昨天' },
|
||||||
|
{ offset: 0, label: '今天' },
|
||||||
|
{ offset: 1, label: '明天' },
|
||||||
|
].map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: item.offset === -1
|
||||||
|
? '昨天'
|
||||||
|
: item.offset === 0
|
||||||
|
? '今天'
|
||||||
|
: '明天',
|
||||||
|
})));
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
|
||||||
|
|
||||||
|
export function FastingStartPickerModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
initialDate,
|
||||||
|
recommendedDate,
|
||||||
|
onConfirm,
|
||||||
|
}: FastingStartPickerModalProps) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
const now = useMemo(() => dayjs(), []);
|
||||||
|
const dayOptions = useMemo(() => buildDayOptions(now), [now]);
|
||||||
|
|
||||||
|
const deriveInitialIndexes = (source?: Date | null) => {
|
||||||
|
const seed = source ? dayjs(source) : now;
|
||||||
|
const dayDiff = seed.startOf('day').diff(now.startOf('day'), 'day');
|
||||||
|
const dayIndex = dayOptions.findIndex((option) => option.offset === dayDiff);
|
||||||
|
const hourIndex = HOURS.findIndex((hour) => hour === seed.hour());
|
||||||
|
const snappedMinute = seed.minute() - (seed.minute() % 5);
|
||||||
|
const minuteIndex = MINUTES.findIndex((minute) => minute === snappedMinute);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dayIndex: dayIndex === -1 ? 1 : dayIndex,
|
||||||
|
hourIndex: hourIndex === -1 ? now.hour() : hourIndex,
|
||||||
|
minuteIndex:
|
||||||
|
minuteIndex === -1
|
||||||
|
? Math.max(0, Math.min(MINUTES.length - 1, Math.floor(seed.minute() / 5)))
|
||||||
|
: minuteIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultBaseRef = useRef(new Date());
|
||||||
|
|
||||||
|
const baseDate = useMemo(
|
||||||
|
() => initialDate ?? recommendedDate ?? defaultBaseRef.current,
|
||||||
|
[initialDate, recommendedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseTimestamp = baseDate.getTime();
|
||||||
|
|
||||||
|
const [{ dayIndex, hourIndex, minuteIndex }, setIndexes] = useState(() =>
|
||||||
|
deriveInitialIndexes(baseDate)
|
||||||
|
);
|
||||||
|
const [pickerKey, setPickerKey] = useState(0);
|
||||||
|
const lastAppliedTimestamp = useRef<number | null>(null);
|
||||||
|
const wasVisibleRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
wasVisibleRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldReset =
|
||||||
|
!wasVisibleRef.current ||
|
||||||
|
lastAppliedTimestamp.current !== baseTimestamp;
|
||||||
|
|
||||||
|
if (shouldReset) {
|
||||||
|
const nextIndexes = deriveInitialIndexes(baseDate);
|
||||||
|
setIndexes(nextIndexes);
|
||||||
|
setPickerKey((prev) => prev + 1);
|
||||||
|
lastAppliedTimestamp.current = baseTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
wasVisibleRef.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, baseTimestamp]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const selectedDay = dayOptions[dayIndex] ?? dayOptions[1];
|
||||||
|
const base = now.startOf('day').add(selectedDay?.offset ?? 0, 'day');
|
||||||
|
const hour = HOURS[hourIndex] ?? now.hour();
|
||||||
|
const minute = MINUTES[minuteIndex] ?? 0;
|
||||||
|
const result = base.hour(hour).minute(minute).second(0).millisecond(0);
|
||||||
|
onConfirm(result.toDate());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseRecommended = () => {
|
||||||
|
if (!recommendedDate) return;
|
||||||
|
setIndexes(deriveInitialIndexes(recommendedDate));
|
||||||
|
setPickerKey((prev) => prev + 1);
|
||||||
|
lastAppliedTimestamp.current = recommendedDate.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerIndicatorStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: `${colors.primary}12`,
|
||||||
|
borderRadius: 12,
|
||||||
|
}),
|
||||||
|
[colors.primary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const textStyle = {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
color: '#2E3142',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingSelectionCard
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
title="断食开始时间"
|
||||||
|
>
|
||||||
|
<View style={styles.pickerRow}>
|
||||||
|
<WheelPickerExpo
|
||||||
|
key={`day-${pickerKey}`}
|
||||||
|
height={180}
|
||||||
|
width={110}
|
||||||
|
initialSelectedIndex={dayIndex}
|
||||||
|
items={dayOptions.map((item) => ({ label: item.label, value: item.offset }))}
|
||||||
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
itemTextStyle={textStyle}
|
||||||
|
selectedIndicatorStyle={pickerIndicatorStyle}
|
||||||
|
haptics
|
||||||
|
/>
|
||||||
|
<WheelPickerExpo
|
||||||
|
key={`hour-${pickerKey}`}
|
||||||
|
height={180}
|
||||||
|
width={110}
|
||||||
|
initialSelectedIndex={hourIndex}
|
||||||
|
items={HOURS.map((hour) => ({ label: hour.toString().padStart(2, '0'), value: hour }))}
|
||||||
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
itemTextStyle={textStyle}
|
||||||
|
selectedIndicatorStyle={pickerIndicatorStyle}
|
||||||
|
haptics
|
||||||
|
/>
|
||||||
|
<WheelPickerExpo
|
||||||
|
key={`minute-${pickerKey}`}
|
||||||
|
height={180}
|
||||||
|
width={110}
|
||||||
|
initialSelectedIndex={minuteIndex}
|
||||||
|
items={MINUTES.map((minute) => ({
|
||||||
|
label: minute.toString().padStart(2, '0'),
|
||||||
|
value: minute,
|
||||||
|
}))}
|
||||||
|
onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
itemTextStyle={textStyle}
|
||||||
|
selectedIndicatorStyle={pickerIndicatorStyle}
|
||||||
|
haptics
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footerRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.recommendButton, { borderColor: colors.primary }]}
|
||||||
|
onPress={handleUseRecommended}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={[styles.recommendButtonText, { color: colors.primary }]}>使用推荐时间</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.confirmButton, { backgroundColor: colors.primary }]}
|
||||||
|
onPress={handleConfirm}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmButtonText}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</FloatingSelectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pickerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
recommendButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1.2,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
},
|
||||||
|
recommendButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
@@ -22,6 +23,21 @@ export function FloatingSelectionCard({
|
|||||||
title,
|
title,
|
||||||
children
|
children
|
||||||
}: FloatingSelectionCardProps) {
|
}: FloatingSelectionCardProps) {
|
||||||
|
const glassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
|
const CloseWrapper = glassAvailable ? GlassView : View;
|
||||||
|
const closeInnerStyle = [
|
||||||
|
styles.closeButtonInnerBase,
|
||||||
|
glassAvailable ? styles.closeButtonInnerGlass : styles.closeButtonInnerFallback,
|
||||||
|
];
|
||||||
|
const closeWrapperProps = glassAvailable
|
||||||
|
? {
|
||||||
|
glassEffectStyle: 'regular' as const,
|
||||||
|
tintColor: 'rgba(255,255,255,0.45)',
|
||||||
|
isInteractive: true,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
@@ -52,9 +68,9 @@ export function FloatingSelectionCard({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.closeButtonInner}>
|
<CloseWrapper style={closeInnerStyle} {...closeWrapperProps}>
|
||||||
<Ionicons name="close" size={24} color="#666" />
|
<Ionicons name="close" size={24} color="#666" />
|
||||||
</View>
|
</CloseWrapper>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -67,6 +83,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
backdrop: {
|
backdrop: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -103,11 +120,10 @@ const styles = StyleSheet.create({
|
|||||||
closeButton: {
|
closeButton: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
},
|
},
|
||||||
closeButtonInner: {
|
closeButtonInnerBase: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -119,4 +135,12 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
|
closeButtonInnerGlass: {
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'rgba(255,255,255,0.45)',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.35)',
|
||||||
|
},
|
||||||
|
closeButtonInnerFallback: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -18,6 +18,10 @@ const MAPPING = {
|
|||||||
'paperplane.fill': 'send',
|
'paperplane.fill': 'send',
|
||||||
'chevron.left.forwardslash.chevron.right': 'code',
|
'chevron.left.forwardslash.chevron.right': 'code',
|
||||||
'chevron.right': 'chevron-right',
|
'chevron.right': 'chevron-right',
|
||||||
|
'chart.pie.fill': 'pie-chart',
|
||||||
|
'flag.fill': 'flag',
|
||||||
|
'trophy.fill': 'emoji-events',
|
||||||
|
'timer': 'timer',
|
||||||
'person.fill': 'person',
|
'person.fill': 'person',
|
||||||
'person.3.fill': 'people',
|
'person.3.fill': 'people',
|
||||||
'message.fill': 'message',
|
'message.fill': 'message',
|
||||||
|
|||||||
206
constants/Fasting.ts
Normal file
206
constants/Fasting.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export type FastingDifficulty = '新手' | '进阶' | '强化';
|
||||||
|
|
||||||
|
export type FastingPlan = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
badge?: string;
|
||||||
|
difficulty: FastingDifficulty;
|
||||||
|
fastingHours: number;
|
||||||
|
eatingHours: number;
|
||||||
|
recommendedStartHour: number;
|
||||||
|
recommendedStartMinute?: number;
|
||||||
|
description: string;
|
||||||
|
highlights: string[];
|
||||||
|
audienceFit: string[];
|
||||||
|
audienceAvoid: string[];
|
||||||
|
nutritionTips: string[];
|
||||||
|
theme: {
|
||||||
|
backdrop: string;
|
||||||
|
accent: string;
|
||||||
|
accentSecondary: string;
|
||||||
|
ringTrack: string;
|
||||||
|
ringProgress: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FASTING_STORAGE_KEYS = {
|
||||||
|
preferredPlanId: '@fasting_preferred_plan',
|
||||||
|
notificationsRegistered: '@fasting_notifications_registered',
|
||||||
|
startNotificationId: '@fasting_notification_start_id',
|
||||||
|
endNotificationId: '@fasting_notification_end_id',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FASTING_PLANS: FastingPlan[] = [
|
||||||
|
{
|
||||||
|
id: '12-12',
|
||||||
|
title: '12-12 新手体验计划',
|
||||||
|
subtitle: '进食12小时 · 断食12小时',
|
||||||
|
badge: '新手',
|
||||||
|
difficulty: '新手',
|
||||||
|
fastingHours: 12,
|
||||||
|
eatingHours: 12,
|
||||||
|
recommendedStartHour: 20,
|
||||||
|
description: '帮助初次尝试轻断食的用户建立稳定节奏,温和过渡到代谢切换。',
|
||||||
|
highlights: [
|
||||||
|
'维持血糖稳定,减少夜间加餐',
|
||||||
|
'保护基础代谢率,循序渐进更易坚持',
|
||||||
|
'适合与轻度有氧、舒缓训练搭配',
|
||||||
|
],
|
||||||
|
audienceFit: [
|
||||||
|
'没有断食经验的初学者',
|
||||||
|
'作息规律、希望改善饮食结构的用户',
|
||||||
|
'需要缓解水肿或控制晚间进食的群体',
|
||||||
|
],
|
||||||
|
audienceAvoid: [
|
||||||
|
'孕期、哺乳期人群',
|
||||||
|
'血糖波动较大或正在服用降糖药者',
|
||||||
|
'近期有胃病或严重胃酸倒流史者',
|
||||||
|
],
|
||||||
|
nutritionTips: [
|
||||||
|
'进食期优先安排高纤维蔬菜 + 优质蛋白 + 复杂碳水搭配',
|
||||||
|
'保持足量饮水,可搭配无糖茶饮、电解质水',
|
||||||
|
'睡前2小时避免高糖、高脂肪食物,降低夜间胃酸',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
backdrop: '#EEF5E3',
|
||||||
|
accent: '#99B75F',
|
||||||
|
accentSecondary: '#F6F7ED',
|
||||||
|
ringTrack: 'rgba(153, 183, 95, 0.18)',
|
||||||
|
ringProgress: '#9FBB62',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14-10',
|
||||||
|
title: '14-10 轻断食计划',
|
||||||
|
subtitle: '进食10小时 · 断食14小时',
|
||||||
|
badge: '热门',
|
||||||
|
difficulty: '新手',
|
||||||
|
fastingHours: 14,
|
||||||
|
eatingHours: 10,
|
||||||
|
recommendedStartHour: 19,
|
||||||
|
description: '黄金入门比例,兼顾脂肪代谢与日常社交,适合作为长期饮食模式。',
|
||||||
|
highlights: [
|
||||||
|
'夜间空腹时长更长,有利于提高睡眠质量',
|
||||||
|
'降低胰岛素分泌频率,优化血脂指标',
|
||||||
|
'搭配力量训练,可提升瘦体重占比',
|
||||||
|
],
|
||||||
|
audienceFit: [
|
||||||
|
'需要控制体脂、保持肌力的办公人群',
|
||||||
|
'晚餐时间较固定的家庭用户',
|
||||||
|
'希望巩固作息、减少夜宵的用户',
|
||||||
|
],
|
||||||
|
audienceAvoid: [
|
||||||
|
'体重过低 (BMI < 18.5) 或营养不良者',
|
||||||
|
'高强度训练日需额外摄入能量的运动员',
|
||||||
|
'正在恢复期的慢性病患者(需医师评估)',
|
||||||
|
],
|
||||||
|
nutritionTips: [
|
||||||
|
'进食窗口内保持 3 餐或 2 餐 1 加餐的均衡结构',
|
||||||
|
'早餐建议加入优质蛋白与低 GI 碳水,避免血糖波动',
|
||||||
|
'断食期可饮用黑咖啡、无糖茶饮,补充矿物质避免疲劳',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
backdrop: '#F8F2DD',
|
||||||
|
accent: '#E4B74B',
|
||||||
|
accentSecondary: '#FBF5E2',
|
||||||
|
ringTrack: 'rgba(228, 183, 75, 0.18)',
|
||||||
|
ringProgress: '#E8B649',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '16-8',
|
||||||
|
title: '16-8 进阶燃脂计划',
|
||||||
|
subtitle: '进食8小时 · 断食16小时',
|
||||||
|
badge: '进阶',
|
||||||
|
difficulty: '进阶',
|
||||||
|
fastingHours: 16,
|
||||||
|
eatingHours: 8,
|
||||||
|
recommendedStartHour: 18,
|
||||||
|
description: '经典进阶版,帮助维持较长脂肪供能区间,适合已有断食基础的用户。',
|
||||||
|
highlights: [
|
||||||
|
'延长生长激素分泌窗口,辅助保持肌肉',
|
||||||
|
'强化脂肪动员,改善胰岛素敏感性',
|
||||||
|
'与力量训练、HIIT 等组合,塑形效率更高',
|
||||||
|
],
|
||||||
|
audienceFit: [
|
||||||
|
'有一定断食经验,希望进一步控制体脂',
|
||||||
|
'每日有规律运动或力量训练基础的用户',
|
||||||
|
'午晚餐可在 12:00-20:00 完成的职场人士',
|
||||||
|
],
|
||||||
|
audienceAvoid: [
|
||||||
|
'近期有暴饮暴食或进食障碍史者',
|
||||||
|
'工作强度大且需要夜间补充能量的岗位',
|
||||||
|
'肝胆问题或血糖控制异常者需遵医嘱执行',
|
||||||
|
],
|
||||||
|
nutritionTips: [
|
||||||
|
'进食期注意蛋白质分配,建议每餐 ≥25g 蛋白',
|
||||||
|
'提前准备低油低糖食谱,避免进食窗口暴食',
|
||||||
|
'断食期补充电解质与维生素C,缓解疲劳与饥饿感',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
backdrop: '#EFE8FD',
|
||||||
|
accent: '#8E75E1',
|
||||||
|
accentSecondary: '#F4EDFF',
|
||||||
|
ringTrack: 'rgba(142, 117, 225, 0.18)',
|
||||||
|
ringProgress: '#8F78E0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18-6',
|
||||||
|
title: '18-6 脂代强化计划',
|
||||||
|
subtitle: '进食6小时 · 断食18小时',
|
||||||
|
badge: '高阶',
|
||||||
|
difficulty: '强化',
|
||||||
|
fastingHours: 18,
|
||||||
|
eatingHours: 6,
|
||||||
|
recommendedStartHour: 17,
|
||||||
|
description: '针对塑形巩固与体重停滞期用户,提供高强度代谢刺激方案。',
|
||||||
|
highlights: [
|
||||||
|
'强化脂肪氧化,提升线粒体效率',
|
||||||
|
'帮助突破减脂平台期,维持血脂稳态',
|
||||||
|
'适合与周期力量训练、冷热交替等手段配合',
|
||||||
|
],
|
||||||
|
audienceFit: [
|
||||||
|
'已有 8 周以上断食经验且生理指标稳定者',
|
||||||
|
'体重调节遇到平台期的进阶用户',
|
||||||
|
'可在 10:30-16:30 内完成主要餐食的用户',
|
||||||
|
],
|
||||||
|
audienceAvoid: [
|
||||||
|
'甲状腺功能异常或血糖控制不佳者',
|
||||||
|
'夜班、倒班等作息紊乱人群',
|
||||||
|
'有过度节食史或需要高能量摄入的用户',
|
||||||
|
],
|
||||||
|
nutritionTips: [
|
||||||
|
'确保进食窗口内能量充足,搭配足量优质脂肪与蛋白',
|
||||||
|
'安排训练前后加餐,保障恢复与肌肉合成',
|
||||||
|
'断食期适量补充电解质、绿叶蔬菜汁,避免头晕疲劳',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
backdrop: '#F7E3E5',
|
||||||
|
accent: '#E26A7F',
|
||||||
|
accentSecondary: '#FCE9EC',
|
||||||
|
ringTrack: 'rgba(226, 106, 127, 0.18)',
|
||||||
|
ringProgress: '#E06B80',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getPlanById = (planId: string) =>
|
||||||
|
FASTING_PLANS.find((plan) => plan.id === planId);
|
||||||
|
|
||||||
|
export const getRecommendedStart = (plan: FastingPlan, baseDate: Date = new Date()) => {
|
||||||
|
const start = dayjs(baseDate)
|
||||||
|
.hour(plan.recommendedStartHour)
|
||||||
|
.minute(plan.recommendedStartMinute ?? 0)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0);
|
||||||
|
|
||||||
|
// If the recommended time already passed today, schedule for tomorrow
|
||||||
|
if (start.isBefore(dayjs())) {
|
||||||
|
return start.add(1, 'day').toDate();
|
||||||
|
}
|
||||||
|
return start.toDate();
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ export const ROUTES = {
|
|||||||
TAB_STATISTICS: '/statistics',
|
TAB_STATISTICS: '/statistics',
|
||||||
TAB_CHALLENGES: '/challenges',
|
TAB_CHALLENGES: '/challenges',
|
||||||
TAB_PERSONAL: '/personal',
|
TAB_PERSONAL: '/personal',
|
||||||
|
TAB_FASTING: '/fasting',
|
||||||
|
|
||||||
// 训练相关路由
|
// 训练相关路由
|
||||||
WORKOUT_TODAY: '/workout/today',
|
WORKOUT_TODAY: '/workout/today',
|
||||||
@@ -53,6 +54,9 @@ export const ROUTES = {
|
|||||||
WATER_SETTINGS: '/water/settings',
|
WATER_SETTINGS: '/water/settings',
|
||||||
WATER_REMINDER_SETTINGS: '/water/reminder-settings',
|
WATER_REMINDER_SETTINGS: '/water/reminder-settings',
|
||||||
|
|
||||||
|
// 轻断食相关
|
||||||
|
FASTING_PLAN_DETAIL: '/fasting',
|
||||||
|
|
||||||
// 任务相关路由
|
// 任务相关路由
|
||||||
TASK_DETAIL: '/task-detail',
|
TASK_DETAIL: '/task-detail',
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@ export const ROUTE_PARAMS = {
|
|||||||
|
|
||||||
// 任务参数
|
// 任务参数
|
||||||
TASK_ID: 'taskId',
|
TASK_ID: 'taskId',
|
||||||
|
FASTING_PLAN_ID: 'planId',
|
||||||
|
|
||||||
// 重定向参数
|
// 重定向参数
|
||||||
REDIRECT_TO: 'redirectTo',
|
REDIRECT_TO: 'redirectTo',
|
||||||
|
|||||||
54
hooks/useCountdown.ts
Normal file
54
hooks/useCountdown.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { formatCountdown } from '@/utils/fasting';
|
||||||
|
|
||||||
|
interface CountdownOptions {
|
||||||
|
target: Date | null | undefined;
|
||||||
|
intervalMs?: number;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCountdown = ({ target, intervalMs = 1000, autoStart = true }: CountdownOptions) => {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const targetTimestamp = target instanceof Date ? target.getTime() : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoStart) return undefined;
|
||||||
|
if (targetTimestamp == null) return undefined;
|
||||||
|
|
||||||
|
timerRef.current && clearInterval(timerRef.current);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [targetTimestamp, intervalMs, autoStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetTimestamp == null) return;
|
||||||
|
setNow(new Date());
|
||||||
|
}, [targetTimestamp]);
|
||||||
|
|
||||||
|
const diffMs = useMemo(() => {
|
||||||
|
if (targetTimestamp == null) return 0;
|
||||||
|
return targetTimestamp - now.getTime();
|
||||||
|
}, [targetTimestamp, now]);
|
||||||
|
|
||||||
|
const formatted = useMemo(() => {
|
||||||
|
if (targetTimestamp == null) return '--:--:--';
|
||||||
|
return formatCountdown(new Date(targetTimestamp), now);
|
||||||
|
}, [targetTimestamp, now]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
diffMs,
|
||||||
|
formatted,
|
||||||
|
isExpired: diffMs <= 0,
|
||||||
|
hasTarget: targetTimestamp != null,
|
||||||
|
};
|
||||||
|
};
|
||||||
162
services/fastingNotifications.ts
Normal file
162
services/fastingNotifications.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { FastingPlan } from '@/constants/Fasting';
|
||||||
|
import { FastingSchedule } from '@/store/fastingSlice';
|
||||||
|
import {
|
||||||
|
clearFastingNotificationIds,
|
||||||
|
getFastingNotificationsRegistered,
|
||||||
|
loadStoredFastingNotificationIds,
|
||||||
|
saveFastingNotificationIds,
|
||||||
|
setFastingNotificationsRegistered,
|
||||||
|
FastingNotificationIds,
|
||||||
|
} from '@/utils/fasting';
|
||||||
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
|
import { notificationService, NotificationTypes } from './notifications';
|
||||||
|
|
||||||
|
const REMINDER_OFFSET_MINUTES = 10;
|
||||||
|
|
||||||
|
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
|
||||||
|
if (!ids) return;
|
||||||
|
const { startId, endId } = ids;
|
||||||
|
try {
|
||||||
|
if (startId) {
|
||||||
|
await notificationService.cancelNotification(startId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('取消断食开始提醒失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (endId) {
|
||||||
|
await notificationService.cancelNotification(endId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('取消断食结束提醒失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureFastingNotificationsReady = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const notificationsEnabled = await getNotificationEnabled();
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await getFastingNotificationsRegistered();
|
||||||
|
if (registered) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await notificationService.getPermissionStatus();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
const requestStatus = await notificationService.requestPermission();
|
||||||
|
if (requestStatus !== 'granted') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await setFastingNotificationsRegistered(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('初始化断食通知失败', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResyncOptions = {
|
||||||
|
schedule: FastingSchedule | null;
|
||||||
|
plan?: FastingPlan;
|
||||||
|
previousIds?: FastingNotificationIds;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resyncFastingNotifications = async ({
|
||||||
|
schedule,
|
||||||
|
plan,
|
||||||
|
previousIds,
|
||||||
|
enabled,
|
||||||
|
}: ResyncOptions): Promise<FastingNotificationIds> => {
|
||||||
|
const storedIds = previousIds ?? (await loadStoredFastingNotificationIds());
|
||||||
|
await cancelNotificationIds(storedIds);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
await setFastingNotificationsRegistered(false);
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferenceEnabled = await getNotificationEnabled();
|
||||||
|
if (!preferenceEnabled) {
|
||||||
|
await setFastingNotificationsRegistered(false);
|
||||||
|
await clearFastingNotificationIds();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!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 notificationIds: FastingNotificationIds = {};
|
||||||
|
|
||||||
|
if (start.isAfter(now)) {
|
||||||
|
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||||
|
const triggerMoment = preStart.isAfter(now) ? preStart : start;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: `${plan.title} 即将开始`,
|
||||||
|
body: preStart.isAfter(now)
|
||||||
|
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
|
||||||
|
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_START,
|
||||||
|
planId: plan.id,
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
triggerMoment.toDate()
|
||||||
|
);
|
||||||
|
notificationIds.startId = startId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食开始通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end.isAfter(now)) {
|
||||||
|
try {
|
||||||
|
const endId = await notificationService.scheduleNotificationAtDate(
|
||||||
|
{
|
||||||
|
title: '补能时刻到啦',
|
||||||
|
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.FASTING_END,
|
||||||
|
planId: plan.id,
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
end.toDate()
|
||||||
|
);
|
||||||
|
notificationIds.endId = endId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('安排断食结束通知失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveFastingNotificationIds(notificationIds);
|
||||||
|
return notificationIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { FastingNotificationIds };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@@ -5,7 +6,6 @@ import { router } from 'expo-router';
|
|||||||
// 配置通知处理方式
|
// 配置通知处理方式
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: true,
|
shouldSetBadge: true,
|
||||||
shouldShowBanner: true,
|
shouldShowBanner: true,
|
||||||
@@ -179,6 +179,8 @@ export class NotificationService {
|
|||||||
if (data?.url) {
|
if (data?.url) {
|
||||||
router.push(data.url as any);
|
router.push(data.url as any);
|
||||||
}
|
}
|
||||||
|
} else if (data?.type === NotificationTypes.FASTING_START || data?.type === NotificationTypes.FASTING_END) {
|
||||||
|
router.push(ROUTES.TAB_FASTING as any);
|
||||||
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
||||||
// 处理锻炼完成通知
|
// 处理锻炼完成通知
|
||||||
console.log('用户点击了锻炼完成通知', data);
|
console.log('用户点击了锻炼完成通知', data);
|
||||||
@@ -517,6 +519,8 @@ export const NotificationTypes = {
|
|||||||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||||
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||||||
WORKOUT_COMPLETION: 'workout_completion',
|
WORKOUT_COMPLETION: 'workout_completion',
|
||||||
|
FASTING_START: 'fasting_start',
|
||||||
|
FASTING_END: 'fasting_end',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
|
|||||||
126
store/fastingSlice.ts
Normal file
126
store/fastingSlice.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting';
|
||||||
|
import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting';
|
||||||
|
import type { RootState } from './index';
|
||||||
|
|
||||||
|
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start';
|
||||||
|
|
||||||
|
export type FastingSchedule = {
|
||||||
|
planId: string;
|
||||||
|
startISO: string;
|
||||||
|
endISO: string;
|
||||||
|
createdAtISO: string;
|
||||||
|
updatedAtISO: string;
|
||||||
|
origin: FastingScheduleOrigin;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FastingState = {
|
||||||
|
activeSchedule: FastingSchedule | null;
|
||||||
|
history: FastingSchedule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: FastingState = {
|
||||||
|
activeSchedule: null,
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fastingSlice = createSlice({
|
||||||
|
name: 'fasting',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
scheduleFastingPlan: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ planId: string; start: Date; origin?: FastingScheduleOrigin }>
|
||||||
|
) => {
|
||||||
|
const plan = getPlanById(action.payload.planId);
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
|
||||||
|
const nowISO = new Date().toISOString();
|
||||||
|
state.activeSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: start.toISOString(),
|
||||||
|
endISO: end.toISOString(),
|
||||||
|
createdAtISO: nowISO,
|
||||||
|
updatedAtISO: nowISO,
|
||||||
|
origin: action.payload.origin ?? 'manual',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rescheduleActivePlan: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ start: Date; origin?: FastingScheduleOrigin }>
|
||||||
|
) => {
|
||||||
|
if (!state.activeSchedule) return;
|
||||||
|
const plan = getPlanById(state.activeSchedule.planId);
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
|
||||||
|
state.activeSchedule = {
|
||||||
|
...state.activeSchedule,
|
||||||
|
startISO: start.toISOString(),
|
||||||
|
endISO: end.toISOString(),
|
||||||
|
updatedAtISO: new Date().toISOString(),
|
||||||
|
origin: action.payload.origin ?? state.activeSchedule.origin,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setRecommendedSchedule: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ planId: string; recommendedStart: Date }>
|
||||||
|
) => {
|
||||||
|
const plan = getPlanById(action.payload.planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const { start, end } = calculateFastingWindow(action.payload.recommendedStart, plan.fastingHours);
|
||||||
|
const nowISO = new Date().toISOString();
|
||||||
|
state.activeSchedule = {
|
||||||
|
planId: plan.id,
|
||||||
|
startISO: start.toISOString(),
|
||||||
|
endISO: end.toISOString(),
|
||||||
|
createdAtISO: nowISO,
|
||||||
|
updatedAtISO: nowISO,
|
||||||
|
origin: 'recommended',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
completeActiveSchedule: (state) => {
|
||||||
|
if (!state.activeSchedule) return;
|
||||||
|
const phase = getFastingPhase(
|
||||||
|
new Date(state.activeSchedule.startISO),
|
||||||
|
new Date(state.activeSchedule.endISO)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phase === 'fasting') {
|
||||||
|
// Allow manual completion only when fasting window已经结束
|
||||||
|
state.activeSchedule = {
|
||||||
|
...state.activeSchedule,
|
||||||
|
endISO: dayjs().toISOString(),
|
||||||
|
updatedAtISO: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.history.unshift(state.activeSchedule);
|
||||||
|
state.activeSchedule = null;
|
||||||
|
},
|
||||||
|
clearActiveSchedule: (state) => {
|
||||||
|
state.activeSchedule = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
scheduleFastingPlan,
|
||||||
|
rescheduleActivePlan,
|
||||||
|
setRecommendedSchedule,
|
||||||
|
completeActiveSchedule,
|
||||||
|
clearActiveSchedule,
|
||||||
|
} = fastingSlice.actions;
|
||||||
|
|
||||||
|
export default fastingSlice.reducer;
|
||||||
|
|
||||||
|
export const selectFastingRoot = (state: RootState) => state.fasting;
|
||||||
|
export const selectActiveFastingSchedule = (state: RootState) => state.fasting.activeSchedule;
|
||||||
|
export const selectFastingPlans = () => FASTING_PLANS;
|
||||||
|
export const selectActiveFastingPlan = (state: RootState): FastingPlan | undefined => {
|
||||||
|
const schedule = state.fasting.activeSchedule;
|
||||||
|
if (!schedule) return undefined;
|
||||||
|
return getPlanById(schedule.planId);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ 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 moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import nutritionReducer from './nutritionSlice';
|
import nutritionReducer from './nutritionSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
@@ -62,6 +63,7 @@ export const store = configureStore({
|
|||||||
foodRecognition: foodRecognitionReducer,
|
foodRecognition: foodRecognitionReducer,
|
||||||
workout: workoutReducer,
|
workout: workoutReducer,
|
||||||
water: waterReducer,
|
water: waterReducer,
|
||||||
|
fasting: fastingReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||||
@@ -69,4 +71,3 @@ export const store = configureStore({
|
|||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
export type AppDispatch = typeof store.dispatch;
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
|
|||||||
191
utils/fasting.ts
Normal file
191
utils/fasting.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
|
||||||
|
|
||||||
|
dayjs.extend(duration);
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
dayjs.extend(isSameOrBefore);
|
||||||
|
|
||||||
|
export type FastingPhase = 'upcoming' | 'fasting' | 'completed';
|
||||||
|
|
||||||
|
export const calculateFastingWindow = (start: Date, fastingHours: number) => {
|
||||||
|
const startDate = dayjs(start).second(0).millisecond(0);
|
||||||
|
const endDate = startDate.add(fastingHours, 'hour');
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startDate.toDate(),
|
||||||
|
end: endDate.toDate(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFastingPhase = (start?: Date | null, end?: Date | null, now: Date = new Date()): FastingPhase => {
|
||||||
|
if (!start || !end) {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowJs = dayjs(now);
|
||||||
|
const startJs = dayjs(start);
|
||||||
|
const endJs = dayjs(end);
|
||||||
|
|
||||||
|
if (nowJs.isBefore(startJs)) {
|
||||||
|
return 'upcoming';
|
||||||
|
}
|
||||||
|
if (nowJs.isSameOrAfter(startJs) && nowJs.isBefore(endJs)) {
|
||||||
|
return 'fasting';
|
||||||
|
}
|
||||||
|
return 'completed';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPhaseLabel = (phase: FastingPhase) => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'fasting':
|
||||||
|
return '断食中';
|
||||||
|
case 'upcoming':
|
||||||
|
return '可饮食';
|
||||||
|
case 'completed':
|
||||||
|
return '可饮食';
|
||||||
|
default:
|
||||||
|
return '可饮食';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDayDescriptor = (date: Date | null | undefined, now: Date = new Date()) => {
|
||||||
|
if (!date) return '--';
|
||||||
|
|
||||||
|
const target = dayjs(date);
|
||||||
|
const today = dayjs(now).startOf('day');
|
||||||
|
|
||||||
|
if (target.isSame(today, 'day')) {
|
||||||
|
return '今天';
|
||||||
|
}
|
||||||
|
if (target.isSame(today.subtract(1, 'day'), 'day')) {
|
||||||
|
return '昨天';
|
||||||
|
}
|
||||||
|
if (target.isSame(today.add(1, 'day'), 'day')) {
|
||||||
|
return '明天';
|
||||||
|
}
|
||||||
|
return target.format('MM-DD');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTime = (date: Date | null | undefined) => {
|
||||||
|
if (!date) return '--:--';
|
||||||
|
return dayjs(date).format('HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCountdown = (target: Date | null | undefined, now: Date = new Date()) => {
|
||||||
|
if (!target) return '--:--:--';
|
||||||
|
|
||||||
|
const diff = dayjs(target).diff(now);
|
||||||
|
if (diff <= 0) {
|
||||||
|
return '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = dayjs.duration(diff);
|
||||||
|
const hours = String(Math.floor(dur.asHours())).padStart(2, '0');
|
||||||
|
const minutes = String(dur.minutes()).padStart(2, '0');
|
||||||
|
const seconds = String(dur.seconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
|
||||||
|
return {
|
||||||
|
startDayLabel: formatDayDescriptor(start ?? null),
|
||||||
|
startTimeLabel: formatTime(start ?? null),
|
||||||
|
endDayLabel: formatDayDescriptor(end ?? null),
|
||||||
|
endTimeLabel: formatTime(end ?? null),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadPreferredPlanId = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('加载断食首选计划ID失败', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePreferredPlanId = async (planId: string) => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preferredPlanId, planId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存断食首选计划ID失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FastingNotificationIds = {
|
||||||
|
startId?: string | null;
|
||||||
|
endId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.notificationsRegistered);
|
||||||
|
return value === 'true';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取断食通知注册状态失败', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFastingNotificationsRegistered = async (registered: boolean) => {
|
||||||
|
try {
|
||||||
|
if (registered) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.notificationsRegistered, 'true');
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.notificationsRegistered);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('更新断食通知注册状态失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
|
||||||
|
try {
|
||||||
|
const [startId, endId] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
|
||||||
|
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startId: startId ?? undefined,
|
||||||
|
endId: endId ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取断食通知ID失败', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
|
||||||
|
try {
|
||||||
|
if (ids.startId) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.endId) {
|
||||||
|
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存断食通知ID失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearFastingNotificationIds = async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
|
||||||
|
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除断食通知ID失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user