- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
529 lines
15 KiB
TypeScript
529 lines
15 KiB
TypeScript
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.toISOString(), origin: 'recommended' }));
|
||
router.replace(ROUTES.TAB_FASTING);
|
||
};
|
||
|
||
const handleOpenPicker = () => {
|
||
setShowPicker(true);
|
||
};
|
||
|
||
const handleConfirmPicker = (date: Date) => {
|
||
if (activeSchedule?.planId === plan.id) {
|
||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||
} else {
|
||
dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), 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',
|
||
},
|
||
});
|