diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx
index 45478b5..b4da961 100644
--- a/app/(tabs)/statistics.tsx
+++ b/app/(tabs)/statistics.tsx
@@ -19,17 +19,17 @@ import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice';
-import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
+import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { calculateNutritionGoals } from '@/utils/nutrition';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
-import { debounce } from 'lodash';
import { LinearGradient } from 'expo-linear-gradient';
+import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
AppState,
@@ -85,14 +85,14 @@ export default function ExploreScreen() {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
-
+
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
-
+
// 获取今日喝水统计数据
const todayWaterStats = useAppSelector(selectTodayStats);
@@ -173,7 +173,7 @@ export default function ExploreScreen() {
const cacheKey = `${dateKey}-${dataType}`;
const lastUpdate = dataTimestampRef.current[cacheKey];
const now = Date.now();
-
+
// 营养数据使用更短的缓存时间,其他数据使用5分钟
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
@@ -427,7 +427,7 @@ export default function ExploreScreen() {
// 执行压力检查
await checkStressLevelAndNotify();
-
+
// 执行喝水目标检查
await checkWaterGoalAndNotify();
} catch (error) {
@@ -625,6 +625,7 @@ export default function ExploreScreen() {
stepGoal={stepGoal}
hourlySteps={hourlySteps}
style={styles.stepsCardOverride}
+ onPress={() => pushIfAuthedElseLogin('/steps/detail')}
/>
diff --git a/app/steps/detail.tsx b/app/steps/detail.tsx
new file mode 100644
index 0000000..95d46c1
--- /dev/null
+++ b/app/steps/detail.tsx
@@ -0,0 +1,544 @@
+import { DateSelector } from '@/components/DateSelector';
+import { useAppDispatch, useAppSelector } from '@/hooks/redux';
+import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
+import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
+import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
+import { getTestHealthData } from '@/utils/mockHealthData';
+import { Ionicons } from '@expo/vector-icons';
+import dayjs from 'dayjs';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useRouter } from 'expo-router';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Animated,
+ SafeAreaView,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+export default function StepsDetailScreen() {
+ const router = useRouter();
+ const dispatch = useAppDispatch();
+ const insets = useSafeAreaInsets();
+
+ // 开发调试:设置为true来使用mock数据
+ const useMockData = __DEV__;
+
+ // 日期选择相关状态
+ const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
+
+ // 数据加载状态
+ const [isLoading, setIsLoading] = useState(false);
+
+ // 获取当前选中日期
+ const currentSelectedDate = useMemo(() => {
+ const days = getMonthDaysZh();
+ return days[selectedIndex]?.date?.toDate() ?? new Date();
+ }, [selectedIndex]);
+
+ const currentSelectedDateString = useMemo(() => {
+ return dayjs(currentSelectedDate).format('YYYY-MM-DD');
+ }, [currentSelectedDate]);
+
+ // 从 Redux 获取指定日期的健康数据
+ const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
+
+ // 解构健康数据(支持mock数据)
+ const mockData = useMockData ? getTestHealthData('mock') : null;
+ const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
+ const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
+
+
+ // 为每个柱体创建独立的动画值
+ const animatedValues = useRef(
+ Array.from({ length: 24 }, () => new Animated.Value(0))
+ ).current;
+
+ // 计算柱状图数据
+ const chartData = useMemo(() => {
+ if (!hourlySteps || hourlySteps.length === 0) {
+ return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
+ }
+
+ // 找到最大步数用于计算高度比例
+ const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
+ const maxHeight = 120; // 详情页面使用更大的高度
+
+ return hourlySteps.map(data => ({
+ ...data,
+ height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
+ }));
+ }, [hourlySteps]);
+
+ // 计算平均值刻度线位置
+ const averageLinePosition = useMemo(() => {
+ if (!hourlySteps || hourlySteps.length === 0 || !chartData || chartData.length === 0) return 0;
+
+ const activeHours = hourlySteps.filter(h => h.steps > 0);
+ if (activeHours.length === 0) return 0;
+
+ const avgSteps = activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length;
+ const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
+ const maxHeight = 120;
+
+ return maxSteps > 0 ? (avgSteps / maxSteps) * maxHeight : 0;
+ }, [hourlySteps, chartData]);
+
+ // 获取当前小时
+ const currentHour = new Date().getHours();
+
+ // 触发柱体动画
+ useEffect(() => {
+ if (chartData && chartData.length > 0) {
+ // 重置所有动画值
+ animatedValues.forEach(animValue => animValue.setValue(0));
+
+ // 延迟启动动画,创建波浪效果
+ chartData.forEach((data, index) => {
+ if (data.steps > 0) {
+ setTimeout(() => {
+ Animated.spring(animatedValues[index], {
+ toValue: 1,
+ tension: 120,
+ friction: 8,
+ useNativeDriver: false,
+ }).start();
+ }, index * 50); // 每个柱体延迟50ms
+ }
+ });
+ }
+ }, [chartData, animatedValues]);
+
+ // 加载健康数据
+ const loadHealthData = async (targetDate: Date) => {
+ if (useMockData) return; // 如果使用mock数据,不需要加载
+
+ try {
+ setIsLoading(true);
+ console.log('加载步数详情数据...', targetDate);
+
+ const ok = await ensureHealthPermissions();
+ if (!ok) {
+ console.warn('无法获取健康权限');
+ return;
+ }
+
+ const data = await fetchHealthDataForDate(targetDate);
+
+ console.log('data', data);
+
+ const dateString = dayjs(targetDate).format('YYYY-MM-DD');
+
+ // 使用 Redux 存储健康数据
+ dispatch(setHealthData({
+ date: dateString,
+ data: data
+ }));
+
+ console.log('步数详情数据加载完成');
+ } catch (error) {
+ console.error('加载步数详情数据失败:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 日期选择处理
+ const onSelectDate = (index: number, date: Date) => {
+ setSelectedIndex(index);
+ loadHealthData(date);
+ };
+
+ // 页面初始化时加载当前日期数据
+ useEffect(() => {
+ loadHealthData(currentSelectedDate);
+ }, []);
+
+ // 计算总步数和平均步数
+ const totalSteps = stepCount || 0;
+ const averageHourlySteps = useMemo(() => {
+ if (!hourlySteps || hourlySteps.length === 0) return 0;
+ const activeHours = hourlySteps.filter(h => h.steps > 0);
+ if (activeHours.length === 0) return 0;
+ return Math.round(activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length);
+ }, [hourlySteps]);
+
+ // 找出最活跃的时间段
+ const mostActiveHour = useMemo(() => {
+ if (!hourlySteps || hourlySteps.length === 0) return null;
+ const maxStepsData = hourlySteps.reduce((max, current) =>
+ current.steps > max.steps ? current : max
+ );
+ return maxStepsData.steps > 0 ? maxStepsData : null;
+ }, [hourlySteps]);
+
+ return (
+
+ {/* 背景渐变 */}
+
+
+
+ {/* 顶部导航栏 */}
+
+ router.back()}
+ >
+
+
+ 步数详情
+
+
+
+
+ {/* 日期选择器 */}
+
+
+ {/* 统计卡片 */}
+
+ {isLoading ? (
+
+ 加载中...
+
+ ) : (
+
+
+ {totalSteps.toLocaleString()}
+ 总步数
+
+
+ {averageHourlySteps}
+ 平均每小时
+
+
+
+ {mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
+
+ 最活跃时段
+
+
+ )}
+
+
+ {/* 详细柱状图卡片 */}
+
+
+ 每小时步数分布
+
+ {dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
+
+
+
+ {/* 柱状图容器 */}
+
+ {/* 平均值刻度线 - 放在chartArea外面,相对于chartContainer定位 */}
+ {averageLinePosition > 0 && (
+
+
+ {/* 创建更多的虚线段来确保完整覆盖 */}
+ {Array.from({ length: 80 }, (_, index) => (
+ 0 ? 2 : 0,
+ flex: 0 // 防止 flex 拉伸
+ }
+ ]}
+ />
+ ))}
+
+
+ 平均 {averageHourlySteps}步
+
+
+ )}
+
+ {/* 柱状图区域 */}
+
+ {chartData.map((data, index) => {
+ const isActive = data.steps > 0;
+ const isCurrent = index <= currentHour;
+ const isKeyTime = index === 0 || index === 12 || index === 23;
+
+ // 动画变换
+ const animatedHeight = animatedValues[index].interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, data.height],
+ });
+
+ const animatedOpacity = animatedValues[index].interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, 1],
+ });
+
+ return (
+
+ {/* 背景柱体 */}
+
+
+ {/* 数据柱体 */}
+ {isActive && (
+
+ )}
+
+ {/* 步数标签(仅在有数据且是关键时间点时显示) */}
+ {/* {isActive && isKeyTime && (
+
+ {data.steps}
+
+ )} */}
+
+ );
+ })}
+
+
+ {/* 底部时间轴标签 */}
+
+ 0:00
+ 12:00
+ 24:00
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ gradientBackground: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ safeArea: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ backgroundColor: 'transparent',
+ },
+ backButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#192126',
+ },
+ headerRight: {
+ width: 40,
+ },
+ scrollView: {
+ flex: 1,
+ paddingHorizontal: 20,
+ },
+ statsCard: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 16,
+ padding: 20,
+ marginVertical: 16,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 4,
+ },
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
+ elevation: 6,
+ },
+ statsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+ statItem: {
+ alignItems: 'center',
+ },
+ statValue: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#192126',
+ marginBottom: 4,
+ },
+ statLabel: {
+ fontSize: 12,
+ color: '#64748B',
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 20,
+ },
+ loadingText: {
+ fontSize: 16,
+ color: '#64748B',
+ },
+ chartCard: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 16,
+ padding: 20,
+ marginBottom: 16,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 4,
+ },
+ shadowOpacity: 0.08,
+ shadowRadius: 12,
+ elevation: 6,
+ },
+ chartHeader: {
+ marginBottom: 20,
+ },
+ chartTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#192126',
+ marginBottom: 4,
+ },
+ chartSubtitle: {
+ fontSize: 14,
+ color: '#64748B',
+ },
+ chartContainer: {
+ position: 'relative',
+ },
+ timeLabels: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: 8,
+ paddingHorizontal: 8,
+ },
+ timeLabel: {
+ fontSize: 12,
+ color: '#64748B',
+ fontWeight: '500',
+ },
+ chartArea: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ height: 120,
+ justifyContent: 'space-between',
+ paddingHorizontal: 4,
+ },
+ barContainer: {
+ width: 8,
+ height: 120,
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ position: 'relative',
+ },
+ backgroundBar: {
+ width: 8,
+ height: 120,
+ borderRadius: 2,
+ position: 'absolute',
+ bottom: 0,
+ },
+ dataBar: {
+ width: 8,
+ borderRadius: 2,
+ position: 'absolute',
+ bottom: 0,
+ },
+ stepLabel: {
+ position: 'absolute',
+ top: -20,
+ alignItems: 'center',
+ },
+ stepLabelText: {
+ fontSize: 10,
+ color: '#64748B',
+ fontWeight: '500',
+ },
+ averageLine: {
+ position: 'absolute',
+ left: 4, // 匹配 chartArea 的 paddingHorizontal
+ right: 4, // 匹配 chartArea 的 paddingHorizontal
+ flexDirection: 'row',
+ alignItems: 'center',
+ zIndex: 1,
+ },
+ averageLineDashContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginRight: 8,
+ overflow: 'hidden', // 防止虚线段溢出容器
+ },
+ dashSegment: {
+ width: 3,
+ height: 1.5,
+ backgroundColor: '#FFA726',
+ opacity: 0.8,
+ },
+ averageLineLabel: {
+ fontSize: 10,
+ color: '#FFA726',
+ fontWeight: '600',
+ backgroundColor: '#FFFFFF',
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 4,
+ borderWidth: 0.5,
+ borderColor: '#FFA726',
+ },
+});
\ No newline at end of file
diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx
index 3a0e40e..13cc461 100644
--- a/components/StepsCard.tsx
+++ b/components/StepsCard.tsx
@@ -3,6 +3,7 @@ import {
Animated,
StyleSheet,
Text,
+ TouchableOpacity,
View,
ViewStyle
} from 'react-native';
@@ -17,13 +18,15 @@ interface StepsCardProps {
stepGoal: number;
hourlySteps: HourlyStepData[];
style?: ViewStyle;
+ onPress?: () => void; // 新增点击事件回调
}
const StepsCard: React.FC = ({
stepCount,
stepGoal,
hourlySteps,
- style
+ style,
+ onPress
}) => {
// 为每个柱体创建独立的动画值
const animatedValues = useRef(
@@ -69,8 +72,8 @@ const StepsCard: React.FC = ({
}
}, [chartData, animatedValues]);
- return (
-
+ const CardContent = () => (
+ <>
{/* 标题和步数显示 */}
步数
@@ -140,6 +143,26 @@ const StepsCard: React.FC = ({
resetToken={stepCount}
/>
+ >
+ );
+
+ // 如果有点击事件,包装在TouchableOpacity中
+ if (onPress) {
+ return (
+
+
+
+ );
+ }
+
+ // 否则使用普通View
+ return (
+
+
);
};
diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx
index d38cdce..f839680 100644
--- a/components/WaterIntakeCard.tsx
+++ b/components/WaterIntakeCard.tsx
@@ -1,6 +1,7 @@
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import dayjs from 'dayjs';
+import * as Haptics from 'expo-haptics';
import React, { useEffect, useMemo, useState } from 'react';
import {
Animated,
@@ -113,6 +114,11 @@ const WaterIntakeCard: React.FC = ({
// 处理添加喝水 - 右上角按钮直接添加
const handleQuickAddWater = async () => {
+ // 触发震动反馈
+ if (process.env.EXPO_OS === 'ios') {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ }
+
// 使用用户配置的快速添加饮水量
const waterAmount = quickWaterAmount;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
@@ -122,6 +128,11 @@ const WaterIntakeCard: React.FC = ({
// 处理卡片点击 - 打开配置饮水弹窗
const handleCardPress = () => {
+ // 触发震动反馈
+ if (process.env.EXPO_OS === 'ios') {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+
setIsModalVisible(true);
};
diff --git a/store/userSlice.ts b/store/userSlice.ts
index 3a26abe..4ee4625 100644
--- a/store/userSlice.ts
+++ b/store/userSlice.ts
@@ -133,9 +133,10 @@ export const login = createAsyncThunk(
);
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
- const [profileStr, privacyAgreedStr] = await Promise.all([
+ const [profileStr, privacyAgreedStr, token] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
+ AsyncStorage.getItem(STORAGE_KEYS.authToken),
]);
let profile: UserProfile = {};
@@ -143,7 +144,13 @@ export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
}
const privacyAgreed = privacyAgreedStr === 'true';
- return { profile, privacyAgreed } as { profile: UserProfile; privacyAgreed: boolean };
+
+ // 如果有 token,需要设置到 API 客户端
+ if (token) {
+ await setAuthToken(token);
+ }
+
+ return { profile, privacyAgreed, token } as { profile: UserProfile; privacyAgreed: boolean; token: string | null };
});
export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => {
@@ -272,6 +279,7 @@ const userSlice = createSlice({
.addCase(rehydrateUser.fulfilled, (state, action) => {
state.profile = action.payload.profile;
state.privacyAgreed = action.payload.privacyAgreed;
+ state.token = action.payload.token;
if (!state.profile?.name || !state.profile.name.trim()) {
state.profile.name = DEFAULT_MEMBER_NAME;
}