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

@@ -1,4 +1,5 @@
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { MedicationCard } from '@/components/medication/MedicationCard'; import { MedicationCard } from '@/components/medication/MedicationCard';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
@@ -15,7 +16,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -39,6 +40,9 @@ export default function MedicationsScreen() {
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1); const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all'); const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
const celebrationRef = useRef<CelebrationAnimationRef>(null);
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
// 从 Redux 获取数据 // 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD'); 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(() => { useEffect(() => {
dispatch(fetchMedications()); dispatch(fetchMedications());
dispatch(fetchMedicationRecords({ date: selectedKey })); dispatch(fetchMedicationRecords({ date: selectedKey }));
}, [dispatch, selectedKey]); }, [dispatch, selectedKey]);
useEffect(() => {
return () => {
if (celebrationTimerRef.current) {
clearTimeout(celebrationTimerRef.current);
}
};
}, []);
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据 // 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -126,6 +154,9 @@ export default function MedicationsScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{isCelebrationVisible ? (
<CelebrationAnimation ref={celebrationRef} visible={isCelebrationVisible} />
) : null}
{/* 背景渐变 */} {/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']} colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']}
@@ -278,6 +309,7 @@ export default function MedicationsScreen() {
colors={colors} colors={colors}
selectedDate={selectedDate} selectedDate={selectedDate}
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)} onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/> />
))} ))}
</View> </View>

View File

