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',
|
||||
|
||||
Reference in New Issue
Block a user