feat(fasting): 新增轻断食功能模块

新增完整的轻断食功能,包括:
- 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划
- 断食状态实时追踪和倒计时显示
- 自定义开始时间选择器
- 断食通知提醒功能
- Redux状态管理和数据持久化
- 新增tab导航入口和路由配置
This commit is contained in:
richarjiang
2025-10-13 19:21:29 +08:00
parent 971aebd560
commit e03b2b3032
17 changed files with 2390 additions and 7 deletions

View File

@@ -21,6 +21,7 @@ type TabConfig = {
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', title: '健康' },
fasting: { icon: 'timer', title: '断食' },
goals: { icon: 'flag.fill', title: '习惯' },
challenges: { icon: 'trophy.fill', title: '挑战' },
personal: { icon: 'person.fill', title: '个人' },
@@ -36,6 +37,7 @@ export default function TabLayout() {
const isTabSelected = (routeName: string): boolean => {
const routeMap: Record<string, string> = {
statistics: ROUTES.TAB_STATISTICS,
fasting: ROUTES.TAB_FASTING,
goals: ROUTES.TAB_GOALS,
challenges: ROUTES.TAB_CHALLENGES,
personal: ROUTES.TAB_PERSONAL,
@@ -176,6 +178,10 @@ export default function TabLayout() {
<Label></Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="goals">
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
<Label></Label>
@@ -198,6 +204,7 @@ export default function TabLayout() {
>
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />

369
app/(tabs)/fasting.tsx Normal file
View 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',
},
});

View File

@@ -209,6 +209,7 @@ export default function RootLayout() {
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" 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="auth/login" options={{ headerShown: false }} />

528
app/fasting/[planId].tsx Normal file
View 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',
},
});