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.
This commit is contained in:
richarjiang
2025-11-28 17:29:51 +08:00
parent fbe0c92f0f
commit bca6670390
42 changed files with 7972 additions and 6632 deletions

View File

@@ -1,9 +1,9 @@
import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Svg, { Circle, Defs, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -26,12 +26,8 @@ export function CalorieRingChart({
protein,
fat,
carbs,
proteinGoal,
fatGoal,
carbsGoal,
}: CalorieRingChartProps) {
const surfaceColor = useThemeColor({}, 'surface');
const { t } = useI18n();
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
@@ -46,9 +42,9 @@ export function CalorieRingChart({
const totalAvailable = metabolism + exercise;
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
// 圆环参数 - 小尺寸以优化空间占用
const radius = 48;
const strokeWidth = 8; // 增加圆环厚度
// 圆环参数 - 小尺寸
const radius = 42;
const strokeWidth = 8;
const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius;
const strokeDasharray = circumference;
@@ -70,34 +66,32 @@ export function CalorieRingChart({
});
return (
<View style={[styles.container, { backgroundColor: surfaceColor }]}>
{/* 左上角公式展示 */}
<View style={styles.formulaContainer}>
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
= + -
</ThemedText>
</View>
{/* 主要内容区域 */}
<View style={styles.container}>
<View style={styles.mainContent}>
{/* 左侧圆环图 */}
<View style={styles.chartContainer}>
<Svg width={center * 2} height={center * 2}>
<Defs>
<SvgLinearGradient id="progressGradient" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={progressPercentage > 80 ? "#FF9966" : "#4facfe"} stopOpacity="1" />
<Stop offset="1" stopColor={progressPercentage > 80 ? "#FF5E62" : "#00f2fe"} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{/* 背景圆环 */}
<Circle
cx={center}
cy={center}
r={radius}
stroke="#F0F0F0"
stroke="#F5F7FA"
strokeWidth={strokeWidth}
fill="none"
/>
{/* 进度圆环 - 保持固定颜色 */}
{/* 进度圆环 */}
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={progressPercentage > 80 ? "#FF6B6B" : "#4ECDC4"}
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${strokeDasharray}`}
@@ -109,68 +103,68 @@ export function CalorieRingChart({
{/* 中心内容 */}
<View style={styles.centerContent}>
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}>
<ThemedText style={styles.centerLabel}>
{t('nutritionRecords.chart.remaining')}
</ThemedText>
<ThemedText style={[styles.centerValue, { color: textColor }]}>
{Math.round(canEat)}
<ThemedText style={styles.centerValue}>
{Math.round(canEat)}
</ThemedText>
<ThemedText style={styles.centerUnit}>
{t('nutritionRecords.nutrients.caloriesUnit')}
</ThemedText>
</View>
</View>
{/* 右侧数据展示 */}
{/* 右侧数据展示 - 优化布局 */}
<View style={styles.dataContainer}>
<View style={styles.dataBackground}>
{/* 左右两列布局 */}
<View style={styles.dataColumns}>
{/* 左列:卡路里数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(metabolism)}
</ThemedText>
</View>
{/* 公式 */}
<View style={styles.formulaContainer}>
<ThemedText style={styles.formulaText}>
{t('nutritionRecords.chart.formula')}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(exercise)}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(consumed)}
</ThemedText>
</View>
</View>
{/* 右列:营养数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(protein)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(fat)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(carbs)}g
</ThemedText>
</View>
</View>
{/* 代谢 & 运动 & 饮食 */}
<View style={styles.statsGroup}>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotMetabolism} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.metabolism')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(metabolism)}</ThemedText>
</View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotExercise} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.exercise')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(exercise)}</ThemedText>
</View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotConsumed} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.diet')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(consumed)}</ThemedText>
</View>
</View>
<View style={styles.divider} />
{/* 营养素 - 水平排布 */}
<View style={styles.nutritionRow}>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(protein)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.protein')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(fat)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.fat')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(carbs)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.carbs')}</ThemedText>
</View>
</View>
</View>
</View>
@@ -181,40 +175,35 @@ export function CalorieRingChart({
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderRadius: 24,
padding: 16,
marginHorizontal: 16,
marginBottom: 8,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
marginHorizontal: 20,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 16,
elevation: 6,
},
formulaContainer: {
alignItems: 'flex-start',
marginBottom: 12,
},
formulaText: {
fontSize: 12,
fontSize: 10,
fontWeight: '500',
color: '#999999',
lineHeight: 16,
color: '#94A3B8',
fontFamily: 'AliRegular',
},
mainContent: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 0, // 移除底部间距,因为不再有底部营养容器
paddingHorizontal: 8,
alignItems: 'flex-start',
},
chartContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
width: 112, // 减少宽度以匹配更小的圆环 (48*2 + 8*2)
flexShrink: 0,
width: 100,
height: 100,
marginTop: 8,
},
centerContent: {
position: 'absolute',
@@ -222,71 +211,95 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
centerLabel: {
fontSize: 11,
fontSize: 10,
fontWeight: '500',
color: '#999999',
marginBottom: 2,
color: '#94A3B8',
marginBottom: 1,
fontFamily: 'AliRegular',
},
centerValue: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 1,
fontSize: 20,
fontWeight: '800',
color: '#1E293B',
lineHeight: 24,
fontFamily: 'AliBold',
},
centerPercentage: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
centerUnit: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
dataContainer: {
flex: 1,
marginLeft: 16,
marginLeft: 20,
},
dataBackground: {
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
borderRadius: 12,
padding: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.06,
shadowRadius: 3,
elevation: 1,
// 添加边框增强毛玻璃效果
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.8)',
gap: 4,
statsGroup: {
gap: 6,
},
dataItem: {
statRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
justifyContent: 'space-between',
},
dataIcon: {
width: 6,
height: 6,
borderRadius: 3,
labelWithDot: {
flexDirection: 'row',
alignItems: 'center',
},
dataLabel: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
minWidth: 28,
dotMetabolism: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#94A3B8',
marginRight: 6,
},
dataValue: {
fontSize: 11,
dotExercise: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#4facfe',
marginRight: 6,
},
dotConsumed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#FF9966',
marginRight: 6,
},
statLabel: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
},
statValue: {
fontSize: 13,
fontWeight: '600',
color: '#333333',
color: '#334155',
fontFamily: 'AliBold',
},
dataColumns: {
divider: {
height: 1,
backgroundColor: '#F1F5F9',
marginVertical: 10,
},
nutritionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
dataColumn: {
flex: 1,
gap: 4,
nutritionItem: {
alignItems: 'center',
},
statLabelSmall: {
fontSize: 10,
color: '#94A3B8',
marginTop: 2,
fontFamily: 'AliRegular',
},
statValueSmall: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
},
});

View File

@@ -463,16 +463,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
monthTitle: {
fontSize: 22,
fontSize: 26,
fontWeight: '800',
color: '#1a1a1a',
letterSpacing: -0.5,
color: '#1c1f3a',
fontFamily: 'AliBold',
marginLeft: 8,
},
calendarIconButton: {
padding: 4,
borderRadius: 6,
marginLeft: 4,
padding: 6,
borderRadius: 12,
marginLeft: 8,
overflow: 'hidden',
},
calendarIconFallback: {
@@ -481,22 +481,19 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 255, 255, 0.3)',
},
todayButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
marginRight: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
marginRight: 4,
overflow: 'hidden',
},
todayButtonFallback: {
backgroundColor: '#EEF2FF',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.2)',
},
todayButtonText: {
fontSize: 12,
fontWeight: '700',
color: '#7c3aed',
letterSpacing: 0.2,
color: '#5F6BF0',
fontFamily: 'AliBold',
},
daysContainer: {
@@ -508,8 +505,8 @@ const styles = StyleSheet.create({
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
width: 48,
height: 68,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
@@ -523,14 +520,12 @@ const styles = StyleSheet.create({
transform: [{ scale: 0.96 }],
},
dayPillSelectedFallback: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
backgroundColor: '#5F6BF0',
shadowColor: 'rgba(95, 107, 240, 0.3)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
dayPillDisabled: {
backgroundColor: 'transparent',
@@ -538,30 +533,30 @@ const styles = StyleSheet.create({
},
dayLabel: {
fontSize: 11,
fontWeight: '700',
color: '#8e8e93',
marginBottom: 2,
letterSpacing: 0.1,
fontFamily: 'AliBold',
fontWeight: '600',
color: '#94A3B8',
marginBottom: 4,
fontFamily: 'AliRegular',
},
dayLabelSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontWeight: '700',
fontFamily: 'AliBold',
opacity: 0.9,
},
dayLabelDisabled: {
color: '#c7c7cc',
},
dayDate: {
fontSize: 13,
fontSize: 15,
fontWeight: '700',
color: '#8e8e93',
letterSpacing: -0.2,
color: '#64748B',
fontFamily: 'AliBold',
},
dayDateSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontSize: 16,
fontFamily: 'AliBold',
},
dayDateDisabled: {

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
@@ -20,6 +21,7 @@ interface FloatingFoodOverlayProps {
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
const router = useRouter();
const { t } = useI18n();
const { pushIfAuthedElseLogin } = useAuthGuard()
@@ -41,21 +43,21 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
const menuItems = [
{
id: 'scan',
title: 'AI识别',
title: t('nutritionRecords.overlay.scan'),
icon: '📷',
backgroundColor: '#4FC3F7',
onPress: handlePhotoRecognition,
},
{
id: 'food-library',
title: '食物库',
title: t('nutritionRecords.overlay.foodLibrary'),
icon: '🍎',
backgroundColor: '#FF9500',
onPress: handleFoodLibrary,
},
{
id: 'voice-record',
title: '一句话记录',
title: t('nutritionRecords.overlay.voiceRecord'),
icon: '🎤',
backgroundColor: '#7B68EE',
onPress: handleVoiceRecord,
@@ -81,7 +83,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
<View style={styles.container}>
<BlurView intensity={80} tint="light" style={styles.blurContainer}>
<View style={styles.header}>
<Text style={styles.title}></Text>
<Text style={styles.title}>{t('nutritionRecords.overlay.title')}</Text>
</View>
<View style={styles.menuGrid}>

View File

@@ -1,4 +1,5 @@
import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import { DietRecord } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons';
@@ -15,14 +16,6 @@ export type NutritionRecordCardProps = {
onDelete?: () => void;
};
const MEAL_TYPE_LABELS = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
other: '其他',
} as const;
const MEAL_TYPE_ICONS = {
breakfast: 'sunny-outline',
lunch: 'partly-sunny-outline',
@@ -44,46 +37,40 @@ export function NutritionRecordCard({
onPress,
onDelete
}: NutritionRecordCardProps) {
const surfaceColor = useThemeColor({}, 'surface');
const { t } = useI18n();
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
// Popover 状态管理
const [showPopover, setShowPopover] = useState(false);
const popoverRef = useRef<any>(null);
// 左滑删除相关
const swipeableRef = useRef<Swipeable>(null);
// 添加滑动状态管理,防止滑动时触发点击事件
const [isSwiping, setIsSwiping] = useState(false);
// 营养数据统计
const nutritionStats = useMemo(() => {
return [
{
label: '蛋白质',
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)}g` : '-',
icon: '🥩',
color: '#FF6B6B'
label: t('nutritionRecords.nutrients.protein'),
value: record.proteinGrams ? `${Math.round(record.proteinGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
{
label: '脂肪',
value: record.fatGrams ? `${record.fatGrams.toFixed(1)}g` : '-',
icon: '🥑',
color: '#FFB366'
label: t('nutritionRecords.nutrients.fat'),
value: record.fatGrams ? `${Math.round(record.fatGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
{
label: '碳水',
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)}g` : '-',
icon: '🍞',
color: '#4ECDC4'
label: t('nutritionRecords.nutrients.carbs'),
value: record.carbohydrateGrams ? `${Math.round(record.carbohydrateGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
];
}, [record]);
}, [record, t]);
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType];
const mealTypeLabel = t(`nutritionRecords.mealTypes.${record.mealType}`);
// 处理点击事件,只有在非滑动状态下才触发
const handlePress = () => {
@@ -92,31 +79,17 @@ export function NutritionRecordCard({
}
};
// 处理滑动开始
const handleSwipeableWillOpen = () => {
setIsSwiping(true);
};
const handleSwipeableWillOpen = () => setIsSwiping(true);
const handleSwipeableClose = () => setTimeout(() => setIsSwiping(false), 100);
// 处理滑动结束
const handleSwipeableClose = () => {
// 延迟重置滑动状态,防止滑动结束时立即触发点击
setTimeout(() => {
setIsSwiping(false);
}, 100);
};
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
`确定要删除这条营养记录吗?此操作无法撤销。`,
t('nutritionRecords.delete.title'),
t('nutritionRecords.delete.message'),
[
{ text: t('nutritionRecords.delete.cancel'), style: 'cancel' },
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
text: t('nutritionRecords.delete.confirm'),
style: 'destructive',
onPress: () => {
onDelete?.();
@@ -127,7 +100,6 @@ export function NutritionRecordCard({
);
};
// 渲染删除按钮
const renderRightActions = () => {
return (
<TouchableOpacity
@@ -136,7 +108,6 @@ export function NutritionRecordCard({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
);
};
@@ -152,239 +123,228 @@ export function NutritionRecordCard({
onSwipeableClose={handleSwipeableClose}
>
<RectButton
style={[
styles.card,
]}
style={styles.card}
onPress={handlePress}
// activeOpacity={0.7}
>
{/* 主要内容区域 - 水平布局 */}
<View style={styles.mainContent}>
{/* 左侧:食物图片 */}
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}>
{record.imageUrl ? (
<Image
source={{ uri: record.imageUrl }}
style={styles.foodImage}
cachePolicy={'memory-disk'}
/>
) : (
<Ionicons name="restaurant" size={28} color={textSecondaryColor} />
)}
{/* 左侧:时间线和图标 */}
<View style={styles.leftSection}>
<View style={styles.mealIconContainer}>
<Image
source={require('@/assets/images/icons/icon-food.png')}
style={styles.mealIcon}
/>
</View>
</View>
{/* 中间:食物信息 */}
<View style={styles.foodInfoContainer}>
{/* 食物名称 */}
<ThemedText style={[styles.foodName, { color: textColor }]}>
{record.foodName}
</ThemedText>
{/* 时间 */}
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
</ThemedText>
{/* 营养信息 - 水平排列 */}
<View style={styles.nutritionContainer}>
{/* 中间:主要信息 */}
<View style={styles.centerSection}>
<View style={styles.titleRow}>
<ThemedText style={styles.foodName} numberOfLines={1}>
{record.foodName}
</ThemedText>
<View style={[styles.mealTag, { backgroundColor: `${mealTypeColor}15` }]}>
<Text style={[styles.mealTagText, { color: mealTypeColor }]}>{mealTypeLabel}</Text>
</View>
</View>
<View style={styles.metaRow}>
<Ionicons name="time-outline" size={12} color="#94A3B8" />
<Text style={styles.timeText}>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
</Text>
{record.portionDescription && (
<>
<Text style={styles.dotSeparator}>·</Text>
<Text style={styles.portionText} numberOfLines={1}>{record.portionDescription}</Text>
</>
)}
</View>
{/* 营养微缩信息 */}
<View style={styles.nutritionRow}>
{nutritionStats.map((stat, index) => (
<View key={stat.label} style={styles.nutritionItem}>
<ThemedText style={styles.nutritionIcon}>{stat.icon}</ThemedText>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
{stat.value}
</ThemedText>
</View>
<View key={index} style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{stat.value}<Text style={styles.nutritionUnit}>{stat.unit}</Text></Text>
<Text style={styles.nutritionLabel}>{stat.label}</Text>
</View>
))}
</View>
</View>
{/* 右侧:热量和餐次标签 */}
{/* 右侧:热量 */}
<View style={styles.rightSection}>
{/* 热量显示 */}
<View style={styles.caloriesContainer}>
<ThemedText style={[styles.caloriesText]}>
{record.estimatedCalories ? `${Math.round(record.estimatedCalories)} kcal` : '- kcal'}
</ThemedText>
</View>
{/* 餐次标签 */}
<View style={[styles.mealTypeBadge]}>
<ThemedText style={[styles.mealTypeText, { color: mealTypeColor }]}>
{mealTypeLabel}
</ThemedText>
</View>
<Text style={styles.caloriesValue}>
{record.estimatedCalories ? Math.round(record.estimatedCalories) : '-'}
</Text>
<Text style={styles.caloriesUnit}>{t('nutritionRecords.nutrients.caloriesUnit')}</Text>
</View>
</View>
{/* 如果有图片,显示图片缩略图 */}
{record.imageUrl && (
<View style={styles.imageSection}>
<Image
source={{ uri: record.imageUrl }}
style={styles.foodImage}
contentFit="cover"
transition={200}
/>
</View>
)}
</RectButton>
</Swipeable>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
// iOS 阴影效果 - 更自然的阴影
shadowColor: '#000000',
shadowOffset: { width: 0, height: 2 },
marginBottom: 12,
marginHorizontal: 24,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
// Android 阴影效果
elevation: 3,
shadowRadius: 12,
elevation: 2,
},
card: {
flex: 1,
minHeight: 100,
backgroundColor: '#FFFFFF',
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 24,
padding: 16,
},
mainContent: {
flex: 1,
flexDirection: 'row',
},
leftSection: {
marginRight: 12,
alignItems: 'center',
},
foodImageContainer: {
width: 48,
height: 48,
borderRadius: 12,
marginRight: 16,
mealIconContainer: {
width: 40,
height: 40,
borderRadius: 14,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
},
mealIcon: {
width: 20,
height: 20,
opacity: 0.8,
},
centerSection: {
flex: 1,
marginRight: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
gap: 8,
},
foodName: {
fontSize: 16,
fontWeight: '700',
color: '#1E293B',
fontFamily: 'AliBold',
flexShrink: 1,
},
mealTag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
},
mealTagText: {
fontSize: 10,
fontWeight: '600',
fontFamily: 'AliBold',
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
timeText: {
fontSize: 12,
color: '#94A3B8',
marginLeft: 4,
fontFamily: 'AliRegular',
},
dotSeparator: {
marginHorizontal: 4,
color: '#CBD5E1',
},
portionText: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
flex: 1,
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 2,
},
nutritionValue: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
},
nutritionUnit: {
fontSize: 10,
fontWeight: '500',
color: '#94A3B8',
marginLeft: 1,
},
nutritionLabel: {
fontSize: 10,
color: '#94A3B8',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingTop: 2,
},
caloriesValue: {
fontSize: 18,
fontWeight: '800',
color: '#1E293B',
fontFamily: 'AliBold',
lineHeight: 22,
},
caloriesUnit: {
fontSize: 10,
color: '#94A3B8',
fontWeight: '500',
fontFamily: 'AliRegular',
},
imageSection: {
marginTop: 12,
height: 120,
width: '100%',
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#F1F5F9',
},
foodImage: {
width: '100%',
height: '100%',
borderRadius: 8,
},
foodImagePlaceholder: {
backgroundColor: '#F8F9FA',
justifyContent: 'center',
alignItems: 'center',
},
foodInfoContainer: {
flex: 1,
justifyContent: 'center',
gap: 4,
},
foodName: {
fontSize: 16,
fontWeight: '600',
color: '#333333',
lineHeight: 20,
},
mealTime: {
fontSize: 12,
fontWeight: '400',
color: '#999999',
lineHeight: 16,
},
nutritionContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 2,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
nutritionIcon: {
fontSize: 14,
},
nutritionValue: {
fontSize: 13,
fontWeight: '500',
color: '#666666',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'center',
gap: 8,
minHeight: 60,
},
caloriesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
caloriesText: {
fontSize: 14,
color: '#333333',
fontWeight: '600',
},
mealTypeBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.05)',
},
mealTypeText: {
fontSize: 12,
fontWeight: '600',
},
moreButton: {
padding: 2,
},
notesSection: {
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
},
notesText: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
fontStyle: 'italic',
},
popoverContainer: {
borderRadius: 12,
backgroundColor: '#FFFFFF',
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
// Android 阴影效果
elevation: 8,
// 添加边框
borderWidth: 0.5,
borderColor: 'rgba(0, 0, 0, 0.08)',
},
popoverBackground: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
popoverContent: {
minWidth: 140,
paddingVertical: 8,
},
popoverItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
popoverText: {
fontSize: 16,
fontWeight: '500',
},
deleteButton: {
backgroundColor: '#EF4444',
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 12,
marginLeft: 8,
},
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
width: 70,
height: '100%',
borderRadius: 24,
marginLeft: 12,
},
});

