diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index c3554f5..a63a88a 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -1,4 +1,5 @@ import { DateSelector } from '@/components/DateSelector'; +import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation'; import { MedicationCard } from '@/components/medication/MedicationCard'; import { ThemedText } from '@/components/ThemedText'; import { IconSymbol } from '@/components/ui/IconSymbol'; @@ -15,7 +16,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ScrollView, StyleSheet, @@ -39,6 +40,9 @@ export default function MedicationsScreen() { const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDateIndex, setSelectedDateIndex] = useState(selectedDate.date() - 1); const [activeFilter, setActiveFilter] = useState('all'); + const celebrationRef = useRef(null); + const celebrationTimerRef = useRef | null>(null); + const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); // 从 Redux 获取数据 const selectedKey = selectedDate.format('YYYY-MM-DD'); @@ -59,12 +63,36 @@ export default function MedicationsScreen() { }); }, []); + const handleMedicationTakenCelebration = useCallback(() => { + if (celebrationTimerRef.current) { + clearTimeout(celebrationTimerRef.current); + } + + setIsCelebrationVisible(true); + + requestAnimationFrame(() => { + celebrationRef.current?.play(); + }); + + celebrationTimerRef.current = setTimeout(() => { + setIsCelebrationVisible(false); + }, 2400); + }, []); + // 加载药物和记录数据 useEffect(() => { dispatch(fetchMedications()); dispatch(fetchMedicationRecords({ date: selectedKey })); }, [dispatch, selectedKey]); + useEffect(() => { + return () => { + if (celebrationTimerRef.current) { + clearTimeout(celebrationTimerRef.current); + } + }; + }, []); + // 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据 useFocusEffect( useCallback(() => { @@ -126,6 +154,9 @@ export default function MedicationsScreen() { return ( + {isCelebrationVisible ? ( + + ) : null} {/* 背景渐变 */} handleOpenMedicationDetails(item.medicationId)} + onCelebrate={handleMedicationTakenCelebration} /> ))} diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 6f12ad3..51a5d53 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -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['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(() => ([ + { + 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() { {displayName} {userProfile.memberNumber && ( - 会员编号: {userProfile.memberNumber} + + {t('personal.memberNumber', { number: userProfile.memberNumber })} + )} {userProfile.freeUsageCount !== undefined && ( - + - 免费AI次数: {userProfile.isVip ? '无限' : userProfile.freeUsageCount} + {t('personal.aiUsage', { value: aiUsageValue })} )} @@ -186,12 +244,12 @@ export default function PersonalScreen() { {isLgAvaliable ? ( pushIfAuthedElseLogin('/profile/edit')}> - {isLoggedIn ? '编辑' : '登录'} + {profileActionLabel} ) : ( pushIfAuthedElseLogin('/profile/edit')}> - {isLoggedIn ? '编辑' : '登录'} + {profileActionLabel} )} @@ -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 ( @@ -251,21 +309,21 @@ export default function PersonalScreen() { - - 尊享会员 + + {t('personal.membership.badge')} {planName} - + - 会员有效期 + {t('personal.membership.expiryLabel')} - + {formattedExpire} @@ -276,8 +334,8 @@ export default function PersonalScreen() { void handleMembershipPress(); }} > - - 更改会员套餐 + + {t('personal.membership.changeButton')} @@ -294,15 +352,15 @@ export default function PersonalScreen() { {formatHeight()} - 身高 + {t('personal.stats.height')} {formatWeight()} - 体重 + {t('personal.stats.weight')} {formatAge()} - 年龄 + {t('personal.stats.age')} @@ -310,7 +368,7 @@ export default function PersonalScreen() { ); // 菜单项组件 - const MenuSection = ({ title, items }: { title: string; items: any[] }) => ( + const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => ( {title} @@ -324,7 +382,6 @@ export default function PersonalScreen() { ) : ( - + + {item.rightText ? ( + {item.rightText} + ) : null} + + )} ))} @@ -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['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['name'], + title: t('personal.menu.notificationSettings'), onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS), - } + }, + ], + }, + { + title: t('personal.language.title'), + items: [ + { + icon: 'language-outline' as React.ComponentProps['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['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['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['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['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['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['name'], + title: t('personal.menu.logout'), onPress: confirmLogout, isDanger: false, }, { - icon: 'trash-outline' as const, - title: '注销帐号', + icon: 'trash-outline' as React.ComponentProps['name'], + title: t('personal.menu.deleteAccount'), onPress: confirmDeleteAccount, isDanger: true, }, @@ -419,6 +502,56 @@ export default function PersonalScreen() { }] : []), ]; + const LanguageSelectorModal = () => ( + setLanguageModalVisible(false)} + > + + setLanguageModalVisible(false)}> + + + + {t('personal.language.modalTitle')} + {t('personal.language.modalSubtitle')} + {languageOptions.map((option) => { + const isSelected = option.code === activeLanguageCode; + return ( + handleLanguageSelect(option.code)} + disabled={isSwitchingLanguage} + > + + {option.label} + {option.description} + + {isSelected && ( + + )} + + ); + })} + setLanguageModalVisible(false)} + activeOpacity={0.8} + > + {t('personal.language.cancel')} + + + + + ); + return ( @@ -455,13 +588,14 @@ export default function PersonalScreen() { transition={200} cachePolicy="memory-disk" /> */} - 能量记录 + {t('personal.fishRecord')} {menuSections.map((section, index) => ( ))} + ); } @@ -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', + }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 81d744f..c61d799 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,7 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import 'react-native-reanimated'; +import '@/i18n'; import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -335,6 +336,10 @@ export default function RootLayout() { + diff --git a/app/health-data-permissions.tsx b/app/health-data-permissions.tsx new file mode 100644 index 0000000..07db539 --- /dev/null +++ b/app/health-data-permissions.tsx @@ -0,0 +1,248 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Ionicons } from '@expo/vector-icons'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type CardConfig = { + key: string; + icon: React.ComponentProps['name']; + color: string; + title: string; + items: string[]; +}; + +export default function HealthDataPermissionsScreen() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + const cards = useMemo(() => ([ + { + key: 'usage', + icon: 'pulse-outline', + color: '#34D399', + title: t('healthPermissions.cards.usage.title'), + items: t('healthPermissions.cards.usage.items', { returnObjects: true }) as string[], + }, + { + key: 'purpose', + icon: 'bulb-outline', + color: '#FBBF24', + title: t('healthPermissions.cards.purpose.title'), + items: t('healthPermissions.cards.purpose.items', { returnObjects: true }) as string[], + }, + { + key: 'control', + icon: 'shield-checkmark-outline', + color: '#60A5FA', + title: t('healthPermissions.cards.control.title'), + items: t('healthPermissions.cards.control.items', { returnObjects: true }) as string[], + }, + { + key: 'privacy', + icon: 'lock-closed-outline', + color: '#A78BFA', + title: t('healthPermissions.cards.privacy.title'), + items: t('healthPermissions.cards.privacy.items', { returnObjects: true }) as string[], + }, + ]), [t]); + + const calloutItems = useMemo(() => ( + t('healthPermissions.callout.items', { returnObjects: true }) as string[] + ), [t]); + + const contactDescription = t('healthPermissions.contact.description'); + const contactEmail = t('healthPermissions.contact.email'); + + const handleContactPress = () => { + if (!contactEmail) return; + void Linking.openURL(`mailto:${contactEmail}`); + }; + + const contentTopPadding = insets.top + 72; + + return ( + + + + + {t('healthPermissions.title')} + {t('healthPermissions.subtitle')} + + + {cards.map((card) => ( + + + + + + {card.title} + + {card.items.map((item, index) => ( + + + {item} + + ))} + + ))} + + + + + + + {t('healthPermissions.callout.title')} + + {calloutItems.map((item, index) => ( + + + {item} + + ))} + + + + {t('healthPermissions.contact.title')} + {contactDescription} + {contactEmail ? ( + + + {contactEmail} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + scrollView: { + flex: 1, + }, + heroCard: { + backgroundColor: '#fff', + borderRadius: 20, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 10, + shadowOffset: { width: 0, height: 8 }, + elevation: 2, + }, + heroTitle: { + fontSize: 24, + fontWeight: '700', + color: '#111827', + marginBottom: 12, + }, + heroSubtitle: { + fontSize: 16, + color: '#4B5563', + lineHeight: 22, + }, + infoCard: { + backgroundColor: '#fff', + borderRadius: 18, + padding: 18, + marginBottom: 14, + borderWidth: 1, + borderColor: '#F3F4F6', + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + cardIcon: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }, + cardTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + }, + cardItemRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 8, + }, + bullet: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: '#9370DB', + marginTop: 8, + marginRight: 10, + }, + cardItemText: { + flex: 1, + fontSize: 14, + color: '#374151', + lineHeight: 20, + }, + calloutCard: { + backgroundColor: '#FEF3F2', + borderRadius: 18, + padding: 18, + marginBottom: 14, + borderWidth: 1, + borderColor: '#FECACA', + }, + contactCard: { + backgroundColor: '#fff', + borderRadius: 18, + padding: 18, + borderWidth: 1, + borderColor: '#F3F4F6', + }, + contactTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + marginBottom: 8, + }, + contactDescription: { + fontSize: 14, + color: '#4B5563', + lineHeight: 20, + marginBottom: 12, + }, + contactButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#111827', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + }, + contactButtonText: { + marginLeft: 8, + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/app/medications/add-medication.tsx b/app/medications/add-medication.tsx index 59457f6..c3c17b7 100644 --- a/app/medications/add-medication.tsx +++ b/app/medications/add-medication.tsx @@ -134,6 +134,10 @@ export default function AddMedicationScreen() { const glassDisabledTint = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.45 : 0.6), [colors.border, theme]); const glassPrimaryBackground = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.35 : 0.7), [colors.primary, theme]); const glassDisabledBackground = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.35 : 0.5), [colors.border, theme]); + const cardShadowColor = useMemo( + () => (theme === 'dark' ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.16)'), + [theme] + ); const [photoPreview, setPhotoPreview] = useState(null); const [photoUrl, setPhotoUrl] = useState(null); @@ -617,9 +621,10 @@ export default function AddMedicationScreen() { @@ -687,9 +692,10 @@ export default function AddMedicationScreen() { @@ -868,7 +874,16 @@ export default function AddMedicationScreen() { 备注 - + void; + onCelebrate?: () => void; }; -export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }: MedicationCardProps) { +export function MedicationCard({ medication, colors, selectedDate, onOpenDetails, onCelebrate }: MedicationCardProps) { const dispatch = useAppDispatch(); const [isSubmitting, setIsSubmitting] = useState(false); const [imageError, setImageError] = useState(false); @@ -81,6 +82,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails recordId: recordId, actualTime: new Date().toISOString(), })).unwrap(); + onCelebrate?.(); // 可选:显示成功提示 // Alert.alert('服药成功', '已记录本次服药'); diff --git a/constants/Routes.ts b/constants/Routes.ts index f21feb0..83ce93f 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -61,6 +61,9 @@ export const ROUTES = { // 新用户引导 ONBOARDING: '/onboarding', + // 健康权限披露 + HEALTH_DATA_PERMISSIONS: '/health-data-permissions', + // 目标管理路由 (已移至tab中) // GOAL_MANAGEMENT: '/goal-management', @@ -71,6 +74,7 @@ export const ROUTES = { // 通知设置路由 NOTIFICATION_SETTINGS: '/notification-settings', + PUSH_NOTIFICATION_SETTINGS: '/push-notification-settings', // 药品相关路由 MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency', diff --git a/i18n/index.ts b/i18n/index.ts new file mode 100644 index 0000000..e7c5c3e --- /dev/null +++ b/i18n/index.ts @@ -0,0 +1,298 @@ +import * as Localization from 'expo-localization'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { getItemSync, setItem } from '@/utils/kvStore'; + +export const LANGUAGE_PREFERENCE_KEY = 'app_language_preference'; +export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const; +export type AppLanguage = typeof SUPPORTED_LANGUAGES[number]; + +const fallbackLanguage: AppLanguage = 'zh'; + +const personalScreenResources = { + edit: '编辑', + login: '登录', + memberNumber: '会员编号: {{number}}', + aiUsage: '免费AI次数: {{value}}', + aiUsageUnlimited: '无限', + fishRecord: '能量记录', + stats: { + height: '身高', + weight: '体重', + age: '年龄', + ageSuffix: '岁', + }, + membership: { + badge: '尊享会员', + planFallback: 'VIP 会员', + expiryLabel: '会员有效期', + changeButton: '更改会员套餐', + validForever: '长期有效', + dateFormat: 'YYYY年MM月DD日', + }, + sections: { + notifications: '通知', + developer: '开发者', + other: '其他', + account: '账号与安全', + language: '语言', + healthData: '健康数据授权', + }, + menu: { + notificationSettings: '通知设置', + developerOptions: '开发者选项', + pushSettings: '推送通知设置', + privacyPolicy: '隐私政策', + feedback: '意见反馈', + userAgreement: '用户协议', + logout: '退出登录', + deleteAccount: '注销帐号', + healthDataPermissions: '健康数据授权说明', + }, + language: { + title: '语言', + menuTitle: '界面语言', + modalTitle: '选择语言', + modalSubtitle: '选择后界面会立即更新', + cancel: '取消', + options: { + zh: { + label: '中文', + description: '推荐中文用户使用', + }, + en: { + label: '英文', + description: '使用英文界面', + }, + }, + }, +}; + +const healthPermissionsResources = { + title: '健康数据授权说明', + subtitle: '我们通过 Apple Health 的 HealthKit/CareKit 接口同步必要的数据,让训练、恢复和提醒更贴合你的身体状态。', + cards: { + usage: { + title: '我们会读取 / 写入的数据', + items: [ + '运动与活动:步数、活动能量、锻炼记录用于生成训练表现和热力图。', + '身体指标:身高、体重、体脂率帮助制定个性化训练与营养建议。', + '睡眠与恢复:睡眠时长与阶段用于智能提醒与恢复建议。', + '水分摄入:读取与写入饮水记录,保持与「健康」App 一致。', + ], + }, + purpose: { + title: '使用这些数据的目的', + items: [ + '提供个性化训练计划、挑战与恢复建议。', + '在统计页展示长期趋势,帮助你理解身体变化。', + '减少重复输入,在提醒与挑战中自动同步进度。', + ], + }, + control: { + title: '你的控制权', + items: [ + '授权流程完全由 Apple Health 控制,你可随时在 iOS 设置 > 健康 > 数据访问与设备 中更改权限。', + '未授权的数据不会被访问,撤销授权后我们会清理相关缓存。', + '核心功能依旧可用,并提供手动输入等替代方案。', + ], + }, + privacy: { + title: '数据存储与隐私', + items: [ + '健康数据仅存储在你的设备上,我们不会上传服务器或共享给第三方。', + '只有在需要同步的功能中才会保存聚合后的匿名统计值。', + '我们遵循 Apple 的审核要求,任何变更都会提前告知。', + ], + }, + }, + callout: { + title: '未授权会怎样?', + items: [ + '相关模块会提示你授权,并提供手动记录入口。', + '拒绝授权不会影响其它与健康数据无关的功能。', + ], + }, + contact: { + title: '需要更多帮助?', + description: '如果你对 HealthKit / CareKit 的使用方式有疑问,可通过以下邮箱或在个人中心提交反馈:', + email: 'richardwei1995@gmail.com', + }, +}; + +const resources = { + zh: { + translation: { + personal: personalScreenResources, + healthPermissions: healthPermissionsResources, + }, + }, + en: { + translation: { + personal: { + edit: 'Edit', + login: 'Log in', + memberNumber: 'Member ID: {{number}}', + aiUsage: 'Free AI credits: {{value}}', + aiUsageUnlimited: 'Unlimited', + fishRecord: 'Energy log', + stats: { + height: 'Height', + weight: 'Weight', + age: 'Age', + ageSuffix: ' yrs', + }, + membership: { + badge: 'Premium member', + planFallback: 'VIP Membership', + expiryLabel: 'Valid until', + changeButton: 'Change plan', + validForever: 'No expiry', + dateFormat: 'YYYY-MM-DD', + }, + sections: { + notifications: 'Notifications', + developer: 'Developer', + other: 'Other', + account: 'Account & Security', + language: 'Language', + healthData: 'Health data permissions', + }, + menu: { + notificationSettings: 'Notification settings', + developerOptions: 'Developer options', + pushSettings: 'Push notification settings', + privacyPolicy: 'Privacy policy', + feedback: 'Feedback', + userAgreement: 'User agreement', + logout: 'Log out', + deleteAccount: 'Delete account', + healthDataPermissions: 'Health data disclosure', + }, + language: { + title: 'Language', + menuTitle: 'Display language', + modalTitle: 'Choose language', + modalSubtitle: 'Your selection applies immediately', + cancel: 'Cancel', + options: { + zh: { + label: 'Chinese', + description: 'Use the Chinese interface', + }, + en: { + label: 'English', + description: 'Use the app in English', + }, + }, + }, + }, + healthPermissions: { + title: 'Health data disclosure', + subtitle: 'We integrate with Apple Health through HealthKit and CareKit to deliver precise training, recovery, and reminder experiences.', + cards: { + usage: { + title: 'Data we read or write', + items: [ + 'Activity: steps, active energy, and workouts fuel performance charts and rings.', + 'Body metrics: height, weight, and body fat keep plans and nutrition tips personalized.', + 'Sleep & recovery: duration and stages unlock recovery advice and reminders.', + 'Hydration: we read and write water intake so Health and the app stay in sync.', + ], + }, + purpose: { + title: 'Why we need it', + items: [ + 'Generate adaptive training plans, challenges, and recovery nudges.', + 'Display long-term trends so you can understand progress at a glance.', + 'Reduce manual input by syncing reminders and challenge progress automatically.', + ], + }, + control: { + title: 'Your control', + items: [ + 'Permissions are granted inside Apple Health; change them anytime under iOS Settings > Health > Data Access & Devices.', + 'We never access data you do not authorize, and cached values are removed if you revoke access.', + 'Core functionality keeps working and offers manual input alternatives.', + ], + }, + privacy: { + title: 'Storage & privacy', + items: [ + 'Health data stays on your device — we do not upload it or share it with third parties.', + 'Only aggregated, anonymized stats are synced when absolutely necessary.', + 'We follow Apple’s review requirements and will notify you before any changes.', + ], + }, + }, + callout: { + title: 'What if I skip authorization?', + items: [ + 'The related modules will ask for permission and provide manual logging options.', + 'Declining does not break other areas of the app that do not rely on Health data.', + ], + }, + contact: { + title: 'Need help?', + description: 'Questions about HealthKit or CareKit? Reach out via email or the in-app feedback form:', + email: 'richardwei1995@gmail.com', + }, + }, + }, + }, +}; + +export const isSupportedLanguage = (language?: string | null): language is AppLanguage => { + if (!language) return false; + return SUPPORTED_LANGUAGES.some((code) => language === code || language.startsWith(`${code}-`)); +}; + +export const getNormalizedLanguage = (language?: string | null): AppLanguage => { + if (!language) return fallbackLanguage; + const normalized = SUPPORTED_LANGUAGES.find((code) => language === code || language.startsWith(`${code}-`)); + return normalized ?? fallbackLanguage; +}; + +const getStoredLanguage = (): AppLanguage | null => { + try { + const stored = getItemSync?.(LANGUAGE_PREFERENCE_KEY) as AppLanguage | null | undefined; + if (stored && isSupportedLanguage(stored)) { + return stored; + } + } catch (error) { + // ignore storage errors and fall back to device preference + } + return null; +}; + +const getDeviceLanguage = (): AppLanguage | null => { + try { + const locales = Localization.getLocales(); + const preferred = locales.find((locale) => locale.languageCode && isSupportedLanguage(locale.languageCode)); + return preferred?.languageCode as AppLanguage | undefined || null; + } catch (error) { + return null; + } +}; + +const initialLanguage = getStoredLanguage() ?? getDeviceLanguage() ?? fallbackLanguage; + +void i18n.use(initReactI18next).init({ + compatibilityJSON: 'v4', + resources, + lng: initialLanguage, + fallbackLng: fallbackLanguage, + interpolation: { + escapeValue: false, + }, + returnNull: false, +}); + +export const changeAppLanguage = async (language: AppLanguage) => { + const nextLanguage = isSupportedLanguage(language) ? language : fallbackLanguage; + await i18n.changeLanguage(nextLanguage); + await setItem(LANGUAGE_PREFERENCE_KEY, nextLanguage); +}; + +export default i18n; diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index bee4710..a0ecc01 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -16,10 +16,10 @@ 79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; }; 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; }; - B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; }; - B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */; }; 91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; }; AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; }; + B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; }; + B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ @@ -37,9 +37,9 @@ 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = ""; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = ""; }; B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = ""; }; - AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -277,6 +277,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle", @@ -299,6 +300,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 90339f7..e313e72 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.25 + 1.0.24 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/Podfile b/ios/Podfile index 87dc0fe..ec566a4 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -4,7 +4,12 @@ require File.join(File.dirname(`node --print "require.resolve('react-native/pack require 'json' podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} -ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false' +new_arch_flag = podfile_properties['newArchEnabled'] +new_arch_enabled = ['true', true].include?(new_arch_flag) +new_arch_disabled = ['false', false].include?(new_arch_flag) + +ENV['RCT_NEW_ARCH_ENABLED'] = '1' if new_arch_enabled +ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if new_arch_disabled ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2ab655b..4ed097c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -71,6 +71,8 @@ PODS: - ExpoModulesCore - ExpoLinking (8.0.8): - ExpoModulesCore + - ExpoLocalization (17.0.7): + - ExpoModulesCore - ExpoMediaLibrary (18.2.0): - ExpoModulesCore - React-Core @@ -2325,6 +2327,7 @@ DEPENDENCIES: - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`) - ExpoLinking (from `../node_modules/expo-linking/ios`) + - ExpoLocalization (from `../node_modules/expo-localization/ios`) - ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoQuickActions (from `../node_modules/expo-quick-actions/ios`) @@ -2479,6 +2482,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-linear-gradient/ios" ExpoLinking: :path: "../node_modules/expo-linking/ios" + ExpoLocalization: + :path: "../node_modules/expo-localization/ios" ExpoMediaLibrary: :path: "../node_modules/expo-media-library/ios" ExpoModulesCore: @@ -2694,6 +2699,7 @@ SPEC CHECKSUMS: ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27 ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d + ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe ExpoModulesCore: 5f20603cf25698682d7c43c05fbba8c748b189d2 ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f @@ -2803,6 +2809,6 @@ SPEC CHECKSUMS: Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: 78eca51725b1f0fcd006b70b9a09e3fb4f960d03 +PODFILE CHECKSUM: eaa675c9798afb03f0e80539fe72dae01c73dd1e COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index 7a94832..0540456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "expo-image-picker": "~17.0.8", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.8", + "expo-localization": "^17.0.7", "expo-media-library": "^18.2.0", "expo-notifications": "~0.32.12", "expo-quick-actions": "^6.0.0", @@ -47,10 +48,12 @@ "expo-system-ui": "~6.0.8", "expo-task-manager": "~14.0.8", "expo-web-browser": "~15.0.7", + "i18next": "^25.6.2", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-i18next": "^16.3.0", "react-native": "0.81.5", "react-native-chart-kit": "^6.12.0", "react-native-device-info": "^14.0.4", @@ -7502,6 +7505,19 @@ "react-native": "*" } }, + "node_modules/expo-localization": { + "version": "17.0.7", + "resolved": "https://mirrors.tencent.com/npm/expo-localization/-/expo-localization-17.0.7.tgz", + "integrity": "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==", + "license": "MIT", + "dependencies": { + "rtl-detect": "^1.0.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-media-library": { "version": "18.2.0", "resolved": "https://mirrors.tencent.com/npm/expo-media-library/-/expo-media-library-18.2.0.tgz", @@ -8823,6 +8839,15 @@ "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://mirrors.tencent.com/npm/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://mirrors.tencent.com/npm/html2canvas/-/html2canvas-1.4.1.tgz", @@ -8963,6 +8988,37 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/i18next": { + "version": "25.6.2", + "resolved": "https://mirrors.tencent.com/npm/i18next/-/i18next-25.6.2.tgz", + "integrity": "sha512-0GawNyVUw0yvJoOEBq1VHMAsqdM23XrHkMtl2gKEjviJQSLVXsrPqsoYAxBEugW5AB96I2pZkwRxyl8WZVoWdw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -12005,6 +12061,33 @@ "react": ">=17.0.0" } }, + "node_modules/react-i18next": { + "version": "16.3.0", + "resolved": "https://mirrors.tencent.com/npm/react-i18next/-/react-i18next-16.3.0.tgz", + "integrity": "sha512-XGYIVU6gCOL4UQsfp87WbbvBc2WvgdkEDI8r4TwACzFg1bXY8pd1d9Cw6u9WJ2soTKHKaF1xQEyWA3/dUvtAGw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.0", "resolved": "https://mirrors.tencent.com/npm/react-is/-/react-is-19.2.0.tgz", @@ -12891,6 +12974,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rtl-detect": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/rtl-detect/-/rtl-detect-1.1.2.tgz", + "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==", + "license": "BSD-3-Clause" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14236,7 +14325,7 @@ "version": "5.9.3", "resolved": "https://mirrors.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14522,9 +14611,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://mirrors.tencent.com/npm/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -14784,6 +14873,15 @@ "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT" }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://mirrors.tencent.com/npm/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 1aa4d67..cb01ed8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "expo-image-picker": "~17.0.8", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.8", + "expo-localization": "^17.0.7", "expo-media-library": "^18.2.0", "expo-notifications": "~0.32.12", "expo-quick-actions": "^6.0.0", @@ -48,10 +49,12 @@ "expo-system-ui": "~6.0.8", "expo-task-manager": "~14.0.8", "expo-web-browser": "~15.0.7", + "i18next": "^25.6.2", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-i18next": "^16.3.0", "react-native": "0.81.5", "react-native-chart-kit": "^6.12.0", "react-native-device-info": "^14.0.4",