Files
digital-pilates/app/weight-records.tsx
richarjiang fbe0c92f0f feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
2025-11-27 17:54:36 +08:00

691 lines
21 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 { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
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'];
console.log('userProfile:', userProfile);
const loadWeightHistory = useCallback(async () => {
try {
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error(t('weightRecords.loadingHistory'), error);
}
}, [dispatch]);
useEffect(() => {
loadWeightHistory();
}, [loadWeightHistory]);
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 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 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={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<HeaderBar
title={t('weightRecords.title')}
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>}
/>
<View style={{
paddingTop: safeAreaTop
}} />
{/* 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}>{t('weightRecords.stats.totalLoss')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
<Text style={styles.statLabel}>{t('weightRecords.stats.currentWeight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}>
<Text style={styles.statLabel}>{t('weightRecords.stats.initialWeight')}</Text>
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={12} 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}>{t('weightRecords.stats.targetWeight')}</Text>
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={12} 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 (
<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}>{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: 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' && 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}>
<Ionicons name="scale-outline" size={20} color="#6366F1" />
</View>
<View style={styles.inputWrapper}>
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
{inputWeight || t('weightRecords.modal.inputPlaceholder')}
</Text>
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.modal.unit')}</Text>
</View>
</View>
{/* Weight Range Hint */}
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
{t('weightRecords.modal.inputHint')}
</Text>
</View>
{/* Quick Selection */}
<View style={styles.quickSelectionSection}>
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>{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())}
>
<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()}
>
<Text style={styles.saveButtonText}>{t('weightRecords.modal.confirm')}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
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: 14,
fontWeight: '800',
color: '#192126',
marginBottom: 4,
},
statLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
statLabel: {
fontSize: 12,
color: '#687076',
marginRight: 4,
textAlign: 'center'
},
editIcon: {
padding: 2,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
},
monthContainer: {
marginBottom: 20,
},
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',
},
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
modalContainer: {
flex: 1,
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '85%',
minHeight: 500,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
},
modalContent: {
flex: 1,
paddingHorizontal: 20,
},
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,
},
weightDisplay: {
flex: 1,
fontSize: 24,
fontWeight: '600',
textAlign: 'center',
paddingVertical: 4,
},
unitLabel: {
fontSize: 18,
fontWeight: '500',
marginLeft: 8,
},
hintText: {
fontSize: 12,
textAlign: 'center',
marginTop: 4,
},
quickSelectionSection: {
paddingHorizontal: 4,
marginBottom: 20,
},
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: {
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 25,
},
saveButton: {
backgroundColor: '#6366F1',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
},
});