feat(auth): 预加载用户数据并优化登录状态同步
- 在启动屏预加载用户 token 与资料,避免首页白屏 - 新增 rehydrateUserSync 同步注入 Redux,减少异步等待 - 登录页兼容 ERR_REQUEST_CANCELED 取消场景 - 各页面统一依赖 isLoggedIn 判断,移除冗余控制台日志 - 步数卡片与详情页改为实时拉取健康数据,不再缓存至 Redux - 后台任务注册移至顶层,防止重复定义 - 体重记录、HeaderBar 等 UI 细节样式微调
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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('通知服务初始化成功');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,18 +207,9 @@ 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}
|
|
||||||
onPress={() => router.back()}
|
|
||||||
>
|
|
||||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>步数详情</Text>
|
|
||||||
<View style={styles.headerRight} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
@@ -434,7 +413,6 @@ export default function StepsDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
|
||||||
</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',
|
||||||
|
|||||||
@@ -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,21 +170,20 @@ 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>
|
|
||||||
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
|
||||||
<Ionicons name="add" size={24} color="#192126" />
|
<Ionicons name="add" size={24} color="#192126" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>}
|
||||||
</View>
|
/>
|
||||||
{/* Weight Statistics */}
|
{/* Weight Statistics */}
|
||||||
<View style={styles.statsContainer}>
|
<View style={styles.statsContainer}>
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
@@ -377,7 +377,6 @@ export default function WeightRecordsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
</LinearGradient>
|
|
||||||
</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',
|
||||||
|
|||||||
@@ -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,25 +180,20 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果有点击事件,包装在TouchableOpacity中
|
|
||||||
if (onPress) {
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, style]}
|
style={[styles.container, style]}
|
||||||
onPress={onPress}
|
onPress={() => {
|
||||||
|
// 传递当前日期参数到详情页
|
||||||
|
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
|
||||||
|
router.push(`/steps/detail?date=${dateParam}`);
|
||||||
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<CardContent />
|
<CardContent />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 否则使用普通View
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<CardContent />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
@@ -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,10 +76,15 @@ export function HeaderBar({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{onBack ? (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={onBack}
|
onPress={() => {
|
||||||
|
if (onBack) {
|
||||||
|
onBack();
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.back()
|
||||||
|
}}
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -89,10 +94,6 @@ export function HeaderBar({
|
|||||||
color={backColor || theme.text}
|
color={backColor || theme.text}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
|
||||||
<View style={{ width: 32 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
{typeof title === 'string' ? (
|
{typeof title === 'string' ? (
|
||||||
<Text style={[
|
<Text style={[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
761
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
return {
|
||||||
|
token: preloaded.token,
|
||||||
profile: {
|
profile: {
|
||||||
name: DEFAULT_MEMBER_NAME,
|
name: DEFAULT_MEMBER_NAME,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
freeUsageCount: 3,
|
freeUsageCount: 3,
|
||||||
maxUsageCount: 5,
|
maxUsageCount: 5,
|
||||||
|
...preloaded.profile, // 合并预加载的用户资料
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
privacyAgreed: false,
|
privacyAgreed: preloaded.privacyAgreed,
|
||||||
weightHistory: [],
|
weightHistory: [],
|
||||||
activityHistory: [],
|
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()) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user