@@ -5,7 +5,6 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice'; import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore'; import { getItem, setItem } from '@/utils/kvStore';
@@ -16,27 +15,82 @@ import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { 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'; 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() { export default function PersonalScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal(); const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets(); 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 languageOptions = useMemo<LanguageOption[]>(() => ([
const { {
requestPermission, code: 'zh' as AppLanguage,
sendNotification, label: t('personal.language.options.zh.label'),
} = useNotifications(); 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); const [showDeveloperSection, setShowDeveloperSection] = useState(false);
@@ -111,11 +165,13 @@ export default function PersonalScreen() {
const birthDate = new Date(userProfile.birthDate); const birthDate = new Date(userProfile.birthDate);
const today = new Date(); const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear(); 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 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(() => { useEffect(() => {
@@ -172,13 +228,15 @@ export default function PersonalScreen() {
<Text style={styles.userName}>{displayName}</Text> <Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity> </TouchableOpacity>
{userProfile.memberNumber && ( {userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>: {userProfile.memberNumber}</Text> <Text style={styles.userMemberNumber}>
{t('personal.memberNumber', { number: userProfile.memberNumber })}
</Text>
)} )}
{userProfile.freeUsageCount !== undefined && ( {userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}> <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}> <Text style={styles.aiUsageText}>
AI次数: {userProfile.isVip ? '无限' : userProfile.freeUsageCount} {t('personal.aiUsage', { value: aiUsageValue })}
</Text> </Text>
</View> </View>
)} )}
@@ -186,12 +244,12 @@ export default function PersonalScreen() {
{isLgAvaliable ? ( {isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}> <GlassView style={styles.editButtonGlass}>
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text> <Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text>
</GlassView> </GlassView>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text> <Text style={styles.editButtonText}>{profileActionLabel}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -225,18 +283,18 @@ export default function PersonalScreen() {
.map((key) => fallbackProfile[key]) .map((key) => fallbackProfile[key])
.find((value): value is string => typeof value === 'string' && value.trim().length > 0); .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) { if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
const parsed = dayjs(rawExpireDate); 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 = const planName =
activeMembershipPlanName?.trim() || activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() || userProfile.vipPlanName?.trim() ||
'VIP 会员'; t('personal.membership.planFallback');
return ( return (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
@@ -251,21 +309,21 @@ export default function PersonalScreen() {
<View style={styles.vipCardHeader}> <View style={styles.vipCardHeader}>
<View style={styles.vipCardHeaderLeft}> <View style={styles.vipCardHeaderLeft}>
<View style={styles.vipBadge}> <View style={styles.vipBadge}>
<Ionicons name="sparkles-outline" size={16} color="#FFD361" /> <Ionicons name="sparkles-outline" as any size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}></Text> <Text style={styles.vipBadgeText}>{t('personal.membership.badge')}</Text>
</View> </View>
<Text style={styles.vipCardTitle}>{planName}</Text> <Text style={styles.vipCardTitle}>{planName}</Text>
</View> </View>
<View style={styles.vipCardIllustration}> <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> </View>
<View style={styles.vipCardFooter}> <View style={styles.vipCardFooter}>
<View style={styles.vipExpiryInfo}> <View style={styles.vipExpiryInfo}>
<Text style={styles.vipExpiryLabel}></Text> <Text style={styles.vipExpiryLabel}>{t('personal.membership.expiryLabel')}</Text>
<View style={styles.vipExpiryRow}> <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> <Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
</View> </View>
</View> </View>
@@ -276,8 +334,8 @@ export default function PersonalScreen() {
void handleMembershipPress(); void handleMembershipPress();
}} }}
> >
<Ionicons name="swap-horizontal-outline" size={16} color="#2F1767" /> <Ionicons name="swap-horizontal-outline" as any size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}></Text> <Text style={styles.vipChangeButtonText}>{t('personal.membership.changeButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</LinearGradient> </LinearGradient>
@@ -294,15 +352,15 @@ export default function PersonalScreen() {
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text> <Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text> <Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text> <Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View> </View>
</View> </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}> <View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{title}</Text> <Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.cardContainer}> <View style={styles.cardContainer}>
@@ -324,7 +382,6 @@ export default function PersonalScreen() {
<View style={styles.menuItemLeft}> <View style={styles.menuItemLeft}>
<View style={[ <View style={[
styles.iconContainer, styles.iconContainer,
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
]}> ]}>
<Ionicons <Ionicons
name={item.icon} name={item.icon}
@@ -343,7 +400,12 @@ export default function PersonalScreen() {
style={styles.switch} 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> </TouchableOpacity>
))} ))}
@@ -352,66 +414,87 @@ export default function PersonalScreen() {
); );
// 菜单项配置 // 菜单项配置
const menuSections = [ const menuSections: MenuSectionConfig[] = [
{ {
title: '通知', title: t('personal.sections.healthData'),
items: [ items: [
{ {
icon: 'notifications-outline' as const, icon: 'medkit-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '通知设置', 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), 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需要连续点击三次用户名激活 // 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{ ...(showDeveloperSection ? [{
title: '开发者', title: t('personal.sections.developer'),
items: [ items: [
{ {
icon: 'code-slash-outline' as const, icon: 'code-slash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '开发者选项', title: t('personal.menu.developerOptions'),
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER), onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
}, },
{ {
icon: 'settings-outline' as const, icon: 'settings-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '推送通知设置', title: t('personal.menu.pushSettings'),
onPress: () => pushIfAuthedElseLogin('/push-notification-settings'), onPress: () => pushIfAuthedElseLogin(ROUTES.PUSH_NOTIFICATION_SETTINGS),
}, },
], ],
}] : []), }] : []),
{ {
title: '其他', title: t('personal.sections.other'),
items: [ items: [
{ {
icon: 'shield-checkmark-outline' as const, icon: 'shield-checkmark-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '隐私政策', title: t('personal.menu.privacyPolicy'),
onPress: () => Linking.openURL(PRIVACY_POLICY_URL), onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
}, },
{ {
icon: 'chatbubble-ellipses-outline' as const, icon: 'chatbubble-ellipses-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '意见反馈', title: t('personal.menu.feedback'),
onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'), onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'),
}, },
{ {
icon: 'document-text-outline' as const, icon: 'document-text-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '用户协议', title: t('personal.menu.userAgreement'),
onPress: () => Linking.openURL(USER_AGREEMENT_URL), onPress: () => Linking.openURL(USER_AGREEMENT_URL),
}, },
], ],
}, },
// 只有登录用户才显示账号与安全菜单 // 只有登录用户才显示账号与安全菜单
...(isLoggedIn ? [{ ...(isLoggedIn ? [{
title: '账号与安全', title: t('personal.sections.account'),
items: [ items: [
{ {
icon: 'log-out-outline' as const, icon: 'log-out-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '退出登录', title: t('personal.menu.logout'),
onPress: confirmLogout, onPress: confirmLogout,
isDanger: false, isDanger: false,
}, },
{ {
icon: 'trash-outline' as const, icon: 'trash-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: '注销帐号', title: t('personal.menu.deleteAccount'),
onPress: confirmDeleteAccount, onPress: confirmDeleteAccount,
isDanger: true, 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent /> <StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
@@ -455,13 +588,14 @@ export default function PersonalScreen() {
transition={200} transition={200}
cachePolicy="memory-disk" cachePolicy="memory-disk"
/> */} /> */}
<Text style={styles.fishRecordText}></Text> <Text style={styles.fishRecordText}>{t('personal.fishRecord')}</Text>
</View> </View>
<ActivityHeatMap /> <ActivityHeatMap />
{menuSections.map((section, index) => ( {menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} /> <MenuSection key={index} title={section.title} items={section.items} />
))} ))}
</ScrollView> </ScrollView>
<LanguageSelectorModal />
</View> </View>
); );
} }
@@ -743,6 +877,15 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
}, },
menuRight: {
flexDirection: 'row',
alignItems: 'center',
},
menuRightText: {
fontSize: 13,
color: '#6C757D',
marginRight: 6,
},
iconContainer: { iconContainer: {
width: 32, width: 32,
height: 32, height: 32,
@@ -771,4 +914,72 @@ const styles = StyleSheet.create({
color: '#2C3E50', color: '#2C3E50',
marginLeft: 4, 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',
},
}); });

