feat(auth): 预加载用户数据并优化登录状态同步
- 在启动屏预加载用户 token 与资料,避免首页白屏 - 新增 rehydrateUserSync 同步注入 Redux,减少异步等待 - 登录页兼容 ERR_REQUEST_CANCELED 取消场景 - 各页面统一依赖 isLoggedIn 判断,移除冗余控制台日志 - 步数卡片与详情页改为实时拉取健康数据,不再缓存至 Redux - 后台任务注册移至顶层,防止重复定义 - 体重记录、HeaderBar 等 UI 细节样式微调
This commit is contained in:
@@ -1,37 +1,43 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useLocalSearchParams } 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();
|
||||
// 获取路由参数
|
||||
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
const useMockData = __DEV__;
|
||||
// 根据传入的日期参数计算初始选中索引
|
||||
const getInitialSelectedIndex = () => {
|
||||
if (date) {
|
||||
const targetDate = dayjs(date);
|
||||
const days = getMonthDaysZh();
|
||||
const foundIndex = days.findIndex(day =>
|
||||
day.date && dayjs(day.date.toDate()).isSame(targetDate, 'day')
|
||||
);
|
||||
return foundIndex >= 0 ? foundIndex : getTodayIndexInMonth();
|
||||
}
|
||||
return getTodayIndexInMonth();
|
||||
};
|
||||
|
||||
// 日期选择相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const [selectedIndex, setSelectedIndex] = useState(getInitialSelectedIndex());
|
||||
|
||||
// 数据加载状态
|
||||
// 步数数据状态
|
||||
const [stepCount, setStepCount] = useState(0);
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 获取当前选中日期
|
||||
@@ -40,17 +46,25 @@ export default function StepsDetailScreen() {
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex]);
|
||||
|
||||
const currentSelectedDateString = useMemo(() => {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
// 获取步数数据的函数,参考 StepsCard 的实现
|
||||
const getStepData = async (date: Date) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
logger.info('获取步数详情数据...');
|
||||
const [steps, hourly] = await Promise.all([
|
||||
fetchStepCount(date),
|
||||
fetchHourlyStepSamples(date)
|
||||
]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
setStepCount(steps);
|
||||
setHourSteps(hourly);
|
||||
|
||||
// 解构健康数据(支持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 ?? []);
|
||||
} catch (error) {
|
||||
logger.error('获取步数详情数据失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 为每个柱体创建独立的动画值
|
||||
@@ -113,50 +127,24 @@ export default function StepsDetailScreen() {
|
||||
}
|
||||
}, [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);
|
||||
getStepData(date);
|
||||
};
|
||||
|
||||
// 页面初始化时加载当前日期数据
|
||||
// 当路由参数变化时更新选中索引
|
||||
useEffect(() => {
|
||||
loadHealthData(currentSelectedDate);
|
||||
}, []);
|
||||
const newIndex = getInitialSelectedIndex();
|
||||
setSelectedIndex(newIndex);
|
||||
}, [date]);
|
||||
|
||||
// 当选中日期变化时获取数据
|
||||
useEffect(() => {
|
||||
if (currentSelectedDate) {
|
||||
getStepData(currentSelectedDate);
|
||||
}
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 计算总步数和平均步数
|
||||
const totalSteps = stepCount || 0;
|
||||
@@ -219,222 +207,212 @@ export default function StepsDetailScreen() {
|
||||
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} />
|
||||
<HeaderBar
|
||||
title="步数详情"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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 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.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 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;
|
||||
{/* 柱状图区域 */}
|
||||
<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 animatedHeight = animatedValues[index].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, data.height],
|
||||
});
|
||||
|
||||
const animatedOpacity = animatedValues[index].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
const animatedOpacity = animatedValues[index].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
|
||||
return (
|
||||
<View key={`bar-${index}`} style={styles.barContainer}>
|
||||
{/* 背景柱体 */}
|
||||
<View
|
||||
return (
|
||||
<View key={`bar-${index}`} style={styles.barContainer}>
|
||||
{/* 背景柱体 */}
|
||||
<View
|
||||
style={[
|
||||
styles.backgroundBar,
|
||||
{
|
||||
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 数据柱体 */}
|
||||
{isActive && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.backgroundBar,
|
||||
styles.dataBar,
|
||||
{
|
||||
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
|
||||
height: animatedHeight,
|
||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||
opacity: animatedOpacity,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 数据柱体 */}
|
||||
{isActive && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dataBar,
|
||||
{
|
||||
height: animatedHeight,
|
||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||
opacity: animatedOpacity,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
|
||||
{/* {isActive && isKeyTime && (
|
||||
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
|
||||
{/* {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>
|
||||
|
||||
{/* 活动等级展示卡片 */}
|
||||
<View style={styles.activityLevelCard}>
|
||||
|
||||
|
||||
{/* 活动级别文本 */}
|
||||
<Text style={styles.activityMainText}>你今天的活动量处于</Text>
|
||||
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={styles.progressBarBackground}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBarFill,
|
||||
{
|
||||
width: `${progressPercentage}%`,
|
||||
backgroundColor: currentActivityLevel.color
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 步数信息 */}
|
||||
<View style={styles.stepsInfoContainer}>
|
||||
<View style={styles.currentStepsInfo}>
|
||||
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
||||
<Text style={styles.stepsLabel}>当前</Text>
|
||||
</View>
|
||||
<View style={styles.nextStepsInfo}>
|
||||
<Text style={styles.stepsValue}>
|
||||
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.stepsLabel}>
|
||||
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 活动等级图例 */}
|
||||
<View style={styles.activityLegendContainer}>
|
||||
{reversedActivityLevels.map((level) => (
|
||||
<View key={level.key} style={styles.legendItem}>
|
||||
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
|
||||
<Text style={styles.legendIconText}>🏃</Text>
|
||||
</View>
|
||||
<Text style={styles.legendLabel}>{level.label}</Text>
|
||||
<Text style={styles.legendRange}>
|
||||
{level.maxSteps === Infinity
|
||||
? `> ${level.minSteps.toLocaleString()}`
|
||||
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
|
||||
</Text>
|
||||
</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>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
{/* 活动等级展示卡片 */}
|
||||
<View style={styles.activityLevelCard}>
|
||||
|
||||
|
||||
{/* 活动级别文本 */}
|
||||
<Text style={styles.activityMainText}>你今天的活动量处于</Text>
|
||||
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={styles.progressBarBackground}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBarFill,
|
||||
{
|
||||
width: `${progressPercentage}%`,
|
||||
backgroundColor: currentActivityLevel.color
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 步数信息 */}
|
||||
<View style={styles.stepsInfoContainer}>
|
||||
<View style={styles.currentStepsInfo}>
|
||||
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
||||
<Text style={styles.stepsLabel}>当前</Text>
|
||||
</View>
|
||||
<View style={styles.nextStepsInfo}>
|
||||
<Text style={styles.stepsValue}>
|
||||
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.stepsLabel}>
|
||||
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 活动等级图例 */}
|
||||
<View style={styles.activityLegendContainer}>
|
||||
{reversedActivityLevels.map((level) => (
|
||||
<View key={level.key} style={styles.legendItem}>
|
||||
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
|
||||
<Text style={styles.legendIconText}>🏃</Text>
|
||||
</View>
|
||||
<Text style={styles.legendLabel}>{level.label}</Text>
|
||||
<Text style={styles.legendRange}>
|
||||
{level.maxSteps === Infinity
|
||||
? `> ${level.minSteps.toLocaleString()}`
|
||||
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -450,9 +428,6 @@ const styles = StyleSheet.create({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user