Files
digital-pilates/app/fasting/[planId].tsx
richarjiang cf069f3537 feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑
- 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时
- 添加通知验证机制,确保通知正确设置和避免重复
- 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项
- 实现断食计划持久化存储,应用重启后自动恢复
- 添加开发者测试面板用于验证通知系统可靠性
- 优化通知同步策略,支持选择性更新减少不必要的操作
- 修复个人页面编辑按钮样式问题
- 更新应用版本号至 1.0.18
2025-10-14 15:05:11 +08:00

529 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

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