feat: 新增步数详情页面,支持日期选择和步数统计展示
feat: 更新StepsCard组件,支持点击事件回调 feat: 在WaterIntakeCard中添加震动反馈功能 fix: 在用户重建时保存authToken
This commit is contained in:
@@ -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
544
app/steps/detail.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user