Files
digital-pilates/app/(tabs)/explore.tsx
richarjiang 6a67fb21f7 feat: 更新应用名称和图标,优化用户界面
- 将应用名称修改为“Health Bot”,提升品牌识别度
- 更新应用图标为 logo.png,确保视觉一致性
- 删除不再使用的 ai-coach-chat 页面,简化代码结构
- 更新多个页面的导航和按钮文本,提升用户体验
- 添加体重历史记录功能,支持用户追踪健康数据
- 优化 Redux 状态管理,确保数据处理的准确性和稳定性
2025-08-17 21:34:04 +08:00

501 lines
14 KiB
TypeScript
Raw 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 { AnimatedNumber } from '@/components/AnimatedNumber';
import { BMICard } from '@/components/BMICard';
import { CircularRing } from '@/components/CircularRing';
import { ProgressBar } from '@/components/ProgressBar';
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function ExploreScreen() {
const router = useRouter();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
// 使用 dayjs当月日期与默认选中“今天”
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
// 记录最近一次请求的“日期键”,避免旧请求覆盖新结果
const latestRequestKeyRef = useRef<string | null>(null);
const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
const loadHealthData = async (targetDate?: Date) => {
try {
console.log('=== 开始HealthKit初始化流程 ===');
setIsLoading(true);
const ok = await ensureHealthPermissions();
if (!ok) {
const errorMsg = '无法获取健康权限请确保在真实iOS设备上运行并授权应用访问健康数据';
console.warn(errorMsg);
return;
}
// 若未显式传入日期,按当前选中索引推导日期
const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date();
const requestKey = getDateKey(derivedDate);
latestRequestKeyRef.current = requestKey;
console.log('权限获取成功,开始获取健康数据...', derivedDate);
const data = await fetchHealthDataForDate(derivedDate);
console.log('设置UI状态:', data);
// 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) {
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
setAnimToken((t) => t + 1);
} else {
console.log('忽略过期健康数据请求结果key=', requestKey, '最新key=', latestRequestKeyRef.current);
}
console.log('=== HealthKit数据获取完成 ===');
} catch (error) {
console.error('HealthKit流程出现异常:', error);
} finally {
setIsLoading(false);
}
};
useFocusEffect(
React.useCallback(() => {
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
loadHealthData();
}, [selectedIndex])
);
// 日期点击时,加载对应日期数据
const onSelectDate = (index: number) => {
setSelectedIndex(index);
scrollToIndex(index);
const target = days[index]?.date?.toDate();
if (target) {
loadHealthData(target);
}
};
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
{/* 体重历史记录卡片 */}
<Text style={styles.sectionTitle}></Text>
<WeightHistoryCard />
{/* 标题与日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
onPress={() => onSelectDate(i)}
activeOpacity={0.8}
>
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
<Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={styles.selectedDot} />}
</View>
);
})}
</ScrollView>
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}>
<Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}>
<CircularRing
size={120}
strokeWidth={12}
trackColor="#E2D9FD"
progressColor="#8B74F3"
progress={trainingProgress}
resetToken={animToken}
/>
</View>
</View>
<View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text>
{activeCalories != null ? (
<AnimatedNumber
value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)} 千卡`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
</View>
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<View style={styles.cardHeaderRow}>
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
<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={18}
trackColor="#FFEBCB"
fillColor="#FFC365"
showLabel={false}
/>
</View>
</View>
</View>
{/* BMI 指数卡片 */}
<BMICard
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
/>
</ScrollView>
</SafeAreaView>
</View>
);
}
const primary = Colors.light.primary;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F6F7F8',
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
monthTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 8,
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 68,
marginRight: 12,
},
dayPill: {
width: 68,
height: 68,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
dayPillNormal: {
backgroundColor: '#C8F852',
},
dayPillSelected: {
backgroundColor: '#192126',
},
dayLabel: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 2,
},
dayLabelSelected: {
color: '#FFFFFF',
},
dayDate: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
dayDateSelected: {
color: '#FFFFFF',
},
selectedDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#192126',
marginTop: 10,
marginBottom: 4,
alignSelf: 'center',
},
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,
},
caloriesCard: {
backgroundColor: '#FFFFFF',
},
trainingCard: {
backgroundColor: '#EEE9FF',
},
cardTitleSecondary: {
color: '#9AA3AE',
fontSize: 14,
fontWeight: '600',
marginBottom: 10,
},
caloriesValue: {
color: '#192126',
fontSize: 22,
fontWeight: '800',
},
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: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
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',
},
stepsCard: {
backgroundColor: '#FFE4B8',
},
stepsValue: {
fontSize: 16,
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,
},
});