View File

@@ -2,6 +2,7 @@
import CustomCheckBox from '@/components/ui/CheckBox';
import { USER_AGREEMENT_URL } from '@/constants/Agree';
import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import {
MEMBERSHIP_PLAN_META,
extractMembershipProductsFromOfferings,
@@ -65,51 +66,6 @@ interface BenefitItem {
regular: PermissionConfig;
}
// 权益对比配置
const BENEFIT_COMPARISON: BenefitItem[] = [
{
title: 'AI拍照记录热量',
description: '通过拍照识别食物并自动记录热量',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日3次'
}
},
{
title: 'AI拍照识别包装',
description: '识别食品包装上的营养成分信息',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日5次'
}
},
{
title: '每日健康提醒',
description: '根据个人目标提供个性化健康提醒',
vip: {
type: 'unlimited',
text: '完全支持',
vipText: '智能提醒'
},
regular: {
type: 'unlimited',
text: '基础提醒',
vipText: '基础提醒'
}
},
];
const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = {
lifetime: {
@@ -151,6 +107,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
};
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
const [loading, setLoading] = useState(false);
@@ -165,6 +122,80 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 保存监听器引用,用于移除监听器
const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null);
// 权益对比配置 - Move inside component to use t function
const benefitComparison: BenefitItem[] = [
{
title: t('membershipModal.benefits.items.aiCalories.title'),
description: t('membershipModal.benefits.items.aiCalories.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 3 })
}
},
{
title: t('membershipModal.benefits.items.aiNutrition.title'),
description: t('membershipModal.benefits.items.aiNutrition.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 5 })
}
},
{
title: t('membershipModal.benefits.items.healthReminder.title'),
description: t('membershipModal.benefits.items.healthReminder.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.fullSupport'),
vipText: t('membershipModal.benefits.permissions.smartReminder')
},
regular: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.basicSupport'),
vipText: t('membershipModal.benefits.permissions.basicSupport')
}
},
{
title: t('membershipModal.benefits.items.aiMedication.title'),
description: t('membershipModal.benefits.items.aiMedication.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.fullAnalysis'),
vipText: t('membershipModal.benefits.permissions.fullAnalysis')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
{
title: t('membershipModal.benefits.items.customChallenge.title'),
description: t('membershipModal.benefits.items.customChallenge.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.createUnlimited'),
vipText: t('membershipModal.benefits.permissions.createUnlimited')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
];
// 根据选中的产品生成tips内容
const getTipsContent = (product: PurchasesStoreProduct | null): string => {
if (!product) return '';
@@ -176,11 +207,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
switch (plan.type) {
case 'lifetime':
return '终身陪伴,见证您的每一次健康蜕变';
return t('membershipModal.plans.lifetime.subtitle');
case 'quarterly':
return '3个月科学计划让健康成为生活习惯';
return t('membershipModal.plans.quarterly.subtitle');
case 'weekly':
return '7天体验期感受专业健康指导的力量';
return t('membershipModal.plans.weekly.subtitle');
default:
return '';
}
@@ -326,7 +357,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 显示成功提示
GlobalToast.show({
message: '会员开通成功',
message: t('membershipModal.success.purchase'),
});
}, 1000);
}
@@ -492,11 +523,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否已同意协议
if (!agreementAccepted) {
Alert.alert(
'请阅读并同意相关协议',
'购买前需要同意用户协议、会员协议和自动续费协议',
t('membershipModal.agreements.alert.title'),
t('membershipModal.agreements.alert.message'),
[
{
text: '确定',
text: t('membershipModal.agreements.alert.confirm'),
style: 'default',
}
]
@@ -517,11 +548,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否选择了产品
if (!selectedProduct) {
Alert.alert(
'请选择会员套餐',
t('membershipModal.errors.selectPlan'),
'',
[
{
text: '确定',
text: t('membershipModal.agreements.alert.confirm'),
style: 'default',
}
]
@@ -579,32 +610,32 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
// 用户取消购买
GlobalToast.show({
message: '购买已取消',
message: t('membershipModal.errors.purchaseCancelled'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
// 商品已拥有
GlobalToast.show({
message: '您已拥有此商品',
message: t('membershipModal.errors.alreadyPurchased'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
// 网络错误
GlobalToast.show({
message: '网络连接失败',
message: t('membershipModal.errors.networkError'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
// 支付待处理
GlobalToast.show({
message: '支付正在处理中',
message: t('membershipModal.errors.paymentPending'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
// 凭据无效
GlobalToast.show({
message: '账户验证失败',
message: t('membershipModal.errors.invalidCredentials'),
});
} else {
// 其他错误
GlobalToast.show({
message: '购买失败',
message: t('membershipModal.errors.purchaseFailed'),
});
}
} finally {
@@ -701,7 +732,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onClose?.();
GlobalToast.show({
message: '恢复购买成功',
message: t('membershipModal.errors.restoreSuccess'),
});
} catch (apiError: any) {
@@ -720,7 +751,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录)
// 但不关闭弹窗,让用户知道可能需要重试
GlobalToast.show({
message: '恢复购买部分失败',
message: t('membershipModal.errors.restorePartialFailed'),
});
}
@@ -734,7 +765,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeSubscriptionsCount: activeSubscriptionIds.length
});
GlobalToast.show({
message: '没有找到购买记录',
message: t('membershipModal.errors.noPurchasesFound'),
});
}
} catch (error: any) {
@@ -754,19 +785,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 处理特定的恢复购买错误
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
GlobalToast.show({
message: '恢复购买已取消',
message: t('membershipModal.errors.restoreCancelled'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
GlobalToast.show({
message: '网络错误',
message: t('membershipModal.errors.networkError'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
GlobalToast.show({
message: '账户验证失败',
message: t('membershipModal.errors.invalidCredentials'),
});
} else {
GlobalToast.show({
message: '恢复购买失败',
message: t('membershipModal.errors.restoreFailed'),
});
}
} finally {
@@ -780,7 +811,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
const renderPlanCard = (product: PurchasesStoreProduct) => {
const planMeta = getPlanMetaById(product.identifier);
const isSelected = selectedProduct === product;
const displayTitle = resolvePlanDisplayName(product, planMeta);
// 优先使用翻译的标题,如果找不到 meta 则回退到产品标题
let displayTitle = product.title;
let displaySubtitle = planMeta?.subtitle ?? '';
if (planMeta) {
displayTitle = t(`membershipModal.plans.${planMeta.type}.title`);
displaySubtitle = t(`membershipModal.plans.${planMeta.type}.subtitle`);
} else {
// 如果没有 meta尝试使用 resolvePlanDisplayName (虽然这里主要依赖 meta)
displayTitle = resolvePlanDisplayName(product, planMeta);
}
const priceLabel = product.priceString || '';
const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
@@ -797,7 +840,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeOpacity={loading ? 1 : 0.8}
accessible={true}
accessibilityLabel={`${displayTitle} ${priceLabel}`}
accessibilityHint={loading ? '购买进行中,无法切换套餐' : `选择${displayTitle}套餐`}
accessibilityHint={loading ? t('membershipModal.loading.purchase') : t('membershipModal.actions.selectPlan', { plan: displayTitle })}
accessibilityState={{ disabled: loading, selected: isSelected }}
>
<LinearGradient
@@ -809,7 +852,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.planCardTopSection}>
{planMeta?.tag && (
<View style={styles.planTag}>
<Text style={styles.planTagText}>{planMeta.tag}</Text>
<Text style={styles.planTagText}>{t('membershipModal.plans.tag')}</Text>
</View>
)}
<Text style={styles.planCardTitle}>{displayTitle}</Text>
@@ -825,7 +868,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View>
<View style={styles.planCardBottomSection}>
<Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text>
<Text style={styles.planCardDescription}>{displaySubtitle}</Text>
</View>
</LinearGradient>
</TouchableOpacity>
@@ -854,8 +897,8 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={onClose}
activeOpacity={0.7}
accessible={true}
accessibilityLabel="返回"
accessibilityHint="关闭会员购买弹窗"
accessibilityLabel={t('membershipModal.actions.back')}
accessibilityHint={t('membershipModal.actions.close')}
style={styles.floatingBackButtonContainer}
>
{isLiquidGlassAvailable() ? (
@@ -887,14 +930,14 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}>
<Ionicons name="star" size={16} color="#7B2CBF" />
</View>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('membershipModal.sectionTitle.plans')}</Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
<Text style={styles.sectionSubtitle}>{t('membershipModal.sectionTitle.plansSubtitle')}</Text>
{products.length === 0 ? (
<View style={styles.configurationNotice}>
<Text style={styles.configurationText}>
RevenueCat iOS Offering
{t('membershipModal.errors.noProducts')}
</Text>
</View>
) : (
@@ -917,17 +960,17 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}>
<Ionicons name="checkbox" size={16} color="#FF9F0A" />
</View>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('membershipModal.benefits.title')}</Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
<Text style={styles.sectionSubtitle}>{t('membershipModal.benefits.subtitle')}</Text>
<View style={styles.comparisonTable}>
<View style={[styles.tableRow, styles.tableHeader]}>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}></Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>VIP</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}></Text>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}>{t('membershipModal.benefits.table.benefit')}</Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>{t('membershipModal.benefits.table.vip')}</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}>{t('membershipModal.benefits.table.regular')}</Text>
</View>
{BENEFIT_COMPARISON.map((row, index) => (
{benefitComparison.map((row, index) => (
<View
key={row.title}
style={[
@@ -963,39 +1006,46 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View>
<View style={styles.bottomSection}>
<View style={styles.agreementRow}>
<CustomCheckBox
checked={agreementAccepted}
onCheckedChange={setAgreementAccepted}
size={16}
checkedColor="#E91E63"
uncheckedColor="#999"
/>
<Text style={styles.agreementPrefix}></Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(USER_AGREEMENT_URL);
captureMessage('click user agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<Text style={styles.agreementSeparator}>|</Text>
<TouchableOpacity
onPress={() => {
captureMessage('click membership agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<Text style={styles.agreementSeparator}>|</Text>
<TouchableOpacity
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<View style={styles.agreementContainer}>
<View style={styles.checkboxWrapper}>
<CustomCheckBox
checked={agreementAccepted}
onCheckedChange={setAgreementAccepted}
size={16}
checkedColor="#E91E63"
uncheckedColor="#999"
/>
</View>
<Text style={styles.agreementText}>
{t('membershipModal.agreements.prefix')}
<Text
style={styles.agreementLink}
onPress={() => {
Linking.openURL(USER_AGREEMENT_URL);
captureMessage('click user agreement');
}}
>
{t('membershipModal.agreements.userAgreement')}
</Text>
<Text style={styles.agreementSeparator}> | </Text>
<Text
style={styles.agreementLink}
onPress={() => {
captureMessage('click membership agreement');
}}
>
{t('membershipModal.agreements.membershipAgreement')}
</Text>
<Text style={styles.agreementSeparator}> | </Text>
<Text
style={styles.agreementLink}
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
{t('membershipModal.agreements.autoRenewalAgreement')}
</Text>
</Text>
</View>
<TouchableOpacity
@@ -1006,10 +1056,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{restoring ? (
<View style={styles.restoreButtonContent}>
<ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} />
<Text style={styles.restoreButtonText}>...</Text>
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restoring')}</Text>
</View>
) : (
<Text style={styles.restoreButtonText}></Text>
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restore')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1031,15 +1081,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
? t('membershipModal.loading.purchase')
: products.length === 0
? '正在加载会员套餐,请稍候'
? t('membershipModal.loading.products')
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
? t('membershipModal.errors.selectPlan')
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
@@ -1047,10 +1097,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View>
) : (
<Text style={styles.purchaseButtonText}></Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)}
</TouchableOpacity>
</GlassView>
@@ -1066,15 +1116,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
? t('membershipModal.loading.purchase')
: products.length === 0
? '正在加载会员套餐,请稍候'
? t('membershipModal.loading.products')
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
? t('membershipModal.errors.selectPlan')
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
@@ -1082,10 +1132,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View>
) : (
<Text style={styles.purchaseButtonText}></Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1168,12 +1218,14 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
color: '#2B2B2E',
fontFamily: 'AliBold',
},
sectionSubtitle: {
fontSize: 13,
color: '#6B6B73',
marginTop: 6,
marginBottom: 16,
fontFamily: 'AliRegular',
},
configurationNotice: {
borderRadius: 16,
@@ -1185,6 +1237,7 @@ const styles = StyleSheet.create({
color: '#B86A04',
textAlign: 'center',
lineHeight: 20,
fontFamily: 'AliRegular',
},
plansContainer: {
flexDirection: 'row',
@@ -1217,35 +1270,40 @@ const styles = StyleSheet.create({
alignSelf: 'flex-start',
backgroundColor: '#2F2F36',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 4,
paddingHorizontal: 6,
paddingVertical: 6,
marginBottom: 12,
},
planTagText: {
color: '#FFFFFF',
fontSize: 11,
fontSize: 10,
fontWeight: '600',
fontFamily: 'AliBold',
},
planCardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#241F1F',
},
planCardPrice: {
fontSize: 16,
fontWeight: '700',
color: '#241F1F',
fontFamily: 'AliBold',
},
planCardPrice: {
fontSize: 14,
fontWeight: '700',
marginTop: 12,
fontFamily: 'AliBold',
},
planCardOriginalPrice: {
fontSize: 13,
color: '#8E8EA1',
textDecorationLine: 'line-through',
marginTop: 2,
fontFamily: 'AliRegular',
},
planCardDescription: {
fontSize: 12,
color: '#6C6C77',
lineHeight: 17,
fontFamily: 'AliRegular',
},
planCardTopSection: {
flex: 1,
@@ -1275,6 +1333,7 @@ const styles = StyleSheet.create({
color: '#9B6200',
marginLeft: 6,
lineHeight: 16,
fontFamily: 'AliRegular',
},
comparisonTable: {
borderRadius: 16,
@@ -1298,10 +1357,12 @@ const styles = StyleSheet.create({
color: '#575764',
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'AliBold',
},
tableCellText: {
fontSize: 13,
color: '#3E3E44',
fontFamily: 'AliRegular',
},
tableTitleCell: {
flex: 1.5,
@@ -1361,6 +1422,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
fontFamily: 'AliBold',
},
loadingContainer: {
flexDirection: 'row',
@@ -1369,29 +1431,34 @@ const styles = StyleSheet.create({
loadingSpinner: {
marginRight: 8,
},
agreementRow: {
agreementContainer: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'center',
flexWrap: 'nowrap',
marginBottom: 16,
marginBottom: 20,
paddingHorizontal: 4,
},
agreementPrefix: {
fontSize: 10,
checkboxWrapper: {
marginTop: 2, // Align with text line-height
marginRight: 8,
},
agreementText: {
flex: 1,
fontSize: 11,
lineHeight: 16,
color: '#666672',
marginRight: 4,
fontFamily: 'AliRegular',
},
agreementLink: {
fontSize: 10,
fontSize: 11,
color: '#E91E63',
textDecorationLine: 'underline',
fontWeight: '500',
marginHorizontal: 2,
textDecorationLine: 'underline',
fontFamily: 'AliBold',
},
agreementSeparator: {
fontSize: 10,
fontSize: 11,
color: '#A0A0B0',
marginHorizontal: 2,
},
restoreButton: {
alignSelf: 'center',
@@ -1401,6 +1468,7 @@ const styles = StyleSheet.create({
color: '#6F6F7A',
fontSize: 12,
fontWeight: '500',
fontFamily: 'AliBold',
},
disabledRestoreButton: {
opacity: 0.5,
@@ -1422,6 +1490,7 @@ const styles = StyleSheet.create({
color: '#8E8E93',
marginTop: 2,
lineHeight: 14,
fontFamily: 'AliRegular',
},
permissionContainer: {
alignItems: 'center',
@@ -1435,5 +1504,6 @@ const styles = StyleSheet.create({
marginTop: 4,
textAlign: 'center',
lineHeight: 12,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,6 +1,11 @@
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -15,11 +20,11 @@ import {
Text,
TextInput,
TouchableOpacity,
View
View,
} from 'react-native';
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { Colors } from '@/constants/Colors';
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
export interface CreateCustomFoodModalProps {
visible: boolean;
@@ -43,9 +48,10 @@ export function CreateCustomFoodModal({
onClose,
onSave
}: CreateCustomFoodModalProps) {
const { t } = useI18n();
const [foodName, setFoodName] = useState('');
const [defaultAmount, setDefaultAmount] = useState('100');
const [caloriesUnit, setCaloriesUnit] = useState('千卡');
const [caloriesUnit, setCaloriesUnit] = useState(t('createCustomFood.units.kcal'));
const [calories, setCalories] = useState('100');
const [imageUrl, setImageUrl] = useState<string>('');
const [protein, setProtein] = useState('0');
@@ -93,7 +99,7 @@ export function CreateCustomFoodModal({
if (visible) {
setFoodName('');
setDefaultAmount('100');
setCaloriesUnit('千卡');
setCaloriesUnit(t('createCustomFood.units.kcal'));
setCalories('100');
setImageUrl('');
setProtein('0');
@@ -102,16 +108,16 @@ export function CreateCustomFoodModal({
}
}, [visible]);
// 选择热量单位
// 选择图片
const handleSelectImage = async () => {
try {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择照片');
Alert.alert(
t('createCustomFood.alerts.permissionDenied.title'),
t('createCustomFood.alerts.permissionDenied.message')
);
return;
}
@@ -137,11 +143,17 @@ export function CreateCustomFoodModal({
setImageUrl(url);
} catch (e) {
console.warn('上传照片失败', e);
Alert.alert('上传失败', '照片上传失败,请重试');
Alert.alert(
t('createCustomFood.alerts.uploadFailed.title'),
t('createCustomFood.alerts.uploadFailed.message')
);
}
}
} catch (e) {
Alert.alert('发生错误', '选择照片失败,请重试');
Alert.alert(
t('createCustomFood.alerts.error.title'),
t('createCustomFood.alerts.error.message')
);
}
};
@@ -151,12 +163,18 @@ export function CreateCustomFoodModal({
// 保存自定义食物
const handleSave = () => {
if (!foodName.trim()) {
Alert.alert('提示', '请输入食物名称');
Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.nameRequired')
);
return;
}
if (!calories.trim() || parseFloat(calories) <= 0) {
Alert.alert('提示', '请输入有效的热量值');
Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.caloriesRequired')
);
return;
}
@@ -175,75 +193,99 @@ export function CreateCustomFoodModal({
onClose();
};
const isSaveDisabled = !foodName.trim() || !calories.trim();
return (
<Modal
visible={visible}
animationType="fade"
animationType="slide"
transparent={true}
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<View style={styles.overlay}>
<BlurView intensity={20} tint="dark" style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<View style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight,
maxHeight: screenHeight - keyboardHeight,
}
]}>
<TouchableOpacity activeOpacity={1} onPress={onClose} style={styles.dismissArea} />
<View
style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight - 60,
maxHeight: screenHeight - keyboardHeight - 60,
},
]}
>
<View style={styles.modalHeaderBar}>
<View style={styles.dragIndicator} />
</View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingBottom: keyboardHeight > 0 ? 20 : 0
paddingBottom: keyboardHeight > 0 ? 20 : 40,
}}
>
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#333" />
<TouchableOpacity onPress={onClose} style={styles.backButton} activeOpacity={0.7}>
<Ionicons name="close-circle" size={32} color="#E2E8F0" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerTitle}>{t('createCustomFood.title')}</Text>
<TouchableOpacity
style={[
styles.saveButton,
(!foodName.trim() || !calories.trim()) && styles.saveButtonDisabled
]}
style={[styles.saveButton, isSaveDisabled && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!foodName.trim() || !calories.trim()}
disabled={isSaveDisabled}
activeOpacity={0.8}
>
<Text style={[
styles.saveButtonText,
(!foodName.trim() || !calories.trim()) && styles.saveButtonTextDisabled
]}></Text>
<LinearGradient
colors={isSaveDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.saveButtonGradient}
>
<Text style={styles.saveButtonText}>{t('createCustomFood.save')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
{/* 效果预览区域 */}
<View style={styles.previewSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.previewCard}>
<LinearGradient
colors={['#ffffff', '#F8F9FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.previewHeader}>
<Text style={styles.sectionTitle}>{t('createCustomFood.preview.title')}</Text>
</View>
<View style={styles.previewContent}>
{imageUrl ? (
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.previewImagePlaceholder}>
<Ionicons name="restaurant" size={20} color="#999" />
</View>
)}
<View style={styles.imageWrapper}>
{imageUrl ? (
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.previewImagePlaceholder}>
<Ionicons name="restaurant" size={24} color="#94A3B8" />
</View>
)}
</View>
<View style={styles.previewInfo}>
<Text style={styles.previewName}>
{foodName || '食物名称'}
</Text>
<Text style={styles.previewCalories}>
{actualCalories}{caloriesUnit}/{defaultAmount}g
<Text style={styles.previewName} numberOfLines={1}>
{foodName || t('createCustomFood.preview.defaultName')}
</Text>
<View style={styles.previewBadge}>
<Ionicons name="flame" size={14} color="#F59E0B" />
<Text style={styles.previewCalories}>
{actualCalories} {caloriesUnit} / {defaultAmount}
{t('createCustomFood.units.g')}
</Text>
</View>
</View>
</View>
</View>
@@ -252,21 +294,21 @@ export function CreateCustomFoodModal({
{/* 基本信息 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('createCustomFood.basicInfo.title')}</Text>
<Text style={styles.requiredIndicator}>*</Text>
</View>
<View style={styles.sectionCard}>
{/* 食物名称和单位 */}
{/* 食物名称 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.name')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={foodName}
onChangeText={setFoodName}
placeholder="例如,汉堡"
placeholderTextColor="#A0A0A0"
placeholder={t('createCustomFood.basicInfo.namePlaceholder')}
placeholderTextColor="#94A3B8"
/>
</View>
</View>
@@ -274,36 +316,36 @@ export function CreateCustomFoodModal({
{/* 默认数量 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.defaultAmount')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={defaultAmount}
onChangeText={setDefaultAmount}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}>g</Text>
<Text style={styles.unitText}>{t('createCustomFood.units.g')}</Text>
</View>
</View>
</View>
{/* 食物热量 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.calories')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
placeholder="0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.kcal')}</Text>
</View>
</View>
</View>
@@ -312,23 +354,26 @@ export function CreateCustomFoodModal({
{/* 可选信息 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('createCustomFood.optionalInfo.title')}</Text>
<View style={styles.sectionCard}>
{/* 照片 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.photo')}</Text>
<View style={styles.inputRowContent}>
<TouchableOpacity
style={styles.modernImageSelector}
<TouchableOpacity
style={styles.modernImageSelector}
onPress={handleSelectImage}
disabled={uploading}
activeOpacity={0.8}
>
{imageUrl ? (
<Image style={styles.selectedImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.modernImagePlaceholder}>
<Ionicons name="camera" size={28} color="#A0A0A0" />
<Text style={styles.imagePlaceholderText}></Text>
<Ionicons name="camera-outline" size={28} color="#94A3B8" />
<Text style={styles.imagePlaceholderText}>
{t('createCustomFood.optionalInfo.addPhoto')}
</Text>
</View>
)}
{uploading && (
@@ -342,54 +387,56 @@ export function CreateCustomFoodModal({
{/* 蛋白质 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.protein')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={protein}
onChangeText={setProtein}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
{/* 脂肪 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.fat')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={fat}
onChangeText={setFat}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
{/* 碳水化合物 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>
{t('createCustomFood.optionalInfo.carbohydrate')}
</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={carbohydrate}
onChangeText={setCarbohydrate}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
@@ -398,7 +445,7 @@ export function CreateCustomFoodModal({
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</BlurView>
</Modal>
);
}
@@ -408,331 +455,272 @@ const { height: screenHeight } = Dimensions.get('window');
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
keyboardAvoidingView: {
flex: 1,
justifyContent: 'flex-end',
},
dismissArea: {
flex: 1,
},
modalContainer: {
flex: 1,
backgroundColor: '#FFFFFF',
marginTop: 50,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
backgroundColor: '#F1F5F9', // Slate 100
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
height: '90%',
maxHeight: '90%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 20,
overflow: 'hidden',
},
modalHeaderBar: {
width: '100%',
height: 24,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F1F5F9',
},
dragIndicator: {
width: 40,
height: 4,
backgroundColor: '#CBD5E1',
borderRadius: 2,
},
scrollView: {
flex: 1,
backgroundColor: '#F1F5F9',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 16,
paddingHorizontal: 20,
paddingBottom: 20,
},
backButton: {
padding: 4,
marginLeft: -8,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
flex: 1,
fontSize: 20,
fontWeight: '800',
color: '#1E293B',
textAlign: 'center',
marginHorizontal: 20,
fontFamily: 'AliBold',
},
saveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderRadius: 20,
overflow: 'hidden',
},
saveButtonDisabled: {
opacity: 0.5,
opacity: 0.6,
},
saveButtonGradient: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
saveButtonText: {
fontSize: 16,
color: Colors.light.primary,
fontWeight: '500',
},
saveButtonTextDisabled: {
color: Colors.light.textMuted,
fontSize: 14,
color: '#FFFFFF',
fontWeight: '700',
fontFamily: 'AliBold',
},
previewSection: {
paddingHorizontal: 16,
paddingBottom: 16,
paddingHorizontal: 20,
marginBottom: 24,
},
previewCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
borderRadius: 24,
padding: 20,
overflow: 'hidden',
backgroundColor: '#FFFFFF',
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
},
previewHeader: {
marginBottom: 16,
},
previewContent: {
flexDirection: 'row',
alignItems: 'center',
},
imageWrapper: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
previewImage: {
width: 32,
height: 32,
borderRadius: 4,
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#F8FAFC',
},
previewImagePlaceholder: {
width: 32,
height: 32,
borderRadius: 4,
backgroundColor: '#E5E5E5',
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
},
previewInfo: {
flex: 1,
marginLeft: 12,
marginLeft: 16,
justifyContent: 'center',
},
previewName: {
fontSize: 16,
fontWeight: '500',
color: '#333',
marginBottom: 2,
fontSize: 18,
fontWeight: '700',
color: '#1E293B',
marginBottom: 6,
fontFamily: 'AliBold',
},
previewBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFBEB',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
gap: 4,
},
previewCalories: {
fontSize: 14,
color: '#666',
fontSize: 13,
color: '#D97706',
fontWeight: '600',
fontFamily: 'AliRegular',
},
section: {
paddingHorizontal: 16,
paddingVertical: 12,
paddingHorizontal: 20,
marginBottom: 24,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
marginBottom: 12,
paddingHorizontal: 4,
},
sectionTitle: {
fontSize: 14,
color: '#333',
marginLeft: 8
fontWeight: '700',
color: '#64748B',
fontFamily: 'AliBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
requiredIndicator: {
fontSize: 16,
color: '#FF4444',
fontSize: 14,
color: '#EF4444',
marginLeft: 4,
},
inputGroup: {
marginBottom: 20,
},
inputRowGroup: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
inputRowItem: {
flex: 1,
},
inputLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
modernTextInput: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
marginLeft: 20,
color: '#333',
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
numberInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
color: '#333',
textAlign: 'right',
},
unitText: {
fontSize: 14,
color: '#666',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
},
modernSelectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
selectButtonText: {
fontSize: 14,
color: 'gray',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 16,
overflow: 'hidden',
},
selectedImage: {
width: 80,
height: 80,
borderRadius: 16,
},
modernImagePlaceholder: {
width: 80,
height: 80,
borderRadius: 16,
backgroundColor: '#F8F8F8',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 12,
color: '#A0A0A0',
marginTop: 4,
fontWeight: '500',
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
nutritionItem: {
flex: 1,
},
// 保留旧样式以防兼容性问题
textInput: {
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
},
numberInput: {
flex: 1,
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
textAlign: 'right',
},
inputWithUnit: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
inputUnit: {
fontSize: 16,
color: '#666',
minWidth: 30,
},
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
},
imageSelector: {
alignSelf: 'flex-end',
borderRadius: 12,
overflow: 'hidden',
},
imagePlaceholder: {
width: 60,
height: 60,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E5E5E5',
},
disclaimer: {
paddingHorizontal: 16,
paddingVertical: 20,
paddingBottom: 40,
},
disclaimerText: {
fontSize: 12,
color: '#999',
lineHeight: 18,
fontWeight: '700',
},
sectionCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 10,
elevation: 2,
},
// 新增行布局样式
inputRowContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
marginBottom: 16,
},
inputRowLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
width: 80,
fontSize: 15,
color: '#475569',
fontWeight: '600',
width: 90,
marginRight: 12,
fontFamily: 'AliRegular',
},
inputRowContent: {
flex: 1,
},
imageLoadingOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
modernInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 16,
backgroundColor: '#F8FAFC',
borderWidth: 1,
borderColor: '#E2E8F0',
overflow: 'hidden',
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: '#1E293B',
textAlign: 'right',
fontFamily: 'AliRegular',
},
unitText: {
fontSize: 14,
color: '#94A3B8',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
selectedImage: {
width: 72,
height: 72,
borderRadius: 20,
},
modernImagePlaceholder: {
width: 72,
height: 72,
borderRadius: 20,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 16,
borderWidth: 1,
borderColor: '#E2E8F0',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 11,
color: '#94A3B8',
marginTop: 4,
fontWeight: '600',
textAlign: 'center',
},
imageLoadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 20,
},
});

View File

@@ -1,5 +1,6 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
@@ -26,6 +27,7 @@ export const SleepStageTimeline = ({
hideHeader = false,
style
}: SleepStageTimelineProps) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -139,7 +141,9 @@ export const SleepStageTimeline = ({
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
@@ -149,7 +153,7 @@ export const SleepStageTimeline = ({
)}
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.noData')}
</Text>
</View>
</View>
@@ -161,7 +165,9 @@ export const SleepStageTimeline = ({
{/* 标题栏 */}
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
@@ -173,13 +179,17 @@ export const SleepStageTimeline = ({
{/* 睡眠时间范围 */}
<View style={styles.timeRange}>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(bedtime)}
</Text>
</View>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepDuration')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(wakeupTime)}
</Text>
@@ -233,21 +243,29 @@ export const SleepStageTimeline = ({
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.deep')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.core')}
</Text>
</View>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.rem')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.awake')}
</Text>
</View>
</View>
</View>

View File

@@ -5,7 +5,7 @@ import { WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React, { useRef } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
interface WeightRecordCardProps {
@@ -58,124 +58,174 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}>{t('weightRecords.card.deleteButton')}</Text>
</TouchableOpacity>
);
};
return (
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View
style={[styles.recordCard]}
<View style={styles.cardContainer}>
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
{dayjs(record.createdAt).format('MM[月]DD[日] HH:mm')}
</Text>
<TouchableOpacity
style={styles.recordEditButton}
onPress={() => onPress?.(record)}
>
<Ionicons name="create-outline" size={16} color="#FF9500" />
</TouchableOpacity>
</View>
<View style={styles.recordContent}>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.card.weightLabel')}</Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}{t('weightRecords.modal.unit')}</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'}
<View style={styles.recordCard}>
<View style={styles.leftContent}>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={styles.icon}
/>
<Text style={[
styles.weightChangeText,
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<View style={styles.textContent}>
<View style={styles.dateTimeContainer}>
<Text style={styles.dateText}>
{dayjs(record.createdAt).format('MM-DD')}
</Text>
<Text style={styles.timeText}>
{dayjs(record.createdAt).format('HH:mm')}
</Text>
</View>
<View style={styles.weightInfo}>
<Text style={styles.weightValue}>{record.weight}<Text style={styles.unitText}>{t('weightRecords.modal.unit')}</Text></Text>
</View>
</View>
</View>
<View style={styles.rightContent}>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.changeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={10}
color={weightChange < 0 ? '#22C55E' : '#FF9500'}
/>
<Text style={[
styles.changeText,
{ color: weightChange < 0 ? '#22C55E' : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<TouchableOpacity
style={styles.editButton}
onPress={() => onPress?.(record)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="ellipsis-vertical" size={16} color="#9ba3c7" />
</TouchableOpacity>
</View>
</View>
</View>
</Swipeable>
</Swipeable>
</View>
);
};
const styles = StyleSheet.create({
cardContainer: {
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
},
recordCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
padding: 20,
marginBottom: 12,
},
recordHeader: {
borderRadius: 24,
padding: 16,
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: {
leftContent: {
flexDirection: 'row',
alignItems: 'center',
},
recordWeightLabel: {
fontSize: 16,
color: '#687076',
fontWeight: '500',
},
recordWeightValue: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginLeft: 4,
flex: 1,
},
weightChangeTag: {
iconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#F0F2F5',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
icon: {
width: 24,
height: 24,
tintColor: '#4F5BD5',
},
textContent: {
justifyContent: 'center',
},
dateTimeContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
marginLeft: 12,
marginBottom: 4,
},
weightChangeText: {
fontSize: 12,
dateText: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
marginRight: 8,
fontFamily: 'AliBold',
},
timeText: {
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
weightInfo: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
unitText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
changeText: {
fontSize: 11,
fontWeight: '700',
marginLeft: 2,
fontFamily: 'AliBold',
},
editButton: {
padding: 4,
},
deleteButton: {
backgroundColor: '#EF4444',
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginBottom: 12,
marginLeft: 8,
},
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
width: 70,
borderRadius: 24,
marginLeft: 12,
height: '100%',
},
});