View File

@@ -4,6 +4,7 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated'; import 'react-native-reanimated';
import '@/i18n';
import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -335,6 +336,10 @@ export default function RootLayout() {
<Stack.Screen name="water-detail" options={{ headerShown: false }} /> <Stack.Screen name="water-detail" options={{ headerShown: false }} />
<Stack.Screen name="water-settings" options={{ headerShown: false }} /> <Stack.Screen name="water-settings" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} /> <Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />

View File

@@ -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<typeof Ionicons>['name'];
color: string;
title: string;
items: string[];
};
export default function HealthDataPermissionsScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const cards = useMemo<CardConfig[]>(() => ([
{
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 (
<View style={styles.container}>
<HeaderBar
title={t('healthPermissions.title')}
variant="elevated"
transparent={true}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: contentTopPadding,
paddingBottom: insets.bottom + 32,
paddingHorizontal: 20,
}}
showsVerticalScrollIndicator={false}
>
<View style={styles.heroCard}>
<Text style={styles.heroTitle}>{t('healthPermissions.title')}</Text>
<Text style={styles.heroSubtitle}>{t('healthPermissions.subtitle')}</Text>
</View>
{cards.map((card) => (
<View key={card.key} style={styles.infoCard}>
<View style={styles.cardHeader}>
<View style={[styles.cardIcon, { backgroundColor: `${card.color}22` }]}>
<Ionicons name={card.icon} size={20} color={card.color} />
</View>
<Text style={styles.cardTitle}>{card.title}</Text>
</View>
{card.items.map((item, index) => (
<View key={`${card.key}-${index}`} style={styles.cardItemRow}>
<View style={styles.bullet} />
<Text style={styles.cardItemText}>{item}</Text>
</View>
))}
</View>
))}
<View style={styles.calloutCard}>
<View style={styles.cardHeader}>
<View style={[styles.cardIcon, { backgroundColor: '#F472B622' }]}>
<Ionicons name="alert-circle-outline" size={20} color="#F472B6" />
</View>
<Text style={styles.cardTitle}>{t('healthPermissions.callout.title')}</Text>
</View>
{calloutItems.map((item, index) => (
<View key={`callout-${index}`} style={styles.cardItemRow}>
<View style={styles.bullet} />
<Text style={styles.cardItemText}>{item}</Text>
</View>
))}
</View>
<View style={styles.contactCard}>
<Text style={styles.contactTitle}>{t('healthPermissions.contact.title')}</Text>
<Text style={styles.contactDescription}>{contactDescription}</Text>
{contactEmail ? (
<TouchableOpacity style={styles.contactButton} onPress={handleContactPress} activeOpacity={0.85}>
<Ionicons name="mail-outline" size={18} color="#fff" />
<Text style={styles.contactButtonText}>{contactEmail}</Text>
</TouchableOpacity>
) : null}
</View>
</ScrollView>
</View>
);
}
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',
},
});

View File

