- 新增 healthSlice,用于管理健康数据的 Redux 状态 - 在 Statistics 组件中整合健康数据获取逻辑,优化数据展示 - 更新 NutritionRadarCard 组件,调整卡路里计算区域,提升用户体验 - 移除不必要的状态管理,简化组件逻辑 - 优化代码结构,提升可读性和维护性
781 lines
21 KiB
TypeScript
781 lines
21 KiB
TypeScript
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||
import { DateSelector } from '@/components/DateSelector';
|
||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||
import { MoodCard } from '@/components/MoodCard';
|
||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||
import { ProgressBar } from '@/components/ProgressBar';
|
||
import { StressMeter } from '@/components/StressMeter';
|
||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { useBackgroundTasks } from '@/hooks/useBackgroundTasks';
|
||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||
import { useFocusEffect } from '@react-navigation/native';
|
||
import dayjs from 'dayjs';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Animated,
|
||
SafeAreaView,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
View
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
// 浮动动画组件
|
||
const FloatingCard = ({ children, delay = 0, style }: {
|
||
children: React.ReactNode;
|
||
delay?: number;
|
||
style?: any;
|
||
}) => {
|
||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
const startAnimation = () => {
|
||
Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(floatAnim, {
|
||
toValue: 1,
|
||
duration: 3000,
|
||
delay: delay,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(floatAnim, {
|
||
toValue: 0,
|
||
duration: 3000,
|
||
useNativeDriver: true,
|
||
}),
|
||
])
|
||
).start();
|
||
};
|
||
|
||
startAnimation();
|
||
}, [floatAnim, delay]);
|
||
|
||
const translateY = floatAnim.interpolate({
|
||
inputRange: [0, 1],
|
||
outputRange: [-2, -6],
|
||
});
|
||
|
||
return (
|
||
<Animated.View
|
||
style={[
|
||
style,
|
||
{
|
||
transform: [{ translateY }],
|
||
marginBottom: 8,
|
||
},
|
||
]}
|
||
>
|
||
{children}
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
export default function ExploreScreen() {
|
||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||
|
||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||
|
||
// 使用 dayjs:当月日期与默认选中"今天"
|
||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||
const tabBarHeight = useBottomTabBarHeight();
|
||
const insets = useSafeAreaInsets();
|
||
const bottomPadding = useMemo(() => {
|
||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||
}, [tabBarHeight, insets?.bottom]);
|
||
|
||
// 获取当前选中日期
|
||
const getCurrentSelectedDate = () => {
|
||
const days = getMonthDaysZh();
|
||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||
};
|
||
|
||
|
||
// 获取当前选中日期
|
||
const currentSelectedDate = getCurrentSelectedDate();
|
||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||
|
||
// 从 Redux 获取指定日期的健康数据
|
||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||
|
||
// 解构健康数据
|
||
const stepCount = healthData?.steps ?? null;
|
||
const activeCalories = healthData?.activeEnergyBurned ?? null;
|
||
const basalMetabolism = healthData?.basalEnergyBurned ?? null;
|
||
const sleepDuration = healthData?.sleepDuration ?? null;
|
||
const hrvValue = healthData?.hrv ?? 0;
|
||
const oxygenSaturation = healthData?.oxygenSaturation ?? null;
|
||
const heartRate = healthData?.heartRate ?? null;
|
||
const fitnessRingsData = healthData ? {
|
||
activeCalories: healthData.activeEnergyBurned,
|
||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||
exerciseMinutes: healthData.exerciseMinutes,
|
||
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
|
||
standHours: healthData.standHours,
|
||
standHoursGoal: healthData.standHoursGoal,
|
||
} : {
|
||
activeCalories: 0,
|
||
activeCaloriesGoal: 350,
|
||
exerciseMinutes: 0,
|
||
exerciseMinutesGoal: 30,
|
||
standHours: 0,
|
||
standHoursGoal: 12,
|
||
};
|
||
|
||
// HRV更新时间
|
||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||
|
||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||
const [animToken, setAnimToken] = useState(0);
|
||
|
||
// 营养数据状态
|
||
const [nutritionSummary, setNutritionSummary] = useState<NutritionSummary | null>(null);
|
||
|
||
const { registerTask } = useBackgroundTasks();
|
||
// 心情相关状态
|
||
const dispatch = useAppDispatch();
|
||
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
||
|
||
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||
const latestRequestKeyRef = useRef<string | null>(null);
|
||
|
||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||
|
||
|
||
// 从 Redux 获取当前日期的心情记录
|
||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||
currentSelectedDateString
|
||
));
|
||
|
||
// 加载心情数据
|
||
const loadMoodData = async (targetDate?: Date) => {
|
||
if (!isLoggedIn) return;
|
||
|
||
try {
|
||
setIsMoodLoading(true);
|
||
|
||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = getCurrentSelectedDate();
|
||
}
|
||
|
||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||
await dispatch(fetchDailyMoodCheckins(dateString));
|
||
} catch (error) {
|
||
console.error('加载心情数据失败:', error);
|
||
} finally {
|
||
setIsMoodLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
const loadHealthData = async (targetDate?: Date) => {
|
||
try {
|
||
console.log('=== 开始HealthKit初始化流程 ===');
|
||
|
||
const ok = await ensureHealthPermissions();
|
||
if (!ok) {
|
||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||
console.warn(errorMsg);
|
||
return;
|
||
}
|
||
|
||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = getCurrentSelectedDate();
|
||
}
|
||
|
||
const requestKey = getDateKey(derivedDate);
|
||
latestRequestKeyRef.current = requestKey;
|
||
|
||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||
const data = await fetchHealthDataForDate(derivedDate);
|
||
|
||
console.log('设置UI状态:', data);
|
||
// 仅当该请求仍是最新时,才应用结果
|
||
if (latestRequestKeyRef.current === requestKey) {
|
||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||
|
||
// 使用 Redux 存储健康数据
|
||
dispatch(setHealthData({
|
||
date: dateString,
|
||
data: data
|
||
}));
|
||
|
||
// 更新HRV数据时间
|
||
setHrvUpdateTime(new Date());
|
||
|
||
setAnimToken((t) => t + 1);
|
||
} else {
|
||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||
}
|
||
console.log('=== HealthKit数据获取完成 ===');
|
||
|
||
} catch (error) {
|
||
console.error('HealthKit流程出现异常:', error);
|
||
}
|
||
};
|
||
|
||
// 加载营养数据
|
||
const loadNutritionData = async (targetDate?: Date) => {
|
||
try {
|
||
|
||
// 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = getCurrentSelectedDate();
|
||
}
|
||
|
||
console.log('加载营养数据...', derivedDate);
|
||
const data = await getDietRecords({
|
||
startDate: dayjs(derivedDate).startOf('day').toISOString(),
|
||
endDate: dayjs(derivedDate).endOf('day').toISOString(),
|
||
});
|
||
|
||
if (data.records.length > 0) {
|
||
const summary = calculateNutritionSummary(data.records);
|
||
setNutritionSummary(summary);
|
||
} else {
|
||
setNutritionSummary(null);
|
||
}
|
||
console.log('营养数据加载完成:', data);
|
||
|
||
} catch (error) {
|
||
console.error('营养数据加载失败:', error);
|
||
setNutritionSummary(null);
|
||
}
|
||
};
|
||
|
||
useFocusEffect(
|
||
React.useCallback(() => {
|
||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||
const currentDate = currentSelectedDate;
|
||
if (currentDate) {
|
||
loadHealthData(currentDate);
|
||
if (isLoggedIn) {
|
||
loadNutritionData(currentDate);
|
||
loadMoodData(currentDate);
|
||
}
|
||
}
|
||
}, [selectedIndex])
|
||
);
|
||
|
||
useEffect(() => {
|
||
// 注册任务
|
||
registerTask({
|
||
id: 'health-data-task',
|
||
name: 'health-data-task',
|
||
handler: async () => {
|
||
try {
|
||
await loadHealthData();
|
||
} catch (error) {
|
||
console.error('健康数据任务执行失败:', error);
|
||
}
|
||
},
|
||
});
|
||
}, []);
|
||
|
||
// 日期点击时,加载对应日期数据
|
||
const onSelectDate = (index: number, date: Date) => {
|
||
setSelectedIndex(index);
|
||
loadHealthData(date);
|
||
if (isLoggedIn) {
|
||
loadNutritionData(date);
|
||
loadMoodData(date);
|
||
}
|
||
};
|
||
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* 背景渐变 */}
|
||
<LinearGradient
|
||
colors={['#F0F9FF', '#E0F2FE']}
|
||
style={styles.gradientBackground}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
/>
|
||
|
||
{/* 装饰性圆圈 */}
|
||
<View style={styles.decorativeCircle1} />
|
||
<View style={styles.decorativeCircle2} />
|
||
|
||
<SafeAreaView style={styles.safeArea}>
|
||
<ScrollView
|
||
style={styles.scrollView}
|
||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* 体重历史记录卡片 */}
|
||
<Text style={styles.sectionTitle}>健康数据</Text>
|
||
<WeightHistoryCard />
|
||
|
||
{/* 日期选择器 */}
|
||
<DateSelector
|
||
selectedIndex={selectedIndex}
|
||
onDateSelect={onSelectDate}
|
||
showMonthTitle={true}
|
||
disableFutureDates={true}
|
||
/>
|
||
|
||
{/* 营养摄入雷达图卡片 */}
|
||
<NutritionRadarCard
|
||
nutritionSummary={nutritionSummary}
|
||
burnedCalories={basalMetabolism || 0}
|
||
calorieDeficit={0}
|
||
resetToken={animToken}
|
||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||
console.log('选择餐次:', mealType);
|
||
// 这里可以导航到营养记录页面
|
||
pushIfAuthedElseLogin('/nutrition/records');
|
||
}}
|
||
/>
|
||
|
||
{/* 真正瀑布流布局 */}
|
||
<View style={styles.masonryContainer}>
|
||
{/* 左列 */}
|
||
<View style={styles.masonryColumn}>
|
||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||
<StressMeter
|
||
value={hrvValue}
|
||
updateTime={hrvUpdateTime}
|
||
hrvValue={hrvValue}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||
<Text style={styles.cardTitle}>消耗卡路里</Text>
|
||
{activeCalories != null ? (
|
||
<AnimatedNumber
|
||
value={activeCalories}
|
||
resetToken={animToken}
|
||
style={styles.caloriesValue}
|
||
format={(v) => `${Math.round(v)} 千卡`}
|
||
/>
|
||
) : (
|
||
<Text style={styles.caloriesValue}>——</Text>
|
||
)}
|
||
</FloatingCard>
|
||
|
||
<FloatingCard style={styles.masonryCard} delay={1000}>
|
||
<View style={styles.cardHeaderRow}>
|
||
<Text style={styles.cardTitle}>步数</Text>
|
||
</View>
|
||
{stepCount != null ? (
|
||
<AnimatedNumber
|
||
value={stepCount}
|
||
resetToken={animToken}
|
||
style={styles.stepsValue}
|
||
format={(v) => `${Math.round(v)}/${stepGoal}`}
|
||
/>
|
||
) : (
|
||
<Text style={styles.stepsValue}>——/{stepGoal}</Text>
|
||
)}
|
||
<ProgressBar
|
||
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
|
||
height={14}
|
||
trackColor="#FFEBCB"
|
||
fillColor="#FFC365"
|
||
showLabel={false}
|
||
/>
|
||
</FloatingCard>
|
||
{/* 心情卡片 */}
|
||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
||
<MoodCard
|
||
moodCheckin={currentMoodCheckin}
|
||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||
isLoading={isMoodLoading}
|
||
/>
|
||
</FloatingCard>
|
||
</View>
|
||
|
||
{/* 右列 */}
|
||
<View style={styles.masonryColumn}>
|
||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||
<FitnessRingsCard
|
||
activeCalories={fitnessRingsData.activeCalories}
|
||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||
standHours={fitnessRingsData.standHours}
|
||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||
resetToken={animToken}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
<FloatingCard style={styles.masonryCard} delay={750}>
|
||
<View style={styles.cardHeaderRow}>
|
||
<Text style={styles.cardTitle}>睡眠</Text>
|
||
</View>
|
||
{sleepDuration != null ? (
|
||
<Text style={styles.sleepValue}>
|
||
{Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟
|
||
</Text>
|
||
) : (
|
||
<Text style={styles.sleepValue}>——</Text>
|
||
)}
|
||
</FloatingCard>
|
||
|
||
{/* 基础代谢卡片 */}
|
||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
||
<BasalMetabolismCard
|
||
value={basalMetabolism}
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
{/* 血氧饱和度卡片 */}
|
||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||
<OxygenSaturationCard
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
oxygenSaturation={oxygenSaturation}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
{/* 心率卡片 */}
|
||
<FloatingCard style={styles.masonryCard} delay={2000}>
|
||
<HeartRateCard
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
heartRate={heartRate}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const primary = Colors.light.primary;
|
||
const lightColors = Colors.light;
|
||
const darkColors = Colors.dark;
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
gradientBackground: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
},
|
||
decorativeCircle1: {
|
||
position: 'absolute',
|
||
top: 40,
|
||
right: 20,
|
||
width: 60,
|
||
height: 60,
|
||
borderRadius: 30,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.1,
|
||
},
|
||
decorativeCircle2: {
|
||
position: 'absolute',
|
||
bottom: -15,
|
||
left: -15,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.05,
|
||
},
|
||
safeArea: {
|
||
flex: 1,
|
||
},
|
||
scrollView: {
|
||
flex: 1,
|
||
paddingHorizontal: 20,
|
||
},
|
||
|
||
|
||
sectionTitle: {
|
||
fontSize: 24,
|
||
fontWeight: '800',
|
||
color: '#192126',
|
||
marginTop: 24,
|
||
marginBottom: 14,
|
||
},
|
||
metricsRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 16,
|
||
},
|
||
card: {
|
||
backgroundColor: '#0F1418',
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
marginBottom: 16,
|
||
},
|
||
metricsLeft: {
|
||
flex: 1,
|
||
backgroundColor: '#EEE9FF',
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
marginRight: 12,
|
||
},
|
||
metricsRight: {
|
||
width: 160,
|
||
gap: 12,
|
||
},
|
||
metricsRightCard: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 22,
|
||
padding: 16,
|
||
},
|
||
caloriesValue: {
|
||
color: '#192126',
|
||
fontSize: 18,
|
||
fontWeight: '800',
|
||
marginTop: 18,
|
||
},
|
||
trainingContent: {
|
||
marginTop: 8,
|
||
width: 120,
|
||
height: 120,
|
||
borderRadius: 60,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
alignSelf: 'center',
|
||
},
|
||
trainingRingTrack: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 60,
|
||
borderWidth: 12,
|
||
borderColor: '#E2D9FD',
|
||
},
|
||
trainingRingProgress: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 60,
|
||
borderWidth: 12,
|
||
borderColor: 'transparent',
|
||
borderTopColor: '#8B74F3',
|
||
borderRightColor: '#8B74F3',
|
||
transform: [{ rotateZ: '45deg' }],
|
||
},
|
||
trainingPercent: {
|
||
fontSize: 18,
|
||
fontWeight: '800',
|
||
color: '#8B74F3',
|
||
},
|
||
cyclingHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
cyclingIconBadge: {
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 6,
|
||
backgroundColor: primary,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 8,
|
||
},
|
||
cyclingTitle: {
|
||
color: '#FFFFFF',
|
||
fontSize: 20,
|
||
fontWeight: '800',
|
||
},
|
||
mapArea: {
|
||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||
borderRadius: 14,
|
||
height: 180,
|
||
padding: 8,
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
overflow: 'hidden',
|
||
},
|
||
mapTile: {
|
||
width: '25%',
|
||
height: '25%',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.12)',
|
||
},
|
||
routeLine: {
|
||
position: 'absolute',
|
||
height: 6,
|
||
backgroundColor: primary,
|
||
borderRadius: 3,
|
||
},
|
||
cardHeaderRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
iconSquare: {
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 8,
|
||
backgroundColor: '#FFFFFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 10,
|
||
},
|
||
cardTitle: {
|
||
fontSize: 14,
|
||
fontWeight: '800',
|
||
color: '#192126',
|
||
},
|
||
heartCard: {
|
||
backgroundColor: '#FFE5E5',
|
||
},
|
||
waveContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
height: 70,
|
||
gap: 6,
|
||
marginBottom: 8,
|
||
},
|
||
waveBar: {
|
||
width: 6,
|
||
borderRadius: 3,
|
||
backgroundColor: '#E54D4D',
|
||
},
|
||
heartValue: {
|
||
alignSelf: 'flex-end',
|
||
color: '#5B5B5B',
|
||
fontWeight: '600',
|
||
},
|
||
stepsValue: {
|
||
fontSize: 14,
|
||
color: '#7A6A42',
|
||
fontWeight: '700',
|
||
marginBottom: 8,
|
||
},
|
||
errorContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: '#FFE5E5',
|
||
borderRadius: 12,
|
||
padding: 12,
|
||
marginBottom: 16,
|
||
},
|
||
errorText: {
|
||
fontSize: 14,
|
||
color: '#E54D4D',
|
||
fontWeight: '600',
|
||
marginLeft: 8,
|
||
flex: 1,
|
||
},
|
||
retryButton: {
|
||
padding: 4,
|
||
marginLeft: 8,
|
||
},
|
||
viewMoreContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
viewMoreText: {
|
||
fontSize: 14,
|
||
color: '#192126',
|
||
},
|
||
viewMoreIcon: {
|
||
fontSize: 16,
|
||
color: '#192126',
|
||
marginLeft: 4,
|
||
},
|
||
stressCardRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-start',
|
||
marginBottom: 16,
|
||
},
|
||
healthCardsRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 16,
|
||
},
|
||
masonryContainer: {
|
||
marginBottom: 16,
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
marginTop: 16,
|
||
},
|
||
masonryColumn: {
|
||
flex: 1,
|
||
},
|
||
masonryCard: {
|
||
width: '100%',
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.12,
|
||
shadowRadius: 12,
|
||
elevation: 6,
|
||
},
|
||
basalMetabolismCardOverride: {
|
||
margin: -16, // 抵消 masonryCard 的 padding
|
||
borderRadius: 16,
|
||
},
|
||
compactStepsCard: {
|
||
minHeight: 100,
|
||
},
|
||
stepsContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginTop: 8,
|
||
},
|
||
sleepValue: {
|
||
fontSize: 16,
|
||
color: '#1E40AF',
|
||
fontWeight: '700',
|
||
marginTop: 8,
|
||
},
|
||
weightCard: {
|
||
backgroundColor: '#F0F9FF',
|
||
},
|
||
weightValue: {
|
||
fontSize: 22,
|
||
color: '#0369A1',
|
||
fontWeight: '800',
|
||
marginTop: 8,
|
||
},
|
||
addWeightButton: {
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: 0,
|
||
padding: 4,
|
||
},
|
||
|
||
});
|