feat(auth): 预加载用户数据并优化登录状态同步

- 在启动屏预加载用户 token 与资料,避免首页白屏
- 新增 rehydrateUserSync 同步注入 Redux,减少异步等待
- 登录页兼容 ERR_REQUEST_CANCELED 取消场景
- 各页面统一依赖 isLoggedIn 判断,移除冗余控制台日志
- 步数卡片与详情页改为实时拉取健康数据,不再缓存至 Redux
- 后台任务注册移至顶层,防止重复定义
- 体重记录、HeaderBar 等 UI 细节样式微调
This commit is contained in:
richarjiang
2025-09-15 09:56:42 +08:00
parent 55d133c470
commit 91df01bd79
18 changed files with 967 additions and 1018 deletions

View File

@@ -46,6 +46,7 @@ export default function GoalsScreen() {
skipError, skipError,
} = useAppSelector((state) => state.tasks); } = useAppSelector((state) => state.tasks);
const { const {
createLoading, createLoading,
createError createError
@@ -67,13 +68,13 @@ export default function GoalsScreen() {
// 页面聚焦时重新加载数据 // 页面聚焦时重新加载数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
console.log('useFocusEffect - loading tasks'); console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
if (isLoggedIn) { if (isLoggedIn) {
loadTasks(); loadTasks();
checkAndShowGuide(); checkAndShowGuide();
} }
}, [dispatch]) }, [dispatch, isLoggedIn])
); );
// 检查并显示用户引导 // 检查并显示用户引导
@@ -94,6 +95,7 @@ export default function GoalsScreen() {
// 加载任务列表 // 加载任务列表
const loadTasks = async () => { const loadTasks = async () => {
try { try {
await dispatch(fetchTasks({ await dispatch(fetchTasks({
startDate: dayjs().startOf('day').toISOString(), startDate: dayjs().startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(), endDate: dayjs().endOf('day').toISOString(),

View File

@@ -46,6 +46,7 @@ export default function PersonalScreen() {
// 直接使用 Redux 中的用户信息,避免重复状态管理 // 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile); const userProfile = useAppSelector((state) => state.user.profile);
// 页面聚焦时获取最新用户信息 // 页面聚焦时获取最新用户信息
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {

View File

@@ -88,8 +88,6 @@ export default function ExploreScreen() {
// 解构健康数据支持mock数据 // 解构健康数据支持mock数据
const mockData = useMockData ? getTestHealthData('mock') : null; 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 activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null); const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null); const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
@@ -271,7 +269,6 @@ export default function ExploreScreen() {
dispatch(setHealthData({ dispatch(setHealthData({
date: dateString, date: dateString,
data: { data: {
steps: data.steps,
activeCalories: data.activeEnergyBurned, activeCalories: data.activeEnergyBurned,
basalEnergyBurned: data.basalEnergyBurned, basalEnergyBurned: data.basalEnergyBurned,
hrv: data.hrv, hrv: data.hrv,
@@ -283,7 +280,6 @@ export default function ExploreScreen() {
exerciseMinutesGoal: data.exerciseMinutesGoal, exerciseMinutesGoal: data.exerciseMinutesGoal,
standHours: data.standHours, standHours: data.standHours,
standHoursGoal: data.standHoursGoal, standHoursGoal: data.standHoursGoal,
hourlySteps: data.hourlySteps,
} }
})); }));
@@ -541,11 +537,9 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}> <FloatingCard style={styles.masonryCard}>
<StepsCard <StepsCard
stepCount={stepCount} curDate={currentSelectedDate}
stepGoal={stepGoal} stepGoal={stepGoal}
hourlySteps={hourlySteps}
style={styles.stepsCardOverride} style={styles.stepsCardOverride}
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
/> />
</FloatingCard> </FloatingCard>

View File

@@ -14,7 +14,7 @@ import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords'; import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store'; import { store } from '@/store';
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice'; import { createWaterRecordAction } from '@/store/waterSlice';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
@@ -25,12 +25,6 @@ import { ToastProvider } from '@/contexts/ToastContext';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
let resolver: (() => void) | null;
// Create a promise and store its resolve function for later
const promise = new Promise<void>((resolve) => {
resolver = resolve;
});
function Bootstrapper({ children }: { children: React.ReactNode }) { function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -43,13 +37,14 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
const loadUserData = async () => { const loadUserData = async () => {
await dispatch(rehydrateUser()); // 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
await dispatch(rehydrateUserSync());
setUserDataLoaded(true); setUserDataLoaded(true);
}; };
const initializeNotifications = async () => { const initializeNotifications = async () => {
try { try {
await BackgroundTaskManager.getInstance().initialize(promise); await BackgroundTaskManager.getInstance().initialize();
// 初始化通知服务 // 初始化通知服务
await notificationService.initialize(); await notificationService.initialize();
console.log('通知服务初始化成功'); console.log('通知服务初始化成功');

View File

@@ -130,7 +130,9 @@ export default function LoginScreen() {
router.back(); router.back();
} }
} catch (err: any) { } catch (err: any) {
if (err?.code === 'ERR_CANCELED') return; console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试'; const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message); Alert.alert('登录失败', message);
} finally { } finally {

View File

@@ -1,6 +1,7 @@
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { preloadUserData } from '@/store/userSlice';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native'; import { ActivityIndicator, View } from 'react-native';
@@ -18,6 +19,11 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => { const checkOnboardingStatus = async () => {
try { try {
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
console.log('开始预加载用户数据...');
await preloadUserData();
console.log('用户数据预加载完成');
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); // const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// if (onboardingCompleted === 'true') { // if (onboardingCompleted === 'true') {
@@ -28,11 +34,9 @@ export default function SplashScreen() {
// setIsLoading(false); // setIsLoading(false);
router.replace(ROUTES.TAB_STATISTICS); router.replace(ROUTES.TAB_STATISTICS);
} catch (error) { } catch (error) {
console.error('检查引导状态失败:', error); console.error('检查引导状态或预加载用户数据失败:', error);
// 如果出现错误,默认显示引导页面 // 如果出现错误,仍然进入应用,但可能会有状态更新
// setTimeout(() => { router.replace(ROUTES.TAB_STATISTICS);
// router.replace('/onboarding');
// }, 1000);
} }
setIsLoading(false); setIsLoading(false);
}; };

View File

@@ -1,37 +1,43 @@
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData'; import { logger } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; 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 React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Animated, Animated,
SafeAreaView,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function StepsDetailScreen() { export default function StepsDetailScreen() {
const router = useRouter(); // 获取路由参数
const dispatch = useAppDispatch(); const { date } = useLocalSearchParams<{ date?: string }>();
const insets = useSafeAreaInsets();
// 开发调试设置为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); const [isLoading, setIsLoading] = useState(false);
// 获取当前选中日期 // 获取当前选中日期
@@ -40,17 +46,25 @@ export default function StepsDetailScreen() {
return days[selectedIndex]?.date?.toDate() ?? new Date(); return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]); }, [selectedIndex]);
const currentSelectedDateString = useMemo(() => { // 获取步数数据的函数,参考 StepsCard 的实现
return dayjs(currentSelectedDate).format('YYYY-MM-DD'); const getStepData = async (date: Date) => {
}, [currentSelectedDate]); try {
setIsLoading(true);
logger.info('获取步数详情数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
]);
// 从 Redux 获取指定日期的健康数据 setStepCount(steps);
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); setHourSteps(hourly);
// 解构健康数据支持mock数据 } catch (error) {
const mockData = useMockData ? getTestHealthData('mock') : null; logger.error('获取步数详情数据失败:', error);
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null); } finally {
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []); setIsLoading(false);
}
};
// 为每个柱体创建独立的动画值 // 为每个柱体创建独立的动画值
@@ -113,50 +127,24 @@ export default function StepsDetailScreen() {
} }
}, [chartData, animatedValues]); }, [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) => { const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index); setSelectedIndex(index);
loadHealthData(date); getStepData(date);
}; };
// 页面初始化时加载当前日期数据 // 当路由参数变化时更新选中索引
useEffect(() => { useEffect(() => {
loadHealthData(currentSelectedDate); const newIndex = getInitialSelectedIndex();
}, []); setSelectedIndex(newIndex);
}, [date]);
// 当选中日期变化时获取数据
useEffect(() => {
if (currentSelectedDate) {
getStepData(currentSelectedDate);
}
}, [currentSelectedDate]);
// 计算总步数和平均步数 // 计算总步数和平均步数
const totalSteps = stepCount || 0; const totalSteps = stepCount || 0;
@@ -219,222 +207,212 @@ export default function StepsDetailScreen() {
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
/> />
<SafeAreaView style={styles.safeArea}> <HeaderBar
{/* 顶部导航栏 */} title="步数详情"
<View style={styles.header}> />
<TouchableOpacity
style={styles.backButton} <ScrollView
onPress={() => router.back()} style={styles.scrollView}
> contentContainerStyle={{}}
<Ionicons name="chevron-back" size={24} color="#192126" /> showsVerticalScrollIndicator={false}
</TouchableOpacity> >
<Text style={styles.headerTitle}></Text> {/* 日期选择器 */}
<View style={styles.headerRight} /> <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>
<ScrollView {/* 详细柱状图卡片 */}
style={styles.scrollView} <View style={styles.chartCard}>
contentContainerStyle={{}} <View style={styles.chartHeader}>
showsVerticalScrollIndicator={false} <Text style={styles.chartTitle}></Text>
> <Text style={styles.chartSubtitle}>
{/* 日期选择器 */} {dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
<DateSelector </Text>
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>
{/* 详细柱状图卡片 */} {/* 柱状图容器 */}
<View style={styles.chartCard}> <View style={styles.chartContainer}>
<View style={styles.chartHeader}> {/* 平均值刻度线 - 放在chartArea外面相对于chartContainer定位 */}
<Text style={styles.chartTitle}></Text> {averageLinePosition > 0 && (
<Text style={styles.chartSubtitle}> <View
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')} style={[
</Text> styles.averageLine,
</View> { bottom: averageLinePosition }
]}
{/* 柱状图容器 */} >
<View style={styles.chartContainer}> <View style={styles.averageLineDashContainer}>
{/* 平均值刻度线 - 放在chartArea外面相对于chartContainer定位 */} {/* 创建更多的虚线段来确保完整覆盖 */}
{averageLinePosition > 0 && ( {Array.from({ length: 80 }, (_, index) => (
<View <View
style={[ key={index}
styles.averageLine, style={[
{ bottom: averageLinePosition } styles.dashSegment,
]} {
> marginLeft: index > 0 ? 2 : 0,
<View style={styles.averageLineDashContainer}> flex: 0 // 防止 flex 拉伸
{/* 创建更多的虚线段来确保完整覆盖 */} }
{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>
)} <Text style={styles.averageLineLabel}>
{averageHourlySteps}
</Text>
</View>
)}
{/* 柱状图区域 */} {/* 柱状图区域 */}
<View style={styles.chartArea}> <View style={styles.chartArea}>
{chartData.map((data, index) => { {chartData.map((data, index) => {
const isActive = data.steps > 0; const isActive = data.steps > 0;
const isCurrent = index <= currentHour; const isCurrent = index <= currentHour;
const isKeyTime = index === 0 || index === 12 || index === 23; const isKeyTime = index === 0 || index === 12 || index === 23;
// 动画变换 // 动画变换
const animatedHeight = animatedValues[index].interpolate({ const animatedHeight = animatedValues[index].interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: [0, data.height], outputRange: [0, data.height],
}); });
const animatedOpacity = animatedValues[index].interpolate({ const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: [0, 1], outputRange: [0, 1],
}); });
return ( return (
<View key={`bar-${index}`} style={styles.barContainer}> <View key={`bar-${index}`} style={styles.barContainer}>
{/* 背景柱体 */} {/* 背景柱体 */}
<View <View
style={[
styles.backgroundBar,
{
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
}
]}
/>
{/* 数据柱体 */}
{isActive && (
<Animated.View
style={[ style={[
styles.backgroundBar, styles.dataBar,
{ {
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC', height: animatedHeight,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
opacity: animatedOpacity,
} }
]} ]}
/> />
)}
{/* 数据柱体 */} {/* 步数标签(仅在有数据且是关键时间点时显示) */}
{isActive && ( {/* {isActive && isKeyTime && (
<Animated.View
style={[
styles.dataBar,
{
height: animatedHeight,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
opacity: animatedOpacity,
}
]}
/>
)}
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
{/* {isActive && isKeyTime && (
<Animated.View <Animated.View
style={[styles.stepLabel, { opacity: animatedOpacity }]} style={[styles.stepLabel, { opacity: animatedOpacity }]}
> >
<Text style={styles.stepLabelText}>{data.steps}</Text> <Text style={styles.stepLabelText}>{data.steps}</Text>
</Animated.View> </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> </View>
<Text style={styles.legendLabel}>{level.label}</Text> );
<Text style={styles.legendRange}> })}
{level.maxSteps === Infinity </View>
? `> ${level.minSteps.toLocaleString()}`
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`} {/* 底部时间轴标签 */}
</Text> <View style={styles.timeLabels}>
</View> <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>
</ScrollView> </View>
</SafeAreaView>
{/* 活动等级展示卡片 */}
<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> </View>
); );
} }
@@ -450,9 +428,6 @@ const styles = StyleSheet.create({
top: 0, top: 0,
bottom: 0, bottom: 0,
}, },
safeArea: {
flex: 1,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -1,4 +1,5 @@
import NumberKeyboard from '@/components/NumberKeyboard'; import NumberKeyboard from '@/components/NumberKeyboard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { WeightRecordCard } from '@/components/weight/WeightRecordCard'; import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
@@ -169,65 +170,64 @@ export default function WeightRecordsPage() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]} colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradient} style={styles.gradientBackground}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 1, y: 1 }}
> />
{/* Header */}
<View style={styles.header}> <HeaderBar
<TouchableOpacity onPress={handleGoBack} style={styles.backButton}> title="体重记录"
<Ionicons name="chevron-back" size={24} color="#192126" /> right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
</TouchableOpacity> <Ionicons name="add" size={24} color="#192126" />
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}> </TouchableOpacity>}
<Ionicons name="add" size={24} color="#192126" /> />
</TouchableOpacity> {/* Weight Statistics */}
</View> <View style={styles.statsContainer}>
{/* Weight Statistics */} <View style={styles.statsRow}>
<View style={styles.statsContainer}> <View style={styles.statItem}>
<View style={styles.statsRow}> <Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
<View style={styles.statItem}> <Text style={styles.statLabel}></Text>
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text> </View>
<Text style={styles.statLabel}></Text> <View style={styles.statItem}>
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text>
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" />
</TouchableOpacity>
</View> </View>
<View style={styles.statItem}> </View>
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text> <View style={styles.statItem}>
<Text style={styles.statLabel}></Text> <Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
</View> <View style={styles.statLabelContainer}>
<View style={styles.statItem}> <Text style={styles.statLabel}></Text>
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text> <TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
<View style={styles.statLabelContainer}> <Ionicons name="create-outline" size={14} color="#FF9500" />
<Text style={styles.statLabel}></Text> </TouchableOpacity>
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" />
</TouchableOpacity>
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text>
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" />
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</View> </View>
</View>
<ScrollView <ScrollView
style={styles.content} style={styles.content}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]} contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Monthly Records */} {/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? ( {Object.keys(groupedHistory).length > 0 ? (
Object.entries(groupedHistory).map(([month, records]) => ( Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}> <View key={month} style={styles.monthContainer}>
{/* Month Header Card */} {/* Month Header Card */}
{/* <View style={styles.monthHeaderCard}> {/* <View style={styles.monthHeaderCard}>
<View style={styles.monthTitleRow}> <View style={styles.monthTitleRow}>
<Text style={styles.monthNumber}> <Text style={styles.monthNumber}>
{dayjs(month, 'YYYY年MM月').format('MM')} {dayjs(month, 'YYYY年MM月').format('MM')}
@@ -245,139 +245,138 @@ export default function WeightRecordsPage() {
</Text> </Text>
</View> */} </View> */}
{/* Individual Record Cards */} {/* Individual Record Cards */}
{records.map((record, recordIndex) => { {records.map((record, recordIndex) => {
// Calculate weight change from previous record // Calculate weight change from previous record
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null; const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
const weightChange = prevRecord ? const weightChange = prevRecord ?
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0; parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
return ( return (
<WeightRecordCard <WeightRecordCard
key={`${record.createdAt}-${recordIndex}`} key={`${record.createdAt}-${recordIndex}`}
record={record} record={record}
onPress={handleEditWeightRecord} onPress={handleEditWeightRecord}
onDelete={handleDeleteWeightRecord} onDelete={handleDeleteWeightRecord}
weightChange={weightChange} weightChange={weightChange}
/> />
); );
})} })}
</View>
))
) : (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
</View> </View>
)} ))
</ScrollView> ) : (
<View style={styles.emptyContainer}>
{/* Weight Input Modal */} <View style={styles.emptyContent}>
<Modal <Text style={styles.emptyText}></Text>
visible={showWeightPicker} <Text style={styles.emptySubtext}></Text>
animationType="fade"
transparent
onRequestClose={() => setShowWeightPicker(false)}
>
<View style={styles.modalContainer}>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setShowWeightPicker(false)}
/>
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
{/* Header */}
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
<Ionicons name="close" size={24} color={themeColors.text} />
</TouchableOpacity>
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
{pickerType === 'current' && '记录体重'}
{pickerType === 'initial' && '编辑初始体重'}
{pickerType === 'target' && '编辑目标体重'}
{pickerType === 'edit' && '编辑体重记录'}
</Text>
<View style={{ width: 24 }} />
</View>
<ScrollView
style={styles.modalContent}
showsVerticalScrollIndicator={false}
>
{/* Weight Display Section */}
<View style={styles.inputSection}>
<View style={styles.weightInputContainer}>
<View style={styles.weightIcon}>
<Ionicons name="scale-outline" size={20} color="#6366F1" />
</View>
<View style={styles.inputWrapper}>
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
{inputWeight || '输入体重'}
</Text>
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
</View>
</View>
{/* Weight Range Hint */}
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
0-500
</Text>
</View>
{/* Quick Selection */}
<View style={styles.quickSelectionSection}>
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}></Text>
<View style={styles.quickButtons}>
{[50, 60, 70, 80, 90].map((weight) => (
<TouchableOpacity
key={weight}
style={[
styles.quickButton,
inputWeight === weight.toString() && styles.quickButtonSelected
]}
onPress={() => setInputWeight(weight.toString())}
>
<Text style={[
styles.quickButtonText,
inputWeight === weight.toString() && styles.quickButtonTextSelected
]}>
{weight}kg
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
{/* Custom Number Keyboard */}
<NumberKeyboard
onNumberPress={handleNumberPress}
onDeletePress={handleDeletePress}
onDecimalPress={handleDecimalPress}
hasDecimal={inputWeight.includes('.')}
maxLength={6}
currentValue={inputWeight}
/>
{/* Save Button */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={[
styles.saveButton,
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
]}
onPress={handleWeightSave}
disabled={!inputWeight.trim()}
>
<Text style={styles.saveButtonText}></Text>
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</Modal> )}
</LinearGradient> </ScrollView>
{/* Weight Input Modal */}
<Modal
visible={showWeightPicker}
animationType="fade"
transparent
onRequestClose={() => setShowWeightPicker(false)}
>
<View style={styles.modalContainer}>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setShowWeightPicker(false)}
/>
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
{/* Header */}
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
<Ionicons name="close" size={24} color={themeColors.text} />
</TouchableOpacity>
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
{pickerType === 'current' && '记录体重'}
{pickerType === 'initial' && '编辑初始体重'}
{pickerType === 'target' && '编辑目标体重'}
{pickerType === 'edit' && '编辑体重记录'}
</Text>
<View style={{ width: 24 }} />
</View>
<ScrollView
style={styles.modalContent}
showsVerticalScrollIndicator={false}
>
{/* Weight Display Section */}
<View style={styles.inputSection}>
<View style={styles.weightInputContainer}>
<View style={styles.weightIcon}>
<Ionicons name="scale-outline" size={20} color="#6366F1" />
</View>
<View style={styles.inputWrapper}>
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
{inputWeight || '输入体重'}
</Text>
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
</View>
</View>
{/* Weight Range Hint */}
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
0-500
</Text>
</View>
{/* Quick Selection */}
<View style={styles.quickSelectionSection}>
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}></Text>
<View style={styles.quickButtons}>
{[50, 60, 70, 80, 90].map((weight) => (
<TouchableOpacity
key={weight}
style={[
styles.quickButton,
inputWeight === weight.toString() && styles.quickButtonSelected
]}
onPress={() => setInputWeight(weight.toString())}
>
<Text style={[
styles.quickButtonText,
inputWeight === weight.toString() && styles.quickButtonTextSelected
]}>
{weight}kg
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
{/* Custom Number Keyboard */}
<NumberKeyboard
onNumberPress={handleNumberPress}
onDeletePress={handleDeletePress}
onDecimalPress={handleDecimalPress}
hasDecimal={inputWeight.includes('.')}
maxLength={6}
currentValue={inputWeight}
/>
{/* Save Button */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={[
styles.saveButton,
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
]}
onPress={handleWeightSave}
disabled={!inputWeight.trim()}
>
<Text style={styles.saveButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View> </View>
); );
} }
@@ -386,8 +385,12 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
gradient: { gradientBackground: {
flex: 1, position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Animated, Animated,
StyleSheet, StyleSheet,
@@ -8,26 +8,52 @@ import {
ViewStyle ViewStyle
} from 'react-native'; } from 'react-native';
import { HourlyStepData } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import { useRouter } from 'expo-router';
import { AnimatedNumber } from './AnimatedNumber'; import { AnimatedNumber } from './AnimatedNumber';
import dayjs from 'dayjs';
// 使用原生View来替代SVG避免导入问题 // 使用原生View来替代SVG避免导入问题
// import Svg, { Rect } from 'react-native-svg'; // import Svg, { Rect } from 'react-native-svg';
interface StepsCardProps { interface StepsCardProps {
stepCount: number | null; curDate: Date
stepGoal: number; stepGoal: number;
hourlySteps: HourlyStepData[];
style?: ViewStyle; style?: ViewStyle;
onPress?: () => void; // 新增点击事件回调
} }
const StepsCard: React.FC<StepsCardProps> = ({ const StepsCard: React.FC<StepsCardProps> = ({
stepCount, curDate,
stepGoal,
hourlySteps,
style, style,
onPress
}) => { }) => {
const router = useRouter();
const [stepCount, setStepCount] = useState(0)
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const getStepData = async (date: Date) => {
try {
logger.info('获取步数数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
])
setStepCount(steps)
setHourSteps(hourly)
} catch (error) {
logger.error('获取步数数据失败:', error);
}
}
useEffect(() => {
if (curDate) {
getStepData(curDate);
}
}, [curDate]);
// 为每个柱体创建独立的动画值 // 为每个柱体创建独立的动画值
const animatedValues = useRef( const animatedValues = useRef(
Array.from({ length: 24 }, () => new Animated.Value(0)) Array.from({ length: 24 }, () => new Animated.Value(0))
@@ -154,24 +180,19 @@ const StepsCard: React.FC<StepsCardProps> = ({
</> </>
); );
// 如果有点击事件包装在TouchableOpacity中
if (onPress) {
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={onPress}
activeOpacity={0.8}
>
<CardContent />
</TouchableOpacity>
);
}
// 否则使用普通View
return ( return (
<View style={[styles.container, style]}> <TouchableOpacity
style={[styles.container, style]}
onPress={() => {
// 传递当前日期参数到详情页
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
router.push(`/steps/detail?date=${dateParam}`);
}}
activeOpacity={0.8}
>
<CardContent /> <CardContent />
</View> </TouchableOpacity>
); );
}; };

View File

@@ -1,11 +1,11 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export type HeaderBarProps = { export type HeaderBarProps = {
title: string | React.ReactNode; title: string | React.ReactNode;
onBack?: () => void; onBack?: () => void;
@@ -76,23 +76,24 @@ export function HeaderBar({
}, },
]} ]}
> >
{onBack ? ( <TouchableOpacity
<TouchableOpacity accessibilityRole="button"
accessibilityRole="button" onPress={() => {
onPress={onBack} if (onBack) {
style={styles.backButton} onBack();
activeOpacity={0.7} return
> }
<Ionicons router.back()
name="chevron-back" }}
size={24} style={styles.backButton}
color={backColor || theme.text} activeOpacity={0.7}
/> >
</TouchableOpacity> <Ionicons
) : ( name="chevron-back"
<View style={{ width: 32 }} /> size={24}
)} color={backColor || theme.text}
/>
</TouchableOpacity>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
{typeof title === 'string' ? ( {typeof title === 'string' ? (
<Text style={[ <Text style={[

View File

@@ -69,7 +69,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
overshootRight={false} overshootRight={false}
> >
<View <View
style={[styles.recordCard, colorScheme === 'dark' && styles.recordCardDark]} style={[styles.recordCard]}
> >
<View style={styles.recordHeader}> <View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}> <Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
@@ -111,14 +111,11 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
recordCard: { recordCard: {
backgroundColor: '#F0F0F0', backgroundColor: '#ffffff',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
marginBottom: 12, marginBottom: 12,
}, },
recordCardDark: {
backgroundColor: '#1F2937',
},
recordHeader: { recordHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -145,8 +142,8 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
recordWeightValue: { recordWeightValue: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '600',
color: '#192126', color: '#192126',
marginLeft: 4, marginLeft: 4,
flex: 1, flex: 1,

View File

@@ -10,7 +10,7 @@ PODS:
- React-Core - React-Core
- EXNotifications (0.32.11): - EXNotifications (0.32.11):
- ExpoModulesCore - ExpoModulesCore
- Expo (54.0.1): - Expo (54.0.7):
- boost - boost
- DoubleConversion - DoubleConversion
- ExpoModulesCore - ExpoModulesCore
@@ -45,26 +45,26 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoAsset (12.0.8): - ExpoAsset (12.0.8):
- ExpoModulesCore - ExpoModulesCore
- ExpoBackgroundTask (1.0.6): - ExpoBackgroundTask (1.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoBlur (15.0.6): - ExpoBlur (15.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoCamera (17.0.7): - ExpoCamera (17.0.7):
- ExpoModulesCore - ExpoModulesCore
- ZXingObjC/OneD - ZXingObjC/OneD
- ZXingObjC/PDF417 - ZXingObjC/PDF417
- ExpoFileSystem (19.0.11): - ExpoFileSystem (19.0.14):
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (14.0.8): - ExpoFont (14.0.8):
- ExpoModulesCore - ExpoModulesCore
- ExpoGlassEffect (0.1.2): - ExpoGlassEffect (0.1.3):
- ExpoModulesCore - ExpoModulesCore
- ExpoHaptics (15.0.6): - ExpoHaptics (15.0.6):
- ExpoModulesCore - ExpoModulesCore
- ExpoHead (6.0.1): - ExpoHead (6.0.4):
- ExpoModulesCore - ExpoModulesCore
- RNScreens - RNScreens
- ExpoImage (3.0.7): - ExpoImage (3.0.8):
- ExpoModulesCore - ExpoModulesCore
- libavif/libdav1d - libavif/libdav1d
- SDWebImage (~> 5.21.0) - SDWebImage (~> 5.21.0)
@@ -73,7 +73,7 @@ PODS:
- SDWebImageWebPCoder (~> 0.14.6) - SDWebImageWebPCoder (~> 0.14.6)
- ExpoImagePicker (17.0.7): - ExpoImagePicker (17.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoKeepAwake (15.0.6): - ExpoKeepAwake (15.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoLinearGradient (15.0.6): - ExpoLinearGradient (15.0.6):
- ExpoModulesCore - ExpoModulesCore
@@ -116,11 +116,11 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoSystemUI (6.0.7): - ExpoSystemUI (6.0.7):
- ExpoModulesCore - ExpoModulesCore
- ExpoUI (0.2.0-beta.1): - ExpoUI (0.2.0-beta.2):
- ExpoModulesCore - ExpoModulesCore
- ExpoWebBrowser (15.0.6): - ExpoWebBrowser (15.0.6):
- ExpoModulesCore - ExpoModulesCore
- EXTaskManager (14.0.6): - EXTaskManager (14.0.7):
- ExpoModulesCore - ExpoModulesCore
- UMAppLoader - UMAppLoader
- fast_float (8.0.0) - fast_float (8.0.0)
@@ -3031,7 +3031,7 @@ PODS:
- SDWebImage/Core (~> 5.17) - SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.53.2) - Sentry/HybridSDK (8.53.2)
- SocketRocket (0.7.1) - SocketRocket (0.7.1)
- UMAppLoader (6.0.6) - UMAppLoader (6.0.7)
- Yoga (0.0.0) - Yoga (0.0.0)
- ZXingObjC/Core (3.6.9) - ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (3.6.9): - ZXingObjC/OneD (3.6.9):
@@ -3423,20 +3423,20 @@ SPEC CHECKSUMS:
EXConstants: 7e4654405af367ff908c863fe77a8a22d60bd37d EXConstants: 7e4654405af367ff908c863fe77a8a22d60bd37d
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXNotifications: 7a2975f4e282b827a0bc78bb1d232650cb569bbd EXNotifications: 7a2975f4e282b827a0bc78bb1d232650cb569bbd
Expo: 449ff2805d3673354f533a360e001f556f0b2009 Expo: b7d4314594ebd7fe5eefd1a06c3b0d92b718cde0
ExpoAppleAuthentication: 9eb1ec7213ee9c9797951df89975136db89bf8ac ExpoAppleAuthentication: 9eb1ec7213ee9c9797951df89975136db89bf8ac
ExpoAsset: 84810d6fed8179f04d4a7a4a6b37028bbd726e26 ExpoAsset: 84810d6fed8179f04d4a7a4a6b37028bbd726e26
ExpoBackgroundTask: f4dac8f09f3b187e464af7a1088d9fd5ae48a836 ExpoBackgroundTask: 22ed53b129d4d5e15c39be9fa68e45d25f6781a1
ExpoBlur: 9bde58a4de1d24a02575d0e24290f2026ce8dc3a ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: ae1d6691b05b753261a845536d2b19a9788a8750 ExpoCamera: ae1d6691b05b753261a845536d2b19a9788a8750
ExpoFileSystem: 7b4a4f6c67a738e826fd816139bac9d098b3b084 ExpoFileSystem: 4fb06865906e781329eb67166bd64fc4749c3019
ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5 ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5
ExpoGlassEffect: 07bafe3374d7d24299582627d040a6c7e403c3f3 ExpoGlassEffect: e48c949ee7dcf2072cca31389bf8fa776c1727a0
ExpoHaptics: e0912a9cf05ba958eefdc595f1990b8f89aa1f3f ExpoHaptics: e0912a9cf05ba958eefdc595f1990b8f89aa1f3f
ExpoHead: 9539b6c97faa57b2deb0414205e084b0a2bc15f1 ExpoHead: 2aad68c730f967d2533599dabb64d1d2cd9f765a
ExpoImage: 18d9836939f8e271364a5a2a3566f099ea73b2e4 ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8
ExpoImagePicker: 66195293e95879fa5ee3eb1319f10b5de0ffccbb ExpoImagePicker: 66195293e95879fa5ee3eb1319f10b5de0ffccbb
ExpoKeepAwake: eba81dfb5728be8c46e382b9314dfa14f40d8764 ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLinearGradient: 74d67832cdb0d2ef91f718d50dd82b273ce2812e ExpoLinearGradient: 74d67832cdb0d2ef91f718d50dd82b273ce2812e
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
ExpoModulesCore: 5d150c790fb491ab10fe431fb794014af841258f ExpoModulesCore: 5d150c790fb491ab10fe431fb794014af841258f
@@ -3444,9 +3444,9 @@ SPEC CHECKSUMS:
ExpoSplashScreen: 1665809071bd907c6fdbfd9c09583ee4d51b41d4 ExpoSplashScreen: 1665809071bd907c6fdbfd9c09583ee4d51b41d4
ExpoSymbols: 3efee6865b1955fe3805ca88b36e8674ce6970dd ExpoSymbols: 3efee6865b1955fe3805ca88b36e8674ce6970dd
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314 ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
ExpoUI: 1e4b3045678eb66004d78d9a6602afdcbdc06bbd ExpoUI: 0f109b0549d1ae2fd955d3b8733b290c5cdeec7e
ExpoWebBrowser: 84d4438464d9754a4c1f1eaa97cd747f3752187e ExpoWebBrowser: 84d4438464d9754a4c1f1eaa97cd747f3752187e
EXTaskManager: eedcd03c1a574c47d3f48d83d4e4659b3c1fa29b EXTaskManager: cf225704fab8de8794a6f57f7fa41a90c0e2cd47
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
FBLazyVector: 941bef1c8eeabd9fe1f501e30a5220beee913886 FBLazyVector: 941bef1c8eeabd9fe1f501e30a5220beee913886
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
@@ -3545,7 +3545,7 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
UMAppLoader: 2af2cc05fcaa9851233893c0e3dbc56a99f57e36 UMAppLoader: e1234c45d2b7da239e9e90fc4bbeacee12afd5b6
Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5

761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,18 @@ import { TaskManagerTaskBody } from 'expo-task-manager';
export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task'; export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task';
// 定义后台任务
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
try {
log.info(`[BackgroundTask] 后台任务执行, 任务 ID: ${BACKGROUND_TASK_IDENTIFIER}`);
await executeBackgroundTasks();
} catch (error) {
console.error('[BackgroundTask] 任务执行失败:', error);
return BackgroundTask.BackgroundTaskResult.Failed;
}
return BackgroundTask.BackgroundTaskResult.Success;
});
// 检查通知权限 // 检查通知权限
async function checkNotificationPermissions(): Promise<boolean> { async function checkNotificationPermissions(): Promise<boolean> {
@@ -147,7 +158,7 @@ async function executeBackgroundTasks(): Promise<void> {
await executeWaterReminderTask(); await executeWaterReminderTask();
// 执行站立提醒检查任务 // 执行站立提醒检查任务
await executeStandReminderTask(); // await executeStandReminderTask();
console.log('后台任务执行完成'); console.log('后台任务执行完成');
} catch (error) { } catch (error) {
@@ -173,24 +184,14 @@ export class BackgroundTaskManager {
/** /**
* 初始化后台任务管理器 * 初始化后台任务管理器
*/ */
async initialize(innerAppMountedPromise: Promise<void>): Promise<void> { async initialize(): Promise<void> {
if (this.isInitialized) { if (this.isInitialized) {
console.log('后台任务管理器已初始化'); console.log('后台任务管理器已初始化');
return; return;
} }
try { try {
// 定义后台任务
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
try {
log.info(`[BackgroundTask] 后台任务执行, 任务 ID: ${BACKGROUND_TASK_IDENTIFIER}`);
await executeBackgroundTasks();
// return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) {
console.error('[BackgroundTask] 任务执行失败:', error);
// return BackgroundTask.BackgroundTaskResult.Failed;
}
});
if (await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_IDENTIFIER)) { if (await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_IDENTIFIER)) {
@@ -201,7 +202,7 @@ export class BackgroundTaskManager {
log.info('[BackgroundTask] 任务未注册, 开始注册...'); log.info('[BackgroundTask] 任务未注册, 开始注册...');
// 注册后台任务 // 注册后台任务
await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, { await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER, {
minimumInterval: 15, minimumInterval: 15 * 2,
}); });

View File

@@ -1,4 +1,3 @@
import { HourlyStepData } from '@/utils/health';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppDispatch, RootState } from './index'; import { AppDispatch, RootState } from './index';
@@ -13,7 +12,6 @@ export interface FitnessRingsData {
} }
export interface HealthData { export interface HealthData {
steps: number | null;
activeCalories: number | null; activeCalories: number | null;
basalEnergyBurned: number | null; basalEnergyBurned: number | null;
hrv: number | null; hrv: number | null;
@@ -25,7 +23,6 @@ export interface HealthData {
exerciseMinutesGoal: number; exerciseMinutesGoal: number;
standHours: number; standHours: number;
standHoursGoal: number; standHoursGoal: number;
hourlySteps: HourlyStepData[];
} }
export interface HealthState { export interface HealthState {

View File

@@ -74,6 +74,8 @@ export const fetchTasks = createAsyncThunk(
'tasks/fetchTasks', 'tasks/fetchTasks',
async (query: GetTasksQuery = {}, { rejectWithValue }) => { async (query: GetTasksQuery = {}, { rejectWithValue }) => {
try { try {
console.log('fetchTasks', fetchTasks);
const response = await tasksApi.getTasks(query); const response = await tasksApi.getTasks(query);
console.log('fetchTasks response', response); console.log('fetchTasks response', response);
return { query, response }; return { query, response };
@@ -217,6 +219,8 @@ const tasksSlice = createSlice({
// 如果是第一页,替换数据;否则追加数据 // 如果是第一页,替换数据;否则追加数据
state.tasks = response.list; state.tasks = response.list;
console.log('state.tasks', state.tasks);
state.tasksPagination = { state.tasksPagination = {
page: response.page, page: response.page,
pageSize: response.pageSize, pageSize: response.pageSize,

View File

@@ -4,6 +4,52 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
// 预加载的用户数据存储
let preloadedUserData: {
token: string | null;
profile: UserProfile;
privacyAgreed: boolean;
} | null = null;
// 预加载用户数据的函数
export async function preloadUserData() {
try {
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 = {};
if (profileStr) {
try {
profile = JSON.parse(profileStr) as UserProfile;
} catch {
profile = {};
}
}
const privacyAgreed = privacyAgreedStr === 'true';
// 如果有 token需要设置到 API 客户端
if (token) {
await setAuthToken(token);
}
preloadedUserData = { token, profile, privacyAgreed };
return preloadedUserData;
} catch (error) {
console.error('预加载用户数据失败:', error);
preloadedUserData = { token: null, profile: {}, privacyAgreed: false };
return preloadedUserData;
}
}
// 获取预加载的用户数据
function getPreloadedUserData() {
return preloadedUserData || { token: null, profile: {}, privacyAgreed: false };
}
export type Gender = 'male' | 'female' | ''; export type Gender = 'male' | 'female' | '';
export type UserProfile = { export type UserProfile = {
@@ -48,21 +94,27 @@ export type UserState = {
export const DEFAULT_MEMBER_NAME = '小海豹'; export const DEFAULT_MEMBER_NAME = '小海豹';
const initialState: UserState = { const getInitialState = (): UserState => {
token: null, const preloaded = getPreloadedUserData();
profile: { return {
name: DEFAULT_MEMBER_NAME, token: preloaded.token,
isVip: false, profile: {
freeUsageCount: 3, name: DEFAULT_MEMBER_NAME,
maxUsageCount: 5, isVip: false,
}, freeUsageCount: 3,
loading: false, maxUsageCount: 5,
error: null, ...preloaded.profile, // 合并预加载的用户资料
privacyAgreed: false, },
weightHistory: [], loading: false,
activityHistory: [], error: null,
privacyAgreed: preloaded.privacyAgreed,
weightHistory: [],
activityHistory: [],
};
}; };
const initialState: UserState = getInitialState();
export type LoginPayload = Record<string, any> & { export type LoginPayload = Record<string, any> & {
// 可扩展用户名密码、Apple 身份、短信验证码等 // 可扩展用户名密码、Apple 身份、短信验证码等
username?: string; username?: string;
@@ -132,6 +184,17 @@ export const login = createAsyncThunk(
} }
); );
// 同步重新hydrate用户数据避免异步状态更新
export const rehydrateUserSync = createAsyncThunk('user/rehydrateSync', async () => {
// 立即从预加载的数据获取,如果没有则异步获取
if (preloadedUserData) {
return preloadedUserData;
}
// 如果预加载的数据不存在,则执行正常的异步加载
return await preloadUserData();
});
export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => {
const [profileStr, privacyAgreedStr, token] = await Promise.all([ const [profileStr, privacyAgreedStr, token] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.userProfile), AsyncStorage.getItem(STORAGE_KEYS.userProfile),
@@ -197,7 +260,6 @@ export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', as
export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory', async (_, { rejectWithValue }) => { export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory', async (_, { rejectWithValue }) => {
try { try {
const data: ActivityHistoryItem[] = await api.get('/api/users/activity-history'); const data: ActivityHistoryItem[] = await api.get('/api/users/activity-history');
console.log('fetchActivityHistory', data);
return data; return data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue(err?.message ?? '获取用户活动历史记录失败'); return rejectWithValue(err?.message ?? '获取用户活动历史记录失败');
@@ -284,6 +346,14 @@ const userSlice = createSlice({
state.profile.name = DEFAULT_MEMBER_NAME; state.profile.name = DEFAULT_MEMBER_NAME;
} }
}) })
.addCase(rehydrateUserSync.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;
}
})
.addCase(fetchMyProfile.fulfilled, (state, action) => { .addCase(fetchMyProfile.fulfilled, (state, action) => {
state.profile = action.payload || {}; state.profile = action.payload || {};
if (!state.profile?.name || !state.profile.name.trim()) { if (!state.profile?.name || !state.profile.name.trim()) {

View File

@@ -54,7 +54,6 @@ export type HourlyStandData = {
}; };
export type TodayHealthData = { export type TodayHealthData = {
steps: number;
activeEnergyBurned: number; // kilocalories activeEnergyBurned: number; // kilocalories
basalEnergyBurned: number; // kilocalories - 基础代谢率 basalEnergyBurned: number; // kilocalories - 基础代谢率
hrv: number | null; // 心率变异性 (ms) hrv: number | null; // 心率变异性 (ms)
@@ -68,8 +67,6 @@ export type TodayHealthData = {
// 新增血氧饱和度和心率数据 // 新增血氧饱和度和心率数据
oxygenSaturation: number | null; oxygenSaturation: number | null;
heartRate: number | null; heartRate: number | null;
// 每小时步数数据
hourlySteps: HourlyStepData[];
}; };
export async function ensureHealthPermissions(): Promise<boolean> { export async function ensureHealthPermissions(): Promise<boolean> {
@@ -172,7 +169,7 @@ function validateHeartRate(value: any): number | null {
} }
// 健康数据获取函数 // 健康数据获取函数
async function fetchStepCount(date: Date): Promise<number> { export async function fetchStepCount(date: Date): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
AppleHealthKit.getStepCount({ AppleHealthKit.getStepCount({
date: dayjs(date).toDate().toISOString() date: dayjs(date).toDate().toISOString()
@@ -193,7 +190,7 @@ async function fetchStepCount(date: Date): Promise<number> {
// 使用样本数据获取每小时步数 // 使用样本数据获取每小时步数
async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> { export async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const startOfDay = dayjs(date).startOf('day'); const startOfDay = dayjs(date).startOf('day');
const endOfDay = dayjs(date).endOf('day'); const endOfDay = dayjs(date).endOf('day');
@@ -565,7 +562,6 @@ export async function fetchMaximumHeartRate(options: HealthDataOptions): Promise
// 默认健康数据 // 默认健康数据
function getDefaultHealthData(): TodayHealthData { function getDefaultHealthData(): TodayHealthData {
return { return {
steps: 0,
activeEnergyBurned: 0, activeEnergyBurned: 0,
basalEnergyBurned: 0, basalEnergyBurned: 0,
hrv: null, hrv: null,
@@ -577,7 +573,6 @@ function getDefaultHealthData(): TodayHealthData {
standHoursGoal: 12, standHoursGoal: 12,
oxygenSaturation: null, oxygenSaturation: null,
heartRate: null, heartRate: null,
hourlySteps: Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 }))
}; };
} }
@@ -590,8 +585,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
// 并行获取所有健康数据 // 并行获取所有健康数据
const [ const [
steps,
hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
hrv, hrv,
@@ -599,8 +592,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
oxygenSaturation, oxygenSaturation,
heartRate heartRate
] = await Promise.all([ ] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date),
fetchActiveEnergyBurned(options), fetchActiveEnergyBurned(options),
fetchBasalEnergyBurned(options), fetchBasalEnergyBurned(options),
fetchHeartRateVariability(options), fetchHeartRateVariability(options),
@@ -610,8 +601,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
]); ]);
return { return {
steps,
hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
hrv, hrv,