@@ -134,6 +134,10 @@ export default function AddMedicationScreen() {
const glassDisabledTint = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.45 : 0.6), [colors.border, theme]); 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 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 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<string | null>(null); const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [photoUrl, setPhotoUrl] = useState<string | null>(null); const [photoUrl, setPhotoUrl] = useState<string | null>(null);
@@ -617,9 +621,10 @@ export default function AddMedicationScreen() {
<View <View
style={[ style={[
styles.searchField, styles.searchField,
styles.inputShadow,
{ {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderColor: softBorderColor, shadowColor: cardShadowColor,
}, },
]} ]}
> >
@@ -687,9 +692,10 @@ export default function AddMedicationScreen() {
<View <View
style={[ style={[
styles.dosageField, styles.dosageField,
styles.inputShadow,
{ {
borderColor: softBorderColor,
backgroundColor: colors.surface, backgroundColor: colors.surface,
shadowColor: cardShadowColor,
}, },
]} ]}
> >
@@ -868,7 +874,16 @@ export default function AddMedicationScreen() {
<View style={styles.stepSection}> <View style={styles.stepSection}>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText> <ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<View style={styles.noteInputWrapper}> <View
style={[
styles.noteInputWrapper,
styles.inputShadow,
{
backgroundColor: colors.surface,
shadowColor: dictationActive ? colors.primary : cardShadowColor,
},
]}
>
<TextInput <TextInput
multiline multiline
numberOfLines={4} numberOfLines={4}
@@ -879,7 +894,6 @@ export default function AddMedicationScreen() {
style={[ style={[
styles.noteInput, styles.noteInput,
{ {
borderColor: dictationActive ? colors.primary : softBorderColor,
backgroundColor: colors.surface, backgroundColor: colors.surface,
color: colors.text, color: colors.text,
}, },
@@ -1357,10 +1371,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 16, borderRadius: 16,
borderWidth: 1,
height: 56, height: 56,
gap: 12, gap: 12,
}, },
inputShadow: {
borderWidth: 0,
shadowColor: 'rgba(15, 23, 42, 0.16)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 14,
elevation: 6,
},
searchInput: { searchInput: {
flex: 1, flex: 1,
fontSize: 16, fontSize: 16,
@@ -1451,7 +1472,6 @@ const styles = StyleSheet.create({
letterSpacing: 0.2, letterSpacing: 0.2,
}, },
dosageField: { dosageField: {
borderWidth: 1,
borderRadius: 16, borderRadius: 16,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
@@ -1582,7 +1602,6 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
}, },
noteInput: { noteInput: {
borderWidth: 1,
borderRadius: 20, borderRadius: 20,
padding: 16, padding: 16,
paddingRight: 72, paddingRight: 72,
@@ -1594,6 +1613,7 @@ const styles = StyleSheet.create({
}, },
noteInputWrapper: { noteInputWrapper: {
position: 'relative', position: 'relative',
borderRadius: 24,
}, },
noteVoiceButton: { noteVoiceButton: {
position: 'absolute', position: 'absolute',

View File

@@ -14,9 +14,10 @@ export type MedicationCardProps = {
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors]; colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
selectedDate: Dayjs; selectedDate: Dayjs;
onOpenDetails?: (medication: MedicationDisplayItem) => void; onOpenDetails?: (medication: MedicationDisplayItem) => void;
onCelebrate?: () => void;
}; };
export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }: MedicationCardProps) { export function MedicationCard({ medication, colors, selectedDate, onOpenDetails, onCelebrate }: MedicationCardProps) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
@@ -81,6 +82,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
recordId: recordId, recordId: recordId,
actualTime: new Date().toISOString(), actualTime: new Date().toISOString(),
})).unwrap(); })).unwrap();
onCelebrate?.();
// 可选:显示成功提示 // 可选:显示成功提示
// Alert.alert('服药成功', '已记录本次服药'); // Alert.alert('服药成功', '已记录本次服药');

View File

@@ -61,6 +61,9 @@ export const ROUTES = {
// 新用户引导 // 新用户引导
ONBOARDING: '/onboarding', ONBOARDING: '/onboarding',
// 健康权限披露
HEALTH_DATA_PERMISSIONS: '/health-data-permissions',
// 目标管理路由 (已移至tab中) // 目标管理路由 (已移至tab中)
// GOAL_MANAGEMENT: '/goal-management', // GOAL_MANAGEMENT: '/goal-management',
@@ -71,6 +74,7 @@ export const ROUTES = {
// 通知设置路由 // 通知设置路由
NOTIFICATION_SETTINGS: '/notification-settings', NOTIFICATION_SETTINGS: '/notification-settings',
PUSH_NOTIFICATION_SETTINGS: '/push-notification-settings',
// 药品相关路由 // 药品相关路由
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency', MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',

298
i18n/index.ts Normal file
View File

@@ -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 Apples 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;

View File

@@ -16,10 +16,10 @@
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; }; 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 */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; }; 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 */; }; 91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; }; 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 */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -37,9 +37,9 @@
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; }; 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = "<group>"; }; B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = "<group>"; };
B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = "<group>"; }; B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; 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}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_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}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.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}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_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}/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}/ExpoMediaLibrary_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",

