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 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>

View File

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

View File

@@ -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" />

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 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',

View File

@@ -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('服药成功', '已记录本次服药');

View File

@@ -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
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 */; };
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",

View File

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

View File

@@ -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'

View File

@@ -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
View File

@@ -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",

View File

@@ -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",