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 { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||||
import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice';
|
import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice';
|
||||||
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||||
|
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { debounce } from 'lodash';
|
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -625,6 +625,7 @@ export default function ExploreScreen() {
|
|||||||
stepGoal={stepGoal}
|
stepGoal={stepGoal}
|
||||||
hourlySteps={hourlySteps}
|
hourlySteps={hourlySteps}
|
||||||
style={styles.stepsCardOverride}
|
style={styles.stepsCardOverride}
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</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,
|
Animated,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewStyle
|
ViewStyle
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -17,13 +18,15 @@ interface StepsCardProps {
|
|||||||
stepGoal: number;
|
stepGoal: number;
|
||||||
hourlySteps: HourlyStepData[];
|
hourlySteps: HourlyStepData[];
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
onPress?: () => void; // 新增点击事件回调
|
||||||
}
|
}
|
||||||
|
|
||||||
const StepsCard: React.FC<StepsCardProps> = ({
|
const StepsCard: React.FC<StepsCardProps> = ({
|
||||||
stepCount,
|
stepCount,
|
||||||
stepGoal,
|
stepGoal,
|
||||||
hourlySteps,
|
hourlySteps,
|
||||||
style
|
style,
|
||||||
|
onPress
|
||||||
}) => {
|
}) => {
|
||||||
// 为每个柱体创建独立的动画值
|
// 为每个柱体创建独立的动画值
|
||||||
const animatedValues = useRef(
|
const animatedValues = useRef(
|
||||||
@@ -69,8 +72,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
}
|
}
|
||||||
}, [chartData, animatedValues]);
|
}, [chartData, animatedValues]);
|
||||||
|
|
||||||
return (
|
const CardContent = () => (
|
||||||
<View style={[styles.container, style]}>
|
<>
|
||||||
{/* 标题和步数显示 */}
|
{/* 标题和步数显示 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>步数</Text>
|
<Text style={styles.title}>步数</Text>
|
||||||
@@ -140,6 +143,26 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
resetToken={stepCount}
|
resetToken={stepCount}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
@@ -113,6 +114,11 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
|
|
||||||
// 处理添加喝水 - 右上角按钮直接添加
|
// 处理添加喝水 - 右上角按钮直接添加
|
||||||
const handleQuickAddWater = async () => {
|
const handleQuickAddWater = async () => {
|
||||||
|
// 触发震动反馈
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
}
|
||||||
|
|
||||||
// 使用用户配置的快速添加饮水量
|
// 使用用户配置的快速添加饮水量
|
||||||
const waterAmount = quickWaterAmount;
|
const waterAmount = quickWaterAmount;
|
||||||
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
||||||
@@ -122,6 +128,11 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
|
|
||||||
// 处理卡片点击 - 打开配置饮水弹窗
|
// 处理卡片点击 - 打开配置饮水弹窗
|
||||||
const handleCardPress = () => {
|
const handleCardPress = () => {
|
||||||
|
// 触发震动反馈
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,10 @@ export const login = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
|
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.userProfile),
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
|
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.authToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let profile: UserProfile = {};
|
let profile: UserProfile = {};
|
||||||
@@ -143,7 +144,13 @@ export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
|
|||||||
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
|
try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; }
|
||||||
}
|
}
|
||||||
const privacyAgreed = privacyAgreedStr === 'true';
|
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 () => {
|
export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => {
|
||||||
@@ -272,6 +279,7 @@ const userSlice = createSlice({
|
|||||||
.addCase(rehydrateUser.fulfilled, (state, action) => {
|
.addCase(rehydrateUser.fulfilled, (state, action) => {
|
||||||
state.profile = action.payload.profile;
|
state.profile = action.payload.profile;
|
||||||
state.privacyAgreed = action.payload.privacyAgreed;
|
state.privacyAgreed = action.payload.privacyAgreed;
|
||||||
|
state.token = action.payload.token;
|
||||||
if (!state.profile?.name || !state.profile.name.trim()) {
|
if (!state.profile?.name || !state.profile.name.trim()) {
|
||||||
state.profile.name = DEFAULT_MEMBER_NAME;
|
state.profile.name = DEFAULT_MEMBER_NAME;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user