Files
digital-pilates/app/weight-records.tsx

692 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
});