View File

@@ -26,7 +26,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.25</string> <string>1.0.24</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -4,7 +4,12 @@ require File.join(File.dirname(`node --print "require.resolve('react-native/pack
require 'json' require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} 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['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_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' ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'

View File

@@ -71,6 +71,8 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoLinking (8.0.8): - ExpoLinking (8.0.8):
- ExpoModulesCore - ExpoModulesCore
- ExpoLocalization (17.0.7):
- ExpoModulesCore
- ExpoMediaLibrary (18.2.0): - ExpoMediaLibrary (18.2.0):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
@@ -2325,6 +2327,7 @@ DEPENDENCIES:
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`) - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- ExpoLinking (from `../node_modules/expo-linking/ios`) - ExpoLinking (from `../node_modules/expo-linking/ios`)
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
- ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`) - ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoQuickActions (from `../node_modules/expo-quick-actions/ios`) - ExpoQuickActions (from `../node_modules/expo-quick-actions/ios`)
@@ -2479,6 +2482,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-linear-gradient/ios" :path: "../node_modules/expo-linear-gradient/ios"
ExpoLinking: ExpoLinking:
:path: "../node_modules/expo-linking/ios" :path: "../node_modules/expo-linking/ios"
ExpoLocalization:
:path: "../node_modules/expo-localization/ios"
ExpoMediaLibrary: ExpoMediaLibrary:
:path: "../node_modules/expo-media-library/ios" :path: "../node_modules/expo-media-library/ios"
ExpoModulesCore: ExpoModulesCore:
@@ -2694,6 +2699,7 @@ SPEC CHECKSUMS:
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27 ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: 5f20603cf25698682d7c43c05fbba8c748b189d2 ExpoModulesCore: 5f20603cf25698682d7c43c05fbba8c748b189d2
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
@@ -2803,6 +2809,6 @@ SPEC CHECKSUMS:
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 78eca51725b1f0fcd006b70b9a09e3fb4f960d03 PODFILE CHECKSUM: eaa675c9798afb03f0e80539fe72dae01c73dd1e
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

106
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"expo-image-picker": "~17.0.8", "expo-image-picker": "~17.0.8",
"expo-linear-gradient": "~15.0.7", "expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "^17.0.7",
"expo-media-library": "^18.2.0", "expo-media-library": "^18.2.0",
"expo-notifications": "~0.32.12", "expo-notifications": "~0.32.12",
"expo-quick-actions": "^6.0.0", "expo-quick-actions": "^6.0.0",
@@ -47,10 +48,12 @@
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8", "expo-task-manager": "~14.0.8",
"expo-web-browser": "~15.0.7", "expo-web-browser": "~15.0.7",
"i18next": "^25.6.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^16.3.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
@@ -7502,6 +7505,19 @@
"react-native": "*" "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": { "node_modules/expo-media-library": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://mirrors.tencent.com/npm/expo-media-library/-/expo-media-library-18.2.0.tgz", "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", "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" "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": { "node_modules/html2canvas": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://mirrors.tencent.com/npm/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://mirrors.tencent.com/npm/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -8963,6 +8988,37 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -12005,6 +12061,33 @@
"react": ">=17.0.0" "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": { "node_modules/react-is": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://mirrors.tencent.com/npm/react-is/-/react-is-19.2.0.tgz", "resolved": "https://mirrors.tencent.com/npm/react-is/-/react-is-19.2.0.tgz",
@@ -12891,6 +12974,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -14236,7 +14325,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://mirrors.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", "resolved": "https://mirrors.tencent.com/npm/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -14522,9 +14611,9 @@
} }
}, },
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", "resolved": "https://mirrors.tencent.com/npm/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -14784,6 +14873,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "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": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View File

@@ -37,6 +37,7 @@
"expo-image-picker": "~17.0.8", "expo-image-picker": "~17.0.8",
"expo-linear-gradient": "~15.0.7", "expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "^17.0.7",
"expo-media-library": "^18.2.0", "expo-media-library": "^18.2.0",
"expo-notifications": "~0.32.12", "expo-notifications": "~0.32.12",
"expo-quick-actions": "^6.0.0", "expo-quick-actions": "^6.0.0",
@@ -48,10 +49,12 @@
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8", "expo-task-manager": "~14.0.8",
"expo-web-browser": "~15.0.7", "expo-web-browser": "~15.0.7",
"i18next": "^25.6.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "^7.3.4", "lottie-react-native": "^7.3.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^16.3.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",