feat(i18n): 添加国际化支持和中英文切换功能
- 实现完整的中英文国际化系统,支持动态语言切换 - 新增健康数据权限说明页面,提供HealthKit数据使用说明 - 为服药记录添加庆祝动画效果,提升用户体验 - 优化药品添加页面的阴影效果和视觉层次 - 更新个人页面以支持多语言显示和语言选择模态框
This commit is contained in:
@@ -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>(dayjs());
|
||||
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
||||
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 获取数据
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
{isCelebrationVisible ? (
|
||||
<CelebrationAnimation ref={celebrationRef} visible={isCelebrationVisible} />
|
||||
) : null}
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']}
|
||||
@@ -278,6 +309,7 @@ export default function MedicationsScreen() {
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
|
||||
onCelebrate={handleMedicationTakenCelebration}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
@@ -16,27 +15,82 @@ import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Linking, Modal, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { AppLanguage, changeAppLanguage, getNormalizedLanguage } from '@/i18n';
|
||||
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||
|
||||
type MenuItem = {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
title: string;
|
||||
onPress?: () => void;
|
||||
type?: 'switch';
|
||||
switchValue?: boolean;
|
||||
onSwitchChange?: (value: boolean) => void;
|
||||
isDanger?: boolean;
|
||||
rightText?: string;
|
||||
};
|
||||
|
||||
type MenuSectionConfig = {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
type LanguageOption = {
|
||||
code: AppLanguage;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function PersonalScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable()
|
||||
const isLgAvaliable = isLiquidGlassAvailable();
|
||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
||||
|
||||
// 推送通知相关
|
||||
const {
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
} = useNotifications();
|
||||
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
||||
{
|
||||
code: 'zh' as AppLanguage,
|
||||
label: t('personal.language.options.zh.label'),
|
||||
description: t('personal.language.options.zh.description'),
|
||||
},
|
||||
{
|
||||
code: 'en' as AppLanguage,
|
||||
label: t('personal.language.options.en.label'),
|
||||
description: t('personal.language.options.en.description'),
|
||||
},
|
||||
]), [t]);
|
||||
|
||||
// 移除 notificationEnabled 状态,因为现在在通知设置页面中管理
|
||||
const activeLanguageCode = getNormalizedLanguage(i18n.language);
|
||||
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label ?? '';
|
||||
|
||||
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
|
||||
setLanguageModalVisible(false);
|
||||
if (language === activeLanguageCode || isSwitchingLanguage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSwitchingLanguage(true);
|
||||
await changeAppLanguage(language);
|
||||
} catch (error) {
|
||||
log.warn('语言切换失败', error);
|
||||
} finally {
|
||||
setIsSwitchingLanguage(false);
|
||||
}
|
||||
}, [activeLanguageCode, isSwitchingLanguage]);
|
||||
|
||||
// 推送通知设置仅在独立页面管理
|
||||
|
||||
// 开发者模式相关状态
|
||||
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
|
||||
@@ -111,11 +165,13 @@ export default function PersonalScreen() {
|
||||
const birthDate = new Date(userProfile.birthDate);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
return `${age}岁`;
|
||||
return `${age}${t('personal.stats.ageSuffix')}`;
|
||||
};
|
||||
|
||||
// 显示名称
|
||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
||||
const aiUsageValue = userProfile.isVip ? t('personal.aiUsageUnlimited') : userProfile.freeUsageCount ?? 0;
|
||||
|
||||
// 初始化时只加载开发者模式状态
|
||||
useEffect(() => {
|
||||
@@ -172,13 +228,15 @@ export default function PersonalScreen() {
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||
<Text style={styles.userMemberNumber}>
|
||||
{t('personal.memberNumber', { number: userProfile.memberNumber })}
|
||||
</Text>
|
||||
)}
|
||||
{userProfile.freeUsageCount !== undefined && (
|
||||
<View style={styles.aiUsageContainer}>
|
||||
<Ionicons name="sparkles-outline" size={12} color="#9370DB" />
|
||||
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
|
||||
<Text style={styles.aiUsageText}>
|
||||
免费AI次数: {userProfile.isVip ? '无限' : userProfile.freeUsageCount}
|
||||
{t('personal.aiUsage', { value: aiUsageValue })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -186,12 +244,12 @@ export default function PersonalScreen() {
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<GlassView style={styles.editButtonGlass}>
|
||||
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
<Text style={styles.editButtonTextGlass}>{profileActionLabel}</Text>
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
<Text style={styles.editButtonText}>{profileActionLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -225,18 +283,18 @@ export default function PersonalScreen() {
|
||||
.map((key) => fallbackProfile[key])
|
||||
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
const rawExpireDate = userProfile.membershipExpiration
|
||||
const rawExpireDate = userProfile.membershipExpiration ?? fallbackExpire;
|
||||
|
||||
let formattedExpire = '长期有效';
|
||||
let formattedExpire = t('personal.membership.validForever');
|
||||
if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
|
||||
const parsed = dayjs(rawExpireDate);
|
||||
formattedExpire = parsed.isValid() ? parsed.format('YYYY年MM月DD日') : rawExpireDate;
|
||||
formattedExpire = parsed.isValid() ? parsed.format(t('personal.membership.dateFormat')) : rawExpireDate;
|
||||
}
|
||||
|
||||
const planName =
|
||||
activeMembershipPlanName?.trim() ||
|
||||
userProfile.vipPlanName?.trim() ||
|
||||
'VIP 会员';
|
||||
t('personal.membership.planFallback');
|
||||
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
@@ -251,21 +309,21 @@ export default function PersonalScreen() {
|
||||
<View style={styles.vipCardHeader}>
|
||||
<View style={styles.vipCardHeaderLeft}>
|
||||
<View style={styles.vipBadge}>
|
||||
<Ionicons name="sparkles-outline" size={16} color="#FFD361" />
|
||||
<Text style={styles.vipBadgeText}>尊享会员</Text>
|
||||
<Ionicons name="sparkles-outline" as any size={16} color="#FFD361" />
|
||||
<Text style={styles.vipBadgeText}>{t('personal.membership.badge')}</Text>
|
||||
</View>
|
||||
<Text style={styles.vipCardTitle}>{planName}</Text>
|
||||
</View>
|
||||
<View style={styles.vipCardIllustration}>
|
||||
<Ionicons name="ribbon" size={32} color="rgba(255,255,255,0.88)" />
|
||||
<Ionicons name="ribbon" as any size={32} color="rgba(255,255,255,0.88)" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.vipCardFooter}>
|
||||
<View style={styles.vipExpiryInfo}>
|
||||
<Text style={styles.vipExpiryLabel}>会员有效期</Text>
|
||||
<Text style={styles.vipExpiryLabel}>{t('personal.membership.expiryLabel')}</Text>
|
||||
<View style={styles.vipExpiryRow}>
|
||||
<Ionicons name="time-outline" size={16} color="rgba(255,255,255,0.85)" />
|
||||
<Ionicons name="time-outline" as any size={16} color="rgba(255,255,255,0.85)" />
|
||||
<Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -276,8 +334,8 @@ export default function PersonalScreen() {
|
||||
void handleMembershipPress();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="swap-horizontal-outline" size={16} color="#2F1767" />
|
||||
<Text style={styles.vipChangeButtonText}>更改会员套餐</Text>
|
||||
<Ionicons name="swap-horizontal-outline" as any size={16} color="#2F1767" />
|
||||
<Text style={styles.vipChangeButtonText}>{t('personal.membership.changeButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
@@ -294,15 +352,15 @@ export default function PersonalScreen() {
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
||||
<Text style={styles.statLabel}>身高</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
||||
<Text style={styles.statLabel}>体重</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
||||
<Text style={styles.statLabel}>年龄</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -310,7 +368,7 @@ export default function PersonalScreen() {
|
||||
);
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.cardContainer}>
|
||||
@@ -324,7 +382,6 @@ export default function PersonalScreen() {
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
@@ -343,7 +400,12 @@ export default function PersonalScreen() {
|
||||
style={styles.switch}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
<View style={styles.menuRight}>
|
||||
{item.rightText ? (
|
||||
<Text style={styles.menuRightText}>{item.rightText}</Text>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -352,66 +414,87 @@ export default function PersonalScreen() {
|
||||
);
|
||||
|
||||
// 菜单项配置
|
||||
const menuSections = [
|
||||
const menuSections: MenuSectionConfig[] = [
|
||||
{
|
||||
title: '通知',
|
||||
title: t('personal.sections.healthData'),
|
||||
items: [
|
||||
{
|
||||
icon: 'notifications-outline' as const,
|
||||
title: '通知设置',
|
||||
icon: 'medkit-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.healthDataPermissions'),
|
||||
onPress: () => router.push(ROUTES.HEALTH_DATA_PERMISSIONS),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('personal.sections.notifications'),
|
||||
items: [
|
||||
{
|
||||
icon: 'notifications-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.notificationSettings'),
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('personal.language.title'),
|
||||
items: [
|
||||
{
|
||||
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.language.menuTitle'),
|
||||
onPress: () => setLanguageModalVisible(true),
|
||||
rightText: activeLanguageLabel,
|
||||
},
|
||||
],
|
||||
},
|
||||
// 开发者section(需要连续点击三次用户名激活)
|
||||
...(showDeveloperSection ? [{
|
||||
title: '开发者',
|
||||
title: t('personal.sections.developer'),
|
||||
items: [
|
||||
{
|
||||
icon: 'code-slash-outline' as const,
|
||||
title: '开发者选项',
|
||||
icon: 'code-slash-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.developerOptions'),
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
|
||||
},
|
||||
{
|
||||
icon: 'settings-outline' as const,
|
||||
title: '推送通知设置',
|
||||
onPress: () => pushIfAuthedElseLogin('/push-notification-settings'),
|
||||
icon: 'settings-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.pushSettings'),
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.PUSH_NOTIFICATION_SETTINGS),
|
||||
},
|
||||
],
|
||||
}] : []),
|
||||
{
|
||||
title: '其他',
|
||||
title: t('personal.sections.other'),
|
||||
items: [
|
||||
{
|
||||
icon: 'shield-checkmark-outline' as const,
|
||||
title: '隐私政策',
|
||||
icon: 'shield-checkmark-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.privacyPolicy'),
|
||||
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
|
||||
},
|
||||
{
|
||||
icon: 'chatbubble-ellipses-outline' as const,
|
||||
title: '意见反馈',
|
||||
icon: 'chatbubble-ellipses-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.feedback'),
|
||||
onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'),
|
||||
},
|
||||
{
|
||||
icon: 'document-text-outline' as const,
|
||||
title: '用户协议',
|
||||
icon: 'document-text-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.userAgreement'),
|
||||
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 只有登录用户才显示账号与安全菜单
|
||||
...(isLoggedIn ? [{
|
||||
title: '账号与安全',
|
||||
title: t('personal.sections.account'),
|
||||
items: [
|
||||
{
|
||||
icon: 'log-out-outline' as const,
|
||||
title: '退出登录',
|
||||
icon: 'log-out-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.logout'),
|
||||
onPress: confirmLogout,
|
||||
isDanger: false,
|
||||
},
|
||||
{
|
||||
icon: 'trash-outline' as const,
|
||||
title: '注销帐号',
|
||||
icon: 'trash-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.deleteAccount'),
|
||||
onPress: confirmDeleteAccount,
|
||||
isDanger: true,
|
||||
},
|
||||
@@ -419,6 +502,56 @@ export default function PersonalScreen() {
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const LanguageSelectorModal = () => (
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent
|
||||
visible={languageModalVisible}
|
||||
onRequestClose={() => setLanguageModalVisible(false)}
|
||||
>
|
||||
<View style={styles.languageModalOverlay}>
|
||||
<TouchableWithoutFeedback onPress={() => setLanguageModalVisible(false)}>
|
||||
<View style={styles.languageModalBackdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
<View style={styles.languageModalContent}>
|
||||
<Text style={styles.languageModalTitle}>{t('personal.language.modalTitle')}</Text>
|
||||
<Text style={styles.languageModalSubtitle}>{t('personal.language.modalSubtitle')}</Text>
|
||||
{languageOptions.map((option) => {
|
||||
const isSelected = option.code === activeLanguageCode;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.code}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.languageOption,
|
||||
isSelected && styles.languageOptionSelected,
|
||||
isSwitchingLanguage && styles.languageOptionDisabled,
|
||||
]}
|
||||
onPress={() => handleLanguageSelect(option.code)}
|
||||
disabled={isSwitchingLanguage}
|
||||
>
|
||||
<View style={styles.languageOptionTextGroup}>
|
||||
<Text style={styles.languageOptionLabel}>{option.label}</Text>
|
||||
<Text style={styles.languageOptionDescription}>{option.description}</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<TouchableOpacity
|
||||
style={styles.languageModalClose}
|
||||
onPress={() => setLanguageModalVisible(false)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.languageModalCloseText}>{t('personal.language.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||
@@ -455,13 +588,14 @@ export default function PersonalScreen() {
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/> */}
|
||||
<Text style={styles.fishRecordText}>能量记录</Text>
|
||||
<Text style={styles.fishRecordText}>{t('personal.fishRecord')}</Text>
|
||||
</View>
|
||||
<ActivityHeatMap />
|
||||
{menuSections.map((section, index) => (
|
||||
<MenuSection key={index} title={section.title} items={section.items} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<LanguageSelectorModal />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -743,6 +877,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
menuRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuRightText: {
|
||||
fontSize: 13,
|
||||
color: '#6C757D',
|
||||
marginRight: 6,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -771,4 +914,72 @@ const styles = StyleSheet.create({
|
||||
color: '#2C3E50',
|
||||
marginLeft: 4,
|
||||
},
|
||||
languageModalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
padding: 24,
|
||||
},
|
||||
languageModalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
},
|
||||
languageModalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
languageModalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
languageModalSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6C757D',
|
||||
marginBottom: 4,
|
||||
},
|
||||
languageOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
languageOptionSelected: {
|
||||
backgroundColor: 'rgba(147, 112, 219, 0.08)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
languageOptionDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
languageOptionTextGroup: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
languageOptionLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
languageOptionDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
marginTop: 4,
|
||||
},
|
||||
languageModalClose: {
|
||||
marginTop: 4,
|
||||
alignItems: 'center',
|
||||
},
|
||||
languageModalCloseText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#9370DB',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-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>
|
||||
<StatusBar style="dark" />
|
||||
|
||||
248
app/health-data-permissions.tsx
Normal file
248
app/health-data-permissions.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
|
||||
@@ -617,9 +621,10 @@ export default function AddMedicationScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.searchField,
|
||||
styles.inputShadow,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: softBorderColor,
|
||||
shadowColor: cardShadowColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -687,9 +692,10 @@ export default function AddMedicationScreen() {
|
||||
<View
|
||||
style={[
|
||||
styles.dosageField,
|
||||
styles.inputShadow,
|
||||
{
|
||||
borderColor: softBorderColor,
|
||||
backgroundColor: colors.surface,
|
||||
shadowColor: cardShadowColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -868,7 +874,16 @@ export default function AddMedicationScreen() {
|
||||
<View style={styles.stepSection}>
|
||||
<View style={styles.inputGroup}>
|
||||
<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
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
@@ -879,7 +894,6 @@ export default function AddMedicationScreen() {
|
||||
style={[
|
||||
styles.noteInput,
|
||||
{
|
||||
borderColor: dictationActive ? colors.primary : softBorderColor,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
@@ -1357,10 +1371,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
height: 56,
|
||||
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: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
@@ -1451,7 +1472,6 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
dosageField: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
@@ -1582,7 +1602,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
noteInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
paddingRight: 72,
|
||||
@@ -1594,6 +1613,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
noteInputWrapper: {
|
||||
position: 'relative',
|
||||
borderRadius: 24,
|
||||
},
|
||||
noteVoiceButton: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -14,9 +14,10 @@ export type MedicationCardProps = {
|
||||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||
selectedDate: Dayjs;
|
||||
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 [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('服药成功', '已记录本次服药');
|
||||
|
||||
@@ -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',
|
||||
|
||||
298
i18n/index.ts
Normal file
298
i18n/index.ts
Normal 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 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;
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -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",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.25</string>
|
||||
<string>1.0.24</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user