feat(i18n): 添加国际化支持和中英文切换功能

- 实现完整的中英文国际化系统,支持动态语言切换
- 新增健康数据权限说明页面,提供HealthKit数据使用说明
- 为服药记录添加庆祝动画效果,提升用户体验
- 优化药品添加页面的阴影效果和视觉层次
- 更新个人页面以支持多语言显示和语言选择模态框
This commit is contained in:
richarjiang
2025-11-13 09:05:23 +08:00
parent 7c8538f5c6
commit 416d144387
14 changed files with 1009 additions and 75 deletions

View File

@@ -5,7 +5,6 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
@@ -16,27 +15,82 @@ import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Linking, Modal, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLanguage, changeAppLanguage, getNormalizedLanguage } from '@/i18n';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
type MenuItem = {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
onPress?: () => void;
type?: 'switch';
switchValue?: boolean;
onSwitchChange?: (value: boolean) => void;
isDanger?: boolean;
rightText?: string;
};
type MenuSectionConfig = {
title: string;
items: MenuItem[];
};
type LanguageOption = {
code: AppLanguage;
label: string;
description: string;
};
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets();
const { t, i18n } = useTranslation();
const router = useRouter();
const isLgAvaliable = isLiquidGlassAvailable()
const isLgAvaliable = isLiquidGlassAvailable();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
// 推送通知相关
const {
requestPermission,
sendNotification,
} = useNotifications();
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
code: 'zh' as AppLanguage,
label: t('personal.language.options.zh.label'),
description: t('personal.language.options.zh.description'),
},
{
code: 'en' as AppLanguage,
label: t('personal.language.options.en.label'),
description: t('personal.language.options.en.description'),
},
]), [t]);
// 移除 notificationEnabled 状态,因为现在在通知设置页面中管理
const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label ?? '';
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false);
if (language === activeLanguageCode || isSwitchingLanguage) {
return;
}
try {
setIsSwitchingLanguage(true);
await changeAppLanguage(language);
} catch (error) {
log.warn('语言切换失败', error);
} finally {
setIsSwitchingLanguage(false);
}
}, [activeLanguageCode, isSwitchingLanguage]);
// 推送通知设置仅在独立页面管理
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
@@ -111,11 +165,13 @@ export default function PersonalScreen() {
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}`;
return `${age}${t('personal.stats.ageSuffix')}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
const aiUsageValue = userProfile.isVip ? t('personal.aiUsageUnlimited') : userProfile.freeUsageCount ?? 0;
// 初始化时只加载开发者模式状态
useEffect(() => {
@@ -172,13 +228,15 @@ export default function PersonalScreen() {
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>: {userProfile.memberNumber}</Text>
<Text style={styles.userMemberNumber}>
{t('personal.memberNumber', { number: userProfile.memberNumber })}
</Text>
)}
{userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}>
<Ionicons name="sparkles-outline" size={12} color="#9370DB" />
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
<Text style={styles.aiUsageText}>
AI次数: {userProfile.isVip ? '无限' : userProfile.freeUsageCount}
{t('personal.aiUsage', { value: aiUsageValue })}
</Text>
</View>
)}
@@ -186,12 +244,12 @@ export default function PersonalScreen() {
{isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}>
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
<Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
<Text style={styles.editButtonText}>{profileActionLabel}</Text>
</TouchableOpacity>
)}
@@ -225,18 +283,18 @@ export default function PersonalScreen() {
.map((key) => fallbackProfile[key])
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
const rawExpireDate = userProfile.membershipExpiration
const rawExpireDate = userProfile.membershipExpiration ?? fallbackExpire;
let formattedExpire = '长期有效';
let formattedExpire = t('personal.membership.validForever');
if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
const parsed = dayjs(rawExpireDate);
formattedExpire = parsed.isValid() ? parsed.format('YYYY年MM月DD日') : rawExpireDate;
formattedExpire = parsed.isValid() ? parsed.format(t('personal.membership.dateFormat')) : rawExpireDate;
}
const planName =
activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() ||
'VIP 会员';
t('personal.membership.planFallback');
return (
<View style={styles.sectionContainer}>
@@ -251,21 +309,21 @@ export default function PersonalScreen() {
<View style={styles.vipCardHeader}>
<View style={styles.vipCardHeaderLeft}>
<View style={styles.vipBadge}>
<Ionicons name="sparkles-outline" size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}></Text>
<Ionicons name="sparkles-outline" as any size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}>{t('personal.membership.badge')}</Text>
</View>
<Text style={styles.vipCardTitle}>{planName}</Text>
</View>
<View style={styles.vipCardIllustration}>
<Ionicons name="ribbon" size={32} color="rgba(255,255,255,0.88)" />
<Ionicons name="ribbon" as any size={32} color="rgba(255,255,255,0.88)" />
</View>
</View>
<View style={styles.vipCardFooter}>
<View style={styles.vipExpiryInfo}>
<Text style={styles.vipExpiryLabel}></Text>
<Text style={styles.vipExpiryLabel}>{t('personal.membership.expiryLabel')}</Text>
<View style={styles.vipExpiryRow}>
<Ionicons name="time-outline" size={16} color="rgba(255,255,255,0.85)" />
<Ionicons name="time-outline" as any size={16} color="rgba(255,255,255,0.85)" />
<Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
</View>
</View>
@@ -276,8 +334,8 @@ export default function PersonalScreen() {
void handleMembershipPress();
}}
>
<Ionicons name="swap-horizontal-outline" size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}></Text>
<Ionicons name="swap-horizontal-outline" as any size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}>{t('personal.membership.changeButton')}</Text>
</TouchableOpacity>
</View>
</LinearGradient>
@@ -294,15 +352,15 @@ export default function PersonalScreen() {
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
@@ -310,7 +368,7 @@ export default function PersonalScreen() {
);
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.cardContainer}>
@@ -324,7 +382,6 @@ export default function PersonalScreen() {
<View style={styles.menuItemLeft}>
<View style={[
styles.iconContainer,
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
]}>
<Ionicons
name={item.icon}
@@ -343,7 +400,12 @@ export default function PersonalScreen() {
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
<View style={styles.menuRight}>
{item.rightText ? (
<Text style={styles.menuRightText}>{item.rightText}</Text>
) : null}
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
</View>
)}
</TouchableOpacity>
))}
@@ -352,66 +414,87 @@ export default function PersonalScreen() {
);
// 菜单项配置
const menuSections = [
const menuSections: MenuSectionConfig[] = [
{
title: '通知',
title: t('personal.sections.healthData'),
items: [
{
icon: 'notifications-outline' as const,
title: '通知设置',
icon: 'medkit-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.healthDataPermissions'),
onPress: () => router.push(ROUTES.HEALTH_DATA_PERMISSIONS),
},
],
},
{
title: t('personal.sections.notifications'),
items: [
{
icon: 'notifications-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.notificationSettings'),
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
}
},
],
},
{
title: t('personal.language.title'),
items: [
{
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.language.menuTitle'),
onPress: () => setLanguageModalVisible(true),
rightText: activeLanguageLabel,
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: '开发者',
title: t('personal.sections.developer'),
items: [
{
icon: 'code-slash-outline' as const,
title: '开发者选项',
icon: 'code-slash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.developerOptions'),
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
},
{
icon: 'settings-outline' as const,
title: '推送通知设置',
onPress: () => pushIfAuthedElseLogin('/push-notification-settings'),
icon: 'settings-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.pushSettings'),
onPress: () => pushIfAuthedElseLogin(ROUTES.PUSH_NOTIFICATION_SETTINGS),
},
],
}] : []),
{
title: '其他',
title: t('personal.sections.other'),
items: [
{
icon: 'shield-checkmark-outline' as const,
title: '隐私政策',
icon: 'shield-checkmark-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.privacyPolicy'),
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
},
{
icon: 'chatbubble-ellipses-outline' as const,
title: '意见反馈',
icon: 'chatbubble-ellipses-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.feedback'),
onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'),
},
{
icon: 'document-text-outline' as const,
title: '用户协议',
icon: 'document-text-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.userAgreement'),
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
},
],
},
// 只有登录用户才显示账号与安全菜单
...(isLoggedIn ? [{
title: '账号与安全',
title: t('personal.sections.account'),
items: [
{
icon: 'log-out-outline' as const,
title: '退出登录',
icon: 'log-out-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.logout'),
onPress: confirmLogout,
isDanger: false,
},
{
icon: 'trash-outline' as const,
title: '注销帐号',
icon: 'trash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.deleteAccount'),
onPress: confirmDeleteAccount,
isDanger: true,
},
@@ -419,6 +502,56 @@ export default function PersonalScreen() {
}] : []),
];
const LanguageSelectorModal = () => (
<Modal
animationType="fade"
transparent
visible={languageModalVisible}
onRequestClose={() => setLanguageModalVisible(false)}
>
<View style={styles.languageModalOverlay}>
<TouchableWithoutFeedback onPress={() => setLanguageModalVisible(false)}>
<View style={styles.languageModalBackdrop} />
</TouchableWithoutFeedback>
<View style={styles.languageModalContent}>
<Text style={styles.languageModalTitle}>{t('personal.language.modalTitle')}</Text>
<Text style={styles.languageModalSubtitle}>{t('personal.language.modalSubtitle')}</Text>
{languageOptions.map((option) => {
const isSelected = option.code === activeLanguageCode;
return (
<TouchableOpacity
key={option.code}
activeOpacity={0.85}
style={[
styles.languageOption,
isSelected && styles.languageOptionSelected,
isSwitchingLanguage && styles.languageOptionDisabled,
]}
onPress={() => handleLanguageSelect(option.code)}
disabled={isSwitchingLanguage}
>
<View style={styles.languageOptionTextGroup}>
<Text style={styles.languageOptionLabel}>{option.label}</Text>
<Text style={styles.languageOptionDescription}>{option.description}</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
)}
</TouchableOpacity>
);
})}
<TouchableOpacity
style={styles.languageModalClose}
onPress={() => setLanguageModalVisible(false)}
activeOpacity={0.8}
>
<Text style={styles.languageModalCloseText}>{t('personal.language.cancel')}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
@@ -455,13 +588,14 @@ export default function PersonalScreen() {
transition={200}
cachePolicy="memory-disk"
/> */}
<Text style={styles.fishRecordText}></Text>
<Text style={styles.fishRecordText}>{t('personal.fishRecord')}</Text>
</View>
<ActivityHeatMap />
{menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} />
))}
</ScrollView>
<LanguageSelectorModal />
</View>
);
}
@@ -743,6 +877,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
flex: 1,
},
menuRight: {
flexDirection: 'row',
alignItems: 'center',
},
menuRightText: {
fontSize: 13,
color: '#6C757D',
marginRight: 6,
},
iconContainer: {
width: 32,
height: 32,
@@ -771,4 +914,72 @@ const styles = StyleSheet.create({
color: '#2C3E50',
marginLeft: 4,
},
languageModalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'stretch',
padding: 24,
},
languageModalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
languageModalContent: {
backgroundColor: '#FFFFFF',
borderRadius: 18,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
},
languageModalTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
},
languageModalSubtitle: {
fontSize: 13,
color: '#6C757D',
marginBottom: 4,
},
languageOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 4,
},
languageOptionSelected: {
backgroundColor: 'rgba(147, 112, 219, 0.08)',
borderRadius: 12,
paddingHorizontal: 12,
},
languageOptionDisabled: {
opacity: 0.5,
},
languageOptionTextGroup: {
flex: 1,
paddingRight: 12,
},
languageOptionLabel: {
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
},
languageOptionDescription: {
fontSize: 12,
color: '#6C757D',
marginTop: 4,
},
languageModalClose: {
marginTop: 4,
alignItems: 'center',
},
languageModalCloseText: {
fontSize: 15,
fontWeight: '500',
color: '#9370DB',
},
});