feat: 新增体重记录功能,优化用户资料更新及图片组件缓存

This commit is contained in:
richarjiang
2025-08-27 19:18:54 +08:00
parent aaa462d476
commit ba2d829e02
10 changed files with 767 additions and 32 deletions

View File

@@ -2,13 +2,13 @@ import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
FlatList, FlatList,
Image,
Keyboard, Keyboard,
Modal, Modal,
Platform, Platform,
@@ -34,7 +34,7 @@ import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiC
import { api, getAuthToken, postTextStream } from '@/services/api'; import { api, getAuthToken, postTextStream } from '@/services/api';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage'; import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image';
import { HistoryModal } from '../../components/model/HistoryModal'; import { HistoryModal } from '../../components/model/HistoryModal';
import { ActionSheet } from '../../components/ui/ActionSheet'; import { ActionSheet } from '../../components/ui/ActionSheet';
@@ -1902,8 +1902,9 @@ export default function CoachScreen() {
}} }}
> >
<Image <Image
source={require('@/assets/images/icons/iconFlash.png')} source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
style={styles.usageIcon} style={styles.usageIcon}
cachePolicy="memory-disk"
/> />
<Text style={styles.usageText}> <Text style={styles.usageText}>
{userProfile?.isVip ? '不限' : `${userProfile?.freeUsageCount || 0}/${userProfile?.maxUsageCount || 0}`} {userProfile?.isVip ? '不限' : `${userProfile?.freeUsageCount || 0}/${userProfile?.maxUsageCount || 0}`}

View File

@@ -5,10 +5,10 @@ import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import { ProgressBar } from '@/components/ProgressBar'; import { ProgressBar } from '@/components/ProgressBar';
import { StressMeter } from '@/components/StressMeter';
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
import HeartRateCard from '@/components/statistic/HeartRateCard'; import HeartRateCard from '@/components/statistic/HeartRateCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import { StressMeter } from '@/components/StressMeter';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';

View File

