Files
digital-pilates/app/weight-records.tsx
richarjiang bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00

828 lines
24 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 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';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { appStoreReviewService } from '@/services/appStoreReview';
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
export default function WeightRecordsPage() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
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' | 'edit'>('current');
const [inputWeight, setInputWeight] = useState('');
const [editingRecord, setEditingRecord] = useState<WeightHistoryItem | null>(null);
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const loadWeightHistory = useCallback(async () => {
try {
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error(t('weightRecords.loadingHistory'), error);
}
}, [dispatch]);
useEffect(() => {
loadWeightHistory();
}, [loadWeightHistory]);
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 handleEditWeightRecord = (record: WeightHistoryItem) => {
setPickerType('edit');
setEditingRecord(record);
initializeInput(parseFloat(record.weight));
setShowWeightPicker(true);
};
const handleDeleteWeightRecord = async (id: string) => {
try {
await dispatch(deleteWeightRecord(id) as any);
await loadWeightHistory();
} catch (error) {
console.error(t('weightRecords.alerts.deleteFailed'), error);
Alert.alert('错误', t('weightRecords.alerts.deleteFailed'));
}
};
const handleWeightSave = async () => {
const weight = parseFloat(inputWeight);
if (isNaN(weight) || weight <= 0 || weight > 500) {
alert(t('weightRecords.alerts.invalidWeight'));
return;
}
try {
if (pickerType === 'current') {
// Update current weight in profile and add weight record
await dispatch(updateUserProfile({ weight: weight }) as any);
// 记录体重后尝试请求应用评分延迟1秒避免阻塞主流程
setTimeout(() => {
appStoreReviewService.requestReview().catch((error) => {
console.error('应用评分请求失败:', error);
});
}, 1000);
} 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);
} else if (pickerType === 'edit' && editingRecord) {
await dispatch(updateWeightRecord({ id: editingRecord.id, weight }) as any);
}
setShowWeightPicker(false);
setInputWeight('');
setEditingRecord(null);
await loadWeightHistory();
} catch (error) {
console.error(t('weightRecords.alerts.saveFailed'), error);
Alert.alert('错误', t('weightRecords.alerts.saveFailed'));
}
};
const handleNumberPress = (number: string) => {
setInputWeight(prev => {
// 防止输入多个0开头
if (prev === '0' && number === '0') return prev;
// 如果当前是0输入非0数字时替换
if (prev === '0' && number !== '0') return number;
return prev + number;
});
};
const handleDeletePress = () => {
setInputWeight(prev => prev.slice(0, -1));
};
const handleDecimalPress = () => {
setInputWeight(prev => {
if (prev.includes('.')) return prev;
// 如果没有输入任何数字自动添加0
if (!prev) return '0.';
return prev + '.';
});
};
// 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 date = dayjs(item.createdAt);
const monthKey = t('weightRecords.historyMonthFormat', {
year: date.format('YYYY'),
month: date.format('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={['#f3f4fb', '#f3f4fb']}
style={StyleSheet.absoluteFillObject}
/>
{/* 顶部装饰性渐变 */}
<LinearGradient
colors={['rgba(229, 252, 254, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<HeaderBar
title={t('weightRecords.title')}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleAddWeight}
activeOpacity={0.7}
>
<GlassView
style={styles.addButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="add" size={24} color="#1c1f3a" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.addButtonFallback}
onPress={handleAddWeight}
activeOpacity={0.7}
>
<Ionicons name="add" size={24} color="#1c1f3a" />
</TouchableOpacity>
)
}
/>
<ScrollView
style={styles.content}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20, paddingTop: safeAreaTop }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerBlock}>
<Text style={styles.pageTitle}>{t('weightRecords.title')}</Text>
<Text style={styles.pageSubtitle}>{t('weightRecords.pageSubtitle')}</Text>
</View>
{/* Weight Statistics Cards */}
<View style={styles.statsGrid}>
{/* Current Weight - Hero Card */}
<View style={styles.mainStatCard}>
<View style={styles.mainStatContent}>
<Text style={styles.mainStatLabel}>{t('weightRecords.stats.currentWeight')}</Text>
<View style={styles.mainStatValueContainer}>
<Text style={styles.mainStatValue}>{currentWeight.toFixed(1)}</Text>
<Text style={styles.mainStatUnit}>kg</Text>
</View>
<View style={styles.totalLossTag}>
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Text style={styles.totalLossText}>
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
</Text>
</View>
</View>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
style={StyleSheet.absoluteFillObject}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
// @ts-ignore
borderRadius={24}
/>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={styles.statIconBg}
/>
</View>
{/* Secondary Stats Row */}
<View style={styles.secondaryStatsRow}>
{/* Initial Weight */}
<TouchableOpacity
style={styles.secondaryStatCard}
onPress={handleEditInitialWeight}
activeOpacity={0.7}
>
<View style={styles.secondaryStatHeader}>
<Text style={styles.secondaryStatLabel}>{t('weightRecords.stats.initialWeight')}</Text>
<Ionicons name="create-outline" size={14} color="#9ba3c7" />
</View>
<Text style={styles.secondaryStatValue}>{initialWeight.toFixed(1)}<Text style={styles.secondaryStatUnit}>kg</Text></Text>
</TouchableOpacity>
{/* Target Weight */}
<TouchableOpacity
style={styles.secondaryStatCard}
onPress={handleEditTargetWeight}
activeOpacity={0.7}
>
<View style={styles.secondaryStatHeader}>
<Text style={styles.secondaryStatLabel}>{t('weightRecords.stats.targetWeight')}</Text>
<Ionicons name="create-outline" size={14} color="#9ba3c7" />
</View>
<Text style={styles.secondaryStatValue}>{targetWeight.toFixed(1)}<Text style={styles.secondaryStatUnit}>kg</Text></Text>
</TouchableOpacity>
</View>
</View>
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
<View style={styles.historySection}>
<Text style={styles.sectionTitle}>{t('weightRecords.history')}</Text>
{Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month}</Text>
</View>
{/* Individual Record Cards */}
<View style={styles.recordsList}>
{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>
))}
</View>
) : (
<View style={styles.emptyContainer}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={{ width: 80, height: 80, opacity: 0.5, marginBottom: 16, tintColor: '#cbd5e1' }}
/>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}>{t('weightRecords.empty.title')}</Text>
<Text style={styles.emptySubtext}>{t('weightRecords.empty.subtitle')}</Text>
</View>
</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: '#ffffff' }]}>
{/* Header */}
<View style={styles.modalHeader}>
<TouchableOpacity
onPress={() => setShowWeightPicker(false)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color="#1c1f3a" />
</TouchableOpacity>
<Text style={styles.modalTitle}>
{pickerType === 'current' && t('weightRecords.modal.recordWeight')}
{pickerType === 'initial' && t('weightRecords.modal.editInitialWeight')}
{pickerType === 'target' && t('weightRecords.modal.editTargetWeight')}
{pickerType === 'edit' && t('weightRecords.modal.editRecord')}
</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}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={{ width: 24, height: 24, tintColor: '#4F5BD5' }}
/>
</View>
<View style={styles.inputWrapper}>
<Text style={[
styles.weightDisplay,
{ color: inputWeight ? '#1c1f3a' : '#9ba3c7' }
]}>
{inputWeight || t('weightRecords.modal.inputPlaceholder')}
</Text>
<Text style={styles.unitLabel}>{t('weightRecords.modal.unit')}</Text>
</View>
</View>
</View>
{/* Quick Selection */}
<View style={styles.quickSelectionSection}>
<Text style={styles.quickSelectionTitle}>{t('weightRecords.modal.quickSelection')}</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())}
activeOpacity={0.7}
>
<Text style={[
styles.quickButtonText,
inputWeight === weight.toString() && styles.quickButtonTextSelected
]}>
{weight}{t('weightRecords.modal.unit')}
</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()}
activeOpacity={0.8}
>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.saveButtonGradient}
>
<Text style={styles.saveButtonText}>{t('weightRecords.modal.confirm')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 300,
},
content: {
flex: 1,
},
contentContainer: {
flexGrow: 1,
paddingBottom: 40,
},
headerBlock: {
paddingHorizontal: 24,
marginTop: 10,
marginBottom: 24,
},
pageTitle: {
fontSize: 28,
fontWeight: '800',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 4,
},
pageSubtitle: {
fontSize: 16,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
// Add Button Styles
addButtonGlass: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
overflow: 'hidden',
},
addButtonFallback: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
// Stats Grid
statsGrid: {
paddingHorizontal: 24,
marginBottom: 32,
gap: 16,
},
mainStatCard: {
backgroundColor: '#4F5BD5',
borderRadius: 28,
padding: 24,
height: 160,
position: 'relative',
overflow: 'hidden',
shadowColor: '#4F5BD5',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 16,
elevation: 8,
},
mainStatContent: {
zIndex: 2,
height: '100%',
justifyContent: 'space-between',
},
mainStatLabel: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.9)',
fontFamily: 'AliRegular',
},
mainStatValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
mainStatValue: {
fontSize: 48,
fontWeight: '800',
color: '#ffffff',
fontFamily: 'AliBold',
marginRight: 8,
},
mainStatUnit: {
fontSize: 20,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.9)',
fontFamily: 'AliRegular',
},
totalLossTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
alignSelf: 'flex-start',
},
totalLossText: {
fontSize: 14,
fontWeight: '600',
color: '#ffffff',
marginLeft: 4,
fontFamily: 'AliBold',
},
statIconBg: {
position: 'absolute',
right: -20,
bottom: -20,
width: 140,
height: 140,
opacity: 0.2,
transform: [{ rotate: '-15deg' }],
tintColor: '#ffffff'
},
secondaryStatsRow: {
flexDirection: 'row',
gap: 16,
},
secondaryStatCard: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 24,
padding: 16,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 3,
},
secondaryStatHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
secondaryStatLabel: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
secondaryStatValue: {
fontSize: 20,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
secondaryStatUnit: {
fontSize: 14,
color: '#6f7ba7',
fontWeight: '500',
fontFamily: 'AliRegular',
marginLeft: 2,
},
// History Section
historySection: {
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
marginBottom: 16,
fontFamily: 'AliBold',
},
monthContainer: {
marginBottom: 24,
},
monthHeader: {
marginBottom: 12,
paddingHorizontal: 4,
},
monthTitle: {
fontSize: 15,
fontWeight: '600',
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
recordsList: {
gap: 12,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyContent: {
alignItems: 'center',
},
emptyText: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
marginBottom: 8,
fontFamily: 'AliBold',
},
emptySubtext: {
fontSize: 14,
color: '#687076',
fontFamily: 'AliRegular',
},
// Modal Styles (Retain but refined)
modalContainer: {
flex: 1,
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
maxHeight: '85%',
minHeight: 500,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 10,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 16,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
modalContent: {
flex: 1,
paddingHorizontal: 24,
},
inputSection: {
backgroundColor: '#F8F9FC',
borderRadius: 24,
padding: 24,
marginBottom: 24,
marginTop: 8,
},
weightInputContainer: {
flexDirection: 'row',
alignItems: 'center',
},
weightIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
inputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'baseline',
borderBottomWidth: 2,
borderBottomColor: '#E2E8F0',
paddingBottom: 8,
},
weightDisplay: {
flex: 1,
fontSize: 36,
fontWeight: '700',
textAlign: 'center',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
unitLabel: {
fontSize: 18,
fontWeight: '600',
color: '#6f7ba7',
marginLeft: 8,
fontFamily: 'AliRegular',
},
quickSelectionSection: {
marginBottom: 24,
},
quickSelectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#6f7ba7',
marginBottom: 12,
fontFamily: 'AliRegular',
marginLeft: 4,
},
quickButtons: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
quickButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: '#F1F5F9',
minWidth: 64,
alignItems: 'center',
},
quickButtonSelected: {
backgroundColor: '#4F5BD5',
},
quickButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
quickButtonTextSelected: {
color: '#FFFFFF',
fontWeight: '700',
},
modalFooter: {
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 34,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
saveButton: {
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#4F5BD5',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
saveButtonGradient: {
paddingVertical: 16,
alignItems: 'center',
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
fontFamily: 'AliBold',
},
});