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

View File

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

View File

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

View File

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

View File

@@ -130,7 +130,9 @@ export default function LoginScreen() {
router.back();
}
} 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 || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
} finally {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import NumberKeyboard from '@/components/NumberKeyboard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
@@ -169,65 +170,64 @@ export default function WeightRecordsPage() {
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
style={styles.gradient}
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleGoBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>
</View>
{/* Weight Statistics */}
<View style={styles.statsContainer}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text>
end={{ x: 1, y: 1 }}
/>
<HeaderBar
title="体重记录"
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>}
/>
{/* Weight Statistics */}
<View style={styles.statsContainer}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text>
</View>
<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 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}>
<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 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>
<ScrollView
style={styles.content}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
showsVerticalScrollIndicator={false}
>
<ScrollView
style={styles.content}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
showsVerticalScrollIndicator={false}
>
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}>
{/* Month Header Card */}
{/* <View style={styles.monthHeaderCard}>
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}>
{/* Month Header Card */}
{/* <View style={styles.monthHeaderCard}>
<View style={styles.monthTitleRow}>
<Text style={styles.monthNumber}>
{dayjs(month, 'YYYY年MM月').format('MM')}
@@ -245,139 +245,138 @@ export default function WeightRecordsPage() {
</Text>
</View> */}
{/* Individual Record Cards */}
{records.map((record, recordIndex) => {
// Calculate weight change from previous record
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
const weightChange = prevRecord ?
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
{/* Individual Record Cards */}
{records.map((record, recordIndex) => {
// Calculate weight change from previous record
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
const weightChange = prevRecord ?
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
return (
<WeightRecordCard
key={`${record.createdAt}-${recordIndex}`}
record={record}
onPress={handleEditWeightRecord}
onDelete={handleDeleteWeightRecord}
weightChange={weightChange}
/>
);
})}
</View>
))
) : (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
return (
<WeightRecordCard
key={`${record.createdAt}-${recordIndex}`}
record={record}
onPress={handleEditWeightRecord}
onDelete={handleDeleteWeightRecord}
weightChange={weightChange}
/>
);
})}
</View>
)}
</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 style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</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>
);
}
@@ -386,8 +385,12 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
header: {
flexDirection: 'row',