@@ -3,8 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { updateUser as updateUserApi } from '@/services/users'; import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
import { fetchMyProfile } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
@@ -175,8 +174,7 @@ export default function EditProfileScreen() {
// 同步到后端(仅更新后端需要的字段) // 同步到后端(仅更新后端需要的字段)
try { try {
await updateUserApi({ await dispatch(updateUserProfile({
userId,
name: next.name || undefined, name: next.name || undefined,
gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined, gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined,
// 头像采用已上传的 URL若有 // 头像采用已上传的 URL若有
@@ -185,7 +183,7 @@ export default function EditProfileScreen() {
height: next.height || undefined, height: next.height || undefined,
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined, birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined,
activityLevel: next.activityLevel || undefined, activityLevel: next.activityLevel || undefined,
}); }));
// 拉取最新用户信息,刷新全局状态 // 拉取最新用户信息,刷新全局状态
await dispatch(fetchMyProfile() as any); await dispatch(fetchMyProfile() as any);
} catch (e: any) { } catch (e: any) {

692
app/weight-records.tsx Normal file
View File

@@ -0,0 +1,692 @@
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchWeightHistory, updateUserProfile, WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
export default function WeightRecordsPage() {
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [showWeightPicker, setShowWeightPicker] = useState(false);
const [pickerType, setPickerType] = useState<'current' | 'initial' | 'target'>('current');
const [inputWeight, setInputWeight] = useState('');
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
console.log('userProfile:', userProfile);
useEffect(() => {
loadWeightHistory();
}, []);
const loadWeightHistory = async () => {
try {
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error('加载体重历史失败:', error);
}
};
const handleGoBack = () => {
router.back();
};
const initializeInput = (weight: number) => {
setInputWeight(weight.toString());
};
const handleAddWeight = () => {
setPickerType('current');
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
initializeInput(weight);
setShowWeightPicker(true);
};
const handleEditInitialWeight = () => {
setPickerType('initial');
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
initializeInput(parseFloat(initialWeight));
setShowWeightPicker(true);
};
const handleEditTargetWeight = () => {
setPickerType('target');
const targetWeight = userProfile?.targetWeight || '60.0';
initializeInput(parseFloat(targetWeight));
setShowWeightPicker(true);
};
const handleWeightSave = async () => {
const weight = parseFloat(inputWeight);
if (isNaN(weight) || weight <= 0 || weight > 500) {
alert('请输入有效的体重值0-500kg');
return;
}
try {
if (pickerType === 'current') {
// Update current weight in profile and add weight record
await dispatch(updateUserProfile({ weight: weight }) as any);
} else if (pickerType === 'initial') {
// Update initial weight in profile
console.log('更新初始体重');
await dispatch(updateUserProfile({ initialWeight: weight }) as any);
} else if (pickerType === 'target') {
// Update target weight in profile
await dispatch(updateUserProfile({ targetWeight: weight }) as any);
}
setShowWeightPicker(false);
setInputWeight('');
await loadWeightHistory();
} catch (error) {
console.error('保存体重失败:', error);
}
};
// Process weight history data
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// Group by month
const groupedHistory = sortedHistory.reduce((acc, item) => {
const monthKey = dayjs(item.createdAt).format('YYYY年MM月');
if (!acc[monthKey]) {
acc[monthKey] = [];
}
acc[monthKey].push(item);
return acc;
}, {} as Record<string, WeightHistoryItem[]>);
// Calculate statistics
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
const initialWeight = userProfile?.initialWeight ? parseFloat(userProfile.initialWeight) :
(sortedHistory.length > 0 ? parseFloat(sortedHistory[sortedHistory.length - 1].weight) : currentWeight);
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
const totalWeightLoss = initialWeight - currentWeight;
return (
<View style={styles.container}>
<LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
style={styles.gradient}
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>
</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>
</View>
<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}>
<View style={styles.monthTitleRow}>
<Text style={styles.monthNumber}>
{dayjs(month, 'YYYY年MM月').format('MM')}
</Text>
<Text style={styles.monthText}>月</Text>
<Text style={styles.yearText}>
{dayjs(month, 'YYYY年MM月').format('YYYY年')}
</Text>
<View style={styles.expandIcon}>
<Ionicons name="chevron-up" size={16} color="#FF9500" />
</View>
</View>
<Text style={styles.monthStatsText}>
累计减重:<Text style={styles.statsBold}>{totalWeightLoss.toFixed(1)}kg</Text> 日均减重:<Text style={styles.statsBold}>{avgWeightLoss.toFixed(1)}kg</Text>
</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;
return (
<View key={`${record.createdAt}-${recordIndex}`} style={styles.recordCard}>
<View style={styles.recordHeader}>
<Text style={styles.recordDateTime}>
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
</Text>
{/* <TouchableOpacity style={styles.recordEditButton}>
<Ionicons name="create-outline" size={16} color="#FF9500" />
</TouchableOpacity> */}
</View>
<View style={styles.recordContent}>
<Text style={styles.recordWeightLabel}></Text>
<Text style={styles.recordWeightValue}>{record.weight}kg</Text>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.weightChangeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={12}
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
/>
<Text style={[
styles.weightChangeText,
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
</View>
</View>
);
})}
</View>
))
) : (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
</View>
)}
</ScrollView>
{/* Weight Input Modal */}
<Modal
visible={showWeightPicker}
animationType="fade"
transparent
onRequestClose={() => setShowWeightPicker(false)}
>
<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' && '编辑目标体重'}
</Text>
<View style={{ width: 24 }} />
</View>
<View style={styles.modalContent}>
{/* Weight Input 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}>
<TextInput
style={[styles.weightInput, { color: themeColors.text }]}
placeholder="输入体重"
placeholderTextColor={themeColors.textSecondary}
value={inputWeight}
onChangeText={setInputWeight}
keyboardType="decimal-pad"
autoFocus={true}
selectTextOnFocus={true}
/>
<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>
</View>
{/* 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>
</Modal>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 60,
paddingHorizontal: 20,
paddingBottom: 10,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
},
content: {
flex: 1,
paddingHorizontal: 20,
},
contentContainer: {
flexGrow: 1,
},
statsContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
statItem: {
flex: 1,
flexDirection: 'column',
alignItems: 'center',
},
statValue: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
statLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
statLabel: {
fontSize: 12,
color: '#687076',
marginRight: 4,
},
editIcon: {
padding: 2,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
},
monthContainer: {
marginBottom: 16,
},
monthHeaderCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 3,
},
monthTitleRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 12,
},
monthNumber: {
fontSize: 48,
fontWeight: '800',
color: '#192126',
lineHeight: 48,
},
monthText: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginLeft: 4,
marginRight: 8,
},
yearText: {
fontSize: 16,
fontWeight: '500',
color: '#687076',
flex: 1,
},
expandIcon: {
padding: 4,
},
monthStatsText: {
fontSize: 14,
color: '#687076',
lineHeight: 20,
},
statsBold: {
fontWeight: '700',
color: '#192126',
},
recordCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
recordHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
recordDateTime: {
fontSize: 14,
color: '#687076',
fontWeight: '500',
},
recordEditButton: {
padding: 6,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
},
recordContent: {
flexDirection: 'row',
alignItems: 'center',
},
recordWeightLabel: {
fontSize: 16,
color: '#687076',
fontWeight: '500',
},
recordWeightValue: {
fontSize: 18,
fontWeight: '700',
color: '#192126',
marginLeft: 4,
flex: 1,
},
weightChangeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
marginLeft: 12,
},
weightChangeText: {
fontSize: 12,
fontWeight: '600',
marginLeft: 2,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight: 300,
},
emptyContent: {
alignItems: 'center',
},
emptyText: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#687076',
},
// Modal Styles
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
},
modalContent: {
paddingHorizontal: 20,
paddingBottom: 10,
},
inputSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 12,
},
weightInputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
weightIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F0F9FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
inputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: '#E5E7EB',
paddingBottom: 6,
},
weightInput: {
flex: 1,
fontSize: 24,
fontWeight: '600',
textAlign: 'center',
},
unitLabel: {
fontSize: 18,
fontWeight: '500',
marginLeft: 8,
},
hintText: {
fontSize: 12,
textAlign: 'center',
marginTop: 4,
},
quickSelectionSection: {
paddingHorizontal: 4,
marginBottom: 8,
},
quickSelectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
quickButtons: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
},
quickButton: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
minWidth: 60,
alignItems: 'center',
},
quickButtonSelected: {
backgroundColor: '#6366F1',
borderColor: '#6366F1',
},
quickButtonText: {
fontSize: 13,
fontWeight: '500',
color: '#6B7280',
},
quickButtonTextSelected: {
color: '#FFFFFF',
fontWeight: '600',
},
modalFooter: {
padding: 20,
paddingBottom: 25,
},
saveButton: {
backgroundColor: '#6366F1',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
},
});

View File

@@ -59,8 +59,6 @@ const ActivityHeatMap = () => {
return data; return data;
}, [activityData, weeksToShow]); }, [activityData, weeksToShow]);
console.log('generateActivityData', generateActivityData);
// 根据活跃度计算颜色 - 优化配色方案 // 根据活跃度计算颜色 - 优化配色方案
const getActivityColor = (level: number): string => { const getActivityColor = (level: number): string => {

View File

@@ -54,13 +54,13 @@ export function WeightHistoryCard() {
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0; const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
const hasHeight = userProfile?.height && parseFloat(userProfile.height) > 0; const hasHeight = userProfile?.height && parseFloat(userProfile.height) > 0;
// BMI 计算 // BMI 计算
const canCalculate = canCalculateBMI( const canCalculate = canCalculateBMI(
userProfile?.weight ? parseFloat(userProfile.weight) : undefined, userProfile?.weight ? parseFloat(userProfile.weight) : undefined,
userProfile?.height ? parseFloat(userProfile.height) : undefined userProfile?.height ? parseFloat(userProfile.height) : undefined
); );
const bmiResult = canCalculate && userProfile?.weight && userProfile?.height const bmiResult = canCalculate && userProfile?.weight && userProfile?.height
? getBMIResult(parseFloat(userProfile.weight), parseFloat(userProfile.height)) ? getBMIResult(parseFloat(userProfile.weight), parseFloat(userProfile.height))
: null; : null;
@@ -95,6 +95,10 @@ export function WeightHistoryCard() {
}; };
// 切换图表显示状态的动画函数 // 切换图表显示状态的动画函数
const navigateToWeightRecords = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
const toggleChart = () => { const toggleChart = () => {
if (isAnimating) return; // 防止动画期间重复触发 if (isAnimating) return; // 防止动画期间重复触发
@@ -195,7 +199,7 @@ export function WeightHistoryCard() {
// 如果正在加载,显示加载状态 // 如果正在加载,显示加载状态
if (isLoading) { if (isLoading) {
return ( return (
<View style={styles.card}> <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.iconSquare}> <View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} /> <Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
@@ -205,14 +209,14 @@ export function WeightHistoryCard() {
<View style={styles.emptyContent}> <View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>...</Text> <Text style={styles.emptyDescription}>...</Text>
</View> </View>
</View> </TouchableOpacity>
); );
} }
// 如果没有体重数据,显示引导卡片 // 如果没有体重数据,显示引导卡片
if (!hasWeight) { if (!hasWeight) {
return ( return (
<View style={styles.card}> <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.iconSquare}> <View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} /> <Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
@@ -227,14 +231,17 @@ export function WeightHistoryCard() {
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.recordButton} style={styles.recordButton}
onPress={navigateToCoach} onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="add" size={18} color="#192126" /> <Ionicons name="add" size={18} color="#192126" />
<Text style={styles.recordButtonText}></Text> <Text style={styles.recordButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </TouchableOpacity>
); );
} }
@@ -245,7 +252,7 @@ export function WeightHistoryCard() {
if (sortedHistory.length === 0) { if (sortedHistory.length === 0) {
return ( return (
<View style={styles.card}> <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.iconSquare}> <View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} /> <Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
@@ -259,14 +266,17 @@ export function WeightHistoryCard() {
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.recordButton} style={styles.recordButton}
onPress={navigateToCoach} onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="add" size={18} color="#FFFFFF" /> <Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text> <Text style={styles.recordButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </TouchableOpacity>
); );
} }
@@ -296,7 +306,7 @@ export function WeightHistoryCard() {
pathData; pathData;
return ( return (
<View style={styles.card}> <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.iconSquare}> <View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} /> <Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
@@ -305,7 +315,10 @@ export function WeightHistoryCard() {
<View style={styles.headerButtons}> <View style={styles.headerButtons}>
<TouchableOpacity <TouchableOpacity
style={styles.chartToggleButton} style={styles.chartToggleButton}
onPress={toggleChart} onPress={(e) => {
e.stopPropagation();
toggleChart();
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons <Ionicons
@@ -316,7 +329,10 @@ export function WeightHistoryCard() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.addButton} style={styles.addButton}
onPress={navigateToCoach} onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="add" size={16} color={Colors.light.primary} /> <Ionicons name="add" size={16} color={Colors.light.primary} />
@@ -352,7 +368,10 @@ export function WeightHistoryCard() {
{bmiResult.value} {bmiResult.value}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
onPress={handleShowBMIModal} onPress={(e) => {
e.stopPropagation();
handleShowBMIModal();
}}
style={styles.bmiInfoButton} style={styles.bmiInfoButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
> >
@@ -499,7 +518,7 @@ export function WeightHistoryCard() {
{BMI_CATEGORIES.map((category, index) => { {BMI_CATEGORIES.map((category, index) => {
const colors = [ const colors = [
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 偏瘦 { bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 偏瘦
{ bg: '#E8F5E8', text: Colors.light.accentGreenDark, border: Colors.light.accentGreen }, // 正常 { bg: '#E8F5E8', text: Colors.light.accentGreen, border: Colors.light.accentGreen }, // 正常
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 超重 { bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 超重
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // 肥胖 { bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // 肥胖
][index]; ][index];
@@ -563,7 +582,7 @@ export function WeightHistoryCard() {
</View> </View>
</LinearGradient> </LinearGradient>
</Modal> </Modal>
</View> </TouchableOpacity>
); );
} }
@@ -711,7 +730,7 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: '#192126', color: '#192126',
}, },
// BMI 相关样式 // BMI 相关样式
bmiValueContainer: { bmiValueContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -734,7 +753,7 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
fontWeight: '700', fontWeight: '700',
}, },
// BMI 弹窗样式 // BMI 弹窗样式
bmiModalContainer: { bmiModalContainer: {
flex: 1, flex: 1,

View File

@@ -148,7 +148,7 @@ export const Colors = {
ornamentAccent: palette.success[100], ornamentAccent: palette.success[100],
// 背景渐变色 // 背景渐变色
backgroundGradientStart: palette.gray[25], backgroundGradientStart: palette.purple[25],
backgroundGradientEnd: palette.base.white, backgroundGradientEnd: palette.base.white,
}, },
dark: { dark: {

View File

@@ -37,6 +37,9 @@ export const ROUTES = {
// 营养相关路由 // 营养相关路由
NUTRITION_RECORDS: '/nutrition/records', NUTRITION_RECORDS: '/nutrition/records',
// 体重记录相关路由
WEIGHT_RECORDS: '/weight-records',
// 任务相关路由 // 任务相关路由
TASK_DETAIL: '/task-detail', TASK_DETAIL: '/task-detail',

View File

@@ -3,7 +3,6 @@ import { api } from '@/services/api';
export type Gender = 'male' | 'female'; export type Gender = 'male' | 'female';
export type UpdateUserDto = { export type UpdateUserDto = {
userId: string;
name?: string; name?: string;
avatar?: string; // base64 字符串 avatar?: string; // base64 字符串
gender?: Gender; gender?: Gender;
@@ -14,6 +13,8 @@ export type UpdateUserDto = {
weight?: number; weight?: number;
height?: number; height?: number;
activityLevel?: number; // 活动水平 1-4 activityLevel?: number; // 活动水平 1-4
initialWeight?: number; // 初始体重
targetWeight?: number; // 目标体重
}; };
export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> { export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> {

View File

@@ -1,4 +1,5 @@
import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api'; import { api, loadPersistedToken, setAuthToken, STORAGE_KEYS } from '@/services/api';
import { updateUser, UpdateUserDto } from '@/services/users';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -11,6 +12,8 @@ export type UserProfile = {
birthDate?: string; birthDate?: string;
weight?: string; weight?: string;
height?: string; height?: string;
initialWeight?: string; // 初始体重
targetWeight?: string; // 目标体重
avatar?: string | null; avatar?: string | null;
dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等) dailyStepsGoal?: number; // 每日步数目标(用于 Explore 页等)
dailyCaloriesGoal?: number; // 每日卡路里消耗目标 dailyCaloriesGoal?: number; // 每日卡路里消耗目标
@@ -194,6 +197,20 @@ export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory'
} }
}); });
// 更新用户资料(包括体重)
export const updateUserProfile = createAsyncThunk(
'user/updateProfile',
async (updateDto: UpdateUserDto, { rejectWithValue }) => {
try {
const data = await updateUser(updateDto);
console.log('updateUserProfile', data);
return data;
} catch (err: any) {
return rejectWithValue(err?.message ?? '更新用户资料失败');
}
}
);
const userSlice = createSlice({ const userSlice = createSlice({
name: 'user', name: 'user',
initialState, initialState,
@@ -263,6 +280,12 @@ const userSlice = createSlice({
}) })
.addCase(fetchActivityHistory.rejected, (state, action) => { .addCase(fetchActivityHistory.rejected, (state, action) => {
state.error = (action.payload as string) ?? '获取用户活动历史记录失败'; state.error = (action.payload as string) ?? '获取用户活动历史记录失败';
})
.addCase(updateUserProfile.fulfilled, (state, action) => {
state.profile = { ...state.profile, ...action.payload };
})
.addCase(updateUserProfile.rejected, (state, action) => {
state.error = (action.payload as string) ?? '更新用户资料失败';
}); });
}, },
}); });