feat: 新增步数详情页面,支持日期选择和步数统计展示

feat: 更新StepsCard组件,支持点击事件回调
feat: 在WaterIntakeCard中添加震动反馈功能
fix: 在用户重建时保存authToken
This commit is contained in:
richarjiang
2025-09-02 19:22:02 +08:00
parent 70e3152158
commit a70cb1e407
5 changed files with 598 additions and 11 deletions

View File

@@ -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')}
/>
</FloatingCard>

544
app/steps/detail.tsx Normal file
View File

@@ -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 (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<SafeAreaView style={styles.safeArea}>
{/* 顶部导航栏 */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerRight} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
{/* 统计卡片 */}
<View style={styles.statsCard}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
)}
</View>
{/* 详细柱状图卡片 */}
<View style={styles.chartCard}>
<View style={styles.chartHeader}>
<Text style={styles.chartTitle}></Text>
<Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text>
</View>
{/* 柱状图容器 */}
<View style={styles.chartContainer}>
{/* 平均值刻度线 - 放在chartArea外面相对于chartContainer定位 */}
{averageLinePosition > 0 && (
<View
style={[
styles.averageLine,
{ bottom: averageLinePosition }
]}
>
<View style={styles.averageLineDashContainer}>
{/* 创建更多的虚线段来确保完整覆盖 */}
{Array.from({ length: 80 }, (_, index) => (
<View
key={index}
style={[
styles.dashSegment,
{
marginLeft: index > 0 ? 2 : 0,
flex: 0 // 防止 flex 拉伸
}
]}
/>
))}
</View>
<Text style={styles.averageLineLabel}>
{averageHourlySteps}
</Text>
</View>
)}
{/* 柱状图区域 */}
<View style={styles.chartArea}>
{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 (
<View key={`bar-${index}`} style={styles.barContainer}>
{/* 背景柱体 */}
<View
style={[
styles.backgroundBar,
{
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
}
]}
/>
{/* 数据柱体 */}
{isActive && (
<Animated.View
style={[
styles.dataBar,
{
height: animatedHeight,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
opacity: animatedOpacity,
}
]}
/>
)}
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
{/* {isActive && isKeyTime && (
<Animated.View
style={[styles.stepLabel, { opacity: animatedOpacity }]}
>
<Text style={styles.stepLabelText}>{data.steps}</Text>
</Animated.View>
)} */}
</View>
);
})}
</View>
{/* 底部时间轴标签 */}
<View style={styles.timeLabels}>
<Text style={styles.timeLabel}>0:00</Text>
<Text style={styles.timeLabel}>12:00</Text>
<Text style={styles.timeLabel}>24:00</Text>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
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',
},
});

View File

@@ -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<StepsCardProps> = ({
stepCount,
stepGoal,
hourlySteps,
style
style,
onPress
}) => {
// 为每个柱体创建独立的动画值
const animatedValues = useRef(
@@ -69,8 +72,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
}
}, [chartData, animatedValues]);
return (
<View style={[styles.container, style]}>
const CardContent = () => (
<>
{/* 标题和步数显示 */}
<View style={styles.header}>
<Text style={styles.title}></Text>
@@ -140,6 +143,26 @@ const StepsCard: React.FC<StepsCardProps> = ({
resetToken={stepCount}
/>
</View>
</>
);
// 如果有点击事件包装在TouchableOpacity中
if (onPress) {
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={onPress}
activeOpacity={0.8}
>
<CardContent />
</TouchableOpacity>
);
}
// 否则使用普通View
return (
<View style={[styles.container, style]}>
<CardContent />
</View>
);
};

View File

@@ -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<WaterIntakeCardProps> = ({
// 处理添加喝水 - 右上角按钮直接添加
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<WaterIntakeCardProps> = ({
// 处理卡片点击 - 打开配置饮水弹窗
const handleCardPress = () => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setIsModalVisible(true);
};

View File

@@ -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;
}