21 Commits

Author SHA1 Message Date
409f125db1 feat: 去掉热更新 2025-12-06 12:33:12 +08:00
richarjiang
eef0134ddc feat(app): 优化Expo Updates更新检查机制,防止重复执行 2025-12-06 10:05:19 +08:00
richarjiang
0013dc3266 feat(个人中心): 移除会员横幅中的皇冠图标 2025-12-05 22:34:43 +08:00
richarjiang
37a0687456 feat(app): 优化Expo Updates更新机制,改进睡眠阶段时间轴UI设计,升级项目依赖 2025-12-05 17:17:16 +08:00
richarjiang
74b49efe23 feat(app): 启用Expo Updates自动更新功能,优化医疗记录上传流程与API集成 2025-12-05 16:09:09 +08:00
richarjiang
3d08721474 feat(个人中心): 优化会员横幅组件,支持深色模式与国际化;新增医疗记录卡片组件,完善健康档案功能 2025-12-05 14:35:10 +08:00
richarjiang
f3d4264b53 feat(家庭健康): 优化家庭组加入流程,移除自动创建家庭组逻辑 2025-12-04 19:10:05 +08:00
richarjiang
a254af92c7 feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理 2025-12-04 17:56:04 +08:00
richarjiang
e713ffbace feat: 增加睡眠分析通知功能,支持睡眠质量评估与建议 2025-12-03 10:13:14 +08:00
richarjiang
02b2de3ea3 feat: 支持健康数据上报 2025-12-02 19:10:55 +08:00
richarjiang
5b46104564 feat(ai报告): 新增AI健康报告画廊功能,支持报告生成、保存与分享 2025-12-02 14:40:45 +08:00
richarjiang
be0dd750eb feat(vip): 限制底部栏自定义功能为VIP专享
非VIP用户尝试配置底部栏时将显示会员购买弹窗,
只有VIP会员才能自由开启或关闭导航标签。
包含会员权益说明的国际化支持和存储结构重构。
2025-12-01 16:56:54 +08:00
richarjiang
a47f0fb72e feat(用药管理): 集成AI智能分析功能,提供用药依从性深度洞察和专业健康建议 2025-12-01 10:49:35 +08:00
a309123b35 feat(app): add version check system and enhance internationalization support
Add comprehensive app update checking functionality with:
- New VersionCheckContext for managing update detection and notifications
- VersionUpdateModal UI component for presenting update information
- Version service API integration with platform-specific update URLs
- Version check menu item in personal settings with manual/automatic checking

Enhance internationalization across workout features:
- Complete workout type translations for English and Chinese
- Localized workout detail modal with proper date/time formatting
- Locale-aware date formatting in fitness rings detail
- Workout notification improvements with deep linking to specific workout details

Improve UI/UX with better chart rendering, sizing fixes, and enhanced navigation flow. Update app version to 1.1.3 and include app version in API headers for better tracking.
2025-11-29 20:47:16 +08:00
83b77615cf feat: Enhance Oxygen Saturation Card with health permissions and loading state management
feat(i18n): Add common translations and mood-related strings in English and Chinese

fix(i18n): Update metabolism titles for consistency in health translations

chore: Update Podfile.lock to include SDWebImage 5.21.4 and other dependency versions

refactor(moodCheckins): Improve mood configuration retrieval with optional translation support

refactor(sleepHealthKit): Replace useI18n with direct i18n import for sleep quality descriptions
2025-11-28 23:48:38 +08:00
richarjiang
bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00
richarjiang
fbe0c92f0f feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
2025-11-27 17:54:36 +08:00
richarjiang
08adf0f20d feat(i18n): 实现用户语言偏好服务器同步功能
- 添加 UserLanguage 类型定义 ('zh-CN' | 'en-US')
- 在 UpdateUserDto 中新增 language 字段
- 实现语言切换时自动同步到服务器
- 为已登录用户保存语言偏好设置
- 服务器同步失败时降级处理,不影响本地语言切换
2025-11-27 11:17:21 +08:00
richarjiang
18d83091a9 feat(challenges): 添加自定义挑战类型并优化字段验证
- 新增 CUSTOM 挑战类型支持
- 移除 requirementLabel 必填验证,改为可选字段
- 添加挑战类型选择器的编辑模式禁用状态
- 优化日期选择器的多语言支持
- 完善中英文国际化文案
- 修复空 requirementLabel 导致的渲染问题
2025-11-27 11:11:15 +08:00
richarjiang
01388a5c4f style(ui): 为应用组件统一添加自定义字体样式 2025-11-27 09:22:55 +08:00
richarjiang
518282ecb8 feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持
- 新增自定义挑战的编辑模式,支持修改挑战信息
- 在详情页为创建者添加删除(归档)挑战的功能入口
- 全面完善挑战创建页面的国际化(i18n)文案适配
- 优化个人中心页面的字体样式,统一使用 AliBold/Regular
- 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
2025-11-26 19:07:19 +08:00
142 changed files with 23163 additions and 11443 deletions

View File

@@ -4,5 +4,6 @@
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
},
"kiroAgent.configureMCP": "Enabled"
"kiroAgent.configureMCP": "Enabled",
"codingcopilot.enableCompletionLanguage": {}
}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.20",
"version": "1.1.4",
"orientation": "portrait",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",
@@ -36,6 +36,7 @@
},
"plugins": [
"expo-router",
"expo-updates",
[
"expo-splash-screen",
{
@@ -70,8 +71,16 @@
"experiments": {
"typedRoutes": true
},
"runtimeVersion": {
"policy": "appVersion"
},
"android": {
"package": "com.anonymous.digitalpilates"
},
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
}
}
}

View File

@@ -23,11 +23,11 @@ type TabConfig = {
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
};
export default function TabLayout() {

View File

@@ -602,14 +602,14 @@ const styles = StyleSheet.create({
marginBottom: 26,
},
title: {
fontSize: 32,
fontSize: 24,
fontWeight: '700',
letterSpacing: 1,
fontFamily: 'AliBold'
},
subtitle: {
marginTop: 6,
fontSize: 14,
fontSize: 12,
fontWeight: '500',
opacity: 0.8,
fontFamily: 'AliRegular'
@@ -619,8 +619,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
joinButtonGlass: {
paddingHorizontal: 16,
paddingVertical: 10,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
minWidth: 70,
alignItems: 'center',
@@ -629,7 +629,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.45)',
},
joinButtonLabel: {
fontSize: 14,
fontSize: 12,
fontWeight: '700',
color: '#0f1528',
letterSpacing: 0.5,
@@ -639,8 +639,8 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.7)',
},
createButton: {
width: 40,
height: 40,
width: 36,
height: 36,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -5,6 +5,7 @@ import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsS
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -59,6 +60,7 @@ export default function MedicationsScreen() {
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
@@ -115,6 +117,33 @@ export default function MedicationsScreen() {
setPendingAction(null);
}, []);
const handleOpenAiSummary = useCallback(async () => {
// 先检查登录状态
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
// 非会员显示介绍弹窗
setAiSummaryInfoVisible(true);
return;
}
// 会员直接跳转到 AI 总结页面
router.push('/medications/ai-summary');
}, [checkServiceAccess, ensureLoggedIn]);
const handleAiSummaryInfoConfirm = useCallback(() => {
setAiSummaryInfoVisible(false);
// 点击"我要订阅"后,弹出会员订阅弹窗
openMembershipModal();
}, [openMembershipModal]);
const handleAiSummaryInfoClose = useCallback(() => {
setAiSummaryInfoVisible(false);
}, []);
const handleOpenMedicationManagement = useCallback(() => {
router.push('/medications/manage-medications');
}, []);
@@ -285,31 +314,59 @@ export default function MedicationsScreen() {
</ThemedText>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
{isLiquidGlassAvailable() ? (
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAiSummary}
>
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
tintColor="rgba(255, 255, 255, 0.36)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
<IconSymbol name="sparkles" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="pills.fill" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAiSummary}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="sparkles" size={18} color="#333" />
</TouchableOpacity>
)}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleAddMedication}
>
{isLiquidGlassAvailable() ? (
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</TouchableOpacity>
)}
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleAddMedication}
>
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
@@ -318,12 +375,16 @@ export default function MedicationsScreen() {
>
<IconSymbol name="plus" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleAddMedication}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="plus" size={18} color="#333" />
</TouchableOpacity>
)}
</View>
</View>
@@ -430,6 +491,13 @@ export default function MedicationsScreen() {
onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm}
/>
{/* AI 用药总结介绍弹窗 */}
<MedicationAiSummaryInfoSheet
visible={aiSummaryInfoVisible}
onClose={handleAiSummaryInfoClose}
onConfirm={handleAiSummaryInfoConfirm}
/>
</View>
);
}
@@ -498,12 +566,14 @@ const styles = StyleSheet.create({
borderRadius: 30,
},
greeting: {
fontSize: 24,
fontSize: 20,
fontWeight: '600',
fontFamily: 'AliBold',
},
welcome: {
marginTop: 6,
fontSize: 14,
fontFamily: 'AliRegular',
},
sectionSpacing: {
gap: 16,
@@ -514,10 +584,12 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 16,
fontWeight: '500',
fontFamily: 'AliBold',
},
sectionHeader: {
fontSize: 20,
fontWeight: '600',
fontFamily: 'AliBold',
},
segmentedControl: {
flexDirection: 'row',
@@ -537,6 +609,7 @@ const styles = StyleSheet.create({
segmentLabel: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
segmentBadge: {
minWidth: 24,
@@ -549,6 +622,7 @@ const styles = StyleSheet.create({
segmentBadgeText: {
fontSize: 12,
fontWeight: '600',
fontFamily: 'AliBold',
},
emptyState: {
alignItems: 'center',
@@ -566,11 +640,13 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
fontFamily: 'AliBold',
},
emptySubtitle: {
textAlign: 'center',
fontSize: 14,
lineHeight: 20,
fontFamily: 'AliRegular',
},
primaryButton: {
marginTop: 8,
@@ -584,6 +660,7 @@ const styles = StyleSheet.create({
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
fontFamily: 'AliBold',
},
cardsWrapper: {
gap: 16,
@@ -597,5 +674,6 @@ const styles = StyleSheet.create({
},
loadingText: {
fontSize: 14,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,14 +1,20 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';
import { getCurrentAppVersion } from '@/services/version';
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
@@ -53,6 +59,8 @@ type LanguageOption = {
};
export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
@@ -65,6 +73,12 @@ export default function PersonalScreen() {
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
@@ -81,6 +95,16 @@ export default function PersonalScreen() {
const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
const currentAppVersion = useMemo(() => getCurrentAppVersion(), []);
const versionRightText = useMemo(() => {
if (isCheckingVersion) {
return t('personal.versionCheck.checking');
}
if (updateInfo?.needsUpdate) {
return t('personal.versionCheck.updateBadge', { version: updateInfo.latestVersion });
}
return `v${currentAppVersion}`;
}, [currentAppVersion, isCheckingVersion, t, updateInfo?.latestVersion, updateInfo?.needsUpdate]);
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false);
@@ -89,13 +113,33 @@ export default function PersonalScreen() {
}
try {
setIsSwitchingLanguage(true);
// 将 AppLanguage ('zh' | 'en') 映射到 UserLanguage ('zh-CN' | 'en-US')
const languageMap: Record<AppLanguage, UserLanguage> = {
'zh': 'zh-CN',
'en': 'en-US',
};
const userLanguage = languageMap[language];
// 先切换本地语言
await changeAppLanguage(language);
// 如果用户已登录,同步更新服务器语言设置
if (isLoggedIn) {
try {
await updateUser({ language: userLanguage });
log.info('语言设置已同步到服务器', { language: userLanguage });
} catch (error) {
log.warn('同步语言设置到服务器失败', error);
// 服务器更新失败不影响本地语言切换,静默处理
}
}
} catch (error) {
log.warn('语言切换失败', error);
} finally {
setIsSwitchingLanguage(false);
}
}, [activeLanguageCode, isSwitchingLanguage]);
}, [activeLanguageCode, isSwitchingLanguage, isLoggedIn]);
// 推送通知设置仅在独立页面管理
@@ -226,25 +270,6 @@ export default function PersonalScreen() {
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
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');
@@ -335,25 +360,6 @@ export default function PersonalScreen() {
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -420,27 +426,33 @@ export default function PersonalScreen() {
);
};
// 数据统计部分
const StatsSection = () => (
// 健康档案入口组件
const HealthProfileEntry = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'transparent'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
<TouchableOpacity
style={styles.healthProfileCard}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
>
<LinearGradient
colors={['#FFFFFF', '#F0F4FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.healthProfileGradient}
>
<View style={styles.healthProfileContent}>
<View style={styles.healthProfileLeft}>
<View style={styles.healthProfileTitleRow}>
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
</View>
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
</View>
<View style={styles.healthProfileRight}>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</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}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
@@ -635,6 +647,19 @@ export default function PersonalScreen() {
},
],
},
{
title: t('personal.versionCheck.sectionTitle'),
items: [
{
icon: 'cloud-download-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.versionCheck.menuTitle'),
onPress: () => {
void checkForUpdate({ manual: true });
},
rightText: versionRightText,
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: t('personal.sections.developer'),
@@ -746,15 +771,13 @@ export default function PersonalScreen() {
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
colors={gradientColors}
style={StyleSheet.absoluteFillObject}
/>
<ScrollView
@@ -776,8 +799,8 @@ export default function PersonalScreen() {
}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<HealthProfileEntry />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
@@ -808,14 +831,6 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
scrollView: {
flex: 1,
@@ -842,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
@@ -981,16 +991,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 4,
fontFamily: 'AliBold',
},
userRole: {
fontSize: 14,
color: '#9370DB',
fontWeight: '500',
fontFamily: 'AliBold',
},
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
fontFamily: 'AliRegular',
},
aiUsageContainer: {
flexDirection: 'row',
@@ -1002,6 +1016,7 @@ const styles = StyleSheet.create({
color: '#9370DB',
marginLeft: 2,
fontWeight: '500',
fontFamily: 'AliRegular',
},
editButton: {
backgroundColor: '#9370DB',
@@ -1020,6 +1035,7 @@ const styles = StyleSheet.create({
color: 'white',
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)',
@@ -1041,11 +1057,13 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#9370DB',
marginBottom: 4,
fontFamily: 'AliBold',
},
statLabel: {
fontSize: 12,
color: '#6C757D',
fontWeight: '500',
fontFamily: 'AliRegular',
},
badgesRowCard: {
flexDirection: 'row',
@@ -1065,6 +1083,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#111827',
fontFamily: 'AliBold',
},
badgesRowContent: {
flexDirection: 'row',
@@ -1103,6 +1122,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
color: '#475467',
fontFamily: 'AliBold',
},
badgeCompactOverlay: {
...StyleSheet.absoluteFillObject,
@@ -1122,11 +1142,14 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '700',
color: '#5B21B6',
fontFamily: 'AliRegular',
},
badgesRowEmpty: {
fontSize: 13,
color: '#6B7280',
fontWeight: '500',
fontFamily: 'AliBold',
},
// 菜单项
menuItem: {
@@ -1151,6 +1174,7 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#6C757D',
marginRight: 6,
fontFamily: 'AliRegular',
},
iconContainer: {
width: 32,
@@ -1179,6 +1203,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
fontFamily: 'AliBold',
},
languageModalOverlay: {
flex: 1,
@@ -1204,11 +1229,13 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
fontFamily: 'AliBold',
},
languageModalSubtitle: {
fontSize: 13,
color: '#6C757D',
marginBottom: 4,
fontFamily: 'AliRegular',
},
languageOption: {
flexDirection: 'row',
@@ -1233,11 +1260,13 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
fontFamily: 'AliBold',
},
languageOptionDescription: {
fontSize: 12,
color: '#6C757D',
marginTop: 4,
fontFamily: 'AliRegular',
},
languageModalClose: {
marginTop: 4,
@@ -1247,5 +1276,62 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
color: '#9370DB',
fontFamily: 'AliBold',
},
// 健康档案入口样式
healthProfileCard: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
backgroundColor: '#FFFFFF',
},
healthProfileGradient: {
padding: 16,
},
healthProfileContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
healthProfileLeft: {
flex: 1,
marginRight: 16,
},
healthProfileTitleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
healthProfileTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginRight: 8,
fontFamily: 'AliBold',
},
healthStatusBadge: {
backgroundColor: '#ECFDF5',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
borderWidth: 1,
borderColor: '#A7F3D0',
},
healthStatusText: {
fontSize: 10,
color: '#059669',
fontWeight: '600',
},
healthProfileSubtitle: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular',
},
healthProfileRight: {
justifyContent: 'center',
},
});

View File

@@ -14,17 +14,19 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { syncHealthKitToServer } from '@/services/healthKitSync';
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { updateUserProfile } from '@/store/userSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -62,9 +64,11 @@ export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
// 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -80,7 +84,11 @@ export default function ExploreScreen() {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
const handleOpenGallery = React.useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
router.push('/gallery');
}, [ensureLoggedIn, router]);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
@@ -287,6 +295,7 @@ export default function ExploreScreen() {
try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 1. 同步个人资料 (身高、体重、出生日期)
// 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer(
async (data) => {
@@ -296,20 +305,36 @@ export default function ExploreScreen() {
);
if (success) {
logger.info('HealthKit 数据同步到服务端成功');
logger.info('HealthKit 个人资料同步到服务端成功');
} else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
}
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
// 传入今日饮水量
const waterIntake = todayWaterStats?.totalAmount;
logger.info('开始同步每日健康数据报表...', { waterIntake });
const reportSuccess = await syncDailyHealthReport(waterIntake);
if (reportSuccess) {
logger.info('每日健康数据报表同步成功');
} else {
logger.info('每日健康数据报表同步跳过(无变化)或失败');
}
} catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error);
}
}, [isLoggedIn, dispatch, userProfile]);
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
// 初始加载时执行数据加载和同步
useEffect(() => {
loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
const syncTimer = setTimeout(() => {
syncHealthDataToServer();
}, 1000);
@@ -384,42 +409,41 @@ export default function ExploreScreen() {
{/* 顶部信息栏 */}
<View style={styles.headerContainer}>
<View style={styles.headerContent}>
{/* 左边logo */}
<Image
source={require('@/assets/machine.png')}
style={styles.logoImage}
resizeMode="cover"
/>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/machine.png')}
style={styles.logoImage}
resizeMode="cover"
/>
{/* 右边文字区域 */}
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
{/* 右边文字区域 */}
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
</View>
</View>
{/* 开发环境调试按钮 */}
{__DEV__ && (
<View style={styles.debugButtonsContainer}>
<TouchableOpacity
style={styles.debugButton}
onPress={async () => {
console.log('🔧 Manual background task test...');
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}}
>
<Text style={styles.debugButtonText}>🔧</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenGallery}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.liquidGlassButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.debugButton, styles.hrvTestButton]}
onPress={async () => {
console.log('🫀 Testing HRV data fetch...');
await testHRVDataFetch();
}}
>
<Text style={styles.debugButtonText}>🫀</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
</View>
@@ -524,6 +548,7 @@ export default function ExploreScreen() {
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
@@ -536,6 +561,7 @@ export default function ExploreScreen() {
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
);
}
@@ -584,6 +610,13 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
logoImage: {
width: 28,
@@ -598,7 +631,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliRegular'
fontFamily: 'AliBold'
},
debugButtonsContainer: {
flexDirection: 'row',
@@ -620,11 +653,9 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 3,
},
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: {
fontSize: 12,
fontFamily: 'AliRegular',
},
metricsRow: {
flexDirection: 'row',
@@ -658,13 +689,15 @@ const styles = StyleSheet.create({
fontSize: 18,
lineHeight: 18,
fontWeight: '600',
textAlignVertical: 'bottom'
textAlignVertical: 'bottom',
fontFamily: 'AliBold'
},
caloriesUnit: {
color: '#515558ff',
fontSize: 12,
marginLeft: 4,
lineHeight: 18,
fontFamily: 'AliRegular',
},
trainingContent: {
marginTop: 8,
@@ -698,6 +731,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
fontFamily: 'AliBold',
},
cyclingHeader: {
flexDirection: 'row',
@@ -717,6 +751,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
fontFamily: 'AliBold',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
@@ -756,6 +791,7 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliBold',
},
heartCard: {
backgroundColor: '#FFE5E5',
@@ -776,12 +812,14 @@ const styles = StyleSheet.create({
alignSelf: 'flex-end',
color: '#5B5B5B',
fontWeight: '600',
fontFamily: 'AliBold',
},
stepsValue: {
fontSize: 14,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,
fontFamily: 'AliBold',
},
errorContainer: {
flexDirection: 'row',
@@ -797,6 +835,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginLeft: 8,
flex: 1,
fontFamily: 'AliRegular',
},
retryButton: {
padding: 4,
@@ -811,11 +850,13 @@ const styles = StyleSheet.create({
viewMoreText: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliRegular',
},
viewMoreIcon: {
fontSize: 16,
color: '#192126',
marginLeft: 4,
fontFamily: 'AliRegular',
},
stressCardRow: {
flexDirection: 'row',
@@ -886,6 +927,7 @@ const styles = StyleSheet.create({
color: '#0369A1',
fontWeight: '800',
marginTop: 8,
fontFamily: 'AliBold',
},
addWeightButton: {
position: 'absolute',
@@ -906,6 +948,54 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#192126',
textAlign: 'left',
fontFamily: 'AliBold',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
reportButton: {
height: 36,
borderRadius: 18,
paddingHorizontal: 12,
backgroundColor: '#F6F7FB',
borderWidth: 1,
borderColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
reportIconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
reportButtonLabel: {
fontSize: 14,
fontFamily: 'AliBold',
color: '#0F172A',
},
// Liquid Glass 风格按钮
liquidGlassButton: {
height: 40,
width: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
liquidGlassFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},

View File

@@ -1,14 +1,7 @@
import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import '@/i18n';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications';
@@ -26,12 +19,19 @@ import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext';
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
@@ -484,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
getPrivacyAgreed();
}, []);
// 使用 ref 确保更新检查只执行一次
// const updateCheckRequestedRef = React.useRef(false);
// useEffect(() => {
// // 如果已经执行过更新检查,直接返回
// if (updateCheckRequestedRef.current) {
// return;
// }
// updateCheckRequestedRef.current = true;
// async function checkUpdate() {
// try {
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
// // 只有在 expo-updates 启用时才检查更新
// // 开发环境或 Updates 未启用时跳过
// if (__DEV__ || !Updates.isEnabled) {
// logger.info('Skipping update check: dev mode or updates not enabled');
// return;
// }
// const update = await Updates.checkForUpdateAsync();
// logger.info("Update check:", update);
// if (update.isAvailable) {
// logger.info("Update available, fetching...");
// const result = await Updates.fetchUpdateAsync();
// logger.info("Fetch result:", result);
// if (result.isNew) {
// logger.info("Reloading app to apply update...");
// await Updates.reloadAsync();
// }
// }
// } catch (e) {
// logger.error("Update error:", e);
// }
// }
// setTimeout(() => {
// checkUpdate();
// }, 5000);
// }, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);
@@ -524,30 +569,32 @@ export default function RootLayout() {
<Provider store={store}>
<Bootstrapper>
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<VersionCheckProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
</VersionCheckProvider>
</ToastProvider>
</Bootstrapper>
</Provider>

View File

@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
@@ -23,6 +24,7 @@ export default function LoginScreen() {
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const { t } = useI18n();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
@@ -79,12 +81,12 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
t('login.agreement.alert.title'),
t('login.agreement.alert.message'),
[
{ text: '取消', style: 'cancel' },
{ text: t('login.agreement.alert.cancel'), style: 'cancel' },
{
text: '同意并继续',
text: t('login.agreement.alert.confirm'),
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
@@ -96,7 +98,7 @@ export default function LoginScreen() {
return;
}
action();
}, [hasAgreed]);
}, [hasAgreed, t]);
const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return;
@@ -110,7 +112,7 @@ export default function LoginScreen() {
});
const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌');
throw new Error(t('login.errors.appleIdentityTokenMissing'));
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
@@ -118,7 +120,7 @@ export default function LoginScreen() {
await dispatch(fetchMyProfile())
Toast.show({
text1: '登录成功',
text1: t('login.success.loginSuccess'),
type: 'success',
});
// 登录成功后处理重定向
@@ -145,12 +147,12 @@ export default function LoginScreen() {
console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
const message = err?.message || t('login.errors.loginFailed');
Alert.alert(t('login.errors.loginFailedTitle'), message);
} finally {
setLoading(false);
}
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo, dispatch, t]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
@@ -244,14 +246,14 @@ export default function LoginScreen() {
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity>
)}
<Text style={[styles.headerTitle, { color: color.text }]}></Text>
<Text style={[styles.headerTitle, { color: color.text }]}>{t('login.title')}</Text>
<View style={{ width: 32 }} />
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{t('login.subtitle')}</ThemedText>
</View>
{/* Apple 登录 */}
@@ -276,12 +278,12 @@ export default function LoginScreen() {
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</>
)}
</GlassView>
@@ -294,12 +296,12 @@ export default function LoginScreen() {
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</>
)}
</View>
@@ -319,13 +321,13 @@ export default function LoginScreen() {
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.readAndAgree')}</Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.and')}</Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
</Pressable>
</View>

View File

@@ -2,9 +2,10 @@ import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { getLocalizedDateFormat, getMonthDays, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
@@ -24,6 +25,7 @@ type BasalMetabolismData = {
};
export default function BasalMetabolismDetailScreen() {
const { t, i18n } = useI18n();
const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge);
const safeAreaTop = useSafeAreaTop()
@@ -140,9 +142,9 @@ export default function BasalMetabolismDetailScreen() {
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
}, [selectedIndex, i18n.language]);
// 计算BMR范围
@@ -203,7 +205,7 @@ export default function BasalMetabolismDetailScreen() {
setSelectedIndex(index);
// 获取选中日期
const days = getMonthDaysZh();
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
const selectedDate = days[index]?.date?.toDate();
if (selectedDate) {
@@ -247,7 +249,7 @@ export default function BasalMetabolismDetailScreen() {
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败');
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
}
} finally {
if (!isCancelled) {
@@ -280,7 +282,8 @@ export default function BasalMetabolismDetailScreen() {
// 显示周数
const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`;
const weekNumber = weekOfYear - firstWeekOfYear + 1;
return t('basalMetabolismDetail.chart.weekLabel', { week: weekNumber });
default:
return dayjs(item.date).format('MM-DD');
}
@@ -319,7 +322,7 @@ export default function BasalMetabolismDetailScreen() {
{/* 头部导航 */}
<HeaderBar
title="基础代谢"
title={t('basalMetabolismDetail.title')}
transparent
right={
<TouchableOpacity
@@ -355,7 +358,9 @@ export default function BasalMetabolismDetailScreen() {
{/* 当前日期基础代谢显示 */}
<View style={styles.currentDataCard}>
<Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')}
{t('basalMetabolismDetail.currentData.title', {
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
})}
</Text>
<View style={styles.currentValueContainer}>
<Text style={styles.currentValue}>
@@ -366,21 +371,24 @@ export default function BasalMetabolismDetailScreen() {
if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString();
}
return '--';
return t('basalMetabolismDetail.currentData.noData');
})()}
</Text>
<Text style={styles.currentUnit}></Text>
<Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
</View>
{bmrRange && (
<Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max}
{t('basalMetabolismDetail.currentData.normalRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text>
)}
</View>
{/* 基础代谢统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
<Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
@@ -390,7 +398,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.week')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -399,7 +407,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.month')}
</Text>
</TouchableOpacity>
</View>
@@ -408,28 +416,30 @@ export default function BasalMetabolismDetailScreen() {
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<Text style={styles.errorText}>
{t('basalMetabolismDetail.chart.error.text', { error })}
</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
// 重新加载数据
// {t('basalMetabolismDetail.comments.reloadData')}
setIsLoading(true);
setError(null);
fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data);
setIsLoading(false);
}).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败');
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
setIsLoading(false);
});
}}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
<Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
@@ -441,7 +451,7 @@ export default function BasalMetabolismDetailScreen() {
width={Dimensions.get('window').width - 80}
height={220}
yAxisLabel=""
yAxisSuffix="千卡"
yAxisSuffix={t('basalMetabolismDetail.chart.yAxisSuffix')}
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
@@ -470,7 +480,7 @@ export default function BasalMetabolismDetailScreen() {
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text>
<Text style={styles.emptyChartText}>{t('basalMetabolismDetail.chart.empty')}</Text>
</View>
)}
</View>
@@ -490,56 +500,66 @@ export default function BasalMetabolismDetailScreen() {
style={styles.closeButton}
onPress={() => setInfoModalVisible(false)}
>
<Text style={styles.closeButtonText}>×</Text>
<Text style={styles.closeButtonText}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
</TouchableOpacity>
{/* 标题 */}
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
{/* 基础代谢定义 */}
<Text style={styles.modalDescription}>
BMR
{t('basalMetabolismDetail.modal.description')}
</Text>
{/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
<Text style={styles.sectionContent}>
60-75%
{t('basalMetabolismDetail.modal.sections.importance.content')}
</Text>
{/* 正常范围 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
</Text>
{bmrRange ? (
<>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text>
<Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.userRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text>
<Text style={styles.rangeNote}>
(15%)
{t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
</Text>
<Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg
{t('basalMetabolismDetail.modal.sections.normalRange.userInfo', {
gender: t(`basalMetabolismDetail.gender.${userProfile.gender === 'male' ? 'male' : 'female'}`),
age: userAge,
height: userProfile.height,
weight: userProfile.weight
})}
</Text>
</>
) : (
<Text style={styles.rangeText}></Text>
<Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
</Text>
)}
{/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.strategyText}></Text>
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
<Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
<View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text>
<Text style={styles.strategyItem}>2. (HIIT)</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text>
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
{(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
<Text key={index} style={styles.strategyItem}>{item}</Text>
))}
</View>
</View>
</View>

View File

@@ -7,11 +7,15 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { ChallengeSource } from '@/services/challengesApi';
import {
archiveCustomChallengeThunk,
fetchChallengeDetail,
fetchChallengeRankings,
fetchChallenges,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectArchiveError,
selectArchiveStatus,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
@@ -117,6 +121,10 @@ export default function ChallengeDetailScreen() {
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
const archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]);
const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle'));
const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]);
const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(state) : undefined));
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
@@ -160,9 +168,13 @@ export default function ChallengeDetailScreen() {
};
}, [showCelebration]);
const progress = challenge?.progress;
const isJoined = challenge?.isJoined ?? false;
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
const isCreator = challenge?.isCreator ?? false;
const isCustomCreator = isCustomChallenge && isCreator;
const canEdit = isCustomChallenge && isCreator;
const lastProgressAt = useMemo(() => {
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
@@ -275,6 +287,20 @@ export default function ChallengeDetailScreen() {
}
};
const handleArchive = async () => {
if (!id || archiveStatus === 'loading') {
return;
}
try {
await dispatch(archiveCustomChallengeThunk(id)).unwrap();
Toast.success(t('challengeDetail.alert.archiveSuccess'));
await dispatch(fetchChallenges());
router.back();
} catch (error) {
Toast.error(t('challengeDetail.alert.archiveFailed'));
}
};
const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') {
return;
@@ -295,6 +321,26 @@ export default function ChallengeDetailScreen() {
);
};
const handleArchiveConfirm = () => {
if (!id || archiveStatus === 'loading') {
return;
}
Alert.alert(
t('challengeDetail.alert.archiveConfirm.title'),
t('challengeDetail.alert.archiveConfirm.message'),
[
{ text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.archiveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleArchive();
},
},
]
);
};
const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') {
return;
@@ -391,6 +437,9 @@ export default function ChallengeDetailScreen() {
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired';
const deleteCtaLabel = archiveStatus === 'loading'
? t('challengeDetail.cta.deleting')
: t('challengeDetail.cta.delete');
const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
const upcomingHighlightSubtitle = upcomingStartLabel
@@ -420,10 +469,17 @@ export default function ChallengeDetailScreen() {
? `分享码 ${challenge?.shareCode ?? ''}`
: leaveHighlightTitle;
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError;
if (isCustomCreator) {
floatingCtaLabel = deleteCtaLabel;
floatingOnPress = handleArchiveConfirm;
floatingDisabled = archiveStatus === 'loading';
floatingError = archiveError;
} else {
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError;
}
}
if (isUpcoming) {
@@ -539,7 +595,7 @@ export default function ChallengeDetailScreen() {
<Ionicons name="flag-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
{challenge.requirementLabel ? <Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text> : null}
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
</View>
</View>
@@ -573,29 +629,63 @@ export default function ChallengeDetailScreen() {
transparent
withSafeTop={false}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleShare}
activeOpacity={0.7}
>
<GlassView
style={styles.shareButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
<View style={styles.headerButtons}>
{canEdit && (
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={styles.editButton}
>
<GlassView
style={styles.editButtonGlass}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={[styles.editButton, styles.fallbackEditButton]}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
)}
{isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleShare}
activeOpacity={0.7}
>
<GlassView
style={styles.shareButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={handleShare}
style={[styles.shareButton, styles.fallbackShareButton]}
activeOpacity={0.7}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={handleShare}
style={[styles.shareButton, styles.fallbackShareButton]}
activeOpacity={0.7}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
</TouchableOpacity>
)}
</View>
}
/>
</View>
@@ -653,7 +743,7 @@ export default function ChallengeDetailScreen() {
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
{challenge.requirementLabel ? <Text style={styles.detailLabel}>{challenge.requirementLabel}</Text> : null}
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
</View>
</View>
@@ -746,52 +836,129 @@ export default function ChallengeDetailScreen() {
</View>
</ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom || 20 }]}>
{isLiquidGlassAvailable() ? (
<View style={styles.glassWrapper}>
{/* 顶部高光线条 */}
<LinearGradient
colors={['rgba(255,255,255,0.9)', 'rgba(255,255,255,0.2)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.glassHighlight}
/>
<GlassView
style={styles.glassContainer}
glassEffectStyle="regular"
tintColor="rgba(243, 244, 251, 0.55)"
isInteractive={true}
>
{/* 内部微光渐变 */}
<LinearGradient
colors={floatingGradientColors}
colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.0)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
end={{ x: 0, y: 0.6 }}
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.7}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.85}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
{/* 按钮内部高光 */}
<LinearGradient
colors={['rgba(255,255,255,0.4)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 0.5 }}
style={StyleSheet.absoluteFill}
/>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</GlassView>
</View>
</BlurView>
) : (
<BlurView intensity={20} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
)}
</View>
</View>
{showCelebration && (
@@ -850,13 +1017,47 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
paddingHorizontal: 20,
zIndex: 100,
},
floatingCTABlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.85)',
backgroundColor: 'rgba(243, 244, 251, 0.9)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
glassWrapper: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
shadowColor: '#5E8BFF',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 10,
},
glassHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
zIndex: 2,
opacity: 0.9,
},
glassContainer: {
borderRadius: 24,
overflow: 'hidden',
},
floatingCTAContent: {
flexDirection: 'row',
@@ -890,6 +1091,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
fontFamily: 'AliRegular',
},
title: {
marginTop: 10,
@@ -897,6 +1099,7 @@ const styles = StyleSheet.create({
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
fontFamily: 'AliBold'
},
summary: {
marginTop: 12,
@@ -904,6 +1107,7 @@ const styles = StyleSheet.create({
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
fontFamily: 'AliRegular',
},
inlineError: {
marginTop: 12,
@@ -950,11 +1154,13 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
avatarRow: {
flexDirection: 'row',
@@ -982,6 +1188,7 @@ const styles = StyleSheet.create({
fontSize: 12,
color: '#4F5BD5',
fontWeight: '600',
fontFamily: 'AliRegular',
},
checkInCard: {
marginTop: 4,
@@ -999,12 +1206,14 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
checkInSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
lineHeight: 18,
fontFamily: 'AliRegular',
},
checkInButton: {
borderRadius: 18,
@@ -1022,6 +1231,7 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '700',
color: '#ffffff',
fontFamily: 'AliBold',
},
checkInButtonLabelDisabled: {
color: '#6f7799',
@@ -1037,11 +1247,13 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
fontFamily: 'AliBold',
},
sectionSubtitle: {
marginTop: 8,
@@ -1049,6 +1261,7 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#6f7ba7',
lineHeight: 18,
fontFamily: 'AliRegular',
},
rankingCard: {
marginTop: 20,
@@ -1069,17 +1282,20 @@ const styles = StyleSheet.create({
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
highlightTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
highlightSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#5f6a97',
lineHeight: 18,
fontFamily: 'AliRegular',
},
shareCodeIconButton: {
paddingHorizontal: 4,
@@ -1105,10 +1321,38 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
fontFamily: 'AliBold',
},
highlightButtonLabelDisabled: {
color: '#6f7799',
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
editButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
editButtonGlass: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackEditButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
shareButton: {
width: 40,
height: 40,
@@ -1131,6 +1375,7 @@ const styles = StyleSheet.create({
missingText: {
fontSize: 16,
textAlign: 'center',
fontFamily: 'AliRegular',
},
retryButton: {
marginTop: 18,
@@ -1142,6 +1387,7 @@ const styles = StyleSheet.create({
retryText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
celebrationOverlay: {
...StyleSheet.absoluteFillObject,
@@ -1185,6 +1431,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
fontFamily: 'AliBold',
},
shareCardSummary: {
fontSize: 15,
@@ -1195,6 +1442,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.25)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
fontFamily: 'AliRegular',
},
shareProgressContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
@@ -1229,11 +1477,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
shareInfoMeta: {
fontSize: 12,
color: '#707baf',
marginTop: 2,
fontFamily: 'AliRegular',
},
shareProgressHeader: {
flexDirection: 'row',
@@ -1245,11 +1495,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
shareProgressValue: {
fontSize: 18,
fontWeight: '800',
color: '#5E8BFF',
fontFamily: 'AliBold',
},
shareProgressTrack: {
height: 8,
@@ -1268,6 +1520,7 @@ const styles = StyleSheet.create({
marginTop: 12,
textAlign: 'center',
fontWeight: '500',
fontFamily: 'AliRegular',
},
shareCardFooter: {
alignItems: 'center',
@@ -1278,5 +1531,6 @@ const styles = StyleSheet.create({
color: '#ffffff',
opacity: 0.8,
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchChallengeDetail,
@@ -37,6 +38,7 @@ export default function ChallengeLeaderboardScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { t } = useI18n();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
@@ -75,12 +77,12 @@ export default function ChallengeLeaderboardScreen() {
if (!id) {
return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
</View>
</View>
);
@@ -89,10 +91,10 @@ export default function ChallengeLeaderboardScreen() {
if (detailStatus === 'loading' && !challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View>
</View>
);
@@ -131,10 +133,10 @@ export default function ChallengeLeaderboardScreen() {
if (!challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
{detailError ?? t('challengeDetail.leaderboard.loadFailed')}
</Text>
</View>
</View>
@@ -146,7 +148,7 @@ export default function ChallengeLeaderboardScreen() {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
@@ -178,7 +180,7 @@ export default function ChallengeLeaderboardScreen() {
{showInitialRankingLoading ? (
<View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View>
) : rankingData.length ? (
rankingData.map((item, index) => (
@@ -196,18 +198,18 @@ export default function ChallengeLeaderboardScreen() {
</View>
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
<Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
</View>
)}
{isLoadingMore ? (
<View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text>
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
</View>
) : null}
{rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text>
<Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
</View>
) : null}
</View>

View File

@@ -1,10 +1,11 @@
import i18n from '@/i18n';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
@@ -27,22 +28,33 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi';
import { useI18n } from '@/hooks/useI18n';
import {
ChallengeType,
type CreateCustomChallengePayload,
type UpdateCustomChallengePayload,
} from '@/services/challengesApi';
import { store } from '@/store';
import {
createCustomChallengeThunk,
fetchChallenges,
selectChallengeById,
selectCreateChallengeError,
selectCreateChallengeStatus,
selectUpdateChallengeError,
selectUpdateChallengeStatus,
updateCustomChallengeThunk
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' },
const getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [
{ value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' },
{ value: ChallengeType.CUSTOM, label: t('challenges.createCustom.typeLabels.custom'), accent: '#8B5CF6' },
];
const FALLBACK_IMAGE =
@@ -51,6 +63,9 @@ const FALLBACK_IMAGE =
type PickerType = 'start' | 'end' | null;
export default function CreateCustomChallengeScreen() {
const { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>();
const isEditMode = mode === 'edit' && !!id;
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
@@ -58,7 +73,14 @@ export default function CreateCustomChallengeScreen() {
const insets = useSafeAreaInsets();
const createStatus = useAppSelector(selectCreateChallengeStatus);
const createError = useAppSelector(selectCreateChallengeError);
const updateError = useAppSelector(selectUpdateChallengeError);
const updateStatus = useAppSelector(selectUpdateChallengeStatus);
const inlineError = isEditMode ? updateError : createError;
const isCreating = createStatus === 'loading';
const isUpdating = updateStatus === 'loading';
const { t } = useI18n();
const typeOptions = useMemo(() => getTypeOptions(t), [t]);
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
@@ -74,10 +96,10 @@ export default function CreateCustomChallengeScreen() {
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
const [requirementLabel, setRequirementLabel] = useState('');
const [summary, setSummary] = useState('');
const [progressUnit] = useState('');
const [progressUnit, setProgressUnit] = useState('');
const [periodLabel, setPeriodLabel] = useState('');
const [periodEdited, setPeriodEdited] = useState(false);
const [rankingDescription] = useState('连续打卡榜');
const [rankingDescription] = useState(t('challenges.createCustom.rankingDescription'));
const [isPublic, setIsPublic] = useState(true);
const [maxParticipants, setMaxParticipants] = useState('100');
const [minimumEdited, setMinimumEdited] = useState(false);
@@ -88,6 +110,28 @@ export default function CreateCustomChallengeScreen() {
const [pickerType, setPickerType] = useState<PickerType>(null);
// 编辑模式下预填充数据
useEffect(() => {
if (isEditMode && id) {
const challengeSelector = selectChallengeById(id);
const challenge = challengeSelector(store.getState());
if (challenge) {
setTitle(challenge.title || '');
setImage(challenge.image);
setType(challenge.type);
setStartDate(new Date(challenge.startAt || Date.now()));
setEndDate(new Date(challenge.endAt || Date.now()));
setTargetValue(String(challenge.progress?.target || ''));
setMinimumCheckInDays(String(challenge.minimumCheckInDays || ''));
setSummary(challenge.summary || '');
setProgressUnit(challenge.unit || '');
setPeriodLabel(challenge.periodLabel || '');
setIsPublic(challenge.isPublic ?? true);
setMaxParticipants(challenge.maxParticipants?.toString() || '100');
}
}
}, [isEditMode, id]);
const durationDays = useMemo(
() =>
Math.max(
@@ -96,16 +140,16 @@ export default function CreateCustomChallengeScreen() {
),
[startDate, endDate]
);
const durationLabel = useMemo(() => `持续${durationDays}`, [durationDays]);
const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]);
useEffect(() => {
if (!periodEdited) {
setPeriodLabel(`${durationDays}天挑战`);
setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays }));
}
if (!minimumEdited) {
setMinimumCheckInDays(String(durationDays));
}
}, [durationDays, minimumEdited, periodEdited]);
}, [durationDays, minimumEdited, periodEdited, t]);
const handleConfirmDate = (date: Date) => {
if (!pickerType) return;
@@ -128,47 +172,43 @@ export default function CreateCustomChallengeScreen() {
};
const handleSubmit = async () => {
if (isCreating) return;
if (isCreating || isUpdating) return;
if (!title.trim()) {
Toast.warning('请填写挑战标题');
Toast.warning(t('challenges.createCustom.alerts.titleRequired'));
return;
}
if (!requirementLabel.trim()) {
Toast.warning('请填写挑战要求说明');
return;
}
const startTimestamp = dayjs(startDate).valueOf();
const endTimestamp = dayjs(endDate).valueOf();
if (endTimestamp <= startTimestamp) {
Toast.warning('结束时间需要晚于开始时间');
Toast.warning(t('challenges.createCustom.alerts.endTimeError'));
return;
}
const target = Number(targetValue);
if (!Number.isFinite(target) || target < 1 || target > 1000) {
Toast.warning('每日目标值需在 1-1000 之间');
Toast.warning(t('challenges.createCustom.alerts.targetValueError'));
return;
}
const minDays = Number(minimumCheckInDays) || durationDays;
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
Toast.warning('最少打卡天数需在 1-365 之间');
Toast.warning(t('challenges.createCustom.alerts.minimumDaysError'));
return;
}
if (minDays > durationDays) {
Toast.warning('最少打卡天数不能超过持续天数');
Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError'));
return;
}
const maxP = maxParticipants ? Number(maxParticipants) : null;
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
Toast.warning(t('challenges.createCustom.alerts.participantsError'));
return;
}
const safeTitle = title.trim() || '自定义挑战';
const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle');
const payload: CreateCustomChallengePayload = {
title: safeTitle,
type,
@@ -178,24 +218,39 @@ export default function CreateCustomChallengeScreen() {
targetValue: target,
minimumCheckInDays: minDays,
durationLabel,
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
requirementLabel: '',
summary: summary.trim() || undefined,
progressUnit: progressUnit.trim() || '天',
progressUnit: progressUnit.trim(),
periodLabel: periodLabel.trim() || undefined,
rankingDescription: rankingDescription.trim() || undefined,
isPublic,
maxParticipants: maxP,
};
const updatePayload: UpdateCustomChallengePayload = {
title: safeTitle,
image: image?.trim() || undefined,
summary: summary.trim() || undefined,
isPublic,
maxParticipants: maxP ?? undefined,
};
try {
if (isEditMode && id) {
await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap();
Toast.success(t('challenges.createCustom.alerts.updateSuccess'));
dispatch(fetchChallenges());
return;
}
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
setShareCode(created.shareCode ?? null);
setCreatedChallengeId(created.id);
setShareModalVisible(true);
Toast.success('自定义挑战已创建');
Toast.success(t('challenges.createCustom.alerts.createSuccess'));
dispatch(fetchChallenges());
} catch (error) {
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed');
Toast.error(message);
}
};
@@ -203,7 +258,7 @@ export default function CreateCustomChallengeScreen() {
const handleCopyShareCode = async () => {
if (!shareCode) return;
await Clipboard.setStringAsync(shareCode);
Toast.success('邀请码已复制');
Toast.success(t('challenges.createCustom.shareModal.copyCode'));
};
const handleTargetInputChange = (value: string) => {
@@ -235,16 +290,16 @@ export default function CreateCustomChallengeScreen() {
const handlePickImage = useCallback(() => {
Alert.alert(
'选择封面图',
'请选择封面来源',
t('challenges.createCustom.imageUpload.selectSource'),
t('challenges.createCustom.imageUpload.selectMessage'),
[
{
text: '拍照',
text: t('challenges.createCustom.imageUpload.camera'),
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄封面');
Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage'));
return;
}
const result = await ImagePicker.launchCameraAsync({
@@ -269,21 +324,21 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
}
} catch (error) {
console.error('[CHALLENGE] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage'));
}
},
},
{
text: '从相册选择',
text: t('challenges.createCustom.imageUpload.album'),
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择封面');
Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage'));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -307,19 +362,19 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null);
} catch (error) {
console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试');
Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
}
} catch (error) {
console.error('[CHALLENGE] 选择封面失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试');
Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage'));
}
},
},
{ text: '取消', style: 'cancel' },
{ text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' },
],
{ cancelable: true }
);
}, [upload]);
}, [upload, t]);
const handleViewChallenge = () => {
setShareModalVisible(false);
@@ -370,7 +425,7 @@ export default function CreateCustomChallengeScreen() {
</View>
);
const progressMeta = `${durationDays} · ${progressUnit || ''}`;
const progressMeta = `${durationDays} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`;
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
return (
@@ -379,7 +434,7 @@ export default function CreateCustomChallengeScreen() {
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
style={StyleSheet.absoluteFillObject}
/>
<HeaderBar title="新建挑战" transparent />
<HeaderBar title={isEditMode ? t('challenges.createCustom.editTitle') : t('challenges.createCustom.title')} transparent />
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -403,20 +458,20 @@ export default function CreateCustomChallengeScreen() {
style={StyleSheet.absoluteFillObject}
/>
<View style={styles.heroOverlay}>
<Text style={styles.heroKicker}></Text>
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
<Text style={styles.heroKicker}>{t('challenges.customChallenges')}</Text>
<Text style={styles.heroTitle}>{title || t('challenges.createCustom.yourChallenge')}</Text>
<Text style={styles.heroMeta}>{progressMeta}</Text>
</View>
</View>
<View style={styles.formCard}>
<View style={styles.formHeader}>
<Text style={styles.sectionTitle}></Text>
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null}
<Text style={styles.sectionTitle}>{t('challenges.createCustom.basicInfo')}</Text>
{inlineError ? <Text style={styles.inlineError}>{inlineError}</Text> : null}
</View>
{renderField('标题', title, setTitle, '挑战标题最多100字')}
{renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.coverImage')}</Text>
<View style={styles.uploadRow}>
<TouchableOpacity
activeOpacity={0.9}
@@ -424,7 +479,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handlePickImage}
disabled={uploading}
>
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
<Text style={styles.uploadButtonLabel}>{uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')}</Text>
</TouchableOpacity>
{image || imagePreview ? (
<TouchableOpacity
@@ -434,20 +489,20 @@ export default function CreateCustomChallengeScreen() {
setImage(undefined);
}}
>
<Text style={styles.clearUpload}></Text>
<Text style={styles.clearUpload}>{t('challenges.createCustom.imageUpload.clear')}</Text>
</TouchableOpacity>
) : null}
</View>
<Text style={styles.helperText}> 16:9</Text>
<Text style={styles.helperText}>{t('challenges.createCustom.imageUpload.helper')}</Text>
</View>
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
{renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('challenges.createCustom.challengeSettings')}</Text>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.challengeType')}</Text>
<View style={styles.chipRow}>
{typeOptions.map((option) => {
const active = option.value === type;
@@ -455,16 +510,19 @@ export default function CreateCustomChallengeScreen() {
<TouchableOpacity
key={option.value}
activeOpacity={0.9}
onPress={() => setType(option.value)}
onPress={() => !isEditMode && setType(option.value)}
disabled={isEditMode}
style={[
styles.chip,
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
isEditMode && styles.chipDisabled,
]}
>
<Text
style={[
styles.chipLabel,
active && { color: option.accent, fontWeight: '700' },
isEditMode && styles.chipLabelDisabled,
]}
>
{option.label}
@@ -473,17 +531,18 @@ export default function CreateCustomChallengeScreen() {
);
})}
</View>
<Text style={styles.helperText}>{t('challenges.createCustom.fields.challengeTypeHelper')}</Text>
</View>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.timeRange')}</Text>
<View style={styles.dateRow}>
<TouchableOpacity
activeOpacity={0.9}
style={styles.datePill}
onPress={() => setPickerType('start')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateLabel}>{t('challenges.createCustom.fields.start')}</Text>
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -491,7 +550,7 @@ export default function CreateCustomChallengeScreen() {
style={styles.datePill}
onPress={() => setPickerType('end')}
>
<Text style={styles.dateLabel}></Text>
<Text style={styles.dateLabel}>{t('challenges.createCustom.fields.end')}</Text>
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity>
</View>
@@ -499,48 +558,59 @@ export default function CreateCustomChallengeScreen() {
<View style={styles.inlineFields}>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.duration')}</Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{durationLabel}</Text>
</View>
</View>
{renderField('周期标签', periodLabel, (v) => {
{renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => {
setPeriodEdited(true);
setPeriodLabel(v);
}, '如21天挑战')}
}, t('challenges.createCustom.fields.periodLabelPlaceholder'))}
</View>
<View style={styles.inlineFields}>
{renderField('每日目标值', targetValue, handleTargetInputChange, '如8', 'numeric')}
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text>
<View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{progressUnit}</Text>
</View>
<View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.dailyTargetAndUnit')}</Text>
<View style={styles.targetUnitRow}>
<TextInput
value={targetValue}
onChangeText={handleTargetInputChange}
placeholder={t('challenges.createCustom.fields.dailyTargetPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.targetInput]}
keyboardType="numeric"
/>
<TextInput
value={progressUnit}
onChangeText={setProgressUnit}
placeholder={t('challenges.createCustom.fields.unitPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.unitInput]}
/>
</View>
<Text style={styles.helperText}>{t('challenges.createCustom.fields.unitHelper')}</Text>
</View>
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
{renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')}
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
</View>
<View style={styles.formCard}>
<Text style={styles.sectionTitle}>&</Text>
<Text style={styles.sectionTitle}>{t('challenges.createCustom.displayInteraction')}</Text>
<View style={styles.inlineFields}>
{renderField('参与人数上限', maxParticipants, (v) => {
{renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => {
const digits = v.replace(/\D/g, '');
if (!digits) {
setMaxParticipants('');
return;
}
setMaxParticipants(String(parseInt(digits, 10)));
}, '留空表示无限制', 'numeric')}
}, t('challenges.createCustom.fields.noLimit'), 'numeric')}
</View>
<View style={styles.switchRow}>
<View>
<Text style={styles.fieldLabel}></Text>
<Text style={styles.switchHint}></Text>
<Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.isPublic')}</Text>
<Text style={styles.switchHint}>{t('challenges.createCustom.fields.publicDescription')}</Text>
</View>
<Switch
value={isPublic}
@@ -557,14 +627,24 @@ export default function CreateCustomChallengeScreen() {
<BlurView intensity={14} tint="light" style={styles.floatingBlur}>
<View style={styles.floatingContent}>
<View style={styles.floatingCopy}>
<Text style={styles.floatingTitle}></Text>
<Text style={styles.floatingSubtitle}></Text>
<Text style={styles.floatingTitle}>
{isEditMode
? t('challenges.createCustom.floatingCTA.editTitle')
: t('challenges.createCustom.floatingCTA.title')
}
</Text>
<Text style={styles.floatingSubtitle}>
{isEditMode
? t('challenges.createCustom.floatingCTA.editSubtitle')
: t('challenges.createCustom.floatingCTA.subtitle')
}
</Text>
</View>
<TouchableOpacity
activeOpacity={0.9}
style={styles.floatingButton}
onPress={handleSubmit}
disabled={isCreating}
disabled={isCreating || isUpdating}
>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
@@ -573,7 +653,14 @@ export default function CreateCustomChallengeScreen() {
style={styles.floatingButtonBackground}
>
<Text style={styles.floatingButtonLabel}>
{isCreating ? '创建中…' : '创建并生成邀请码'}
{isCreating
? t('challenges.createCustom.buttons.creating')
: isUpdating
? t('challenges.createCustom.buttons.updating')
: isEditMode
? t('challenges.createCustom.buttons.updateAndSave')
: t('challenges.createCustom.buttons.createAndGenerateCode')
}
</Text>
</LinearGradient>
</TouchableOpacity>
@@ -588,6 +675,9 @@ export default function CreateCustomChallengeScreen() {
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
onConfirm={handleConfirmDate}
onCancel={() => setPickerType(null)}
locale={i18n.language}
confirmTextIOS={t('challenges.createCustom.datePicker.confirm')}
cancelTextIOS={t('challenges.createCustom.datePicker.cancel')}
/>
<Modal
@@ -598,10 +688,10 @@ export default function CreateCustomChallengeScreen() {
>
<View style={styles.modalOverlay}>
<View style={styles.shareCard}>
<Text style={styles.shareTitle}></Text>
<Text style={styles.shareSubtitle}></Text>
<Text style={styles.shareTitle}>{t('challenges.createCustom.shareModal.title')}</Text>
<Text style={styles.shareSubtitle}>{t('challenges.createCustom.shareModal.subtitle')}</Text>
<View style={styles.shareCodeBadge}>
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
<Text style={styles.shareCode}>{shareCode ?? t('challenges.createCustom.shareModal.generatingCode')}</Text>
</View>
<View style={styles.shareActions}>
<TouchableOpacity
@@ -610,7 +700,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handleCopyShareCode}
disabled={!shareCode}
>
<Text style={styles.shareButtonGhostLabel}></Text>
<Text style={styles.shareButtonGhostLabel}>{t('challenges.createCustom.shareModal.copyCode')}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
@@ -623,7 +713,7 @@ export default function CreateCustomChallengeScreen() {
end={{ x: 1, y: 1 }}
style={styles.shareButtonPrimary}
>
<Text style={styles.shareButtonPrimaryLabel}></Text>
<Text style={styles.shareButtonPrimaryLabel}>{t('challenges.createCustom.shareModal.viewChallenge')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
@@ -632,7 +722,7 @@ export default function CreateCustomChallengeScreen() {
activeOpacity={0.8}
onPress={() => setShareModalVisible(false)}
>
<Text style={styles.shareCloseLabel}></Text>
<Text style={styles.shareCloseLabel}>{t('challenges.createCustom.shareModal.later')}</Text>
</TouchableOpacity>
</View>
</View>
@@ -720,6 +810,16 @@ const styles = StyleSheet.create({
fontSize: 15,
color: '#111827',
},
targetUnitRow: {
flexDirection: 'row',
gap: 12,
},
targetInput: {
flex: 1,
},
unitInput: {
flex: 1,
},
textarea: {
minHeight: 90,
},
@@ -736,10 +836,17 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: '#e5e7eb',
},
chipDisabled: {
opacity: 0.5,
backgroundColor: '#f1f5f9',
},
chipLabel: {
fontSize: 13,
color: '#334155',
},
chipLabelDisabled: {
color: '#94a3b8',
},
uploadRow: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -25,6 +25,7 @@ const CIRCUMFERENCE_TYPES = [
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
];
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
@@ -35,6 +36,7 @@ export default function CircumferenceDetailScreen() {
const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile);
const { ensureLoggedIn } = useAuthGuard();
const { t } = useI18n();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -78,37 +80,37 @@ export default function CircumferenceDetailScreen() {
const measurements = [
{
key: 'chestCircumference',
label: '胸围',
label: t('circumferenceDetail.measurements.chest'),
value: userProfile?.chestCircumference,
color: '#FF6B6B',
},
{
key: 'waistCircumference',
label: '腰围',
label: t('circumferenceDetail.measurements.waist'),
value: userProfile?.waistCircumference,
color: '#4ECDC4',
},
{
key: 'upperHipCircumference',
label: '上臀围',
label: t('circumferenceDetail.measurements.upperHip'),
value: userProfile?.upperHipCircumference,
color: '#45B7D1',
},
{
key: 'armCircumference',
label: '臂围',
label: t('circumferenceDetail.measurements.arm'),
value: userProfile?.armCircumference,
color: '#96CEB4',
},
{
key: 'thighCircumference',
label: '大腿围',
label: t('circumferenceDetail.measurements.thigh'),
value: userProfile?.thighCircumference,
color: '#FFEAA7',
},
{
key: 'calfCircumference',
label: '小腿围',
label: t('circumferenceDetail.measurements.calf'),
value: userProfile?.calfCircumference,
color: '#DDA0DD',
},
@@ -243,10 +245,10 @@ export default function CircumferenceDetailScreen() {
// 将YYYY-MM-DD格式转换为第几周
const weekOfYear = dayjs(item.label).week();
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
return `${weekOfYear - firstWeekOfMonth + 1}`;
return t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
case 'year':
// 将YYYY-MM格式转换为月份
return dayjs(item.label).format('M');
return t('circumferenceDetail.chart.monthLabel', { month: dayjs(item.label).format('M') });
default:
return item.label;
}
@@ -287,7 +289,7 @@ export default function CircumferenceDetailScreen() {
{/* 头部导航 */}
<HeaderBar
title="围度统计"
title={t('circumferenceDetail.title')}
transparent
/>
@@ -338,7 +340,7 @@ export default function CircumferenceDetailScreen() {
{/* 围度统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
<Text style={styles.statsTitle}>{t('circumferenceDetail.title')}</Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
@@ -348,7 +350,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.week')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -357,7 +359,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.month')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -366,7 +368,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.year')}
</Text>
</TouchableOpacity>
</View>
@@ -390,7 +392,7 @@ export default function CircumferenceDetailScreen() {
styles.legendText,
!isVisible && styles.legendTextHidden
]}>
{type.label}
{t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '')}`)}
</Text>
</TouchableOpacity>
);
@@ -401,17 +403,17 @@ export default function CircumferenceDetailScreen() {
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
<Text style={styles.retryText}>{t('circumferenceDetail.retry')}</Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 ? (
@@ -453,8 +455,8 @@ export default function CircumferenceDetailScreen() {
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}>
{processedChartData.datasets.length === 0 && !isLoading && !error
? '暂无数据'
: '请选择要显示的围度数据'
? t('circumferenceDetail.chart.empty')
: t('circumferenceDetail.chart.noSelection')
}
</Text>
</View>
@@ -469,12 +471,12 @@ export default function CircumferenceDetailScreen() {
setModalVisible(false);
setSelectedMeasurement(null);
}}
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
items={circumferenceOptions}
selectedValue={selectedMeasurement?.currentValue}
onValueChange={() => { }} // Real-time update not needed
onConfirm={handleUpdateMeasurement}
confirmButtonText="确认"
confirmButtonText={t('circumferenceDetail.modal.confirm')}
pickerHeight={180}
/>
</View>

View File

@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchActivityRingsForDate,
@@ -34,6 +35,8 @@ import {
TouchableOpacity,
View
} from 'react-native';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
// 配置 dayjs 插件
dayjs.extend(utc);
@@ -51,7 +54,8 @@ type WeekData = {
};
export default function FitnessRingsDetailScreen() {
const safeAreaTop = useSafeAreaTop()
const { t, i18n } = useI18n();
const safeAreaTop = useSafeAreaTop();
const colorScheme = useColorScheme();
const [weekData, setWeekData] = useState<WeekData[]>([]);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@@ -82,7 +86,7 @@ export default function FitnessRingsDetailScreen() {
exerciseInfoAnim.setValue(0);
}
} catch (error) {
console.error('加载锻炼分钟说明偏好失败:', error);
console.error(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), error);
}
};
@@ -98,7 +102,15 @@ export default function FitnessRingsDetailScreen() {
for (let i = 0; i < 7; i++) {
const currentDay = startOfWeek.add(i, 'day');
const isToday = currentDay.isSame(today, 'day');
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const dayNames = [
t('fitnessRingsDetail.weekDays.monday'),
t('fitnessRingsDetail.weekDays.tuesday'),
t('fitnessRingsDetail.weekDays.wednesday'),
t('fitnessRingsDetail.weekDays.thursday'),
t('fitnessRingsDetail.weekDays.friday'),
t('fitnessRingsDetail.weekDays.saturday'),
t('fitnessRingsDetail.weekDays.sunday')
];
try {
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
@@ -164,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
// 格式化头部显示的日期
const formatHeaderDate = (date: Date) => {
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
return `${dayJsDate.format('YYYY年MM月DD日')}`;
const dayJsDate = dayjs(date).tz('Asia/Shanghai').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
return dayJsDate.format(dateFormat);
};
const renderWeekRingItem = (item: WeekData, index: number) => {
@@ -303,7 +316,7 @@ export default function FitnessRingsDetailScreen() {
setShowExerciseInfo(false);
});
} catch (error) {
console.error('保存锻炼分钟说明偏好失败:', error);
console.error(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), error);
}
};
@@ -380,7 +393,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动热量卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
@@ -390,25 +403,25 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
</Text>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(activeEnergyBurned)}
{Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
</Text>
{renderBarChart(
hourlyCaloriesData.map(h => h.calories),
Math.max(activeEnergyBurnedGoal / 24, 1),
'#FF3B30',
'千卡'
t('fitnessRingsDetail.cards.activeCalories.unit')
)}
</View>
{/* 锻炼分钟卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
@@ -418,18 +431,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF9500' }]}>
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
</Text>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(appleExerciseTime)}
{Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
</Text>
{renderBarChart(
hourlyExerciseData.map(h => h.minutes),
Math.max(appleExerciseTimeGoal / 8, 1),
'#FF9500',
'分钟'
t('fitnessRingsDetail.cards.exerciseMinutes.unit')
)}
{/* 锻炼分钟说明 */}
@@ -450,15 +463,15 @@ export default function FitnessRingsDetailScreen() {
}
]}
>
<Text style={styles.exerciseTitle}>:</Text>
<Text style={styles.exerciseTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.title')}</Text>
<Text style={styles.exerciseDesc}>
"快走"
{t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
</Text>
<Text style={styles.exerciseRecommendation}>
30
{t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
</Text>
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
<Text style={styles.knowButtonText}></Text>
<Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
</TouchableOpacity>
</Animated.View>
)}
@@ -467,7 +480,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动小时数卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
@@ -477,18 +490,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#007AFF' }]}>
{Math.round(appleStandHours)}/{appleStandHoursGoal}
</Text>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(appleStandHours)}
{Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
</Text>
{renderBarChart(
hourlyStandData.map(h => h.hasStood),
1,
'#007AFF',
'小时'
t('fitnessRingsDetail.cards.standHours.unit')
)}
</View>
</View>
@@ -536,9 +549,9 @@ export default function FitnessRingsDetailScreen() {
{/* 周闭环天数统计 */}
<View style={styles.statsContainer}>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}></Text>
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>{t('fitnessRingsDetail.stats.weeklyClosedRings')}</Text>
<View style={styles.statValue}>
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}</Text>
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</Text>
</View>
</View>
</View>
@@ -559,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date()}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -575,12 +588,12 @@ export default function FitnessRingsDetailScreen() {
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
</Pressable>
<Pressable onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
</Pressable>
</View>
)}
@@ -874,4 +887,4 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontWeight: '700',
},
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
@@ -65,15 +66,8 @@ const mockFoodItems = [
}
];
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export default function FoodAnalysisResultScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const router = useRouter();
const params = useLocalSearchParams<{
@@ -190,6 +184,15 @@ export default function FoodAnalysisResultScreen() {
}
};
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: t('nutritionRecords.mealTypes.breakfast'),
lunch: t('nutritionRecords.mealTypes.lunch'),
dinner: t('nutritionRecords.mealTypes.dinner'),
snack: t('nutritionRecords.mealTypes.snack'),
other: t('nutritionRecords.mealTypes.other'),
};
// 计算所有食物的总营养数据
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
@@ -253,24 +256,24 @@ export default function FoodAnalysisResultScreen() {
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
];
if (!imageUri && !recognitionResult) {
return (
<View style={styles.container}>
<HeaderBar
title="分析结果"
title={t('foodAnalysisResult.title')}
onBack={() => router.back()}
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}></Text>
<Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</Text>
</View>
</View>
);
@@ -287,7 +290,7 @@ export default function FoodAnalysisResultScreen() {
/>
<HeaderBar
title="分析结果"
title={t('foodAnalysisResult.title')}
onBack={() => router.back()}
transparent={true}
/>
@@ -316,7 +319,7 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}>
<Ionicons name="restaurant-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text>
<Text style={styles.placeholderText}>{t('foodAnalysisResult.placeholder')}</Text>
</View>
</View>
)}
@@ -325,8 +328,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.descriptionBubble}>
<Text style={styles.descriptionText}>
{recognitionResult ?
`置信度: ${recognitionResult.confidence}%` :
dayjs().format('YYYY年M月D日')
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format(t('foodAnalysisResult.dateFormats.today'))
}
</Text>
</View>
@@ -337,31 +340,31 @@ export default function FoodAnalysisResultScreen() {
{/* 卡路里 */}
<View style={styles.calorieSection}>
<Text style={styles.calorieValue}>{totalCalories}</Text>
<Text style={styles.calorieUnit}></Text>
<Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
</View>
{/* 营养圆环图 */}
<View style={styles.nutritionRings}>
<NutritionRing
label="蛋白质"
label={t('foodAnalysisResult.nutrients.protein')}
value={totalProtein.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, proteinPercentage)}
color="#4CAF50"
resetToken={animationTrigger}
/>
<NutritionRing
label="脂肪"
label={t('foodAnalysisResult.nutrients.fat')}
value={totalFat.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, fatPercentage)}
color="#FF9800"
resetToken={animationTrigger}
/>
<NutritionRing
label="碳水"
label={t('foodAnalysisResult.nutrients.carbs')}
value={totalCarbohydrate.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, carbohydratePercentage)}
color="#2196F3"
resetToken={animationTrigger}
@@ -372,7 +375,7 @@ export default function FoodAnalysisResultScreen() {
{/* 食物摄入部分 */}
<View style={styles.foodIntakeSection}>
<Text style={styles.foodIntakeTitle}>
{recognitionResult ? '识别结果' : '食物摄入'}
{recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
</Text>
{recognitionResult && recognitionResult.analysisText && (
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
@@ -384,15 +387,15 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.nonFoodIcon}>
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
</View>
<Text style={styles.nonFoodTitle}></Text>
<Text style={styles.nonFoodTitle}>{t('foodAnalysisResult.nonFood.title')}</Text>
<Text style={styles.nonFoodMessage}>
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
</Text>
<View style={styles.nonFoodSuggestions}>
<Text style={styles.nonFoodSuggestionsTitle}></Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> 线</Text>
<Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</Text>
</View>
</View>
)}
@@ -411,7 +414,7 @@ export default function FoodAnalysisResultScreen() {
</View>
<View style={styles.foodIntakeCalories}>
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}</Text>
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
{shouldHideRecordBar ? null : <TouchableOpacity
style={styles.editButton}
onPress={() => handleEditFood(item)}
@@ -442,7 +445,7 @@ export default function FoodAnalysisResultScreen() {
activeOpacity={0.8}
>
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.retakePhotoButtonText}></Text>
<Text style={styles.retakePhotoButtonText}>{t('foodAnalysisResult.actions.retake')}</Text>
</TouchableOpacity>
</View>
) : (
@@ -471,7 +474,7 @@ export default function FoodAnalysisResultScreen() {
{isRecording ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.recordButtonText}></Text>
<Text style={styles.recordButtonText}>{t('foodAnalysisResult.actions.record')}</Text>
)}
</TouchableOpacity>
</View>
@@ -492,7 +495,7 @@ export default function FoodAnalysisResultScreen() {
/>
<View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text>
<Text style={styles.mealSelectorTitle}>{t('foodAnalysisResult.mealSelector.title')}</Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
@@ -539,8 +542,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{recognitionResult ?
`置信度: ${recognitionResult.confidence}%` :
dayjs().format('YYYY年M月D日 HH:mm')
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format(t('foodAnalysisResult.dateFormats.full'))
}
</Text>
</View>
@@ -551,7 +554,7 @@ export default function FoodAnalysisResultScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</Text>
</TouchableOpacity>
</View>
)}
@@ -587,6 +590,8 @@ function FoodEditModal({
onFormDataChange({ ...formData, [field]: value });
};
const { t } = useI18n();
return (
<Modal
visible={visible}
@@ -598,14 +603,14 @@ function FoodEditModal({
<View style={styles.editModalSheet}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('foodAnalysisResult.editModal.title')}</Text>
{/* 食物名称 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}></Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入食物名称"
placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
placeholderTextColor="#999"
value={formData.name}
onChangeText={(value) => handleFieldChange('name', value)}
@@ -615,10 +620,10 @@ function FoodEditModal({
{/* 重量/数量 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入重量"
placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
placeholderTextColor="#999"
value={formData.amount}
onChangeText={(value) => handleFieldChange('amount', value)}
@@ -628,10 +633,10 @@ function FoodEditModal({
{/* 卡路里 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入卡路里"
placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
placeholderTextColor="#999"
value={formData.calories}
onChangeText={(value) => handleFieldChange('calories', value)}
@@ -645,13 +650,13 @@ function FoodEditModal({
onPress={onClose}
style={styles.modalCancelBtn}
>
<Text style={styles.modalCancelText}></Text>
<Text style={styles.modalCancelText}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onSave}
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
>
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}></Text>
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>{t('foodAnalysisResult.editModal.actions.save')}</Text>
</TouchableOpacity>
</View>
</View>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
deleteNutritionAnalysisRecord,
@@ -30,6 +31,7 @@ import {
import ImageViewing from 'react-native-image-viewing';
export default function NutritionAnalysisHistoryScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
@@ -95,15 +97,15 @@ export default function NutritionAnalysisHistoryScreen() {
setHasMore(page < response.data.totalPages);
setCurrentPage(page);
} else {
const errorMessage = response.message || '获取历史记录失败';
const errorMessage = response.message || t('nutritionAnalysisHistory.errors.fetchFailed');
setError(errorMessage);
Alert.alert('错误', errorMessage);
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
}
} catch (error) {
console.error('[HISTORY] 获取历史记录失败:', error);
const errorMessage = '获取历史记录失败,请稍后重试';
const errorMessage = t('nutritionAnalysisHistory.errors.fetchFailedRetry');
setError(errorMessage);
Alert.alert('错误', errorMessage);
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
} finally {
setLoading(false);
setRefreshing(false);
@@ -173,13 +175,13 @@ export default function NutritionAnalysisHistoryScreen() {
const getStatusText = (status: string) => {
switch (status) {
case 'success':
return '成功';
return t('nutritionAnalysisHistory.status.success');
case 'failed':
return '失败';
return t('nutritionAnalysisHistory.status.failed');
case 'processing':
return '处理中';
return t('nutritionAnalysisHistory.status.processing');
default:
return '未知';
return t('nutritionAnalysisHistory.status.unknown');
}
};
@@ -208,15 +210,15 @@ export default function NutritionAnalysisHistoryScreen() {
// 处理删除记录
const handleDeleteRecord = useCallback((recordId: number) => {
Alert.alert(
'确认删除',
'确定要删除这条营养分析记录吗?此操作无法撤销。',
t('nutritionAnalysisHistory.delete.confirmTitle'),
t('nutritionAnalysisHistory.delete.confirmMessage'),
[
{
text: '取消',
text: t('nutritionAnalysisHistory.delete.cancel'),
style: 'cancel',
},
{
text: '删除',
text: t('nutritionAnalysisHistory.delete.delete'),
style: 'destructive',
onPress: async () => {
try {
@@ -231,10 +233,10 @@ export default function NutritionAnalysisHistoryScreen() {
triggerLightHaptic();
// 显示成功提示
Alert.alert('成功', '记录已删除');
Alert.alert(t('nutritionAnalysisHistory.delete.successTitle'), t('nutritionAnalysisHistory.delete.successMessage'));
} catch (error) {
console.error('[HISTORY] 删除记录失败:', error);
Alert.alert('错误', '删除失败,请稍后重试');
Alert.alert(t('nutritionAnalysisHistory.errors.error'), t('nutritionAnalysisHistory.errors.deleteFailed'));
} finally {
setDeletingId(null);
}
@@ -256,11 +258,11 @@ export default function NutritionAnalysisHistoryScreen() {
<View style={styles.recordInfo}>
{isSuccess && (
<Text style={styles.recordTitle}>
{item.nutritionCount}
{t('nutritionAnalysisHistory.recognized', { count: item.nutritionCount })}
</Text>
)}
<Text style={styles.recordDate}>
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
{dayjs(item.createdAt).format(t('nutritionAnalysisHistory.dateFormat'))}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
@@ -327,25 +329,25 @@ export default function NutritionAnalysisHistoryScreen() {
<>
{mainNutrients.energy && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.energy')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
</View>
)}
{mainNutrients.protein && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.protein')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
</View>
)}
{mainNutrients.carbs && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.carbs')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
</View>
)}
{mainNutrients.fat && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.fat')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
</View>
)}
@@ -371,7 +373,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={styles.expandButtonText}>
{isExpanded ? '收起详情' : '展开详情'}
{isExpanded ? t('nutritionAnalysisHistory.actions.collapse') : t('nutritionAnalysisHistory.actions.expand')}
</Text>
<Ionicons
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
@@ -383,7 +385,7 @@ export default function NutritionAnalysisHistoryScreen() {
{/* 详细信息 */}
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
<View style={styles.detailsContainer}>
<Text style={styles.detailsTitle}></Text>
<Text style={styles.detailsTitle}>{t('nutritionAnalysisHistory.details.title')}</Text>
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
<View key={nutritionItem.key} style={styles.detailItem}>
<View style={styles.nutritionInfo}>
@@ -397,8 +399,8 @@ export default function NutritionAnalysisHistoryScreen() {
))}
<View style={styles.metaInfo}>
<Text style={styles.metaText}>AI : {item.aiModel}</Text>
<Text style={styles.metaText}>: {item.aiProvider}</Text>
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.aiModel')}: {item.aiModel}</Text>
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.provider')}: {item.aiProvider}</Text>
</View>
</View>
)}
@@ -410,8 +412,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Ionicons name="document-text-outline" size={64} color="#CCC" />
<Text style={styles.emptyStateText}></Text>
<Text style={styles.emptyStateSubtext}></Text>
<Text style={styles.emptyStateText}>{t('nutritionAnalysisHistory.empty.title')}</Text>
<Text style={styles.emptyStateSubtext}>{t('nutritionAnalysisHistory.empty.subtitle')}</Text>
</View>
);
@@ -419,8 +421,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderErrorState = () => (
<View style={styles.errorState}>
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
<Text style={styles.errorStateText}></Text>
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text>
<Text style={styles.errorStateText}>{t('nutritionAnalysisHistory.errors.loadFailed')}</Text>
<Text style={styles.errorStateSubtext}>{error || t('nutritionAnalysisHistory.errors.unknownError')}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
@@ -428,7 +430,7 @@ export default function NutritionAnalysisHistoryScreen() {
fetchRecords(1, true);
}}
>
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('nutritionAnalysisHistory.actions.retry')}</Text>
</TouchableOpacity>
</View>
);
@@ -440,7 +442,7 @@ export default function NutritionAnalysisHistoryScreen() {
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.loadingFooterText}>...</Text>
<Text style={styles.loadingFooterText}>{t('nutritionAnalysisHistory.loadingMore')}</Text>
</View>
);
};
@@ -456,7 +458,7 @@ export default function NutritionAnalysisHistoryScreen() {
/>
<HeaderBar
title="历史记录"
title={t('nutritionAnalysisHistory.title')}
onBack={() => router.back()}
transparent={true}
/>
@@ -477,7 +479,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.filter.all')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -494,7 +496,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.success')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -511,7 +513,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.failed')}
</Text>
</TouchableOpacity>
</View>
@@ -520,7 +522,7 @@ export default function NutritionAnalysisHistoryScreen() {
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('nutritionAnalysisHistory.loading')}</Text>
</View>
) : (
<FlatList
@@ -555,7 +557,7 @@ export default function NutritionAnalysisHistoryScreen() {
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
{dayjs().format(t('nutritionAnalysisHistory.dateFormat'))}
</Text>
</View>
)}
@@ -565,7 +567,7 @@ export default function NutritionAnalysisHistoryScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
</TouchableOpacity>
</View>
)}

View File

@@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
analyzeNutritionImage,
@@ -29,6 +30,7 @@ import {
import ImageViewing from 'react-native-image-viewing';
export default function NutritionLabelAnalysisScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
@@ -77,7 +79,7 @@ export default function NutritionLabelAnalysisScreen() {
const requestCameraPermission = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限不足', '需要相机权限才能拍摄成分表');
Alert.alert(t('nutritionLabelAnalysis.camera.permissionDenied'), t('nutritionLabelAnalysis.camera.permissionMessage'));
return false;
}
return true;
@@ -153,7 +155,7 @@ export default function NutritionLabelAnalysisScreen() {
// 直接使用服务端返回的数据,不做任何转换
setNewAnalysisResult(analysisResponse);
} else {
throw new Error(analysisResponse.message || '分析失败');
throw new Error(analysisResponse.message || t('nutritionLabelAnalysis.errors.analysisFailed.defaultMessage'));
}
} catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
@@ -162,8 +164,8 @@ export default function NutritionLabelAnalysisScreen() {
// 显示错误提示
Alert.alert(
'分析失败',
error.message || '无法识别成分表,请尝试拍摄更清晰的照片'
t('nutritionLabelAnalysis.errors.analysisFailed.title'),
error.message || t('nutritionLabelAnalysis.errors.analysisFailed.message')
);
} finally {
setIsUploading(false);
@@ -182,7 +184,7 @@ export default function NutritionLabelAnalysisScreen() {
/>
<HeaderBar
title="成分表分析"
title={t('nutritionLabelAnalysis.title')}
onBack={() => router.back()}
transparent={true}
right={
@@ -253,7 +255,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8}
>
<Ionicons name="search-outline" size={20} color="#FFF" />
<Text style={styles.analyzeButtonText}></Text>
<Text style={styles.analyzeButtonText}>{t('nutritionLabelAnalysis.actions.startAnalysis')}</Text>
</TouchableOpacity>
)}
@@ -274,7 +276,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}>
<Ionicons name="document-text-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text>
<Text style={styles.placeholderText}>{t('nutritionLabelAnalysis.placeholder.text')}</Text>
</View>
{/* 操作按钮区域 */}
<View style={styles.imageActionButtonsContainer}>
@@ -284,7 +286,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8}
>
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
<Text style={styles.imageActionButtonText}></Text>
<Text style={styles.imageActionButtonText}>{t('nutritionLabelAnalysis.actions.takePhoto')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
@@ -292,7 +294,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8}
>
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}></Text>
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>{t('nutritionLabelAnalysis.actions.selectFromAlbum')}</Text>
</TouchableOpacity>
</View>
</View>
@@ -307,7 +309,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View>
<Text style={styles.analysisSectionTitle}></Text>
<Text style={styles.analysisSectionTitle}>{t('nutritionLabelAnalysis.results.title')}</Text>
</View>
<View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => (
@@ -352,7 +354,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>
... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
{t('nutritionLabelAnalysis.status.uploading')} {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
</Text>
</View>
)}
@@ -361,7 +363,7 @@ export default function NutritionLabelAnalysisScreen() {
{isAnalyzing && !newAnalysisResult && !isUploading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('nutritionLabelAnalysis.status.analyzing')}</Text>
</View>
)}
</ScrollView>
@@ -377,7 +379,7 @@ export default function NutritionLabelAnalysisScreen() {
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
{dayjs().format(t('nutritionLabelAnalysis.imageViewer.dateFormat'))}
</Text>
</View>
)}
@@ -387,7 +389,7 @@ export default function NutritionLabelAnalysisScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
</TouchableOpacity>
</View>
)}
@@ -514,7 +516,7 @@ const styles = StyleSheet.create({
},
imageActionButtonText: {
color: Colors.light.onPrimary,
fontSize: 14,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
},

687
app/gallery/index.tsx Normal file
View File

@@ -0,0 +1,687 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useVipService } from '@/hooks/useVipService';
import { AiReportRecord, generateAiReport, getAiReportHistory } from '@/services/aiReport';
import { getAuthToken } from '@/services/api';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as FileSystem from 'expo-file-system/legacy';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Animated,
Platform,
Pressable,
RefreshControl,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function GalleryScreen() {
const { t, i18n } = useTranslation();
const insets = useSafeAreaInsets();
const { ensureLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
// 报告历史列表
const [reports, setReports] = useState<AiReportRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [reportImageUrl, setReportImageUrl] = useState<string | null>(null);
const [reportLocalUri, setReportLocalUri] = useState<string | null>(null);
const [reportModalVisible, setReportModalVisible] = useState(false);
const [isSavingReport, setIsSavingReport] = useState(false);
const [isSharingReport, setIsSharingReport] = useState(false);
const reportSpinAnim = useRef(new Animated.Value(0)).current;
const reportIconSpin = reportSpinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
});
const emptyImageHeight = useMemo(() => screenHeight / 1.5, [screenHeight]);
const todayString = useMemo(() => dayjs().format('YYYY-MM-DD'), []);
const reportImageSize = useMemo(() => {
const maxWidth = Math.min(screenWidth - 40, 440);
const maxHeight = screenHeight - 240;
let width = maxWidth;
let height = (maxWidth * 16) / 9;
if (height > maxHeight) {
height = maxHeight;
width = (maxHeight * 9) / 16;
}
return { width, height };
}, [screenHeight, screenWidth]);
// 加载报告历史
const loadReports = useCallback(async (pageNum: number, refresh = false) => {
try {
const response = await getAiReportHistory({
page: pageNum,
pageSize: 10,
status: 'success',
});
if (refresh) {
setReports(response.records);
} else {
setReports(prev => [...prev, ...response.records]);
}
setHasMore(pageNum < response.totalPages);
setPage(pageNum);
} catch (error: any) {
console.error('load-ai-report-history-failed', error);
if (refresh) {
Toast.error(t('statistics.aiReport.loadFailed', '加载报告历史失败'));
}
}
}, [t]);
// 初始加载
useEffect(() => {
const init = async () => {
setIsLoading(true);
await loadReports(1, true);
setIsLoading(false);
};
init();
}, [loadReports]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await loadReports(1, true);
setIsRefreshing(false);
}, [loadReports]);
// 加载更多
const handleLoadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
await loadReports(page + 1, false);
setIsLoadingMore(false);
}, [isLoadingMore, hasMore, page, loadReports]);
useEffect(() => {
if (!isGeneratingReport) {
reportSpinAnim.stopAnimation();
return;
}
reportSpinAnim.setValue(0);
const loop = Animated.loop(
Animated.timing(reportSpinAnim, {
toValue: 1,
duration: 1400,
useNativeDriver: true,
})
);
loop.start();
return () => loop.stop();
}, [isGeneratingReport, reportSpinAnim]);
const handleGenerateReport = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok || isGeneratingReport) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal({
onPurchaseSuccess: () => {
// 购买成功后自动触发生成
handleGenerateReport();
},
});
return;
}
setIsGeneratingReport(true);
setReportLocalUri(null);
Toast.info(t('statistics.aiReport.generating', '正在生成健康报告,预计 1030 秒…'));
try {
const response = await generateAiReport({ date: todayString });
const imageUrl = (response as any)?.imageUrl ?? (response as any)?.url ?? (response as any)?.image_url;
if (!imageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportImageUrl(imageUrl);
setReportModalVisible(true);
Toast.success(t('statistics.aiReport.success', '报告已生成'));
// 生成成功后刷新列表
handleRefresh();
} catch (error: any) {
console.error('generate-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.failed', '生成报告失败,请稍后重试'));
} finally {
setIsGeneratingReport(false);
}
}, [ensureLoggedIn, isGeneratingReport, checkServiceAccess, openMembershipModal, t, todayString, handleRefresh]);
const prepareLocalReportImage = useCallback(async () => {
if (!reportImageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
if (reportLocalUri) {
return reportLocalUri;
}
const fileUri = `${FileSystem.cacheDirectory}ai-report-${Date.now()}.jpg`;
const token = await getAuthToken();
const download = await FileSystem.downloadAsync(
reportImageUrl,
fileUri,
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
);
if (!download?.uri) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportLocalUri(download.uri);
return download.uri;
}, [reportImageUrl, reportLocalUri, t]);
const handleSaveReport = useCallback(async () => {
if (isSavingReport) return;
try {
setIsSavingReport(true);
const permission = await MediaLibrary.requestPermissionsAsync();
if (permission.status !== 'granted') {
Toast.warning(t('statistics.aiReport.permission', '需要相册权限才能保存图片'));
return;
}
const localUri = await prepareLocalReportImage();
await MediaLibrary.saveToLibraryAsync(localUri);
Toast.success(t('statistics.aiReport.saved', '已保存到相册'));
} catch (error: any) {
console.error('save-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.saveFailed', '保存失败,请稍后重试'));
} finally {
setIsSavingReport(false);
}
}, [isSavingReport, prepareLocalReportImage, t]);
const handleShareReport = useCallback(async () => {
if (isSharingReport) return;
try {
setIsSharingReport(true);
const localUri = await prepareLocalReportImage();
await Share.share({
message: t('statistics.aiReport.shareMessage', '这是我的 AI 健康报告,分享给你看看!'),
url: Platform.OS === 'ios' ? localUri : `file://${localUri}`,
title: t('statistics.aiReport.shareTitle', 'AI 健康报告')
});
} catch (error: any) {
console.error('share-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.shareFailed', '分享失败,请稍后重试'));
} finally {
setIsSharingReport(false);
}
}, [isSharingReport, prepareLocalReportImage, t]);
// 点击卡片查看报告
const handleCardPress = useCallback((report: AiReportRecord) => {
if (!report.imageUrl) return;
setReportImageUrl(report.imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}, []);
// 滚动到底部加载更多
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 100;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
}, [handleLoadMore]);
const headerRight = isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleGenerateReport}
disabled={isGeneratingReport}
>
<GlassView
style={styles.reportButton}
glassEffectStyle="clear"
isInteractive
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleGenerateReport}
style={[styles.reportButton, styles.reportButtonFallback]}
disabled={isGeneratingReport}
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</TouchableOpacity>
);
const headerTitle = (
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>{t('statistics.aiReport.galleryTitle', 'AI 报告画廊')}</Text>
<Text style={styles.headerSubtitle}>{t('statistics.aiReport.gallerySubtitle', '沉浸式浏览你的健康报告')}</Text>
</View>
);
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
<LinearGradient
colors={['#f0f4ff', '#fdf8ff', '#f6f8fa']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title={headerTitle}
right={headerRight}
tone="light"
transparent
/>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 56,
paddingBottom: 40,
paddingHorizontal: 16,
...(reports.length === 0 && !isLoading ? { flexGrow: 1, justifyContent: 'center' } : {})
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor="#6B7280"
/>
}
onScroll={handleScroll}
scrollEventThrottle={400}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
</View>
) : reports.length === 0 ? (
<View style={styles.emptyContainer}>
<Pressable
style={styles.emptyImageCard}
onPress={() => {
const imageUrl = i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg';
setReportImageUrl(imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}}
>
<ExpoImage
source={{
uri: i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg'
}}
style={[styles.emptyImage, { height: emptyImageHeight }]}
contentFit="contain"
transition={300}
/>
<View style={styles.emptyImageOverlay}>
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={14} color="#fff" />
<Text style={styles.previewHintText}>{t('statistics.aiReport.clickToPreview', '点击预览模板')}</Text>
</View>
</View>
</Pressable>
<View style={styles.emptyContent}>
<Text style={styles.emptyTitle}>{t('statistics.aiReport.emptyHistory', '暂无报告记录')}</Text>
<Text style={styles.emptySubtitle}>{t('statistics.aiReport.emptyHistoryHint', '点击右上方按钮生成你的第一份报告')}</Text>
</View>
</View>
) : (
<View style={styles.galleryGrid}>
{reports.map((report) => (
<Pressable
key={report.id}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => handleCardPress(report)}
>
<ExpoImage
source={{ uri: report.imageUrl }}
style={styles.cardImage}
contentFit="cover"
transition={250}
/>
<View style={styles.cardBody}>
<Text numberOfLines={1} style={styles.cardTitle}>
{dayjs(report.reportDate).format('YYYY年M月D日')}
</Text>
<Text style={styles.cardSubtitle}>
{dayjs(report.createdAt).format('HH:mm')} {t('statistics.aiReport.generated', '生成')}
</Text>
</View>
</Pressable>
))}
{isLoadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#6B7280" />
</View>
)}
</View>
)}
</ScrollView>
{reportModalVisible && (
<View style={styles.modalOverlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setReportModalVisible(false)} />
<View style={styles.modalCard}>
{reportImageUrl ? (
<ExpoImage
source={{ uri: reportImageUrl }}
style={[styles.reportImage, { width: reportImageSize.width, height: reportImageSize.height }]}
contentFit="cover"
/>
) : (
<View style={[styles.reportImageFallback, { width: reportImageSize.width, height: reportImageSize.height }]}>
<Text style={styles.reportFallbackText}>{t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试')}</Text>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, isSavingReport && styles.modalButtonDisabled]}
onPress={handleSaveReport}
disabled={isSavingReport}
>
<Ionicons name="download-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSavingReport ? t('statistics.aiReport.saving', '保存中…') : t('statistics.aiReport.save', '保存')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, isSharingReport && styles.modalButtonDisabled]}
onPress={handleShareReport}
disabled={isSharingReport}
>
<Ionicons name="share-social-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSharingReport ? t('statistics.aiReport.sharing', '分享中…') : t('statistics.aiReport.share', '分享')}
</Text>
</TouchableOpacity>
</View>
<Pressable style={styles.closeRow} onPress={() => setReportModalVisible(false)}>
<Ionicons name="close" size={18} color="#4B5563" />
<Text style={styles.closeLabel}>{t('statistics.aiReport.close', '收起')}</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f7f8fb',
},
headerCenter: {
flex: 1,
minWidth: 0,
},
headerTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#0F172A',
textAlign: 'center',
},
headerSubtitle: {
marginTop: 2,
color: '#6B7280',
fontSize: 12,
fontFamily: 'AliRegular',
textAlign: 'center',
},
reportButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
reportButtonFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderWidth: 1,
borderColor: '#E5E7EB',
},
reportIconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E0F2FE',
alignItems: 'center',
justifyContent: 'center',
},
loadingContainer: {
flex: 1,
paddingTop: 100,
alignItems: 'center',
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
gap: 24,
},
emptyImageCard: {
width: '100%',
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
emptyImage: {
width: '100%',
height: 380,
},
emptyImageOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.15)',
borderRadius: 20,
},
previewHint: {
position: 'absolute',
top: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
previewHintText: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#fff',
},
emptyContent: {
alignItems: 'center',
gap: 12,
paddingHorizontal: 20,
},
emptyTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#1F2937',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontFamily: 'AliRegular',
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
},
emptyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 28,
paddingVertical: 14,
backgroundColor: '#3B82F6',
borderRadius: 28,
marginTop: 8,
shadowColor: '#3B82F6',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
},
emptyButtonText: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#fff',
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
galleryGrid: {
gap: 18,
},
card: {
backgroundColor: '#fff',
borderRadius: 22,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
cardPressed: {
transform: [{ scale: 0.99 }],
},
cardImage: {
width: '100%',
height: 360,
},
cardBody: {
paddingHorizontal: 16,
paddingVertical: 14,
gap: 4,
},
cardTitle: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#111827',
},
cardSubtitle: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#9CA3AF',
},
modalOverlay: {
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(12, 18, 27, 0.78)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
modalCard: {
backgroundColor: '#FDFDFE',
borderRadius: 20,
padding: 14,
alignItems: 'center',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.28,
shadowRadius: 18,
elevation: 16,
},
reportImage: {
borderRadius: 14,
overflow: 'hidden',
},
reportImageFallback: {
borderRadius: 14,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
},
reportFallbackText: {
textAlign: 'center',
color: '#111827',
fontFamily: 'AliRegular',
},
modalActions: {
flexDirection: 'row',
gap: 10,
},
modalButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#E0F2FE',
borderRadius: 12,
borderWidth: 1,
borderColor: '#BAE6FD',
},
modalButtonDisabled: {
opacity: 0.6,
},
modalButtonText: {
fontSize: 14,
color: '#0F172A',
fontFamily: 'AliBold',
},
closeRow: {
marginTop: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 6,
},
closeLabel: {
fontSize: 14,
color: '#4B5563',
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,620 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import {
fetchFamilyGroup,
generateInviteCode,
selectFamilyGroup,
selectFamilyHealthLoading,
selectInviteCode,
selectInviteLoading,
} from '@/store/familyHealthSlice';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FamilyInviteScreen() {
const insets = useSafeAreaInsets();
const dispatch = useAppDispatch();
const [agreed, setAgreed] = useState(true);
const [showQRModal, setShowQRModal] = useState(false);
// Redux state
const familyGroup = useAppSelector(selectFamilyGroup);
const inviteCode = useAppSelector(selectInviteCode);
const isLoading = useAppSelector(selectFamilyHealthLoading);
const isInviteLoading = useAppSelector(selectInviteLoading);
// 初始化时获取家庭组信息
useEffect(() => {
dispatch(fetchFamilyGroup());
}, [dispatch]);
// 处理邀请按钮点击
const handleInvite = async () => {
try {
// 生成邀请码
await dispatch(generateInviteCode(24)).unwrap();
// 显示二维码弹窗
setShowQRModal(true);
} catch (error: any) {
Alert.alert('邀请失败', error?.message || '请稍后重试');
}
};
// 分享邀请码
const handleShare = async () => {
if (!inviteCode) return;
try {
await Share.share({
message: `邀请您加入我的家庭健康管理组!\n邀请码${inviteCode.inviteCode}\n有效期至${new Date(inviteCode.expiresAt).toLocaleString()}`,
title: '家庭健康管理邀请',
});
} catch (error) {
console.error('分享失败:', error);
}
};
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<HeaderBar title="" transparent />
<LinearGradient
colors={['#Eef2FF', '#F5F3FF', '#FFFFFF']}
style={styles.background}
/>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 40 }]}
showsVerticalScrollIndicator={false}
>
{/* Header Title Area */}
<View style={styles.headerSection}>
<Text style={styles.mainTitle}></Text>
<Text style={styles.mainTitle}></Text>
<View style={styles.subtitleBadge}>
<Ionicons name="home" size={12} color="#5B4CFF" />
<Text style={styles.subtitleText}></Text>
</View>
</View>
{/* Hero Image / House Icon Area */}
<View style={styles.heroContainer}>
{/* Floating Labels */}
<View style={[styles.floatingLabel, styles.labelLeft]}>
<Text style={styles.floatingLabelText}></Text>
<View style={styles.dot} />
</View>
<View style={[styles.floatingLabel, styles.labelRight]}>
<View style={styles.dot} />
<Text style={styles.floatingLabelText}></Text>
</View>
{/* Main 3D House Icon Placeholder */}
<View style={styles.houseIconPlaceholder}>
<LinearGradient
colors={['#A78BFA', '#5B4CFF']}
style={styles.houseIconGradient}
>
<Ionicons name="heart" size={60} color="#FFFFFF" />
</LinearGradient>
</View>
</View>
{/* Features Grid */}
<View style={styles.featuresCard}>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#EEF2FF' }]}>
<Ionicons name="share-social" size={24} color="#5B4CFF" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#FEF2F2' }]}>
<Ionicons name="alert-circle" size={24} color="#EF4444" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#FFF7ED' }]}>
<Ionicons name="medkit" size={24} color="#F97316" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
</View>
{/* Steps Section */}
<View style={styles.stepsContainer}>
<Text style={styles.stepsTitle}>3</Text>
<View style={styles.stepsSubtitleContainer}>
<Text style={styles.stepsSubtitle}>624</Text>
</View>
<View style={styles.stepsRow}>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepDesc}></Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>2</Text>
<Text style={styles.stepDesc}>App</Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>3</Text>
<Text style={styles.stepDesc}></Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
</View>
</View>
{/* Bottom Spacing */}
<View style={{ height: 120 }} />
</ScrollView>
{/* Bottom Action Area */}
<View style={[styles.bottomArea, { paddingBottom: insets.bottom + 16 }]}>
<TouchableOpacity
style={styles.checkboxRow}
onPress={() => setAgreed(!agreed)}
activeOpacity={0.8}
>
<Ionicons
name={agreed ? "checkmark-circle" : "ellipse-outline"}
size={20}
color={agreed ? "#5B4CFF" : "#9CA3AF"}
/>
<Text style={styles.checkboxText}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.inviteButton, (!agreed || isLoading) && styles.inviteButtonDisabled]}
disabled={!agreed || isLoading}
onPress={handleInvite}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.inviteButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* QR Code Modal */}
<Modal
visible={showQRModal}
transparent
animationType="fade"
onRequestClose={() => setShowQRModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}></Text>
<TouchableOpacity onPress={() => setShowQRModal(false)}>
<Ionicons name="close" size={24} color="#6B7280" />
</TouchableOpacity>
</View>
{isInviteLoading ? (
<View style={styles.qrContainer}>
<ActivityIndicator size="large" color="#5B4CFF" />
</View>
) : inviteCode ? (
<>
<View style={styles.qrContainer}>
{/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */}
<View style={styles.inviteCodeDisplay}>
<Ionicons name="qr-code-outline" size={48} color="#5B4CFF" />
<Text style={styles.inviteCodeBig}>{inviteCode.inviteCode}</Text>
<Text style={styles.inviteCodeHint}> App </Text>
</View>
</View>
<View style={styles.inviteCodeContainer}>
<Text style={styles.inviteCodeLabel}></Text>
<Text style={styles.inviteCodeText}>{inviteCode.inviteCode}</Text>
</View>
<Text style={styles.expireText}>
{new Date(inviteCode.expiresAt).toLocaleString()}
</Text>
<TouchableOpacity style={styles.shareButton} onPress={handleShare}>
<Ionicons name="share-outline" size={20} color="#FFFFFF" />
<Text style={styles.shareButtonText}></Text>
</TouchableOpacity>
</>
) : null}
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
background: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollContent: {
paddingHorizontal: 20,
},
headerSection: {
alignItems: 'center',
marginBottom: 30,
},
mainTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#1F2937',
lineHeight: 36,
textAlign: 'center',
},
subtitleBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.6)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
marginTop: 16,
borderWidth: 1,
borderColor: '#FFFFFF',
},
subtitleText: {
fontSize: 12,
color: '#5B4CFF',
marginLeft: 6,
fontWeight: '600',
},
heroContainer: {
alignItems: 'center',
justifyContent: 'center',
height: 180,
marginBottom: 20,
position: 'relative',
},
houseIconPlaceholder: {
width: 140,
height: 140,
alignItems: 'center',
justifyContent: 'center',
},
houseIconGradient: {
width: 100,
height: 100,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate: '45deg' }],
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
floatingLabel: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
labelLeft: {
left: 0,
top: 40,
},
labelRight: {
right: 0,
top: 20,
},
floatingLabelText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '600',
marginHorizontal: 4,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#5B4CFF',
},
featuresCard: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
justifyContent: 'space-between',
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
featureItem: {
flex: 1,
alignItems: 'center',
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
},
featureTitle: {
fontSize: 14,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 4,
},
featureDesc: {
fontSize: 10,
color: '#9CA3AF',
textAlign: 'center',
},
stepsContainer: {
backgroundColor: 'rgba(255,255,255,0.6)',
borderRadius: 24,
padding: 20,
paddingBottom: 30,
marginBottom: 20,
},
stepsTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#1F2937',
textAlign: 'center',
marginBottom: 8,
},
stepsSubtitleContainer: {
backgroundColor: '#F3F4F6',
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 10,
marginBottom: 24,
},
stepsSubtitle: {
fontSize: 11,
color: '#6B7280',
},
stepsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
stepItem: {
flex: 1,
alignItems: 'center',
},
stepNumber: {
fontSize: 20,
fontWeight: 'bold',
color: '#5B4CFF',
marginBottom: 8,
fontStyle: 'italic',
},
stepDesc: {
fontSize: 12,
color: '#4B5563',
textAlign: 'center',
marginBottom: 12,
height: 32,
},
stepPhoneMockup: {
width: 60,
height: 100,
backgroundColor: '#FFFFFF',
borderRadius: 10,
borderWidth: 2,
borderColor: '#E5E7EB',
padding: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
mockupScreen: {
flex: 1,
backgroundColor: '#F3F4F6',
borderRadius: 6,
},
bottomArea: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
paddingTop: 16,
paddingHorizontal: 20,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 10,
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 16,
paddingHorizontal: 4,
},
checkboxText: {
flex: 1,
marginLeft: 8,
fontSize: 12,
color: '#6B7280',
lineHeight: 18,
},
inviteButton: {
backgroundColor: '#5B4CFF',
borderRadius: 28,
height: 56,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
inviteButtonDisabled: {
backgroundColor: '#C4B5FD',
shadowOpacity: 0,
},
inviteButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'bold',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '100%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 24,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1F2937',
},
qrContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
backgroundColor: '#F9FAFB',
borderRadius: 16,
marginBottom: 20,
minHeight: 180,
},
inviteCodeDisplay: {
alignItems: 'center',
justifyContent: 'center',
},
inviteCodeBig: {
fontSize: 36,
fontWeight: 'bold',
color: '#5B4CFF',
letterSpacing: 4,
marginTop: 16,
marginBottom: 8,
},
inviteCodeHint: {
fontSize: 12,
color: '#9CA3AF',
},
inviteCodeContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 12,
marginBottom: 12,
},
inviteCodeLabel: {
fontSize: 14,
color: '#6B7280',
marginRight: 8,
},
inviteCodeText: {
fontSize: 20,
fontWeight: 'bold',
color: '#5B4CFF',
letterSpacing: 2,
},
expireText: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
shareButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#5B4CFF',
borderRadius: 16,
paddingVertical: 14,
},
shareButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
});

617
app/health/profile.tsx Normal file
View File

@@ -0,0 +1,617 @@
import { HealthProgressRing } from '@/components/health/HealthProgressRing';
import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import {
fetchFamilyGroup,
joinFamilyGroup,
selectFamilyGroup,
} from '@/store/familyHealthSlice';
import {
fetchHealthHistory,
selectHealthHistoryProgress
} from '@/store/healthSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function HealthProfileScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const { t } = useI18n();
const dispatch = useAppDispatch();
const { ensureLoggedIn } = useAuthGuard();
const glassAvailable = isLiquidGlassAvailable();
const [activeTab, setActiveTab] = useState(0);
const [joinModalVisible, setJoinModalVisible] = useState(false);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedRelationship, setSelectedRelationship] = useState('');
const [isJoining, setIsJoining] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
// Redux state
const familyGroup = useAppSelector(selectFamilyGroup);
const medicalRecords = useAppSelector((state) => state.health.medicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
// Calculate Medical Records Count
const medicalRecordsCount = useMemo(() => records.length + prescriptions.length, [records, prescriptions]);
// 亲属关系选项
const relationshipOptions = useMemo(() => [
{ key: 'spouse', label: t('familyGroup.relationships.spouse') },
{ key: 'father', label: t('familyGroup.relationships.father') },
{ key: 'mother', label: t('familyGroup.relationships.mother') },
{ key: 'son', label: t('familyGroup.relationships.son') },
{ key: 'daughter', label: t('familyGroup.relationships.daughter') },
{ key: 'grandfather', label: t('familyGroup.relationships.grandfather') },
{ key: 'grandmother', label: t('familyGroup.relationships.grandmother') },
{ key: 'grandson', label: t('familyGroup.relationships.grandson') },
{ key: 'granddaughter', label: t('familyGroup.relationships.granddaughter') },
{ key: 'brother', label: t('familyGroup.relationships.brother') },
{ key: 'sister', label: t('familyGroup.relationships.sister') },
{ key: 'uncle', label: t('familyGroup.relationships.uncle') },
{ key: 'aunt', label: t('familyGroup.relationships.aunt') },
{ key: 'nephew', label: t('familyGroup.relationships.nephew') },
{ key: 'niece', label: t('familyGroup.relationships.niece') },
{ key: 'cousin', label: t('familyGroup.relationships.cousin') },
{ key: 'other', label: t('familyGroup.relationships.other') },
], [t]);
// Mock user data - in a real app this would come from Redux/Context
const userProfile = useAppSelector((state) => state.user.profile);
const displayName = userProfile.name?.trim() ? userProfile.name : DEFAULT_MEMBER_NAME;
const avatarUrl = userProfile.avatar || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
// 从 Redux 获取健康史进度
const healthHistoryProgress = useAppSelector(selectHealthHistoryProgress);
// Mock health data
const healthData = {
bmi: userProfile.weight && userProfile.height ? (parseFloat(userProfile.weight) / Math.pow(parseFloat(userProfile.height) / 100, 2)).toFixed(1) : '--',
height: userProfile.height ? `${parseFloat(userProfile.height).toFixed(1)}` : '--',
weight: userProfile.weight ? `${parseFloat(userProfile.weight).toFixed(1)}` : '--',
waist: userProfile.waistCircumference ? `${parseFloat(userProfile.waistCircumference.toString()).toFixed(1)}` : '--',
status: '健康状况良好',
statusDesc: '请继续保持良好的生活习惯',
statusMessage: '您的健康状况不错哦~'
};
// Calculate Basic Info completion percentage
const basicInfoProgress = useMemo(() => {
let filledCount = 0;
const totalFields = 3; // height, weight, waist
if (userProfile.height && parseFloat(userProfile.height) > 0) filledCount++;
if (userProfile.weight && parseFloat(userProfile.weight) > 0) filledCount++;
if (userProfile.waistCircumference && parseFloat(userProfile.waistCircumference.toString()) > 0) filledCount++;
return Math.round((filledCount / totalFields) * 100);
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
// 初始化获取家庭组信息和健康史数据
useEffect(() => {
dispatch(fetchFamilyGroup());
dispatch(fetchHealthHistory());
}, [dispatch]);
// 重置弹窗状态
useEffect(() => {
if (!joinModalVisible) {
setInviteCodeInput('');
setSelectedRelationship('');
setJoinError(null);
}
}, [joinModalVisible]);
// 打开加入弹窗
const handleOpenJoin = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setJoinModalVisible(true);
}, [ensureLoggedIn]);
// 提交加入家庭组
const handleSubmitJoin = useCallback(async () => {
if (isJoining) return;
const ok = await ensureLoggedIn();
if (!ok) return;
const code = inviteCodeInput.trim().toUpperCase();
if (!code) {
setJoinError(t('familyGroup.errors.emptyCode'));
return;
}
if (!selectedRelationship) {
setJoinError(t('familyGroup.errors.emptyRelationship'));
return;
}
// 获取选中关系的显示文本
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
setIsJoining(true);
setJoinError(null);
try {
await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
await dispatch(fetchFamilyGroup());
setJoinModalVisible(false);
Toast.success(t('familyGroup.success'));
} catch (error) {
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
setJoinError(message);
} finally {
setIsJoining(false);
}
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const tabs = [
t('health.tabs.healthProfile.basicInfo'),
t('health.tabs.healthProfile.healthHistory'),
// t('health.tabs.healthProfile.medicalRecords'),
t('health.tabs.healthProfile.checkupRecords'),
t('health.tabs.healthProfile.medicineBox')
];
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
const handleTabPress = (index: number) => {
if (index === 3) {
// Handle Medicine Box tab specially
router.push('/medications/manage-medications');
return;
}
setActiveTab(index);
};
const renderActiveTab = () => {
switch (activeTab) {
case 0:
return <BasicInfoTab healthData={healthData} />;
case 1:
return <HealthHistoryTab />;
case 2:
return <MedicalRecordsTab />;
case 3:
return <CheckupRecordsTab />;
default:
return <BasicInfoTab healthData={healthData} />;
}
};
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Stack.Screen options={{ headerShown: false }} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<HeaderBar
title={t('health.tabs.healthProfile.title')}
transparent
right={
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */}
{!familyGroup && (
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin} style={{ marginRight: 10 }}>
{glassAvailable ? (
<GlassView
style={styles.joinButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.18)"
isInteractive
>
<Text style={styles.joinButtonLabel}></Text>
</GlassView>
) : (
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}></Text>
</View>
)}
</TouchableOpacity>
)}
<TouchableOpacity style={{ marginRight: 12 }}>
<Ionicons name="settings-outline" size={22} color="#1F2937" />
</TouchableOpacity>
</View>
}
/>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 60 }]}
showsVerticalScrollIndicator={false}
>
{/* Top Section with Avatar and Status */}
<View style={styles.topSection}>
<View style={styles.avatarRow}>
<View style={styles.miniAvatarContainer}>
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
<Text style={styles.miniAvatarName}>{displayName}</Text>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
>
<Ionicons name="add" size={16} color="#6B7280" />
</TouchableOpacity>
</View>
{/* Action Buttons - Replaced with HealthProgressRing */}
<View style={styles.actionButtonsRow}>
<HealthProgressRing
title={t('health.tabs.healthProfile.basicInfo')}
progress={basicInfoProgress}
gradientColors={['#9B8AFB', '#5B4CFF']}
/>
<HealthProgressRing
title={t('health.tabs.healthProfile.healthHistory')}
progress={healthHistoryProgress}
gradientColors={['#E0E7FF', '#C7D2FE']}
label={healthHistoryProgress.toString()}
suffix="%"
/>
<HealthProgressRing
title={t('health.tabs.healthProfile.medicalRecords')}
progress={0}
gradientColors={['#E0E7FF', '#C7D2FE']}
label={medicalRecordsCount.toString()}
suffix="份"
/>
</View>
</View>
{/* Family Invite Banner */}
<TouchableOpacity
style={styles.inviteBanner}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
>
<View style={styles.inviteContent}>
<View style={styles.inviteIconContainer}>
<Ionicons name="home" size={18} color="#5B4CFF" />
</View>
<Text style={styles.inviteText}>{t('health.tabs.healthProfile.subtitle')}</Text>
<Ionicons name="chevron-forward" size={18} color="#6B7280" />
</View>
</TouchableOpacity>
{/* Tab/Segment Control */}
<View style={styles.segmentControl}>
{tabs.map((tab, index) => (
<TouchableOpacity
key={index}
style={styles.segmentItem}
onPress={() => handleTabPress(index)}
activeOpacity={0.7}
>
<View style={[styles.segmentIconPlaceholder, index === activeTab && styles.segmentIconActive]}>
<Ionicons
name={tabIcons[index] as any}
size={20}
color={index === activeTab ? "#5B4CFF" : "#6B7280"}
/>
</View>
<Text style={[styles.segmentText, index === activeTab && styles.segmentTextActive]}>{tab}</Text>
</TouchableOpacity>
))}
</View>
{/* Active Tab Content */}
{renderActiveTab()}
{/* Privacy Notice Footer */}
<View style={styles.privacyNoticeContainer}>
<View style={styles.privacyIconWrapper}>
<Ionicons name="shield-checkmark" size={16} color="#9CA3AF" />
</View>
<Text style={styles.privacyNoticeText}>
{t('health.tabs.healthProfile.privacyNotice')}
</Text>
</View>
</ScrollView>
{/* 加入家庭组弹窗 */}
<ConfirmationSheet
visible={joinModalVisible}
onClose={() => setJoinModalVisible(false)}
onConfirm={handleSubmitJoin}
title={t('familyGroup.joinTitle')}
description={t('familyGroup.joinDescription')}
confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
cancelText={t('familyGroup.cancel')}
loading={isJoining}
content={
<View style={styles.joinModalContent}>
{/* 邀请码输入 */}
<TextInput
style={styles.inviteCodeInput}
placeholder={t('familyGroup.inviteCodePlaceholder')}
placeholderTextColor="#9ca3af"
value={inviteCodeInput}
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
autoCapitalize="characters"
autoCorrect={false}
keyboardType="default"
maxLength={12}
/>
{/* 关系选择标签 */}
<Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
{/* 关系选项网格 - 固定高度可滚动 */}
<ScrollView
style={styles.relationshipScrollView}
contentContainerStyle={styles.relationshipGrid}
showsVerticalScrollIndicator={true}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
{relationshipOptions.map((option) => {
const isSelected = selectedRelationship === option.key;
return (
<Pressable
key={option.key}
style={[
styles.relationshipChip,
isSelected && styles.relationshipChipSelected,
]}
onPress={() => setSelectedRelationship(option.key)}
>
<Text
style={[
styles.relationshipChipText,
isSelected && styles.relationshipChipTextSelected,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
{/* 错误提示 */}
{joinError && joinModalVisible ? (
<Text style={styles.modalError}>{joinError}</Text>
) : null}
</View>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 100,
},
topSection: {
marginBottom: 20,
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
miniAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#5B4CFF',
paddingVertical: 4,
paddingHorizontal: 4,
paddingRight: 12,
borderRadius: 20,
},
miniAvatar: {
width: 24,
height: 24,
borderRadius: 12,
marginRight: 6,
borderWidth: 1,
borderColor: '#FFF',
},
miniAvatarName: {
color: '#FFF',
fontSize: 12,
fontWeight: 'bold',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
actionButtonsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 24,
marginBottom: 12,
},
inviteBanner: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 16,
marginBottom: 20,
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
inviteContent: {
flexDirection: 'row',
alignItems: 'center',
},
inviteIconContainer: {
marginRight: 8,
},
inviteText: {
flex: 1,
fontSize: 13,
color: '#1F2138',
fontWeight: '600',
},
segmentControl: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
paddingHorizontal: 18,
},
segmentItem: {
alignItems: 'center',
},
segmentIconPlaceholder: {
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4,
},
segmentIconActive: {
backgroundColor: '#E0E7FF',
},
segmentText: {
fontSize: 14,
color: '#6B7280',
},
segmentTextActive: {
color: '#5B4CFF',
fontWeight: 'bold',
},
privacyNoticeContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
paddingHorizontal: 16,
marginTop: 32,
marginBottom: 16,
},
privacyIconWrapper: {
marginRight: 6,
},
privacyNoticeText: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 18,
},
joinButtonGlass: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
minWidth: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.45)',
},
joinButtonLabel: {
fontSize: 12,
fontWeight: '700',
color: '#0f1528',
letterSpacing: 0.5,
fontFamily: 'AliBold',
},
joinButtonFallback: {
backgroundColor: 'rgba(255,255,255,0.7)',
},
// 加入家庭组弹窗样式
joinModalContent: {
gap: 12,
},
inviteCodeInput: {
backgroundColor: '#f8fafc',
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 18,
fontWeight: '700',
letterSpacing: 2,
color: '#0f1528',
textAlign: 'center',
},
relationshipLabel: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginTop: 4,
marginBottom: 2,
},
relationshipScrollView: {
maxHeight: 160,
borderRadius: 12,
backgroundColor: '#fafafa',
},
relationshipGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
padding: 8,
},
relationshipChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f3f4f6',
borderWidth: 1.5,
borderColor: 'transparent',
},
relationshipChipSelected: {
backgroundColor: '#ede9fe',
borderColor: '#8b5cf6',
},
relationshipChipText: {
fontSize: 14,
color: '#6b7280',
fontWeight: '500',
},
relationshipChipTextSelected: {
color: '#7c3aed',
fontWeight: '600',
},
modalError: {
marginTop: 6,
fontSize: 12,
color: '#ef4444',
},
});

View File

@@ -559,19 +559,17 @@ export default function MedicationDetailScreen() {
const formLabel = medication ? t(`medications.manage.formLabels.${medication.form}`) : '';
const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--';
const startDateLabel = medication
? dayjs(medication.startDate).format('YYYY年M月D日')
: '--';
// 计算服药周期显示
const medicationPeriodLabel = useMemo(() => {
if (!medication) return '--';
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
const startDate = dayjs(medication.startDate).format(format);
if (medication.endDate) {
// 有结束日期,显示开始日期到结束日期
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
const endDate = dayjs(medication.endDate).format(format);
return `${startDate} - ${endDate}`;
} else {
// 没有结束日期,显示长期
@@ -581,22 +579,23 @@ export default function MedicationDetailScreen() {
// 计算有效期显示
const expiryDateLabel = useMemo(() => {
if (!medication?.expiryDate) return '未设置';
if (!medication?.expiryDate) return t('medications.detail.plan.expiryStatus.notSet');
const expiry = dayjs(medication.expiryDate);
const today = dayjs();
const daysUntilExpiry = expiry.diff(today, 'day');
const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
if (daysUntilExpiry < 0) {
return `${expiry.format('YYYY年M月D日')} (已过期)`;
return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expired')})`;
} else if (daysUntilExpiry === 0) {
return `${expiry.format('YYYY年M月D日')} (今天到期)`;
return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expiresToday')})`;
} else if (daysUntilExpiry <= 30) {
return `${expiry.format('YYYY年M月D日')} (${daysUntilExpiry}天后到期)`;
return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expiresInDays', { days: daysUntilExpiry })})`;
} else {
return expiry.format('YYYY年M月D日');
return expiry.format(format);
}
}, [medication?.expiryDate]);
}, [medication?.expiryDate, t]);
const reminderTimes = medication?.medicationTimes?.length
? medication.medicationTimes.join('、')
@@ -617,8 +616,8 @@ export default function MedicationDetailScreen() {
const aiActionLabel = aiAnalysisLoading
? t('medications.detail.aiAnalysis.analyzingButton')
: hasAiAnalysis
? '重新分析'
: '获取 AI 分析';
? t('medications.detail.aiAnalysis.reanalyzeButton')
: t('medications.detail.aiAnalysis.getAnalysisButton');
const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? '');
@@ -645,15 +644,15 @@ export default function MedicationDetailScreen() {
const trimmed = nameDraft.trim();
if (!trimmed) {
Alert.alert(
'提示',
'药物名称不能为空'
t('common.hint'),
t('medications.detail.name.errors.empty')
);
return;
}
if (Array.from(trimmed).length > 10) {
Alert.alert(
'提示',
'药物名称不能超过10个字'
t('common.hint'),
t('medications.detail.name.errors.tooLong')
);
return;
}
@@ -675,8 +674,8 @@ export default function MedicationDetailScreen() {
} catch (err) {
console.error('更新药物名称失败', err);
Alert.alert(
'提示',
'名称更新失败,请稍后再试'
t('common.hint'),
t('medications.detail.name.errors.updateFailed')
);
} finally {
setNameSaving(false);
@@ -908,16 +907,17 @@ export default function MedicationDetailScreen() {
const handleStartDatePress = useCallback(() => {
if (!medication) return;
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
const startDate = dayjs(medication.startDate).format(format);
let message;
if (medication.endDate) {
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
message = `${startDate}${endDate}`;
const endDate = dayjs(medication.endDate).format(format);
message = t('medications.detail.plan.periodRange', { startDate, endDate, defaultValue: `${startDate}${endDate}` });
} else {
message = `${startDate} 至长期`;
message = t('medications.detail.plan.periodLongTerm', { startDate, defaultValue: `${startDate} 至长期` });
}
Alert.alert('服药周期', message);
Alert.alert(t('medications.detail.plan.period'), message);
}, [medication, t]);
const handleTimePress = useCallback(() => {
@@ -990,7 +990,7 @@ export default function MedicationDetailScreen() {
} catch (err) {
console.error('更新有效期失败', err);
Alert.alert('更新失败', '有效期更新失败,请稍后重试');
Alert.alert(t('medications.detail.updateErrors.expiryDate'), t('medications.detail.updateErrors.expiryDateMessage'));
} finally {
setUpdatePending(false);
}
@@ -1185,7 +1185,7 @@ export default function MedicationDetailScreen() {
});
} catch (err: any) {
console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err);
Alert.alert('保存失败', err?.message || '请稍后再试');
Alert.alert(t('medications.detail.aiDraft.saveError.title'), err?.message || t('medications.detail.aiDraft.saveError.message'));
} finally {
setAiDraftSaving(false);
}
@@ -1297,7 +1297,7 @@ export default function MedicationDetailScreen() {
<View style={styles.photoUploadingIndicator}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
...
{t('medications.detail.photo.uploading')}
</Text>
</View>
)}
@@ -1415,12 +1415,12 @@ export default function MedicationDetailScreen() {
</TouchableOpacity>
</Section>
<Section title="AI 分析" color={colors.text}>
<Section title={t('medications.detail.sections.aiAnalysis')} color={colors.text}>
<View style={[styles.aiCardContainer, { backgroundColor: colors.surface }]}>
<View style={styles.aiHeaderRow}>
<View style={styles.aiHeaderLeft}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.aiHeaderTitle, { color: colors.text }]}></Text>
<Text style={[styles.aiHeaderTitle, { color: colors.text }]}>{t('medications.detail.aiAnalysis.title')}</Text>
</View>
<View
style={[
@@ -1439,7 +1439,7 @@ export default function MedicationDetailScreen() {
{ color: hasAiAnalysis ? '#16a34a' : aiAnalysisLocked ? '#ef4444' : colors.primary },
]}
>
{hasAiAnalysis ? '已生成' : aiAnalysisLocked ? '会员专享' : '待生成'}
{hasAiAnalysis ? t('medications.detail.aiAnalysis.status.generated') : aiAnalysisLocked ? t('medications.detail.aiAnalysis.status.memberExclusive') : t('medications.detail.aiAnalysis.status.pending')}
</Text>
</View>
</View>
@@ -1467,7 +1467,7 @@ export default function MedicationDetailScreen() {
style={styles.aiScoreBadge}
>
<Ionicons name="thumbs-up-outline" size={14} color="#fff" />
<Text style={styles.aiScoreBadgeText}>AI </Text>
<Text style={styles.aiScoreBadgeText}>{t('medications.detail.aiAnalysis.recommendation')}</Text>
</LinearGradient>
)}
</View>
@@ -1476,7 +1476,7 @@ export default function MedicationDetailScreen() {
{medication.name}
</Text>
<Text style={[styles.aiHeroSubtitle, { color: colors.textSecondary }]} numberOfLines={3}>
{aiAnalysisResult?.mainUsage || '获取 AI 分析,快速了解适用人群、成分安全与使用建议。'}
{aiAnalysisResult?.mainUsage || t('medications.detail.aiAnalysis.placeholder')}
</Text>
<View style={styles.aiChipRow}>
<View style={styles.aiChip}>
@@ -1527,7 +1527,7 @@ export default function MedicationDetailScreen() {
<View style={styles.aiColumns}>
<View style={[styles.aiBubbleCard, { backgroundColor: '#ECFEFF', borderColor: '#BAF2F4' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}></Text>
<Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}>{t('medications.detail.aiAnalysis.categories.suitableFor')}</Text>
<Ionicons name="checkmark-circle" size={16} color="#0ea5e9" />
</View>
{aiAnalysisResult.suitableFor.map((item, idx) => (
@@ -1540,7 +1540,7 @@ export default function MedicationDetailScreen() {
<View style={[styles.aiBubbleCard, { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}></Text>
<Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}>{t('medications.detail.aiAnalysis.categories.unsuitableFor')}</Text>
<Ionicons name="alert-circle" size={16} color="#ef4444" />
</View>
{aiAnalysisResult.unsuitableFor.map((item, idx) => (
@@ -1552,9 +1552,9 @@ export default function MedicationDetailScreen() {
</View>
</View>
{renderAdviceCard('可能的副作用', aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')}
{renderAdviceCard('储存建议', aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')}
{renderAdviceCard('健康/使用建议', aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')}
{renderAdviceCard(t('medications.detail.aiAnalysis.categories.sideEffects'), aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')}
{renderAdviceCard(t('medications.detail.aiAnalysis.categories.storageAdvice'), aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')}
{renderAdviceCard(t('medications.detail.aiAnalysis.categories.healthAdvice'), aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')}
</>
) : null}
@@ -1580,8 +1580,8 @@ export default function MedicationDetailScreen() {
<View style={styles.aiMembershipLeft}>
<Ionicons name="diamond-outline" size={18} color="#f59e0b" />
<View>
<Text style={styles.aiMembershipTitle}> AI </Text>
<Text style={styles.aiMembershipSub}>使</Text>
<Text style={styles.aiMembershipTitle}>{t('medications.detail.aiAnalysis.membershipCard.title')}</Text>
<Text style={styles.aiMembershipSub}>{t('medications.detail.aiAnalysis.membershipCard.subtitle')}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#f59e0b" />
@@ -1622,22 +1622,67 @@ export default function MedicationDetailScreen() {
{isAiDraft ? (
<View style={styles.footerButtonContainer}>
<TouchableOpacity
style={styles.secondaryFooterBtn}
activeOpacity={0.9}
onPress={() => router.replace('/medications/ai-camera')}
>
<Text style={styles.secondaryFooterText}></Text>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.secondaryFooterBtn, { borderWidth: 0, overflow: 'hidden', backgroundColor: 'transparent' }]}
glassEffectStyle="regular"
isInteractive={true}
>
<Text style={styles.secondaryFooterText}>{t('medications.detail.aiDraft.reshoot')}</Text>
</GlassView>
) : (
<View style={styles.secondaryFooterBtn}>
<Text style={styles.secondaryFooterText}>{t('medications.detail.aiDraft.reshoot')}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryFooterBtn, { backgroundColor: colors.primary }]}
style={{ flex: 1 }}
activeOpacity={0.9}
onPress={handleAiDraftSave}
disabled={aiDraftSaving}
>
{aiDraftSaving ? (
<ActivityIndicator color={colors.onPrimary} />
{isLiquidGlassAvailable() ? (
<GlassView
style={[
styles.primaryFooterBtn,
{
width: '100%',
shadowOpacity: 0,
backgroundColor: 'transparent',
overflow: 'hidden',
},
]}
glassEffectStyle="regular"
tintColor={colors.primary}
isInteractive={true}
>
{aiDraftSaving ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={[styles.primaryFooterText, { color: '#fff' }]}>
{t('medications.detail.aiDraft.saveAndCreate')}
</Text>
)}
</GlassView>
) : (
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}></Text>
<View
style={[
styles.primaryFooterBtn,
{ width: '100%', backgroundColor: colors.primary },
]}
>
{aiDraftSaving ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}>
{t('medications.detail.aiDraft.saveAndCreate')}
</Text>
)}
</View>
)}
</TouchableOpacity>
</View>
@@ -1726,7 +1771,7 @@ export default function MedicationDetailScreen() {
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{t('medications.detail.name.edit')}
</Text>
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
@@ -1744,7 +1789,7 @@ export default function MedicationDetailScreen() {
<TextInput
value={nameDraft}
onChangeText={handleNameChange}
placeholder="请输入药物名称"
placeholder={t('medications.detail.name.placeholder')}
placeholderTextColor={colors.textMuted}
style={[styles.nameInput, { color: colors.text }]}
autoFocus
@@ -1777,7 +1822,7 @@ export default function MedicationDetailScreen() {
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
{t('common.save')}
</Text>
)}
</TouchableOpacity>
@@ -2599,7 +2644,7 @@ const styles = StyleSheet.create({
elevation: 4,
},
aiScoreBadgeText: {
fontSize: 12,
fontSize: 10,
fontWeight: '700',
color: '#fff',
},

View File

@@ -4,6 +4,7 @@ import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons';
@@ -39,9 +40,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
{ key: 'front', mandatory: true },
{ key: 'side', mandatory: true },
{ key: 'aux', mandatory: false },
] as const;
type CaptureKey = (typeof captureSteps)[number]['key'];
@@ -51,6 +52,7 @@ type Shot = {
};
export default function MedicationAiCameraScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
@@ -113,7 +115,14 @@ export default function MedicationAiCameraScreen() {
}
}, [allRequiredCaptured]);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
const stepTitle = useMemo(
() =>
t('medications.aiCamera.steps.stepProgress', {
current: currentStepIndex + 1,
total: captureSteps.length,
}),
[currentStepIndex, t]
);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
const cameraHeight = useMemo(() => {
@@ -149,7 +158,7 @@ export default function MedicationAiCameraScreen() {
if (!result.canceled && result.assets?.length) {
const asset = result.assets[0];
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
@@ -159,7 +168,10 @@ export default function MedicationAiCameraScreen() {
}
} catch (error) {
console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片');
Alert.alert(
t('medications.aiCamera.alerts.pickFailed.title'),
t('medications.aiCamera.alerts.pickFailed.message')
);
}
};
@@ -169,7 +181,7 @@ export default function MedicationAiCameraScreen() {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
if (photo?.uri) {
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
@@ -179,7 +191,10 @@ export default function MedicationAiCameraScreen() {
}
} catch (error) {
console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试');
Alert.alert(
t('medications.aiCamera.alerts.captureFailed.title'),
t('medications.aiCamera.alerts.captureFailed.message')
);
}
};
@@ -192,7 +207,10 @@ export default function MedicationAiCameraScreen() {
const handleStartRecognition = async () => {
// 检查必需照片是否完成
if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
Alert.alert(
t('medications.aiCamera.alerts.insufficientPhotos.title'),
t('medications.aiCamera.alerts.insufficientPhotos.message')
);
return;
}
@@ -209,7 +227,9 @@ export default function MedicationAiCameraScreen() {
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
shots.aux
? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' })
: Promise.resolve(null),
]);
const task = await createMedicationRecognitionTask({
@@ -227,7 +247,10 @@ export default function MedicationAiCameraScreen() {
});
} catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
Alert.alert(
t('medications.aiCamera.alerts.taskFailed.title'),
error?.message || t('medications.aiCamera.alerts.taskFailed.defaultMessage')
);
} finally {
setCreatingTask(false);
}
@@ -278,12 +301,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -440,12 +467,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -470,7 +501,9 @@ export default function MedicationAiCameraScreen() {
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</>
)}
</GlassView>
@@ -481,7 +514,9 @@ export default function MedicationAiCameraScreen() {
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</>
)}
</View>
@@ -501,12 +536,25 @@ export default function MedicationAiCameraScreen() {
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
<HeaderBar
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
/>
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}></Text>
<Text style={styles.permissionTip}></Text>
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={styles.permissionBtnText}>访</Text>
<Text style={styles.permissionTitle}>
{t('medications.aiCamera.permission.title')}
</Text>
<Text style={styles.permissionTip}>
{t('medications.aiCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('medications.aiCamera.permission.button')}
</Text>
</TouchableOpacity>
</View>
</View>
@@ -524,14 +572,14 @@ export default function MedicationAiCameraScreen() {
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title="AI 用药识别"
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
right={
<TouchableOpacity
onPress={() => setShowGuideModal(true)}
activeOpacity={0.7}
accessibilityLabel="查看拍摄说明"
accessibilityLabel={t('medications.aiCamera.guideModal.title')}
>
{isLiquidGlassAvailable() ? (
<GlassView
@@ -556,8 +604,12 @@ export default function MedicationAiCameraScreen() {
<View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
</View>
<Text style={styles.metaTitle}>{currentStep.title}</Text>
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
<Text style={styles.metaTitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.title`)}
</Text>
<Text style={styles.metaSubtitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.subtitle`)}
</Text>
</View>
<View style={styles.cameraCard}>
@@ -587,14 +639,22 @@ export default function MedicationAiCameraScreen() {
style={[styles.shotCard, active && styles.shotCardActive]}
>
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title}
{!step.mandatory ? '(可选)' : ''}
{t(`medications.aiCamera.steps.${step.key}.title`)}
{!step.mandatory
? ` ${t('medications.aiCamera.steps.optional')}`
: ''}
</Text>
{shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
<Image
source={{ uri: shot.uri }}
style={styles.shotThumb}
contentFit="cover"
/>
) : (
<View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text>
<Text style={styles.shotPlaceholderText}>
{t('medications.aiCamera.steps.notTaken')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -617,12 +677,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</View>
)}
</TouchableOpacity>

View File

@@ -1,5 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { Colors, palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { getMedicationRecognitionStatus } from '@/services/medications';
import { MedicationRecognitionTask } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
@@ -13,14 +14,15 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
{ key: 'analyzing_product', label: '正在进行产品分析...' },
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
const STEP_KEYS: MedicationRecognitionTask['status'][] = [
'analyzing_product',
'analyzing_suitability',
'analyzing_ingredients',
'analyzing_effects',
];
export default function MedicationAiProgressScreen() {
const { t } = useI18n();
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
const insets = useSafeAreaInsets();
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
@@ -35,11 +37,16 @@ export default function MedicationAiProgressScreen() {
const floatAnim = useRef(new Animated.Value(0)).current;
const opacityAnim = useRef(new Animated.Value(0.3)).current;
const steps = useMemo(() => STEP_KEYS.map(key => ({
key,
label: t(`medications.aiProgress.steps.${key}`)
})), [t]);
const currentStepIndex = useMemo(() => {
if (!task) return 0;
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
const idx = STEP_KEYS.indexOf(task.status as any);
if (idx >= 0) return idx;
if (task.status === 'completed') return STATUS_STEPS.length;
if (task.status === 'completed') return STEP_KEYS.length;
return 0;
}, [task]);
@@ -77,12 +84,12 @@ export default function MedicationAiProgressScreen() {
pollingTimerRef.current = null;
}
// 显示错误提示弹窗
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
setErrorMessage(data.errorMessage || t('medications.aiProgress.errors.default'));
setShowErrorModal(true);
}
} catch (err: any) {
console.error('[MEDICATION_AI] status failed', err);
setError(err?.message || '查询失败,请稍后再试');
setError(err?.message || t('medications.aiProgress.errors.queryFailed'));
} finally {
setLoading(false);
}
@@ -148,12 +155,12 @@ export default function MedicationAiProgressScreen() {
};
}, []);
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
const progress = task?.progress ?? Math.min(100, (currentStepIndex / steps.length) * 100 + 10);
return (
<SafeAreaView style={styles.container}>
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
<LinearGradient colors={[palette.gray[25], palette.gray[50]]} style={StyleSheet.absoluteFill} />
<HeaderBar title={t('medications.aiProgress.title')} onBack={() => router.back()} transparent />
<View style={{ height: insets.top }} />
<View style={styles.heroCard}>
@@ -172,7 +179,7 @@ export default function MedicationAiProgressScreen() {
{/* 渐变蒙版边框,增加视觉层次 */}
<LinearGradient
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
colors={[Colors.light.primary + '4D', Colors.light.accentPurple + '33', 'transparent']}
style={styles.gradientBorder}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@@ -206,7 +213,7 @@ export default function MedicationAiProgressScreen() {
</View>
<View style={styles.stepList}>
{STATUS_STEPS.map((step, index) => {
{steps.map((step, index) => {
const active = index === currentStepIndex;
const done = index < currentStepIndex;
return (
@@ -221,7 +228,7 @@ export default function MedicationAiProgressScreen() {
{task?.status === 'completed' && (
<View style={styles.stepRow}>
<View style={[styles.bullet, styles.bulletDone]} />
<Text style={[styles.stepLabel, styles.stepLabelDone]}>...</Text>
<Text style={[styles.stepLabel, styles.stepLabelDone]}>{t('medications.aiProgress.steps.completed')}</Text>
</View>
)}
</View>
@@ -251,7 +258,7 @@ export default function MedicationAiProgressScreen() {
<View style={styles.errorModalContent}>
{/* 标题 */}
<Text style={styles.errorModalTitle}></Text>
<Text style={styles.errorModalTitle}>{t('medications.aiProgress.modal.title')}</Text>
{/* 提示信息 */}
<View style={styles.errorMessageBox}>
@@ -268,29 +275,29 @@ export default function MedicationAiProgressScreen() {
<GlassView
style={styles.retryButton}
glassEffectStyle="regular"
tintColor="rgba(14, 165, 233, 0.9)"
tintColor={Colors.light.primary}
isInteractive={true}
>
<LinearGradient
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient}
>
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient>
</GlassView>
) : (
<View style={styles.retryButton}>
<LinearGradient
colors={['#0ea5e9', '#06b6d4']}
colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient}
>
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient>
</View>
)}
@@ -311,9 +318,9 @@ const styles = StyleSheet.create({
marginHorizontal: 20,
marginTop: 24,
borderRadius: 24,
backgroundColor: '#fff',
backgroundColor: Colors.light.card,
padding: 16,
shadowColor: '#0f172a',
shadowColor: Colors.light.text,
shadowOpacity: 0.08,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
@@ -322,7 +329,7 @@ const styles = StyleSheet.create({
height: 230,
borderRadius: 18,
overflow: 'hidden',
backgroundColor: '#e2e8f0',
backgroundColor: palette.gray[50],
},
heroImage: {
width: '100%',
@@ -330,7 +337,7 @@ const styles = StyleSheet.create({
},
heroPlaceholder: {
flex: 1,
backgroundColor: '#e2e8f0',
backgroundColor: palette.gray[50],
},
// 深色蒙版层,让点阵更清晰可见
overlayMask: {
@@ -368,15 +375,15 @@ const styles = StyleSheet.create({
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: '#FFFFFF',
shadowColor: '#0ea5e9',
backgroundColor: Colors.light.background,
shadowColor: Colors.light.primary,
shadowOpacity: 0.9,
shadowRadius: 6,
shadowOffset: { width: 0, height: 0 },
},
progressRow: {
height: 8,
backgroundColor: '#f1f5f9',
backgroundColor: palette.gray[50],
borderRadius: 10,
marginTop: 14,
overflow: 'hidden',
@@ -384,13 +391,13 @@ const styles = StyleSheet.create({
progressBar: {
height: '100%',
borderRadius: 10,
backgroundColor: '#0ea5e9',
backgroundColor: Colors.light.primary,
},
progressText: {
marginTop: 8,
fontSize: 14,
fontWeight: '700',
color: '#0f172a',
color: Colors.light.text,
textAlign: 'right',
},
stepList: {
@@ -407,24 +414,24 @@ const styles = StyleSheet.create({
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: '#e2e8f0',
backgroundColor: palette.gray[50],
},
bulletActive: {
backgroundColor: '#0ea5e9',
backgroundColor: Colors.light.primary,
},
bulletDone: {
backgroundColor: '#22c55e',
backgroundColor: Colors.light.success,
},
stepLabel: {
fontSize: 15,
color: '#94a3b8',
color: Colors.light.textMuted,
},
stepLabelActive: {
color: '#0f172a',
color: Colors.light.text,
fontWeight: '700',
},
stepLabelDone: {
color: '#16a34a',
color: Colors.light.successDark,
fontWeight: '700',
},
loadingBox: {
@@ -433,7 +440,7 @@ const styles = StyleSheet.create({
gap: 12,
},
errorText: {
color: '#ef4444',
color: Colors.light.danger,
fontSize: 14,
},
// Modal 样式
@@ -445,10 +452,10 @@ const styles = StyleSheet.create({
},
errorModalContainer: {
width: SCREEN_WIDTH - 48,
backgroundColor: '#FFFFFF',
backgroundColor: Colors.light.card,
borderRadius: 28,
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowColor: Colors.light.primary,
shadowOpacity: 0.15,
shadowRadius: 24,
shadowOffset: { width: 0, height: 8 },
@@ -465,36 +472,36 @@ const styles = StyleSheet.create({
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: 'rgba(14, 165, 233, 0.08)',
backgroundColor: palette.purple[50],
alignItems: 'center',
justifyContent: 'center',
},
errorModalTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
color: Colors.light.text,
marginBottom: 16,
textAlign: 'center',
},
errorMessageBox: {
backgroundColor: '#f0f9ff',
backgroundColor: palette.purple[25],
borderRadius: 16,
padding: 20,
marginBottom: 28,
width: '100%',
borderWidth: 1,
borderColor: 'rgba(14, 165, 233, 0.2)',
borderColor: palette.purple[200],
},
errorMessageText: {
fontSize: 15,
lineHeight: 24,
color: '#475569',
color: Colors.light.textSecondary,
textAlign: 'center',
},
retryButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowColor: Colors.light.primary,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
@@ -509,6 +516,6 @@ const styles = StyleSheet.create({
retryButtonText: {
fontSize: 18,
fontWeight: '700',
color: '#FFFFFF',
color: Colors.light.onPrimary,
},
});

View File

@@ -0,0 +1,886 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { getMedicationAiSummary } from '@/services/medications';
import { type MedicationAiSummary, type MedicationAiSummaryItem } from '@/types/medication';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function MedicationAiSummaryScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [summary, setSummary] = useState<MedicationAiSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string>('');
const [showInfoModal, setShowInfoModal] = useState(false);
const [showCompletionInfoModal, setShowCompletionInfoModal] = useState(false);
const fetchSummary = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getMedicationAiSummary();
setSummary(data);
setLastUpdated(dayjs().format('YYYY.MM.DD HH:mm'));
} catch (err: any) {
const status = err?.status;
if (status === 403) {
setError(t('medications.aiSummary.error403'));
} else {
setError(err?.message || t('medications.aiSummary.genericError'));
}
setSummary(null);
} finally {
setLoading(false);
}
}, [t]);
useFocusEffect(
useCallback(() => {
fetchSummary();
}, [fetchSummary])
);
const handleExplainRefresh = useCallback(() => {
setShowInfoModal(true);
}, []);
const handleExplainCompletion = useCallback(() => {
setShowCompletionInfoModal(true);
}, []);
const medicationItems = summary?.medicationAnalysis ?? [];
const isEmpty = !loading && !error && medicationItems.length === 0;
const stats = useMemo(() => {
const plannedDoses = medicationItems.reduce((acc, item) => acc + (item.plannedDoses || 0), 0);
const takenDoses = medicationItems.reduce((acc, item) => acc + (item.takenDoses || 0), 0);
const completion = plannedDoses > 0 ? takenDoses / plannedDoses : 0;
const avgCompletion =
medicationItems.length > 0
? medicationItems.reduce((acc, item) => acc + (item.completionRate || 0), 0) /
medicationItems.length
: 0;
const plannedDays = medicationItems.reduce((acc, item) => acc + (item.plannedDays || 0), 0);
return {
plannedDoses,
takenDoses,
completion,
avgCompletion,
plannedDays,
activePlans: medicationItems.length,
};
}, [medicationItems]);
const completionPercent = Math.min(100, Math.round(stats.completion * 100));
const renderMedicationCard = (item: MedicationAiSummaryItem) => {
const percent = Math.min(100, Math.round((item.completionRate || 0) * 100));
return (
<View key={item.id} style={styles.planCard}>
<View style={styles.planHeader}>
<View style={{ flex: 1 }}>
<ThemedText style={styles.planName}>{item.name}</ThemedText>
<ThemedText style={styles.planMeta}>
{t('medications.aiSummary.daysLabel', {
days: item.plannedDays,
times: item.timesPerDay,
})}
</ThemedText>
</View>
<View style={styles.planChip}>
<IconSymbol name="sparkles" size={14} color="#d6b37f" />
<ThemedText style={styles.planChipText}>
{t('medications.aiSummary.badges.adherence')}
</ThemedText>
</View>
</View>
<View style={styles.progressRow}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${percent}%` }]} />
</View>
<ThemedText style={styles.progressValue}>
{t('medications.aiSummary.completionLabel', { value: percent })}
</ThemedText>
</View>
<View style={styles.planFooter}>
<ThemedText style={styles.planStat}>
{t('medications.aiSummary.doseSummary', {
taken: item.takenDoses,
planned: item.plannedDoses,
})}
</ThemedText>
<ThemedText style={styles.planDate}>
{dayjs(item.startDate).format('YYYY.MM.DD')}
</ThemedText>
</View>
</View>
);
};
const headerTitle = (
<View style={styles.headerTitle}>
<ThemedText style={styles.title}>{t('medications.aiSummary.title')}</ThemedText>
<ThemedText style={styles.subtitle}>{t('medications.aiSummary.subtitle')}</ThemedText>
</View>
);
return (
<View style={styles.container}>
<LinearGradient
colors={['#0a0e16', '#0b101a', '#0b0f16']}
style={StyleSheet.absoluteFill}
/>
<View style={styles.glowTop} />
<View style={styles.glowBottom} />
<HeaderBar
title={headerTitle}
tone="dark"
transparent
variant="minimal"
right={
<TouchableOpacity
style={styles.iconButton}
onPress={handleExplainRefresh}
activeOpacity={0.8}
>
<IconSymbol name="info.circle" size={20} color="#dfe8ff" />
</TouchableOpacity>
}
/>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 32, paddingTop: insets.top + 80 },
]}
showsVerticalScrollIndicator={false}
>
<LinearGradient
colors={['#131a28', '#0f1623']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.heroCard}
>
<View style={styles.heroHeader}>
<ThemedText style={styles.heroLabel}>
{t('medications.aiSummary.overviewTitle')}
</ThemedText>
<ThemedText style={styles.updatedAt}>
{lastUpdated ? t('medications.aiSummary.updatedAt', { time: lastUpdated }) : ' '}
</ThemedText>
</View>
<View style={styles.heroMainRow}>
<View style={styles.heroLeft}>
<ThemedText style={styles.heroValue}>{completionPercent}%</ThemedText>
<ThemedText style={styles.heroCaption}>
{t('medications.aiSummary.doseSummary', {
taken: stats.takenDoses,
planned: stats.plannedDoses,
})}
</ThemedText>
<View style={styles.heroProgressTrack}>
<View style={[styles.heroProgressFill, { width: `${completionPercent}%` }]} />
</View>
</View>
<View style={styles.heroChip}>
<ThemedText style={styles.heroChipLabel}>
{t('medications.aiSummary.badges.safety')}
</ThemedText>
<ThemedText style={styles.heroChipValue}>{stats.activePlans}</ThemedText>
<ThemedText style={styles.heroChipHint}>
{t('medications.aiSummary.stats.activePlans')}
</ThemedText>
</View>
</View>
<View style={styles.heroStatsRow}>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.avgCompletion')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>
{Math.round(stats.avgCompletion * 100)}%
</ThemedText>
</View>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.activeDays')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>{stats.plannedDays}</ThemedText>
</View>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.takenDoses')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>{stats.takenDoses}</ThemedText>
</View>
</View>
</LinearGradient>
{error ? (
<View style={styles.errorCard}>
<ThemedText style={styles.errorTitle}>{error}</ThemedText>
<TouchableOpacity style={styles.retryButton} onPress={fetchSummary} activeOpacity={0.85}>
<ThemedText style={styles.retryText}>{t('medications.aiSummary.retry')}</ThemedText>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t('medications.aiSummary.keyInsights')}
</ThemedText>
<View style={styles.pillChip}>
<IconSymbol name="sparkles" size={14} color="#0b0f16" />
<ThemedText style={styles.pillChipText}>
{t('medications.aiSummary.pillChip')}
</ThemedText>
</View>
</View>
<ThemedText style={styles.insightText}>
{summary?.keyInsights || t('medications.aiSummary.keyInsightPlaceholder')}
</ThemedText>
</View>
<View style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t('medications.aiSummary.listTitle')}
</ThemedText>
<TouchableOpacity
style={styles.infoIconButton}
onPress={handleExplainCompletion}
activeOpacity={0.8}
>
<IconSymbol name="info.circle" size={16} color="#8b94a8" />
</TouchableOpacity>
</View>
{loading ? (
<View style={styles.loadingRow}>
<ActivityIndicator color="#d6b37f" />
<ThemedText style={styles.loadingText}>
{t('medications.aiSummary.refresh')}
</ThemedText>
</View>
) : isEmpty ? (
<View style={styles.emptyState}>
<ThemedText style={styles.emptyTitle}>
{t('medications.aiSummary.emptyTitle')}
</ThemedText>
<ThemedText style={styles.emptySubtitle}>
{t('medications.aiSummary.emptyDescription')}
</ThemedText>
</View>
) : (
<View style={styles.planList}>{medicationItems.map(renderMedicationCard)}</View>
)}
</View>
</>
)}
</ScrollView>
<Modal
visible={showInfoModal}
transparent
animationType="fade"
onRequestClose={() => setShowInfoModal(false)}
>
<TouchableOpacity
style={styles.infoOverlay}
activeOpacity={1}
onPress={() => setShowInfoModal(false)}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.infoModal}
>
<LinearGradient
colors={['#111827', '#0b1220']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.infoGradient}
>
<View style={styles.infoHeader}>
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.infoModal.badge')}</ThemedText>
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.infoModal.title')}</ThemedText>
<TouchableOpacity
onPress={() => setShowInfoModal(false)}
style={styles.infoClose}
accessibilityLabel="close"
>
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
</TouchableOpacity>
</View>
<View style={styles.infoContent}>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point1')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point2')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point3')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point4')}
</Text>
</View>
<View style={styles.infoButtonContainer}>
<TouchableOpacity
onPress={() => setShowInfoModal(false)}
>
<LinearGradient
colors={['#d6b37f', '#c59b63']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.infoButton}
>
<Text style={styles.infoButtonText}>{t('medications.aiSummary.infoModal.button')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal
visible={showCompletionInfoModal}
transparent
animationType="fade"
onRequestClose={() => setShowCompletionInfoModal(false)}
>
<TouchableOpacity
style={styles.infoOverlay}
activeOpacity={1}
onPress={() => setShowCompletionInfoModal(false)}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.infoModal}
>
<LinearGradient
colors={['#111827', '#0b1220']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.infoGradient}
>
<View style={styles.infoHeader}>
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.completionInfoModal.badge')}</ThemedText>
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.completionInfoModal.title')}</ThemedText>
<TouchableOpacity
onPress={() => setShowCompletionInfoModal(false)}
style={styles.infoClose}
accessibilityLabel="close"
>
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
</TouchableOpacity>
</View>
<View style={styles.infoContent}>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point1')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point2')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point3')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point4')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point5')}
</Text>
</View>
<View style={styles.infoButtonContainer}>
<TouchableOpacity
onPress={() => setShowCompletionInfoModal(false)}
>
<LinearGradient
colors={['#d6b37f', '#c59b63']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.infoButton}
>
<Text style={styles.infoButtonText}>{t('medications.aiSummary.completionInfoModal.button')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0b0f16',
},
scrollContent: {
paddingHorizontal: 20,
gap: 20,
},
glowTop: {
position: 'absolute',
top: -80,
left: -40,
width: 200,
height: 200,
backgroundColor: '#1b2a44',
opacity: 0.35,
borderRadius: 140,
},
glowBottom: {
position: 'absolute',
bottom: -120,
right: -60,
width: 240,
height: 240,
backgroundColor: '#123125',
opacity: 0.25,
borderRadius: 200,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.04)',
},
headerTitle: {
alignItems: 'center',
flex: 1,
gap: 6,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: '#d6b37f',
},
badgeText: {
color: '#0b0f16',
fontSize: 12,
fontWeight: '700',
fontFamily: 'AliBold',
},
title: {
color: '#f6f7fb',
fontSize: 22,
fontFamily: 'AliBold',
},
subtitle: {
color: '#b9c2d3',
fontSize: 14,
fontFamily: 'AliRegular',
},
heroCard: {
borderRadius: 24,
padding: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 16,
gap: 14,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
heroLabel: {
color: '#f5f6fb',
fontSize: 16,
fontFamily: 'AliBold',
},
updatedAt: {
color: '#8b94a8',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroMainRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
},
heroLeft: {
flex: 1,
minWidth: 0,
},
heroValue: {
color: '#36d0a5',
fontSize: 38,
lineHeight: 42,
fontFamily: 'AliBold',
letterSpacing: 0.5,
flexShrink: 1,
},
heroCaption: {
color: '#c2ccdf',
fontSize: 13,
fontFamily: 'AliRegular',
marginTop: 4,
},
heroProgressTrack: {
marginTop: 12,
height: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
heroProgressFill: {
height: '100%',
borderRadius: 10,
backgroundColor: '#36d0a5',
},
heroChip: {
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 18,
backgroundColor: 'rgba(214, 179, 127, 0.12)',
borderWidth: 1,
borderColor: 'rgba(214, 179, 127, 0.3)',
minWidth: 120,
alignItems: 'flex-start',
gap: 4,
},
heroChipLabel: {
color: '#d6b37f',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroChipValue: {
color: '#f6f7fb',
fontSize: 20,
fontFamily: 'AliBold',
lineHeight: 24,
},
heroChipHint: {
color: '#b9c2d3',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroStatsRow: {
flexDirection: 'row',
gap: 12,
justifyContent: 'space-between',
},
heroStatItem: {
flex: 1,
padding: 12,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
heroStatLabel: {
color: '#9dabc4',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroStatValue: {
color: '#f6f7fb',
fontSize: 18,
marginTop: 6,
fontFamily: 'AliBold',
},
sectionCard: {
borderRadius: 20,
padding: 16,
backgroundColor: 'rgba(255,255,255,0.03)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
gap: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
color: '#f5f6fb',
fontSize: 16,
fontFamily: 'AliBold',
},
pillChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: '#d6b37f',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
pillChipText: {
color: '#0b0f16',
fontSize: 12,
fontFamily: 'AliBold',
},
insightText: {
color: '#d9e2f2',
fontSize: 15,
lineHeight: 22,
fontFamily: 'AliRegular',
},
planList: {
gap: 12,
},
planCard: {
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
gap: 10,
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
planName: {
color: '#f6f7fb',
fontSize: 16,
fontFamily: 'AliBold',
},
planMeta: {
color: '#9dabc4',
fontSize: 12,
fontFamily: 'AliRegular',
marginTop: 2,
},
planChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(214, 179, 127, 0.15)',
borderWidth: 1,
borderColor: 'rgba(214, 179, 127, 0.35)',
},
planChipText: {
color: '#d6b37f',
fontSize: 12,
fontFamily: 'AliBold',
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
progressTrack: {
flex: 1,
height: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#36d0a5',
borderRadius: 10,
},
progressValue: {
color: '#f6f7fb',
fontSize: 12,
fontFamily: 'AliBold',
},
planFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planStat: {
color: '#c7d1e4',
fontSize: 13,
fontFamily: 'AliRegular',
},
planDate: {
color: '#7f8aa4',
fontSize: 12,
fontFamily: 'AliRegular',
},
errorCard: {
padding: 16,
borderRadius: 16,
backgroundColor: 'rgba(255, 86, 86, 0.08)',
borderWidth: 1,
borderColor: 'rgba(255, 86, 86, 0.3)',
alignItems: 'center',
gap: 12,
},
errorTitle: {
color: '#ff9c9c',
fontSize: 14,
textAlign: 'center',
fontFamily: 'AliBold',
},
retryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 999,
backgroundColor: '#ff9c9c',
},
retryText: {
color: '#0b0f16',
fontSize: 13,
fontFamily: 'AliBold',
},
loadingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
},
loadingText: {
color: '#c7d1e4',
fontSize: 13,
fontFamily: 'AliRegular',
},
emptyState: {
paddingVertical: 12,
gap: 6,
},
emptyTitle: {
color: '#f6f7fb',
fontSize: 15,
fontFamily: 'AliBold',
},
emptySubtitle: {
color: '#9dabc4',
fontSize: 13,
fontFamily: 'AliRegular',
lineHeight: 20,
},
infoOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
infoModal: {
width: '100%',
maxWidth: 400,
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
infoGradient: {
padding: 24,
gap: 20,
},
infoHeader: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4,
},
infoBadge: {
color: '#d6b37f',
fontSize: 24,
lineHeight: 28,
fontFamily: 'AliBold',
marginBottom: 10,
letterSpacing: 0.5,
},
infoTitle: {
color: '#f6f7fb',
fontSize: 16,
fontFamily: 'AliBold',
textAlign: 'center',
},
infoClose: {
position: 'absolute',
right: -4,
top: -4,
padding: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.05)',
},
infoContent: {
gap: 14,
},
infoText: {
color: '#d9e2f2',
fontSize: 14,
lineHeight: 18,
fontFamily: 'AliRegular',
},
infoButtonContainer: {
marginTop: 12,
alignItems: 'center',
},
infoButtonWrapper: {
// minWidth: 120,
// maxWidth: 180,
},
infoButton: {
borderRadius: 12,
paddingVertical: 10,
paddingHorizontal: 28,
alignItems: 'center',
overflow: 'hidden',
},
infoButtonGlass: {
paddingVertical: 10,
paddingHorizontal: 28,
alignItems: 'center',
},
infoButtonText: {
color: '#0b0f16',
fontSize: 15,
fontFamily: 'AliBold',
letterSpacing: 0.2,
},
infoIconButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(139, 148, 168, 0.1)',
},
});

View File

@@ -1,5 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useMoodData } from '@/hooks/useMoodData';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions } from '@/services/moodCheckins';
@@ -61,6 +62,7 @@ const generateCalendarData = (targetDate: Date) => {
};
export default function MoodCalendarScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const params = useLocalSearchParams();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
@@ -89,9 +91,30 @@ export default function MoodCalendarScreen() {
return selectLatestMoodRecordByDate(selectedDateString)(state);
});
const moodOptions = getMoodOptions();
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const moodOptions = getMoodOptions(t);
const weekDays = [
t('mood.calendar.weekDays.monday'),
t('mood.calendar.weekDays.tuesday'),
t('mood.calendar.weekDays.wednesday'),
t('mood.calendar.weekDays.thursday'),
t('mood.calendar.weekDays.friday'),
t('mood.calendar.weekDays.saturday'),
t('mood.calendar.weekDays.sunday'),
];
const monthNames = [
t('mood.calendar.months.january'),
t('mood.calendar.months.february'),
t('mood.calendar.months.march'),
t('mood.calendar.months.april'),
t('mood.calendar.months.may'),
t('mood.calendar.months.june'),
t('mood.calendar.months.july'),
t('mood.calendar.months.august'),
t('mood.calendar.months.september'),
t('mood.calendar.months.october'),
t('mood.calendar.months.november'),
t('mood.calendar.months.december'),
];
// 生成当前月份的日历数据
const { calendar, today, month, year } = generateCalendarData(currentMonth);
@@ -103,7 +126,7 @@ export default function MoodCalendarScreen() {
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
} catch (error) {
console.error('加载月份心情数据失败:', error);
console.error(t('mood.calendar.errors.loadMonthDataFailed'), error);
}
}, []);
@@ -112,7 +135,7 @@ export default function MoodCalendarScreen() {
try {
await fetchMoodRecordsRef.current(dateString);
} catch (error) {
console.error('加载心情记录失败:', error);
console.error(t('mood.calendar.errors.loadDailyDataFailed'), error);
}
}, []);
@@ -235,7 +258,7 @@ export default function MoodCalendarScreen() {
<View style={styles.safeArea}>
<HeaderBar
title="心情日历"
title={t('mood.calendar.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -255,7 +278,7 @@ export default function MoodCalendarScreen() {
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
<Text style={styles.monthTitle}>{year}{monthNames[month - 1]}</Text>
<Text style={styles.monthTitle}>{year} {monthNames[month - 1]}</Text>
<TouchableOpacity
style={styles.navButton}
onPress={goToNextMonth}
@@ -315,13 +338,13 @@ export default function MoodCalendarScreen() {
<View style={styles.selectedDateSection}>
<View style={styles.selectedDateHeader}>
<Text style={styles.selectedDateTitle}>
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
{selectedDay ? dayjs(currentMonth).date(selectedDay).format(t('mood.calendar.selectedDate.dateFormat')) : t('mood.calendar.selectedDate.selectDate')}
</Text>
<TouchableOpacity
style={styles.addMoodButton}
onPress={openMoodEdit}
>
<Text style={styles.addMoodButtonText}></Text>
<Text style={styles.addMoodButtonText}>{t('mood.calendar.selectedDate.record')}</Text>
</TouchableOpacity>
</View>
@@ -343,7 +366,7 @@ export default function MoodCalendarScreen() {
<Text style={styles.recordMood}>
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
</Text>
<Text style={styles.recordIntensity}>: {selectedDateMood.intensity}</Text>
<Text style={styles.recordIntensity}>{t('mood.calendar.selectedDate.intensity')}: {selectedDateMood.intensity}</Text>
{selectedDateMood.description && (
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
)}
@@ -355,14 +378,14 @@ export default function MoodCalendarScreen() {
</TouchableOpacity>
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noRecord')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noRecordHint')}</Text>
</View>
)
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noDateSelected')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noDateSelectedHint')}</Text>
</View>
)}
</View>

View File

@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
import {
@@ -31,6 +32,7 @@ import {
} from 'react-native';
export default function MoodEditScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
@@ -51,7 +53,7 @@ export default function MoodEditScreen() {
const scrollViewRef = useRef<ScrollView>(null);
const textInputRef = useRef<TextInput>(null);
const moodOptions = getMoodOptions();
const moodOptions = getMoodOptions(t);
// 从 Redux 获取数据
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
@@ -95,7 +97,7 @@ export default function MoodEditScreen() {
const handleSave = async () => {
if (!selectedMood) {
Alert.alert('提示', '请选择心情');
Alert.alert(t('common.alert'), t('mood.edit.alerts.selectMood'));
return;
}
@@ -120,12 +122,12 @@ export default function MoodEditScreen() {
})).unwrap();
}
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [
{ text: '确定', onPress: () => router.back() }
Alert.alert(t('common.success'), existingMood ? t('mood.edit.alerts.updateSuccess') : t('mood.edit.alerts.saveSuccess'), [
{ text: t('common.confirm'), onPress: () => router.back() }
]);
} catch (error) {
console.error('保存心情失败:', error);
Alert.alert('错误', '保存心情失败,请重试');
Alert.alert(t('common.error'), t('mood.edit.alerts.saveError'));
} finally {
setIsLoading(false);
}
@@ -135,24 +137,24 @@ export default function MoodEditScreen() {
if (!existingMood) return;
Alert.alert(
'确认删除',
'确定要删除这条心情记录吗?',
t('mood.edit.alerts.confirmDeleteTitle'),
t('mood.edit.alerts.confirmDelete'),
[
{ text: '取消', style: 'cancel' },
{ text: t('common.cancel'), style: 'cancel' },
{
text: '删除',
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
try {
setIsDeleting(true);
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
Alert.alert('成功', '心情记录已删除', [
{ text: '确定', onPress: () => router.back() }
Alert.alert(t('common.success'), t('mood.edit.alerts.deleteSuccess'), [
{ text: t('common.confirm'), onPress: () => router.back() }
]);
} catch (error) {
console.error('删除心情失败:', error);
Alert.alert('错误', '删除心情失败,请重试');
Alert.alert(t('common.error'), t('mood.edit.alerts.deleteError'));
} finally {
setIsDeleting(false);
}
@@ -183,7 +185,7 @@ export default function MoodEditScreen() {
<View style={styles.decorativeCircle2} />
<View style={styles.safeArea} >
<HeaderBar
title={existingMood ? '编辑心情' : '记录心情'}
title={existingMood ? t('mood.edit.editTitle') : t('mood.edit.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -207,13 +209,13 @@ export default function MoodEditScreen() {
{/* 日期显示 */}
<View style={styles.dateSection}>
<Text style={styles.dateTitle}>
{dayjs(selectedDate).format('YYYY年M月D日')}
{dayjs(selectedDate).format(t('mood.edit.dateFormat'))}
</Text>
</View>
{/* 心情选择 */}
<View style={styles.moodSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('mood.edit.selectMood')}</Text>
<View style={styles.moodOptions}>
{moodOptions.map((mood, index) => (
<TouchableOpacity
@@ -233,7 +235,7 @@ export default function MoodEditScreen() {
{/* 心情强度选择 */}
<View style={styles.intensitySection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('mood.edit.intensity')}</Text>
<MoodIntensitySlider
value={intensity}
onValueChange={handleIntensityChange}
@@ -248,18 +250,12 @@ export default function MoodEditScreen() {
{/* 心情描述 */}
<View style={styles.descriptionSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.diarySubtitle}></Text>
<Text style={styles.sectionTitle}>{t('mood.edit.diary')}</Text>
<Text style={styles.diarySubtitle}>{t('mood.edit.diarySubtitle')}</Text>
<TextInput
ref={textInputRef}
style={styles.descriptionInput}
placeholder={`今天的心情如何?
你经历过什么特别的事情吗?
有什么让你开心的事?
或者,有什么让你感到困扰?
写下你的感受,让这些时刻成为你珍贵的记忆...`}
placeholder={t('mood.edit.placeholder')}
placeholderTextColor="#a8a8a8"
value={description}
onChangeText={setDescription}
@@ -289,7 +285,7 @@ export default function MoodEditScreen() {
disabled={!selectedMood || isLoading}
>
<Text style={styles.saveButtonText}>
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
{isLoading ? t('mood.edit.saving') : existingMood ? t('mood.edit.update') : t('mood.edit.save')}
</Text>
</TouchableOpacity>
{existingMood && (

View File

@@ -7,6 +7,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { DietRecord } from '@/services/dietRecords';
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
@@ -20,16 +21,20 @@ import {
selectNutritionRecordsByDate,
selectNutritionSummaryByDate
} from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
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, useState } from 'react';
import {
FlatList,
RefreshControl,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
@@ -39,26 +44,21 @@ import {
type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const safeAreaTop = useSafeAreaTop()
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const isGlassAvailable = isLiquidGlassAvailable();
const { isLoggedIn } = useAuthGuard()
const { isLoggedIn } = useAuthGuard();
// 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 直接使用 state 管理当前选中日期,而不是从 days 数组派生,以支持 DateSelector 内部月份切换
const [currentSelectedDate, setCurrentSelectedDate] = useState<Date>(new Date());
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
const currentSelectedDate = useMemo(() => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex, days]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
// 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
@@ -89,7 +89,6 @@ export default function NutritionRecordsScreen() {
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据
useFocusEffect(
useCallback(() => {
@@ -123,7 +122,7 @@ export default function NutritionRecordsScreen() {
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch])
}, [viewMode, currentSelectedDateString, dispatch, isLoggedIn])
);
// 当选中日期或视图模式变化时重新加载数据
@@ -327,71 +326,6 @@ export default function NutritionRecordsScreen() {
});
};
// 渲染日期选择器(仅在按天查看模式下显示)
const renderDateSelector = () => {
if (viewMode !== 'daily') return null;
return (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => setSelectedIndex(index)}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={{
paddingHorizontal: 16
}}
/>
);
};
const renderEmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
</View>
);
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
</Text>
</TouchableOpacity>
);
}
return null;
};
// 根据当前时间智能判断餐次类型
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
const hour = new Date().getHours();
@@ -415,68 +349,160 @@ export default function NutritionRecordsScreen() {
// 渲染右侧添加按钮
const renderRightButton = () => (
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={handleAddFood}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colorTokens.primary} />
{isGlassAvailable ? (
<GlassView
style={styles.glassAddButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</GlassView>
) : (
<View style={[styles.fallbackAddButton, { backgroundColor: 'rgba(255,255,255,0.8)' }]}>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</View>
)}
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<HeaderBar
title="营养记录"
onBack={() => router.back()}
right={renderRightButton()}
/>
<View style={{
paddingTop: safeAreaTop
}}>
{/* {renderViewModeToggle()} */}
{renderDateSelector()}
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
const renderEmptyState = () => (
<View style={styles.emptySimpleContainer}>
<Image
source={require('@/assets/images/icons/icon-yingyang.png')}
style={styles.emptySimpleImage}
contentFit="contain"
/>
<Text style={styles.emptySimpleText}>
{t('nutritionRecords.empty.title')}
</Text>
<TouchableOpacity onPress={handleAddFood}>
<Text style={[styles.emptyActionText, { color: colorTokens.primary }]}>
{t('nutritionRecords.empty.action')}
</Text>
</TouchableOpacity>
</View>
);
{(
<FlatList
data={displayRecords}
renderItem={({ item, index }) => renderRecord({ item, index })}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingBottom: 40, paddingTop: 16 }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
const renderRecord = ({ item }: { item: DietRecord }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
if (displayRecords.length === 0) return null;
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
{t('nutritionRecords.footer.end')}
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
{t('nutritionRecords.footer.loadMore')}
</Text>
</TouchableOpacity>
);
}
return null;
};
const ListHeader = () => (
<View>
<View style={styles.headerContent}>
{viewMode === 'daily' && (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => {
setSelectedIndex(index);
setCurrentSelectedDate(date);
}}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={styles.dateSelectorContainer}
/>
)}
<View style={styles.chartWrapper}>
<CalorieRingChart
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/>
</View>
<View style={styles.listTitleContainer}>
<Text style={styles.listTitle}>{t('nutritionRecords.listTitle')}</Text>
{displayRecords.length > 0 && (
<Text style={styles.listSubtitle}>{t('nutritionRecords.recordCount', { count: displayRecords.length })}</Text>
)}
</View>
</View>
</View>
);
return (
<View style={[styles.container, { backgroundColor: '#f3f4fb' }]}>
<StatusBar barStyle="dark-content" />
{/* 顶部柔和渐变背景 */}
<LinearGradient
colors={['rgba(255, 243, 224, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<HeaderBar
title={t('nutritionRecords.title')}
onBack={() => router.back()}
right={renderRightButton()}
transparent={true}
/>
<FlatList
data={displayRecords}
renderItem={renderRecord}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListHeaderComponent={ListHeader}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
/>
{/* 食物添加悬浮窗 */}
<FloatingFoodOverlay
@@ -492,130 +518,105 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
viewModeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 8,
},
monthTitle: {
fontSize: 22,
fontWeight: '800',
},
toggleContainer: {
flexDirection: 'row',
borderRadius: 20,
padding: 2,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
minWidth: 80,
alignItems: 'center',
},
toggleText: {
fontSize: 14,
fontWeight: '600',
},
daysContainer: {
marginBottom: 12,
},
daysScrollContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
dayPill: {
width: 48,
height: 48,
borderRadius: 34,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
dayNumber: {
fontSize: 18,
textAlign: 'center',
},
dayLabel: {
fontSize: 12,
marginTop: 2,
textAlign: 'center',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
fontWeight: '500',
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 320,
},
listContainer: {
paddingBottom: 100, // 留出底部空间防止遮挡
},
headerContent: {
marginBottom: 16,
},
dateSelectorContainer: {
paddingHorizontal: 16,
paddingTop: 8,
marginBottom: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 16,
chartWrapper: {
marginBottom: 24,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
emptyContent: {
alignItems: 'center',
maxWidth: 320,
listTitleContainer: {
flexDirection: 'row',
alignItems: 'baseline',
paddingHorizontal: 24,
marginBottom: 12,
gap: 8,
},
emptyTitle: {
listTitle: {
fontSize: 18,
fontWeight: '700',
marginTop: 16,
marginBottom: 8,
textAlign: 'center',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
emptySubtitle: {
listSubtitle: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
glassAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
overflow: 'hidden',
},
fallbackAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
emptySimpleContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptySimpleImage: {
width: 48,
height: 48,
opacity: 0.4,
marginBottom: 12,
},
emptySimpleText: {
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
color: '#94A3B8',
fontFamily: 'AliRegular',
marginBottom: 8,
},
emptyActionText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
footerContainer: {
paddingVertical: 20,
paddingVertical: 24,
alignItems: 'center',
},
footerText: {
fontSize: 14,
fontSize: 12,
fontWeight: '500',
opacity: 0.6,
fontFamily: 'AliRegular',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 16,
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -1,5 +1,6 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import {
resetToDefault,
selectTabBarConfigs,
@@ -7,11 +8,9 @@ import {
type TabConfig,
} from '@/store/tabBarConfigSlice';
import { Ionicons } from '@expo/vector-icons';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
ScrollView,
@@ -22,26 +21,50 @@ import {
View
} from 'react-native';
import { MembershipModal } from '@/components/model/MembershipModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
export default function TabBarConfigScreen() {
const { t } = useTranslation();
const { t } = useI18n();
const router = useRouter();
const dispatch = useAppDispatch();
const safeAreaTop = useSafeAreaTop(60);
const configs = useAppSelector(selectTabBarConfigs);
const isGlassAvailable = isLiquidGlassAvailable();
const { isVip } = useVipService();
const [showMembershipModal, setShowMembershipModal] = useState(false);
// 处理开关切换
const handleToggle = useCallback(
(tabId: string) => {
dispatch(toggleTabEnabled(tabId));
// 直接检查用户是否是 VIP底部栏配置不是权益类功能而是基础功能
if (isVip) {
// VIP 用户可以正常切换
dispatch(toggleTabEnabled(tabId));
} else {
// 非 VIP 用户显示购买弹窗
setShowMembershipModal(true);
}
},
[dispatch]
[dispatch, isVip]
);
// 页面加载时检查 VIP 状态
useEffect(() => {
if (!isVip) {
// 非 VIP 用户进入页面时立即显示购买弹窗
setShowMembershipModal(true);
}
}, [isVip]);
// 购买成功回调
const handlePurchaseSuccess = useCallback(() => {
// 购买成功后可以执行一些操作,比如刷新用户信息
console.log('会员购买成功');
}, []);
// 恢复默认设置
const handleReset = useCallback(() => {
Alert.alert(
@@ -82,6 +105,11 @@ export default function TabBarConfigScreen() {
{t('personal.tabBarConfig.cannotDisable')}
</Text>
)}
{item.canBeDisabled && !isVip && (
<Text style={styles.vipSubtitle}>
{t('personal.tabBarConfig.vipOnly')}
</Text>
)}
</View>
</View>
@@ -89,7 +117,7 @@ export default function TabBarConfigScreen() {
<Switch
value={item.enabled}
onValueChange={() => handleToggle(item.id)}
disabled={!item.canBeDisabled}
disabled={!item.canBeDisabled || !isVip}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
@@ -155,6 +183,13 @@ export default function TabBarConfigScreen() {
</View>
</ScrollView>
{/* 会员购买弹窗 */}
<MembershipModal
visible={showMembershipModal}
onClose={() => setShowMembershipModal(false)}
onPurchaseSuccess={handlePurchaseSuccess}
/>
</View>
);
}
@@ -255,6 +290,10 @@ const styles = StyleSheet.create({
fontSize: 12,
color: '#9370DB',
},
vipSubtitle: {
fontSize: 12,
color: '#FF6B6B',
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
@@ -17,6 +18,7 @@ import {
} from 'react-native';
export default function StepsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
// 获取路由参数
@@ -169,11 +171,11 @@ export default function StepsDetailScreen() {
// 活动等级配置
const activityLevels = useMemo(() => [
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], []);
{ key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], [t]);
// 计算当前活动等级
const currentActivityLevel = useMemo(() => {
@@ -211,7 +213,7 @@ export default function StepsDetailScreen() {
/>
<HeaderBar
title="步数详情"
title={t('stepsDetail.title')}
/>
<ScrollView
@@ -233,23 +235,23 @@ export default function StepsDetailScreen() {
<View style={styles.statsCard}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
</View>
) : (
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
</View>
</View>
)}
@@ -258,7 +260,7 @@ export default function StepsDetailScreen() {
{/* 详细柱状图卡片 */}
<View style={styles.chartCard}>
<View style={styles.chartHeader}>
<Text style={styles.chartTitle}></Text>
<Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
<Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text>
@@ -290,7 +292,7 @@ export default function StepsDetailScreen() {
))}
</View>
<Text style={styles.averageLineLabel}>
{averageHourlySteps}
{t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
</Text>
</View>
)}
@@ -354,9 +356,9 @@ export default function StepsDetailScreen() {
{/* 底部时间轴标签 */}
<View style={styles.timeLabels}>
<Text style={styles.timeLabel}>0:00</Text>
<Text style={styles.timeLabel}>12:00</Text>
<Text style={styles.timeLabel}>24:00</Text>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
</View>
</View>
</View>
@@ -366,7 +368,7 @@ export default function StepsDetailScreen() {
{/* 活动级别文本 */}
<Text style={styles.activityMainText}></Text>
<Text style={styles.activityMainText}>{t('stepsDetail.activityLevel.currentActivity')}</Text>
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
{/* 进度条 */}
@@ -388,14 +390,14 @@ export default function StepsDetailScreen() {
<View style={styles.stepsInfoContainer}>
<View style={styles.currentStepsInfo}>
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text>
<Text style={styles.stepsLabel}></Text>
<Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
</View>
<View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text>
<Text style={styles.stepsLabel}>
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
{nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
</Text>
</View>
</View>

View File

@@ -3,6 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { analyzeFoodFromText } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
@@ -24,6 +25,7 @@ import {
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
export default function VoiceRecordScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = useColorScheme() ?? 'light';
const colorTokens = Colors[theme];
@@ -118,7 +120,7 @@ export default function VoiceRecordScreen() {
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
const onSpeechStart = useCallback(() => {
console.log('语音开始');
console.log('Voice started');
if (!isMountedRef.current) return;
setIsListening(true);
@@ -128,11 +130,11 @@ export default function VoiceRecordScreen() {
}, []);
const onSpeechRecognized = useCallback(() => {
console.log('语音识别中...');
console.log('Voice recognition in progress...');
}, []);
const onSpeechEnd = useCallback(() => {
console.log('语音结束');
console.log('Voice ended');
if (!isMountedRef.current) return;
setIsListening(false);
@@ -141,7 +143,7 @@ export default function VoiceRecordScreen() {
}, []);
const onSpeechError = useCallback((error: any) => {
console.log('语音识别错误:', error);
console.log('Voice recognition error:', error);
if (!isMountedRef.current) return;
setIsListening(false);
@@ -150,16 +152,16 @@ export default function VoiceRecordScreen() {
// 显示更友好的错误信息
if (error.error?.code === '7') {
Alert.alert('提示', '没有检测到语音输入,请重试');
Alert.alert(t('voiceRecord.alerts.noVoiceInput'), t('voiceRecord.alerts.noVoiceInput'));
} else if (error.error?.code === '2') {
Alert.alert('提示', '网络连接异常,请检查网络后重试');
Alert.alert(t('voiceRecord.alerts.networkError'), t('voiceRecord.alerts.networkError'));
} else {
Alert.alert('提示', '语音识别出现问题,请重试');
Alert.alert(t('voiceRecord.alerts.voiceError'), t('voiceRecord.alerts.voiceError'));
}
}, []);
const onSpeechResults = useCallback((event: any) => {
console.log('语音识别结果:', event);
console.log('Voice recognition result:', event);
if (!isMountedRef.current) return;
const text = event.value?.[0] || '';
@@ -168,7 +170,7 @@ export default function VoiceRecordScreen() {
setRecordState('result');
} else {
setRecordState('idle');
Alert.alert('提示', '未识别到有效内容,请重新录音');
Alert.alert(t('voiceRecord.alerts.noValidContent'), t('voiceRecord.alerts.noValidContent'));
}
stopAnimations();
}, []);
@@ -215,7 +217,7 @@ export default function VoiceRecordScreen() {
await Voice.destroy();
Voice.removeAllListeners();
} catch (error) {
console.log('清理语音识别资源失败:', error);
console.log('Failed to clean up voice recognition resources:', error);
}
};
cleanup();
@@ -246,22 +248,22 @@ export default function VoiceRecordScreen() {
await Voice.start('zh-CN');
} catch (error) {
console.log('启动语音识别失败:', error);
console.log('Failed to start voice recognition:', error);
setRecordState('idle');
setIsListening(false);
Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置');
Alert.alert(t('voiceRecord.alerts.recordingFailed'), t('voiceRecord.alerts.recordingPermissionError'));
}
};
// 停止录音
const stopRecording = async () => {
try {
console.log('停止录音');
console.log('Stop recording');
setIsListening(false);
await Voice.stop();
triggerHapticFeedback('impactLight');
} catch (error) {
console.log('停止语音识别失败:', error);
console.log('Failed to stop voice recognition:', error);
setIsListening(false);
setRecordState('idle');
}
@@ -287,7 +289,7 @@ export default function VoiceRecordScreen() {
startRecording();
}, 200);
} catch (error) {
console.log('重新录音失败:', error);
console.log('Failed to retry recording:', error);
setRecordState('idle');
setIsListening(false);
}
@@ -296,7 +298,7 @@ export default function VoiceRecordScreen() {
// 确认并分析食物文本
const confirmResult = async () => {
if (!recognizedText.trim()) {
Alert.alert('提示', '请先进行语音识别');
Alert.alert(t('voiceRecord.alerts.pleaseRecordFirst'), t('voiceRecord.alerts.pleaseRecordFirst'));
return;
}
@@ -382,7 +384,7 @@ export default function VoiceRecordScreen() {
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
dispatch(setError(errorMessage));
Alert.alert('分析失败', errorMessage);
Alert.alert(t('voiceRecord.alerts.analysisFailed'), errorMessage);
}
};
@@ -401,7 +403,7 @@ export default function VoiceRecordScreen() {
router.back();
} catch (error) {
console.log('返回时清理资源失败:', error);
console.log('Failed to clean up resources when returning:', error);
router.back();
}
};
@@ -410,15 +412,15 @@ export default function VoiceRecordScreen() {
const getStatusText = () => {
switch (recordState) {
case 'idle':
return '轻触麦克风开始录音';
return t('voiceRecord.status.idle');
case 'listening':
return '正在聆听中,请开始说话...';
return t('voiceRecord.status.listening');
case 'processing':
return 'AI正在处理语音内容...';
return t('voiceRecord.status.processing');
case 'analyzing':
return 'AI大模型深度分析营养成分中...';
return t('voiceRecord.status.analyzing');
case 'result':
return '语音识别完成,请确认结果';
return t('voiceRecord.status.result');
default:
return '';
}
@@ -470,7 +472,7 @@ export default function VoiceRecordScreen() {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar
title="一句话记录"
title={t('voiceRecord.title')}
onBack={handleBack}
tone={theme}
variant="elevated"
@@ -485,7 +487,7 @@ export default function VoiceRecordScreen() {
<View style={styles.topSection}>
<View style={styles.introContainer}>
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
AI将智能分析营养成分和卡路里
{t('voiceRecord.intro.description')}
</Text>
</View>
</View>
@@ -605,7 +607,7 @@ export default function VoiceRecordScreen() {
{recordState === 'listening' && (
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
{t('voiceRecord.hints.listening')}
</Text>
)}
@@ -614,18 +616,18 @@ export default function VoiceRecordScreen() {
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
<View style={styles.examplesContent}>
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
{t('voiceRecord.examples.title')}
</Text>
<View style={styles.examplesList}>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;150&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
{[
t('voiceRecord.examples.items.0'),
t('voiceRecord.examples.items.1'),
t('voiceRecord.examples.items.2')
].map((example: string, index: number) => (
<Text key={index} style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;{example}&rdquo;
</Text>
))}
</View>
</View>
</BlurView>
@@ -634,7 +636,7 @@ export default function VoiceRecordScreen() {
{recordState === 'analyzing' && (
<View style={styles.analysisProgressContainer}>
<Text style={[styles.progressText, { color: colorTokens.text }]}>
: {Math.round(analysisProgress)}%
{t('voiceRecord.analysis.progress', { progress: Math.round(analysisProgress) })}
</Text>
<View style={styles.progressBarContainer}>
<Animated.View
@@ -650,7 +652,7 @@ export default function VoiceRecordScreen() {
/>
</View>
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
AI正在深度分析您的食物描述...
{t('voiceRecord.analysis.hint')}
</Text>
</View>
)}
@@ -662,7 +664,7 @@ export default function VoiceRecordScreen() {
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
<View style={styles.resultContent}>
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
:
{t('voiceRecord.result.label')}
</Text>
<Text style={[styles.resultText, { color: colorTokens.text }]}>
{recognizedText}
@@ -675,7 +677,7 @@ export default function VoiceRecordScreen() {
onPress={retryRecording}
>
<Ionicons name="refresh" size={16} color="#7B68EE" />
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('voiceRecord.actions.retry')}</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -683,7 +685,7 @@ export default function VoiceRecordScreen() {
onPress={confirmResult}
>
<Ionicons name="checkmark" size={16} color="white" />
<Text style={styles.confirmButtonText}>使</Text>
<Text style={styles.confirmButtonText}>{t('voiceRecord.actions.confirm')}</Text>
</TouchableOpacity>
</View>
)}

View File

@@ -3,6 +3,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
@@ -20,6 +21,7 @@ import {
import { Swipeable } from 'react-native-gesture-handler';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import dayjs from 'dayjs';
@@ -28,6 +30,7 @@ interface WaterDetailProps {
}
const WaterDetail: React.FC<WaterDetailProps> = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// Remove modal states as they are now in separate settings page
// 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 处理设置按钮点击 - 跳转到设置页面
const handleSettingsPress = () => {
router.push('/water/settings');
};
// Remove all modal-related functions as they are now in separate settings page
// 删除饮水记录
const handleDeleteRecord = async (recordId: string) => {
await removeWaterRecord(recordId);
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
setDailyGoal(dailyWaterGoal.toString());
}
} catch (error) {
console.error('加载用户偏好设置失败:', error);
console.error(t('waterDetail.loadingUserPreferences'), error);
}
};
loadUserPreferences();
}, [dailyWaterGoal]);
const totalAmount = waterRecords?.reduce((sum, record) => sum + record.amount, 0) || 0;
const currentGoal = dailyWaterGoal || 2000;
const progress = Math.min(100, (totalAmount / currentGoal) * 100);
// 新增:饮水记录卡片组件
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
const swipeableRef = React.useRef<Swipeable>(null);
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
'确定要删除这条饮水记录吗?此操作无法撤销。',
t('waterDetail.deleteConfirm.title'),
t('waterDetail.deleteConfirm.message'),
[
{
text: '取消',
text: t('waterDetail.deleteConfirm.cancel'),
style: 'cancel',
},
{
text: '删除',
text: t('waterDetail.deleteConfirm.confirm'),
style: 'destructive',
onPress: () => {
onDelete();
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteSwipeButtonText}></Text>
</TouchableOpacity>
);
};
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
rightThreshold={40}
overshootRight={false}
>
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<View style={styles.recordCard}>
<View style={styles.recordMainContent}>
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}>
<View style={styles.recordIconContainer}>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
style={styles.recordIcon}
/>
</View>
<View style={styles.recordInfo}>
<Text style={[styles.recordLabel, { color: colorTokens.text }]}></Text>
<Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
<View style={styles.recordTimeContainer}>
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} />
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}>
<Ionicons name="time-outline" size={14} color="#6f7ba7" />
<Text style={styles.recordTimeText}>
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
</Text>
</View>
</View>
<View style={styles.recordAmountContainer}>
<Text style={[styles.recordAmount, { color: colorTokens.text }]}>{record.amount}ml</Text>
<Text style={styles.recordAmount}>{record.amount}ml</Text>
</View>
</View>
{record.note && (
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text>
<Text style={styles.recordNote}>{record.note}</Text>
)}
</View>
</Swipeable>
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
return (
<View style={styles.container}>
{/* 背景渐变 */}
{/* 背景 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
colors={['#f3f4fb', '#f3f4fb']}
style={StyleSheet.absoluteFillObject}
/>
{/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
<LinearGradient
colors={['rgba(229, 252, 254, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar
title="饮水详情"
onBack={() => {
// 这里会通过路由自动处理返回
router.back();
}}
title={t('waterDetail.title')}
onBack={() => router.back()}
right={
<TouchableOpacity
style={styles.settingsButton}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
</TouchableOpacity>
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleSettingsPress}
activeOpacity={0.7}
style={styles.settingsButtonWrapper}
>
<GlassView
style={styles.settingsButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.settingsButtonFallback}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</TouchableOpacity>
)
}
/>
@@ -197,13 +210,37 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
}]}
showsVerticalScrollIndicator={false}
>
{/* 第二部分:饮水记录 */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}
<View style={styles.headerBlock}>
<Text style={styles.pageTitle}>
{selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
</Text>
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
</View>
{/* 进度卡片 */}
<View style={styles.progressCard}>
<View style={styles.progressInfo}>
<View>
<Text style={styles.progressLabel}>{t('waterDetail.total')}</Text>
<Text style={styles.progressValue}>{totalAmount}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.progressLabel}>{t('waterDetail.goal')}</Text>
<Text style={styles.progressGoalValue}>{currentGoal}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
</View>
<View style={styles.progressBarBg}>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressBarFill, { width: `${progress}%` }]}
/>
</View>
</View>
{/* 记录列表 */}
<View style={styles.section}>
{waterRecords && waterRecords.length > 0 ? (
<View style={styles.recordsList}>
{waterRecords.map((record) => (
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
onDelete={() => handleDeleteRecord(record.id)}
/>
))}
{/* 总计显示 */}
<View style={[styles.recordsSummary, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Text style={[styles.summaryText, { color: colorTokens.text }]}>
{waterRecords.reduce((sum, record) => sum + record.amount, 0)}ml
</Text>
<Text style={[styles.summaryGoal, { color: colorTokens.textSecondary }]}>
{dailyWaterGoal}ml
</Text>
</View>
</View>
) : (
<View style={styles.noRecordsContainer}>
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>&quot;&quot;</Text>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
style={{ width: 60, height: 60, opacity: 0.5, marginBottom: 16 }}
/>
<Text style={styles.noRecordsText}>{t('waterDetail.noRecords')}</Text>
<Text style={styles.noRecordsSubText}>{t('waterDetail.noRecordsSubtitle')}</Text>
</View>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* All modals have been moved to the separate water-settings page */}
</View>
);
};
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#f3f4fb',
},
gradientBackground: {
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 80,
right: 30,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#4F5BD5',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: 100,
left: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#4F5BD5',
opacity: 0.06,
height: 300,
},
keyboardAvoidingView: {
flex: 1,
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
headerBlock: {
paddingHorizontal: 24,
paddingTop: 20,
marginTop: 10,
marginBottom: 24,
},
section: {
marginBottom: 36,
pageTitle: {
fontSize: 28,
fontWeight: '800',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 4,
},
sectionTitle: {
pageSubtitle: {
fontSize: 16,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
// 进度卡片
progressCard: {
marginHorizontal: 24,
marginBottom: 32,
padding: 24,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.1)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 16,
},
progressLabel: {
fontSize: 14,
color: '#6f7ba7',
marginBottom: 6,
fontFamily: 'AliRegular',
},
progressValue: {
fontSize: 28,
fontWeight: '800',
color: '#4F5BD5',
fontFamily: 'AliBold',
lineHeight: 32,
},
progressGoalValue: {
fontSize: 20,
fontWeight: '700',
marginBottom: 24,
letterSpacing: -0.5,
color: '#1c1f3a',
fontFamily: 'AliBold',
lineHeight: 32,
},
subsectionTitle: {
progressUnit: {
fontSize: 16,
fontWeight: '600',
marginBottom: 16,
letterSpacing: -0.3,
color: '#1c1f3a',
},
sectionSubtitle: {
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
},
// 饮水记录相关样式
progressBarBg: {
height: 12,
backgroundColor: '#F0F2F5',
borderRadius: 6,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 6,
},
section: {
paddingHorizontal: 24,
},
// 记录列表样式
recordsList: {
gap: 16,
},
recordCardContainer: {
// iOS 阴影效果 - 增强阴影效果
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.16,
shadowRadius: 16,
// Android 阴影效果
elevation: 6,
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
marginBottom: 2,
},
recordCard: {
borderRadius: 20,
borderRadius: 24,
padding: 18,
backgroundColor: '#ffffff',
},
recordMainContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
recordIconContainer: {
width: 48,
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.08)',
backgroundColor: '#f5f6ff',
},
recordIcon: {
width: 24,
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
marginLeft: 16,
},
recordLabel: {
fontSize: 17,
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
marginBottom: 6,
marginBottom: 4,
fontFamily: 'AliBold',
},
recordTimeContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
gap: 4,
},
recordTimeText: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
recordAmountContainer: {
alignItems: 'flex-end',
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
color: '#4F5BD5',
},
deleteSwipeButton: {
backgroundColor: '#EF4444',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginLeft: 12,
},
deleteSwipeButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
},
recordTimeText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
fontFamily: 'AliBold',
},
recordNote: {
marginTop: 12,
marginTop: 14,
padding: 12,
backgroundColor: 'rgba(79, 91, 213, 0.04)',
backgroundColor: '#F8F9FC',
borderRadius: 12,
fontSize: 14,
fontStyle: 'normal',
lineHeight: 20,
fontSize: 13,
lineHeight: 18,
color: '#5f6a97',
fontFamily: 'AliRegular',
},
recordsSummary: {
marginTop: 24,
padding: 20,
borderRadius: 20,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
flexDirection: 'row',
justifyContent: 'space-between',
deleteSwipeButton: {
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 70,
height: '100%',
borderRadius: 24,
marginLeft: 12,
},
summaryText: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
},
summaryGoal: {
fontSize: 14,
fontWeight: '600',
color: '#6f7ba7',
},
noRecordsContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
gap: 20,
backgroundColor: '#ffffff',
borderRadius: 28,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
},
noRecordsText: {
fontSize: 17,
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
color: '#6f7ba7',
color: '#1c1f3a',
marginBottom: 8,
fontFamily: 'AliBold',
},
noRecordsSubText: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
color: '#9ba3c7',
fontFamily: 'AliRegular',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
// Settings Button
settingsButtonWrapper: {
width: 40,
height: 40,
borderRadius: 20,
overflow: 'hidden',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
// Android 阴影效果
elevation: 16,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
pickerContainer: {
height: 200,
marginBottom: 20,
},
picker: {
height: 200,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
minWidth: 80,
settingsButtonGlass: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
modalBtnPrimary: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
settingsButton: {
settingsButtonFallback: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.24)',
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
settingsModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
settingsModalTitle: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
settingsMenuContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
settingsMenuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
settingsMenuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingsIconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
settingsMenuItemContent: {
flex: 1,
},
settingsMenuItemTitle: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
settingsMenuItemSubtitle: {
fontSize: 12,
marginBottom: 4,
},
settingsMenuItemValue: {
fontSize: 14,
},
// 喝水提醒配置弹窗样式
waterReminderModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
waterReminderContent: {
flex: 1,
marginBottom: 20,
},
waterReminderSection: {
marginBottom: 24,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 16,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 120,
},
// 时间选择器弹窗样式
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
borderColor: 'rgba(0,0,0,0.05)',
},
});

View File

@@ -22,9 +22,11 @@ import {
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterReminderSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
setStartTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'开始时间不能晚于或等于结束时间,请重新选择',
[{ text: '确定' }]
t('waterReminderSettings.alerts.timeValidation.title'),
t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
[{ text: t('waterReminderSettings.buttons.confirm') }]
);
}
};
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
setEndTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'结束时间不能早于或等于开始时间,请重新选择',
[{ text: '确定' }]
t('waterReminderSettings.alerts.timeValidation.title'),
t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
[{ text: t('waterReminderSettings.buttons.confirm') }]
);
}
};
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
if (waterReminderSettings.enabled) {
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
const intervalInfo = `${waterReminderSettings.interval}分钟`;
const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
Alert.alert(
'设置成功',
`喝水提醒已开启\n\n时间段${timeInfo}\n提醒间隔${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
[{ text: '确定', onPress: () => router.back() }]
t('waterReminderSettings.alerts.success.enabled'),
t('waterReminderSettings.alerts.success.enabledMessage', {
timeRange: timeInfo,
interval: intervalInfo
}),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
);
} else {
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]);
Alert.alert(
t('waterReminderSettings.alerts.success.disabled'),
t('waterReminderSettings.alerts.success.disabledMessage'),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
);
}
} catch (error) {
console.error('保存喝水提醒设置失败:', error);
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试');
Alert.alert(
t('waterReminderSettings.alerts.error.title'),
t('waterReminderSettings.alerts.error.message')
);
}
};
@@ -176,7 +188,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.decorativeCircle2} />
<HeaderBar
title="喝水提醒"
title={t('waterReminderSettings.title')}
onBack={() => {
router.back();
}}
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}>
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.notifications')}</Text>
</View>
<Switch
value={waterReminderSettings.enabled}
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
/>
</View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.notifications')}
</Text>
</View>
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
{waterReminderSettings.enabled && (
<>
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.timeRange')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.timeRange')}
</Text>
<View style={styles.timeRangeContainer}>
{/* 开始时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker}
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
{/* 结束时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker}
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
{/* 提醒间隔设置 */}
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.interval')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
30-120
{t('waterReminderSettings.descriptions.interval')}
</Text>
<View style={styles.intervalContainer}>
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
style={styles.intervalPicker}
>
{[30, 45, 60, 90, 120, 150, 180].map(interval => (
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} />
<Picker.Item key={interval} label={`${interval}${t('waterReminderSettings.labels.minutes')}`} value={interval} />
))}
</Picker>
</View>
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
onPress={handleWaterReminderSave}
activeOpacity={0.8}
>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
</TouchableOpacity>
</View>
</ScrollView>
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempStartHour}
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text>
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
<Text style={styles.timeRangeWarning}> </Text>
<Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.startTimeInvalid')}</Text>
)}
</View>
</View>
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setStartTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable>
<Pressable
onPress={confirmStartTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable>
</View>
</View>
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempEndHour}
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text>
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
<Text style={styles.timeRangeWarning}> </Text>
<Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.endTimeInvalid')}</Text>
)}
</View>
</View>
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setEndTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable>
<Pressable
onPress={confirmEndTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable>
</View>
</View>

View File

@@ -21,9 +21,11 @@ import {
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
setGoalModalVisible(false);
// 这里可以添加保存到本地存储或发送到后端的逻辑
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`);
Alert.alert(
t('waterSettings.alerts.goalSuccess.title'),
t('waterSettings.alerts.goalSuccess.message', { amount: tempGoal })
);
};
// 处理快速添加默认值确认
@@ -84,9 +89,15 @@ const WaterSettings: React.FC = () => {
try {
await setQuickWaterAmount(tempQuickAdd);
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
Alert.alert(
t('waterSettings.alerts.quickAddSuccess.title'),
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
);
} catch {
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
Alert.alert(
t('waterSettings.alerts.quickAddFailed.title'),
t('waterSettings.alerts.quickAddFailed.message')
);
}
};
@@ -101,7 +112,7 @@ const WaterSettings: React.FC = () => {
const reminderSettings = await getWaterReminderSettings();
setWaterReminderSettings(reminderSettings);
} catch (error) {
console.error('加载用户偏好设置失败:', error);
console.error('Failed to load user preferences:', error);
}
}, []);
@@ -132,7 +143,7 @@ const WaterSettings: React.FC = () => {
<View style={styles.decorativeCircle2} />
<HeaderBar
title="饮水设置"
title={t('waterSettings.title')}
onBack={() => {
router.back();
}}
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
<Ionicons name="flag-outline" size={20} color="#9370DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
<Ionicons name="add-outline" size={20} color="#9370DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
"+"
{t('waterSettings.descriptions.quickAdd')}
</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}{t('waterSettings.labels.ml')}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.reminder')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
{t('waterSettings.descriptions.reminder')}
</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'}
{waterReminderSettings.enabled
? t('waterSettings.status.reminderEnabled', {
startTime: waterReminderSettings.startTime,
endTime: waterReminderSettings.endTime,
interval: waterReminderSettings.interval
})
: t('waterSettings.labels.disabled')
}
</Text>
</View>
</View>
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempGoal}
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker}
>
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
<Picker.Item key={goal} label={`${goal}${t('waterSettings.labels.ml')}`} value={goal} />
))}
</Picker>
</View>
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setGoalModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable>
<Pressable
onPress={handleGoalConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable>
</View>
</View>
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempQuickAdd}
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker}
>
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
<Picker.Item key={amount} label={`${amount}${t('waterSettings.labels.ml')}`} value={amount} />
))}
</Picker>
</View>
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setQuickAddModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable>
<Pressable
onPress={handleQuickAddConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable>
</View>
</View>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -16,6 +17,7 @@ import {
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
import {
@@ -233,23 +235,23 @@ function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
};
}
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
function getIntensityBadge(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } {
if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
}
const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
}
if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' };
}
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' };
}
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
@@ -265,13 +267,15 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'),
title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
}));
}
export default function WorkoutHistoryScreen() {
const { t } = useI18n();
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -283,8 +287,19 @@ export default function WorkoutHistoryScreen() {
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
const [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
const safeAreaTop = useSafeAreaTop()
const safeAreaTop = useSafeAreaTop();
React.useEffect(() => {
if (!workoutIdParam) {
return;
}
const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam;
if (idParam) {
setPendingWorkoutId(idParam);
}
}, [workoutIdParam]);
const loadHistory = useCallback(async () => {
setIsLoading(true);
@@ -302,7 +317,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) {
setSections([]);
setError('尚未授予健康数据权限');
setError(t('workoutHistory.error.permissionDenied'));
setMonthlyStats(null);
return;
}
@@ -315,8 +330,8 @@ export default function WorkoutHistoryScreen() {
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts));
} catch (err) {
console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
console.error('Failed to load workout history:', err);
setError(t('workoutHistory.error.loadFailed'));
setSections([]);
setMonthlyStats(null);
} finally {
@@ -350,9 +365,9 @@ export default function WorkoutHistoryScreen() {
? dayjs(monthlyStats.snapshotDate).format('M月D日')
: dayjs().format('M月D日');
const overviewText = monthlyStats
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}`
: '本月还没有锻炼记录,动起来收集第一条吧!';
const periodText = `统计周期1日 - ${monthEndDay}日(本月)`;
? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
: t('workoutHistory.monthlyStats.overviewEmpty');
const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
const maxDuration = statsItems[0]?.duration || 1;
return (
@@ -369,7 +384,7 @@ export default function WorkoutHistoryScreen() {
end={{ x: 1, y: 1 }}
style={styles.monthlyStatsCard}
>
<Text style={styles.statSectionLabel}></Text>
<Text style={styles.statSectionLabel}>{t('workoutHistory.monthlyStats.title')}</Text>
<Text style={styles.statPeriodText}>{periodText}</Text>
<Text style={styles.statDescription}>{overviewText}</Text>
@@ -403,7 +418,7 @@ export default function WorkoutHistoryScreen() {
) : (
<View style={styles.statEmptyState}>
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
<Text style={styles.statEmptyText}></Text>
<Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
</View>
)}
</LinearGradient>
@@ -416,8 +431,8 @@ export default function WorkoutHistoryScreen() {
const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubText}></Text>
<Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
<Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
</View>
), []);
@@ -453,7 +468,7 @@ export default function WorkoutHistoryScreen() {
}
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
return `这是你${workoutDate.format('M月')}的第 ${index + 1}${activityLabel}`;
return t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel });
}, [sections]);
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
@@ -463,16 +478,16 @@ export default function WorkoutHistoryScreen() {
const metrics = await getWorkoutDetailMetrics(workout);
setDetailMetrics(metrics);
} catch (err) {
console.error('加载锻炼详情失败:', err);
console.error('Failed to load workout details:', err);
setDetailMetrics(null);
setDetailError('加载锻炼详情失败,请稍后再试');
setDetailError(t('workoutHistory.error.detailLoadFailed'));
} finally {
setDetailLoading(false);
}
}, []);
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
setSelectedIntensity(intensity);
setSelectedWorkout(workout);
setDetailMetrics(null);
@@ -482,6 +497,22 @@ export default function WorkoutHistoryScreen() {
loadWorkoutDetail(workout);
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
React.useEffect(() => {
if (!pendingWorkoutId || isLoading) {
return;
}
const allWorkouts = sections.flatMap((section) => section.data);
const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId);
if (targetWorkout) {
handleWorkoutPress(targetWorkout);
}
// 清理待处理状态,避免重复触发
setPendingWorkoutId(null);
}, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]);
const handleRetryDetail = useCallback(() => {
if (selectedWorkout) {
loadWorkoutDetail(selectedWorkout);
@@ -495,7 +526,7 @@ export default function WorkoutHistoryScreen() {
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
const intensity = getIntensityBadge(t, item.totalEnergyBurned, item.duration || 0);
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
@@ -512,12 +543,12 @@ export default function WorkoutHistoryScreen() {
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text style={styles.cardTitle}>{calories} · {minutes}</Text>
<Text style={styles.cardTitle}>{t('workoutHistory.historyCard.calories', { calories, minutes })}</Text>
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View>
</View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text>
<Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
</View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
@@ -535,11 +566,11 @@ export default function WorkoutHistoryScreen() {
colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill}
/>
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
<HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
</View>
) : (
<SectionList
@@ -556,7 +587,7 @@ export default function WorkoutHistoryScreen() {
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text>
<Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
</TouchableOpacity>
</View>
) : emptyComponent}

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

@@ -242,6 +242,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#0F172A',
fontWeight: '600',
fontFamily: 'AliBold',
},
titleIcon: {
width: 16,
@@ -256,6 +257,7 @@ const styles = StyleSheet.create({
statusText: {
fontSize: 11,
fontWeight: '600',
fontFamily: 'AliBold',
},
valueSection: {
flexDirection: 'row',
@@ -267,10 +269,12 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#0F172A',
lineHeight: 28,
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
color: '#64748B',
marginLeft: 6,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,9 +1,9 @@
import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Svg, { Circle, Defs, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -26,12 +26,8 @@ export function CalorieRingChart({
protein,
fat,
carbs,
proteinGoal,
fatGoal,
carbsGoal,
}: CalorieRingChartProps) {
const surfaceColor = useThemeColor({}, 'surface');
const { t } = useI18n();
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
@@ -46,9 +42,9 @@ export function CalorieRingChart({
const totalAvailable = metabolism + exercise;
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
// 圆环参数 - 小尺寸以优化空间占用
const radius = 48;
const strokeWidth = 8; // 增加圆环厚度
// 圆环参数 - 小尺寸
const radius = 42;
const strokeWidth = 8;
const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius;
const strokeDasharray = circumference;
@@ -70,34 +66,32 @@ export function CalorieRingChart({
});
return (
<View style={[styles.container, { backgroundColor: surfaceColor }]}>
{/* 左上角公式展示 */}
<View style={styles.formulaContainer}>
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
= + -
</ThemedText>
</View>
{/* 主要内容区域 */}
<View style={styles.container}>
<View style={styles.mainContent}>
{/* 左侧圆环图 */}
<View style={styles.chartContainer}>
<Svg width={center * 2} height={center * 2}>
<Defs>
<SvgLinearGradient id="progressGradient" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={progressPercentage > 80 ? "#FF9966" : "#4facfe"} stopOpacity="1" />
<Stop offset="1" stopColor={progressPercentage > 80 ? "#FF5E62" : "#00f2fe"} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{/* 背景圆环 */}
<Circle
cx={center}
cy={center}
r={radius}
stroke="#F0F0F0"
stroke="#F5F7FA"
strokeWidth={strokeWidth}
fill="none"
/>
{/* 进度圆环 - 保持固定颜色 */}
{/* 进度圆环 */}
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={progressPercentage > 80 ? "#FF6B6B" : "#4ECDC4"}
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${strokeDasharray}`}
@@ -109,68 +103,68 @@ export function CalorieRingChart({
{/* 中心内容 */}
<View style={styles.centerContent}>
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}>
<ThemedText style={styles.centerLabel}>
{t('nutritionRecords.chart.remaining')}
</ThemedText>
<ThemedText style={[styles.centerValue, { color: textColor }]}>
{Math.round(canEat)}
<ThemedText style={styles.centerValue}>
{Math.round(canEat)}
</ThemedText>
<ThemedText style={styles.centerUnit}>
{t('nutritionRecords.nutrients.caloriesUnit')}
</ThemedText>
</View>
</View>
{/* 右侧数据展示 */}
{/* 右侧数据展示 - 优化布局 */}
<View style={styles.dataContainer}>
<View style={styles.dataBackground}>
{/* 左右两列布局 */}
<View style={styles.dataColumns}>
{/* 左列:卡路里数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(metabolism)}
</ThemedText>
</View>
{/* 公式 */}
<View style={styles.formulaContainer}>
<ThemedText style={styles.formulaText}>
{t('nutritionRecords.chart.formula')}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(exercise)}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(consumed)}
</ThemedText>
</View>
</View>
{/* 右列:营养数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(protein)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(fat)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(carbs)}g
</ThemedText>
</View>
</View>
{/* 代谢 & 运动 & 饮食 */}
<View style={styles.statsGroup}>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotMetabolism} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.metabolism')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(metabolism)}</ThemedText>
</View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotExercise} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.exercise')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(exercise)}</ThemedText>
</View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotConsumed} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.diet')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(consumed)}</ThemedText>
</View>
</View>
<View style={styles.divider} />
{/* 营养素 - 水平排布 */}
<View style={styles.nutritionRow}>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(protein)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.protein')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(fat)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.fat')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(carbs)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.carbs')}</ThemedText>
</View>
</View>
</View>
</View>
@@ -181,40 +175,35 @@ export function CalorieRingChart({
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderRadius: 24,
padding: 16,
marginHorizontal: 16,
marginBottom: 8,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
marginHorizontal: 20,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 16,
elevation: 6,
},
formulaContainer: {
alignItems: 'flex-start',
marginBottom: 12,
},
formulaText: {
fontSize: 12,
fontSize: 10,
fontWeight: '500',
color: '#999999',
lineHeight: 16,
color: '#94A3B8',
fontFamily: 'AliRegular',
},
mainContent: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 0, // 移除底部间距,因为不再有底部营养容器
paddingHorizontal: 8,
alignItems: 'flex-start',
},
chartContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
width: 112, // 减少宽度以匹配更小的圆环 (48*2 + 8*2)
flexShrink: 0,
width: 100,
height: 100,
marginTop: 8,
},
centerContent: {
position: 'absolute',
@@ -222,71 +211,95 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
centerLabel: {
fontSize: 11,
fontSize: 10,
fontWeight: '500',
color: '#999999',
marginBottom: 2,
color: '#94A3B8',
marginBottom: 1,
fontFamily: 'AliRegular',
},
centerValue: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 1,
fontSize: 20,
fontWeight: '800',
color: '#1E293B',
lineHeight: 24,
fontFamily: 'AliBold',
},
centerPercentage: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
centerUnit: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
dataContainer: {
flex: 1,
marginLeft: 16,
marginLeft: 20,
},
dataBackground: {
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
borderRadius: 12,
padding: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.06,
shadowRadius: 3,
elevation: 1,
// 添加边框增强毛玻璃效果
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.8)',
gap: 4,
statsGroup: {
gap: 6,
},
dataItem: {
statRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
justifyContent: 'space-between',
},
dataIcon: {
width: 6,
height: 6,
borderRadius: 3,
labelWithDot: {
flexDirection: 'row',
alignItems: 'center',
},
dataLabel: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
minWidth: 28,
dotMetabolism: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#94A3B8',
marginRight: 6,
},
dataValue: {
fontSize: 11,
dotExercise: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#4facfe',
marginRight: 6,
},
dotConsumed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#FF9966',
marginRight: 6,
},
statLabel: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
},
statValue: {
fontSize: 13,
fontWeight: '600',
color: '#333333',
color: '#334155',
fontFamily: 'AliBold',
},
dataColumns: {
divider: {
height: 1,
backgroundColor: '#F1F5F9',
marginVertical: 10,
},
nutritionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
dataColumn: {
flex: 1,
gap: 4,
nutritionItem: {
alignItems: 'center',
},
statLabelSmall: {
fontSize: 10,
color: '#94A3B8',
marginTop: 2,
fontFamily: 'AliRegular',
},
statValueSmall: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
},
});

View File

@@ -1,4 +1,5 @@
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { useI18n } from '@/hooks/useI18n';
import { getMonthDays, getMonthTitle, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
autoScrollToSelected = true,
showCalendarIcon = true,
}) => {
const { t, i18n } = useI18n();
// 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const isGlassAvailable = isLiquidGlassAvailable();
// 获取日期数据
const days = getMonthDaysZh(currentMonth);
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
// 判断当前选中的日期是否是今天
const isSelectedDateToday = () => {
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
setCurrentMonth(selectedMonth);
// 计算选中日期在新月份中的索引
const newMonthDays = getMonthDaysZh(selectedMonth);
const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
const selectedDay = selectedMonth.date();
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const handleGoToday = () => {
const today = dayjs();
setCurrentMonth(today);
const todayDays = getMonthDaysZh(today);
const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
if (newSelectedIndex !== -1) {
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true}
>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</GlassView>
) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</View>
)}
</TouchableOpacity>
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}
@@ -460,15 +463,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
monthTitle: {
fontSize: 22,
fontSize: 26,
fontWeight: '800',
color: '#1a1a1a',
letterSpacing: -0.5,
color: '#1c1f3a',
fontFamily: 'AliBold',
marginLeft: 8,
},
calendarIconButton: {
padding: 4,
borderRadius: 6,
marginLeft: 4,
padding: 6,
borderRadius: 12,
marginLeft: 8,
overflow: 'hidden',
},
calendarIconFallback: {
@@ -477,22 +481,20 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 255, 255, 0.3)',
},
todayButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
marginRight: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
marginRight: 4,
overflow: 'hidden',
},
todayButtonFallback: {
backgroundColor: '#EEF2FF',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.2)',
},
todayButtonText: {
fontSize: 12,
fontWeight: '700',
color: '#7c3aed',
letterSpacing: 0.2,
color: '#5F6BF0',
fontFamily: 'AliBold',
},
daysContainer: {
paddingBottom: 8,
@@ -503,8 +505,8 @@ const styles = StyleSheet.create({
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
width: 48,
height: 68,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
@@ -518,14 +520,12 @@ const styles = StyleSheet.create({
transform: [{ scale: 0.96 }],
},
dayPillSelectedFallback: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
backgroundColor: '#5F6BF0',
shadowColor: 'rgba(95, 107, 240, 0.3)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
dayPillDisabled: {
backgroundColor: 'transparent',
@@ -533,27 +533,31 @@ const styles = StyleSheet.create({
},
dayLabel: {
fontSize: 11,
fontWeight: '700',
color: '#8e8e93',
marginBottom: 2,
letterSpacing: 0.1,
fontWeight: '600',
color: '#94A3B8',
marginBottom: 4,
fontFamily: 'AliRegular',
},
dayLabelSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontWeight: '700',
fontFamily: 'AliBold',
opacity: 0.9,
},
dayLabelDisabled: {
color: '#c7c7cc',
},
dayDate: {
fontSize: 13,
fontSize: 15,
fontWeight: '700',
color: '#8e8e93',
letterSpacing: -0.2,
color: '#64748B',
fontFamily: 'AliBold',
},
dayDateSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontSize: 16,
fontFamily: 'AliBold',
},
dayDateDisabled: {
color: '#c7c7cc',
@@ -607,11 +611,13 @@ const styles = StyleSheet.create({
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.1,
fontFamily: 'AliBold',
},
modalBtnTextPrimary: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.1,
fontFamily: 'AliBold',
},
});

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
@@ -26,6 +27,7 @@ export function FitnessRingsCard({
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
@@ -135,6 +137,24 @@ export function FitnessRingsCard({
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
const units = useMemo(
() => ({
kcal: t('statistics.components.fitness.kcal'),
minutes: t('statistics.components.fitness.minutes'),
hours: t('statistics.components.fitness.hours'),
}),
[t]
);
const fitnessRows = useMemo(
() => [
{ key: 'active', value: Math.round(activeCalories), goal: activeCaloriesGoal, unit: units.kcal },
{ key: 'exercise', value: Math.round(exerciseMinutes), goal: exerciseMinutesGoal, unit: units.minutes },
{ key: 'stand', value: Math.round(standHours), goal: standHoursGoal, unit: units.hours },
],
[activeCalories, activeCaloriesGoal, exerciseMinutes, exerciseMinutesGoal, standHours, standHoursGoal, units]
);
const handlePress = () => {
router.push(ROUTES.FITNESS_RINGS_DETAIL);
};
@@ -191,47 +211,23 @@ export function FitnessRingsCard({
{/* 右侧数据显示 */}
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
{fitnessRows.map((row) => (
<View key={row.key} style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{row.value}</Text>
<Text style={styles.dataGoal}>
{t('statistics.components.fitnessRings.goal', { goal: row.goal })}
</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}>{row.unit}</Text>
</View>
))}
</View>
</View>
</TouchableOpacity>
@@ -285,6 +281,7 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '700',
flex: 1,
fontFamily: 'AliBold',
},
dataValue: {
color: '#192126',
@@ -298,5 +295,6 @@ const styles = StyleSheet.create({
fontWeight: '500',
minWidth: 25,
textAlign: 'right',
fontFamily: 'AliRegular',
},
});

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
@@ -20,6 +21,7 @@ interface FloatingFoodOverlayProps {
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
const router = useRouter();
const { t } = useI18n();
const { pushIfAuthedElseLogin } = useAuthGuard()
@@ -41,21 +43,21 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
const menuItems = [
{
id: 'scan',
title: 'AI识别',
title: t('nutritionRecords.overlay.scan'),
icon: '📷',
backgroundColor: '#4FC3F7',
onPress: handlePhotoRecognition,
},
{
id: 'food-library',
title: '食物库',
title: t('nutritionRecords.overlay.foodLibrary'),
icon: '🍎',
backgroundColor: '#FF9500',
onPress: handleFoodLibrary,
},
{
id: 'voice-record',
title: '一句话记录',
title: t('nutritionRecords.overlay.voiceRecord'),
icon: '🎤',
backgroundColor: '#7B68EE',
onPress: handleVoiceRecord,
@@ -81,7 +83,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
<View style={styles.container}>
<BlurView intensity={80} tint="light" style={styles.blurContainer}>
<View style={styles.header}>
<Text style={styles.title}></Text>
<Text style={styles.title}>{t('nutritionRecords.overlay.title')}</Text>
</View>
<View style={styles.menuGrid}>

View File

@@ -0,0 +1,176 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MembershipBannerProps {
onPress: () => void;
}
export const MembershipBanner: React.FC<MembershipBannerProps> = ({ onPress }) => {
const { t } = useTranslation();
return (
<View style={styles.container}>
<TouchableOpacity
activeOpacity={0.9}
onPress={onPress}
style={styles.touchable}
>
<LinearGradient
colors={['#4C3AFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
{/* Decorative Elements */}
<View style={styles.decorationCircleLarge} />
<View style={styles.decorationCircleSmall} />
<View style={styles.contentContainer}>
<View style={styles.textContainer}>
<View style={styles.badgeContainer}>
<Text style={styles.badgeText}>PRO</Text>
</View>
<Text style={styles.title}>
{t('personal.membershipBanner.title', 'Unlock Premium Access')}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{t('personal.membershipBanner.subtitle', 'Get unlimited access to all features')}
</Text>
<View style={styles.ctaButton}>
<Text style={styles.ctaText}>{t('personal.membershipBanner.cta', 'Upgrade')}</Text>
<Ionicons name="arrow-forward" size={12} color="#4C3AFF" />
</View>
</View>
<View style={styles.illustrationContainer}>
{/* Use Ionicons as illustration or you can use Image if passed as prop */}
<Ionicons name="diamond-outline" size={56} color="rgba(255,255,255,0.15)" />
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 20,
borderRadius: 16,
// Premium Shadow
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
marginHorizontal: 4, // Add margin to avoid cutting off shadow
},
touchable: {
borderRadius: 16,
overflow: 'hidden',
},
gradient: {
padding: 16,
minHeight: 100,
position: 'relative',
justifyContent: 'center',
},
decorationCircleLarge: {
position: 'absolute',
top: -40,
right: -40,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.08)',
},
decorationCircleSmall: {
position: 'absolute',
bottom: -30,
left: -30,
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255,255,255,0.05)',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1,
},
textContainer: {
flex: 1,
paddingRight: 12,
zIndex: 2,
},
badgeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
alignSelf: 'flex-start',
marginBottom: 8,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.3)',
},
badgeIcon: {
marginRight: 3,
},
badgeText: {
color: '#FFD700',
fontSize: 9,
fontWeight: '800',
letterSpacing: 0.5,
fontFamily: 'AliBold',
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
marginBottom: 4,
fontFamily: 'AliBold',
lineHeight: 20,
},
subtitle: {
fontSize: 11,
color: 'rgba(255,255,255,0.9)',
marginBottom: 12,
lineHeight: 14,
fontFamily: 'AliRegular',
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
alignSelf: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
ctaText: {
color: '#4C3AFF',
fontSize: 11,
fontWeight: '700',
marginRight: 4,
fontFamily: 'AliBold',
},
illustrationContainer: {
position: 'absolute',
right: -6,
bottom: -6,
zIndex: 1,
transform: [{ rotate: '-15deg' }]
}
});

View File

@@ -13,7 +13,7 @@ interface MoodCardProps {
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
const { t } = useTranslation();
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null;
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType, t) : null;
const animationRef = useRef<LottieView>(null);
useEffect(() => {
@@ -82,7 +82,8 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
lottieAnimation: {
@@ -100,21 +101,25 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#059669',
fontWeight: '600',
fontFamily: 'AliBold',
},
moodPreviewTime: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular',
},
moodEmptyText: {
fontSize: 12,
color: '#9CA3AF',
fontStyle: 'italic',
marginTop: 22,
fontFamily: 'AliRegular',
},
moodLoadingText: {
fontSize: 12,
color: '#9CA3AF',
fontStyle: 'italic',
marginTop: 22,
fontFamily: 'AliRegular',
},
});
});

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
import dayjs from 'dayjs';
import React from 'react';
@@ -8,7 +9,9 @@ interface MoodHistoryCardProps {
title?: string;
}
export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHistoryCardProps) {
export function MoodHistoryCard({ moodCheckins, title }: MoodHistoryCardProps) {
const { t } = useI18n();
const defaultTitle = t('mood.history.title');
// 计算心情统计
const moodStats = React.useMemo(() => {
const stats = {
@@ -26,7 +29,7 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
// 计算心情分布
moodCheckins.forEach(checkin => {
const moodLabel = getMoodConfig(checkin.moodType)?.label || checkin.moodType;
const moodLabel = getMoodConfig(checkin.moodType, t)?.label || checkin.moodType;
stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1;
});
@@ -45,11 +48,11 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title}>{title || defaultTitle}</Text>
{moodCheckins.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptyText}>{t('mood.history.noRecords')}</Text>
</View>
) : (
<>
@@ -57,36 +60,36 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.total}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.totalRecords')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.averageIntensity}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.averageIntensity')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.mostFrequent')}</Text>
</View>
</View>
{/* 最近记录 */}
<View style={styles.recentContainer}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('mood.history.recentRecords')}</Text>
{recentMoods.map((checkin, index) => {
const moodConfig = getMoodConfig(checkin.moodType);
const moodConfig = getMoodConfig(checkin.moodType, t);
return (
<View key={checkin.id} style={styles.moodItem}>
<View style={styles.moodInfo}>
<Text style={styles.moodEmoji}>{moodConfig?.emoji}</Text>
<Text style={styles.moodEmoji}>😊</Text>
<View style={styles.moodDetails}>
<Text style={styles.moodLabel}>{moodConfig?.label}</Text>
<Text style={styles.moodDate}>
{dayjs(checkin.createdAt).format('MM月DD日 HH:mm')}
{dayjs(checkin.createdAt).format(t('mood.history.dateTimeFormat'))}
</Text>
</View>
</View>
<View style={styles.moodIntensity}>
<Text style={styles.intensityText}> {checkin.intensity}</Text>
<Text style={styles.intensityText}>{t('mood.history.intensity')} {checkin.intensity}</Text>
</View>
</View>
);

View File

@@ -6,6 +6,7 @@ import {
Text,
View,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import {
Gesture,
GestureDetector,
@@ -38,6 +39,7 @@ export default function MoodIntensitySlider({
width = 320,
height = 16, // 更粗的进度条
}: MoodIntensitySliderProps) {
const { t } = useTranslation();
const thumbSize = 32; // 合适的触摸区域
const translateX = useSharedValue(0);
const isDragging = useSharedValue(0);
@@ -175,8 +177,8 @@ export default function MoodIntensitySlider({
{/* 标签 */}
<View style={[styles.labelsContainer, { width: width }]}>
<Text style={styles.labelText}></Text>
<Text style={styles.labelText}></Text>
<Text style={styles.labelText}>{t('mood.edit.intensityLow')}</Text>
<Text style={styles.labelText}>{t('mood.edit.intensityHigh')}</Text>
</View>
{/* 刻度 */}

View File

@@ -6,11 +6,13 @@ import {
TouchableOpacity,
View
} from 'react-native';
import { useI18n } from '../hooks/useI18n';
import { useNotifications } from '../hooks/useNotifications';
import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView';
export const NotificationTest: React.FC = () => {
const { t } = useI18n();
const {
isInitialized,
permissionStatus,
@@ -95,8 +97,8 @@ export const NotificationTest: React.FC = () => {
const handleSendMoodCheckinReminder = async () => {
try {
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦');
Alert.alert('成功', '心情打卡提醒已发送');
await sendMoodCheckinReminder(t('notifications.moodReminder.title'), t('notifications.moodReminder.body'));
Alert.alert(t('common.success'), t('notifications.moodReminder.sent'));
} catch (error) {
Alert.alert('错误', '发送心情打卡提醒失败');
}

View File

@@ -82,10 +82,10 @@ const SimpleRingProgress = ({
/>
</Svg>
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}>
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126', fontFamily: 'AliBold' }}>
{Math.round(remainingCalories)}
</Text>
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>{t('statistics.components.diet.remaining')}</Text>
<Text style={{ fontSize: 8, color: '#9AA3AE', fontFamily: 'AliRegular' }}>{t('statistics.components.diet.remaining')}</Text>
</View>
</View>
);
@@ -361,12 +361,14 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
cardSubtitle: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '600',
fontFamily: 'AliRegular',
},
contentContainer: {
flexDirection: 'row',
@@ -419,11 +421,13 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#9AA3AE',
flex: 1,
fontFamily: 'AliRegular',
},
statValue: {
fontSize: 12,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
// 卡路里相关样式
calorieSection: {
@@ -442,6 +446,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '800',
color: '#192126',
fontFamily: 'AliBold',
},
calorieContent: {
},
@@ -450,6 +455,7 @@ const styles = StyleSheet.create({
color: '#64748B',
fontWeight: '600',
marginRight: 4,
fontFamily: 'AliRegular',
},
calculationRow: {
flexDirection: 'row',
@@ -461,11 +467,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
calculationText: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
calculationItem: {
flexDirection: 'row',
@@ -476,11 +484,13 @@ const styles = StyleSheet.create({
fontSize: 9,
color: '#64748B',
fontWeight: '500',
fontFamily: 'AliRegular',
},
calculationValue: {
fontSize: 11,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold',
},
remainingCaloriesContainer: {
flexDirection: 'row',
@@ -491,6 +501,7 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#64748B',
fontWeight: '500',
fontFamily: 'AliRegular',
},
mealsContainer: {
flexDirection: 'row',
@@ -514,6 +525,7 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#64748B',
fontWeight: '600',
fontFamily: 'AliRegular',
},
// 食物选项样式
foodOptionsContainer: {
@@ -559,5 +571,6 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: '#192126',
textAlign: 'center',
fontFamily: 'AliRegular',
},
});

View File

@@ -1,4 +1,5 @@
import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import { DietRecord } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons';
@@ -15,14 +16,6 @@ export type NutritionRecordCardProps = {
onDelete?: () => void;
};
const MEAL_TYPE_LABELS = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
other: '其他',
} as const;
const MEAL_TYPE_ICONS = {
breakfast: 'sunny-outline',
lunch: 'partly-sunny-outline',
@@ -44,46 +37,40 @@ export function NutritionRecordCard({
onPress,
onDelete
}: NutritionRecordCardProps) {
const surfaceColor = useThemeColor({}, 'surface');
const { t } = useI18n();
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
// Popover 状态管理
const [showPopover, setShowPopover] = useState(false);
const popoverRef = useRef<any>(null);
// 左滑删除相关
const swipeableRef = useRef<Swipeable>(null);
// 添加滑动状态管理,防止滑动时触发点击事件
const [isSwiping, setIsSwiping] = useState(false);
// 营养数据统计
const nutritionStats = useMemo(() => {
return [
{
label: '蛋白质',
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)}g` : '-',
icon: '🥩',
color: '#FF6B6B'
label: t('nutritionRecords.nutrients.protein'),
value: record.proteinGrams ? `${Math.round(record.proteinGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
{
label: '脂肪',
value: record.fatGrams ? `${record.fatGrams.toFixed(1)}g` : '-',
icon: '🥑',
color: '#FFB366'
label: t('nutritionRecords.nutrients.fat'),
value: record.fatGrams ? `${Math.round(record.fatGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
{
label: '碳水',
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)}g` : '-',
icon: '🍞',
color: '#4ECDC4'
label: t('nutritionRecords.nutrients.carbs'),
value: record.carbohydrateGrams ? `${Math.round(record.carbohydrateGrams)}` : '-',
unit: t('nutritionRecords.nutrients.unit'),
color: '#64748B'
},
];
}, [record]);
}, [record, t]);
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType];
const mealTypeLabel = t(`nutritionRecords.mealTypes.${record.mealType}`);
// 处理点击事件,只有在非滑动状态下才触发
const handlePress = () => {
@@ -92,31 +79,17 @@ export function NutritionRecordCard({
}
};
// 处理滑动开始
const handleSwipeableWillOpen = () => {
setIsSwiping(true);
};
const handleSwipeableWillOpen = () => setIsSwiping(true);
const handleSwipeableClose = () => setTimeout(() => setIsSwiping(false), 100);
// 处理滑动结束
const handleSwipeableClose = () => {
// 延迟重置滑动状态,防止滑动结束时立即触发点击
setTimeout(() => {
setIsSwiping(false);
}, 100);
};
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
`确定要删除这条营养记录吗?此操作无法撤销。`,
t('nutritionRecords.delete.title'),
t('nutritionRecords.delete.message'),
[
{ text: t('nutritionRecords.delete.cancel'), style: 'cancel' },
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
text: t('nutritionRecords.delete.confirm'),
style: 'destructive',
onPress: () => {
onDelete?.();
@@ -127,7 +100,6 @@ export function NutritionRecordCard({
);
};
// 渲染删除按钮
const renderRightActions = () => {
return (
<TouchableOpacity
@@ -136,7 +108,6 @@ export function NutritionRecordCard({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
);
};
@@ -152,239 +123,228 @@ export function NutritionRecordCard({
onSwipeableClose={handleSwipeableClose}
>
<RectButton
style={[
styles.card,
]}
style={styles.card}
onPress={handlePress}
// activeOpacity={0.7}
>
{/* 主要内容区域 - 水平布局 */}
<View style={styles.mainContent}>
{/* 左侧:食物图片 */}
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}>
{record.imageUrl ? (
<Image
source={{ uri: record.imageUrl }}
style={styles.foodImage}
cachePolicy={'memory-disk'}
/>
) : (
<Ionicons name="restaurant" size={28} color={textSecondaryColor} />
)}
{/* 左侧:时间线和图标 */}
<View style={styles.leftSection}>
<View style={styles.mealIconContainer}>
<Image
source={require('@/assets/images/icons/icon-food.png')}
style={styles.mealIcon}
/>
</View>
</View>
{/* 中间:食物信息 */}
<View style={styles.foodInfoContainer}>
{/* 食物名称 */}
<ThemedText style={[styles.foodName, { color: textColor }]}>
{record.foodName}
</ThemedText>
{/* 时间 */}
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
</ThemedText>
{/* 营养信息 - 水平排列 */}
<View style={styles.nutritionContainer}>
{/* 中间:主要信息 */}
<View style={styles.centerSection}>
<View style={styles.titleRow}>
<ThemedText style={styles.foodName} numberOfLines={1}>
{record.foodName}
</ThemedText>
<View style={[styles.mealTag, { backgroundColor: `${mealTypeColor}15` }]}>
<Text style={[styles.mealTagText, { color: mealTypeColor }]}>{mealTypeLabel}</Text>
</View>
</View>
<View style={styles.metaRow}>
<Ionicons name="time-outline" size={12} color="#94A3B8" />
<Text style={styles.timeText}>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
</Text>
{record.portionDescription && (
<>
<Text style={styles.dotSeparator}>·</Text>
<Text style={styles.portionText} numberOfLines={1}>{record.portionDescription}</Text>
</>
)}
</View>
{/* 营养微缩信息 */}
<View style={styles.nutritionRow}>
{nutritionStats.map((stat, index) => (
<View key={stat.label} style={styles.nutritionItem}>
<ThemedText style={styles.nutritionIcon}>{stat.icon}</ThemedText>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
{stat.value}
</ThemedText>
</View>
<View key={index} style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{stat.value}<Text style={styles.nutritionUnit}>{stat.unit}</Text></Text>
<Text style={styles.nutritionLabel}>{stat.label}</Text>
</View>
))}
</View>
</View>
{/* 右侧:热量和餐次标签 */}
{/* 右侧:热量 */}
<View style={styles.rightSection}>
{/* 热量显示 */}
<View style={styles.caloriesContainer}>
<ThemedText style={[styles.caloriesText]}>
{record.estimatedCalories ? `${Math.round(record.estimatedCalories)} kcal` : '- kcal'}
</ThemedText>
</View>
{/* 餐次标签 */}
<View style={[styles.mealTypeBadge]}>
<ThemedText style={[styles.mealTypeText, { color: mealTypeColor }]}>
{mealTypeLabel}
</ThemedText>
</View>
<Text style={styles.caloriesValue}>
{record.estimatedCalories ? Math.round(record.estimatedCalories) : '-'}
</Text>
<Text style={styles.caloriesUnit}>{t('nutritionRecords.nutrients.caloriesUnit')}</Text>
</View>
</View>
{/* 如果有图片,显示图片缩略图 */}
{record.imageUrl && (
<View style={styles.imageSection}>
<Image
source={{ uri: record.imageUrl }}
style={styles.foodImage}
contentFit="cover"
transition={200}
/>
</View>
)}
</RectButton>
</Swipeable>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
// iOS 阴影效果 - 更自然的阴影
shadowColor: '#000000',
shadowOffset: { width: 0, height: 2 },
marginBottom: 12,
marginHorizontal: 24,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
// Android 阴影效果
elevation: 3,
shadowRadius: 12,
elevation: 2,
},
card: {
flex: 1,
minHeight: 100,
backgroundColor: '#FFFFFF',
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 24,
padding: 16,
},
mainContent: {
flex: 1,
flexDirection: 'row',
},
leftSection: {
marginRight: 12,
alignItems: 'center',
},
foodImageContainer: {
width: 48,
height: 48,
borderRadius: 12,
marginRight: 16,
mealIconContainer: {
width: 40,
height: 40,
borderRadius: 14,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
},
mealIcon: {
width: 20,
height: 20,
opacity: 0.8,
},
centerSection: {
flex: 1,
marginRight: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
gap: 8,
},
foodName: {
fontSize: 16,
fontWeight: '700',
color: '#1E293B',
fontFamily: 'AliBold',
flexShrink: 1,
},
mealTag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
},
mealTagText: {
fontSize: 10,
fontWeight: '600',
fontFamily: 'AliBold',
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
timeText: {
fontSize: 12,
color: '#94A3B8',
marginLeft: 4,
fontFamily: 'AliRegular',
},
dotSeparator: {
marginHorizontal: 4,
color: '#CBD5E1',
},
portionText: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
flex: 1,
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 2,
},
nutritionValue: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
},
nutritionUnit: {
fontSize: 10,
fontWeight: '500',
color: '#94A3B8',
marginLeft: 1,
},
nutritionLabel: {
fontSize: 10,
color: '#94A3B8',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingTop: 2,
},
caloriesValue: {
fontSize: 18,
fontWeight: '800',
color: '#1E293B',
fontFamily: 'AliBold',
lineHeight: 22,
},
caloriesUnit: {
fontSize: 10,
color: '#94A3B8',
fontWeight: '500',
fontFamily: 'AliRegular',
},
imageSection: {
marginTop: 12,
height: 120,
width: '100%',
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#F1F5F9',
},
foodImage: {
width: '100%',
height: '100%',
borderRadius: 8,
},
foodImagePlaceholder: {
backgroundColor: '#F8F9FA',
justifyContent: 'center',
alignItems: 'center',
},
foodInfoContainer: {
flex: 1,
justifyContent: 'center',
gap: 4,
},
foodName: {
fontSize: 16,
fontWeight: '600',
color: '#333333',
lineHeight: 20,
},
mealTime: {
fontSize: 12,
fontWeight: '400',
color: '#999999',
lineHeight: 16,
},
nutritionContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 2,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
nutritionIcon: {
fontSize: 14,
},
nutritionValue: {
fontSize: 13,
fontWeight: '500',
color: '#666666',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'center',
gap: 8,
minHeight: 60,
},
caloriesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
caloriesText: {
fontSize: 14,
color: '#333333',
fontWeight: '600',
},
mealTypeBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.05)',
},
mealTypeText: {
fontSize: 12,
fontWeight: '600',
},
moreButton: {
padding: 2,
},
notesSection: {
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
},
notesText: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
fontStyle: 'italic',
},
popoverContainer: {
borderRadius: 12,
backgroundColor: '#FFFFFF',
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
// Android 阴影效果
elevation: 8,
// 添加边框
borderWidth: 0.5,
borderColor: 'rgba(0, 0, 0, 0.08)',
},
popoverBackground: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
popoverContent: {
minWidth: 140,
paddingVertical: 8,
},
popoverItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
popoverText: {
fontSize: 16,
fontWeight: '500',
},
deleteButton: {
backgroundColor: '#EF4444',
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 12,
marginLeft: 8,
},
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
width: 70,
height: '100%',
borderRadius: 24,
marginLeft: 12,
},
});

View File

@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
InteractionManager,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
Animated,
InteractionManager,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
@@ -20,8 +23,8 @@ import { AnimatedNumber } from './AnimatedNumber';
// import Svg, { Rect } from 'react-native-svg';
interface StepsCardProps {
curDate: Date
stepGoal: number;
curDate: Date;
stepGoal?: number;
style?: ViewStyle;
}
@@ -31,9 +34,20 @@ const StepsCard: React.FC<StepsCardProps> = ({
}) => {
const { t } = useTranslation();
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [stepCount, setStepCount] = useState(0)
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const [stepCount, setStepCount] = useState(0);
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
// 过滤出已参加的步数挑战
const joinedStepsChallenges = useMemo(
() => challenges.filter((challenge) => challenge.type === ChallengeType.STEP && challenge.isJoined && challenge.status === 'ongoing'),
[challenges]
);
// 跟踪上次上报的记录,避免重复上报
const lastReportedRef = useRef<{ date: string; value: number } | null>(null);
const getStepData = useCallback(async (date: Date) => {
@@ -59,6 +73,42 @@ const StepsCard: React.FC<StepsCardProps> = ({
}
}, [curDate]);
// 步数挑战进度上报逻辑
useEffect(() => {
if (!curDate || !stepCount || !joinedStepsChallenges.length) {
return;
}
// 如果当前日期不是今天,不上报
if (!dayjs(curDate).isSame(dayjs(), 'day')) {
return;
}
const dateKey = dayjs(curDate).format('YYYY-MM-DD');
const lastReport = lastReportedRef.current;
if (lastReport && lastReport.date === dateKey && lastReport.value === stepCount) {
return;
}
const reportProgress = async () => {
const stepsChallenge = joinedStepsChallenges.find((c) => c.type === ChallengeType.STEP);
if (!stepsChallenge) {
return;
}
try {
await dispatch(reportChallengeProgress({ id: stepsChallenge.id, value: stepCount })).unwrap();
} catch (error) {
logger.warn('StepsCard: Challenge progress report failed', { error, challengeId: stepsChallenge.id });
}
lastReportedRef.current = { date: dateKey, value: stepCount };
};
reportProgress();
}, [dispatch, joinedStepsChallenges, curDate, stepCount]);
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
@@ -244,7 +294,8 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
footprintIcons: {
flexDirection: 'row',
@@ -290,6 +341,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
});

View File

@@ -1,323 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle,
InteractionManager
} from 'react-native';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import { AnimatedNumber } from './AnimatedNumber';
interface StepsCardProps {
curDate: Date
stepGoal: number;
style?: ViewStyle;
}
const StepsCardOptimized: React.FC<StepsCardProps> = ({
curDate,
style,
}) => {
const router = useRouter();
const [stepCount, setStepCount] = useState(0)
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const [isLoading, setIsLoading] = useState(false)
// 优化使用debounce减少频繁的数据获取
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const getStepData = useCallback(async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数数据...');
// 先获取步数立即更新UI
const steps = await fetchStepCount(date);
setStepCount(steps);
// 清除之前的定时器
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
InteractionManager.runAfterInteractions(async () => {
try {
const hourly = await fetchHourlyStepSamples(date);
setHourSteps(hourly);
} catch (error) {
logger.error('获取小时步数数据失败:', error);
} finally {
setIsLoading(false);
}
});
} catch (error) {
logger.error('获取步数数据失败:', error);
setIsLoading(false);
}
}, []);
useEffect(() => {
if (curDate) {
getStepData(curDate);
}
}, [curDate, getStepData]);
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
// 优化:简化柱状图数据计算,减少计算量
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 优化:只计算有数据的小时的最大步数
const activeSteps = hourlySteps.filter(data => data.steps > 0);
if (activeSteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
const maxHeight = 20;
return hourlySteps.map(data => ({
...data,
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
// 优化延迟执行动画减少UI阻塞
useEffect(() => {
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
if (hasData && !isLoading) {
// 使用 InteractionManager 确保动画不会阻塞用户交互
InteractionManager.runAfterInteractions(() => {
// 只为有数据的小时创建和执行动画
const animations = chartData
.map((data, index) => {
if (data.steps > 0) {
// 懒创建动画值
if (!animatedValues.has(index)) {
animatedValues.set(index, new Animated.Value(0));
}
const animValue = animatedValues.get(index)!;
animValue.setValue(0);
// 使用更高性能的timing动画替代spring
return Animated.timing(animValue, {
toValue: 1,
duration: 200, // 减少动画时长
useNativeDriver: false,
});
}
return null;
})
.filter(Boolean) as Animated.CompositeAnimation[];
// 批量执行动画,提高性能
if (animations.length > 0) {
Animated.stagger(50, animations).start();
}
});
}
}, [chartData, animatedValues, isLoading]);
// 优化使用React.memo包装复杂的渲染组件
const ChartBars = useMemo(() => {
return chartData.map((data, index) => {
// 判断是否是当前小时或者有活动的小时
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
// 优化:只为有数据的柱体创建动画插值
const animValue = animatedValues.get(index);
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
if (animValue && isActive) {
animatedScale = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
animatedOpacity = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用相似色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: isCurrent ? '#FFF4E6' : '#FFF8F0', // 更淡的相似色系
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: data.height,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
opacity: animatedOpacity || 1,
}
]}
/>
)}
</View>
);
});
}, [chartData, currentHour, animatedValues]);
const CardContent = () => (
<>
{/* 标题和步数显示 */}
<View style={styles.header}>
<Image
source={require('@/assets/images/icons/icon-step.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}></Text>
{isLoading && <Text style={styles.loadingText}>...</Text>}
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{ChartBars}
</View>
</View>
</View>
{/* 步数和目标显示 */}
<View style={styles.statsContainer}>
<AnimatedNumber
value={stepCount || 0}
style={styles.stepCount}
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
resetToken={stepCount}
/>
</View>
</>
);
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={() => {
// 传递当前日期参数到详情页
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
router.push(`/steps/detail?date=${dateParam}`);
}}
activeOpacity={0.8}
>
<CardContent />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
loadingText: {
fontSize: 10,
color: '#666',
marginLeft: 8,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
marginTop: 6
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 4,
height: 20,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
chartBar: {
width: 4,
borderRadius: 1,
position: 'absolute',
bottom: 0,
},
statsContainer: {
alignItems: 'flex-start',
marginTop: 6
},
stepCount: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
});
export default StepsCardOptimized;

View File

@@ -1,9 +1,15 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchHRVSamples, HRVData } from '@/utils/health';
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
updateTime: Date;
}
interface StressStats {
percentage: number;
count: number;
range: string;
}
interface HistoryData {
goodEvents: StressStats;
energetic: StressStats;
stressed: StressStats;
totalSamples: number;
}
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const [loading, setLoading] = useState(true);
const [historyData, setHistoryData] = useState<HistoryData>({
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
totalSamples: 0
});
// 模拟30天HRV数据
const hrvData = {
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
// 当前压力状态
const stressIndex = convertHrvToStressIndex(hrvValue);
const stressInfo = getStressLevelInfo(stressIndex);
useEffect(() => {
if (visible) {
loadHistoryData();
}
}, [visible]);
const loadHistoryData = async () => {
setLoading(true);
try {
const endDate = new Date();
const startDate = dayjs().subtract(30, 'day').toDate();
const samples = await fetchHRVSamples(startDate, endDate);
processHistoryData(samples);
} catch (error) {
console.error('Failed to load HRV history:', error);
} finally {
setLoading(false);
}
};
const processHistoryData = (samples: HRVData[]) => {
if (!samples.length) return;
let goodCount = 0;
let energeticCount = 0;
let stressedCount = 0;
samples.forEach(sample => {
const val = sample.value;
if (val > 75) {
goodCount++;
} else if (val >= 40) {
energeticCount++;
} else {
stressedCount++;
}
});
const total = samples.length;
setHistoryData({
goodEvents: {
percentage: Math.round((goodCount / total) * 100),
count: goodCount,
range: '>75毫秒'
},
energetic: {
percentage: Math.round((energeticCount / total) * 100),
count: energeticCount,
range: '40-75毫秒'
},
stressed: {
percentage: Math.round((stressedCount / total) * 100),
count: stressedCount,
range: '<40毫秒'
},
totalSamples: total
});
};
const getStatusColor = (level: string) => {
switch (level) {
case 'low': return '#10B981';
case 'moderate': return '#3B82F6';
case 'high': return '#F59E0B';
default: return colors.text;
}
};
return (
<Modal
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
end={{ x: 0, y: 1 }}
>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 标题 */}
<Text style={styles.title}></Text>
{/* 标题区域 */}
<Text style={styles.title}></Text>
{/* 当前状态卡片 */}
<View style={styles.currentStatusCard}>
<View style={styles.statusHeader}>
<Text style={styles.statusLabel}></Text>
<Text style={styles.updateTime}> {dayjs(updateTime).format('HH:mm')}</Text>
</View>
<View style={styles.statusValueContainer}>
<View>
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
{stressInfo.label}
</Text>
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
</View>
<View style={styles.hrvValueBox}>
<Text style={styles.hrvValueLabel}>HRV</Text>
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
</View>
</View>
</View>
{/* 最近30天HRV情况 */}
<Text style={styles.sectionTitle}>30HRV情况</Text>
<Text style={styles.sectionTitle}>30</Text>
{/* 彩色横条图 */}
<View style={styles.chartContainer}>
<View style={styles.colorBar}>
<LinearGradient
colors={['#F59E0B', '#3B82F6', '#10B981']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
/>
</View>
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
{/* 数据统计卡片 */}
<View style={styles.statsCard}>
{/* 好事发生 & 活力满满 */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#10B981' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.goodEvents.range}</Text>
{loading ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
) : (
<>
{/* 彩色横条图 */}
<View style={styles.chartContainer}>
<View style={styles.colorBar}>
{historyData.totalSamples > 0 ? (
<View style={styles.progressBarContainer}>
{historyData.stressed.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
)}
{historyData.energetic.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
)}
{historyData.goodEvents.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
)}
</View>
) : (
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
)}
</View>
<Text style={styles.statCount}>{hrvData.goodEvents.count}</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#3B82F6' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.energetic.range}</Text>
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
<Text style={styles.statCount}>{hrvData.energetic.count}</Text>
</View>
</View>
{/* 鸭梨山大 */}
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#F59E0B' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.stressed.range}</Text>
{/* 数据统计卡片 */}
<View style={styles.statsCard}>
{/* 好事发生 & 活力满满 */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#10B981' }]}></Text>
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
HRV {historyData.goodEvents.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.goodEvents.count}</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#3B82F6' }]}></Text>
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
HRV {historyData.energetic.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.energetic.count}</Text>
</View>
</View>
{/* 鸭梨山大 */}
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#F59E0B' }]}></Text>
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
HRV {historyData.stressed.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.stressed.count}</Text>
</View>
</View>
<Text style={styles.statCount}>{hrvData.stressed.count}</Text>
</View>
</View>
</>
)}
</ScrollView>
{/* 底部继续按钮 */}
<View style={styles.bottomContainer}>
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
<View style={styles.buttonBackground}>
<Text style={styles.buttonText}></Text>
</View>
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
{isLiquidGlassAvailable() ? (
<GlassView
glassEffectStyle="regular"
tintColor="rgba(139, 92, 246, 0.85)"
isInteractive={true}
style={styles.glassButton}
>
<Text style={styles.buttonText}></Text>
</GlassView>
) : (
<LinearGradient
colors={['#8B5CF6', '#7C3AED']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.buttonGradient}
>
<Text style={styles.buttonText}></Text>
</LinearGradient>
)}
</TouchableOpacity>
<View style={styles.homeIndicator} />
</View>
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
fontWeight: '800',
color: '#111827',
textAlign: 'center',
marginTop: 20,
marginTop: 24,
marginBottom: 32,
fontFamily: 'AliBold',
},
sectionTitle: {
fontSize: 22,
currentStatusCard: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
marginBottom: 32,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
statusHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
statusLabel: {
fontSize: 16,
fontWeight: '700',
color: '#374151',
},
updateTime: {
fontSize: 12,
color: '#9CA3AF',
},
statusValueContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusText: {
fontSize: 28,
fontWeight: '800',
marginBottom: 4,
},
statusDesc: {
fontSize: 14,
color: '#6B7280',
maxWidth: 200,
},
hrvValueBox: {
alignItems: 'flex-end',
},
hrvValueLabel: {
fontSize: 12,
color: '#9CA3AF',
fontWeight: '600',
marginBottom: 2,
},
hrvValue: {
fontSize: 32,
fontWeight: '800',
color: '#111827',
lineHeight: 36,
},
hrvUnit: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
marginLeft: 2,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
marginBottom: 20,
fontFamily: 'AliBold',
},
chartContainer: {
marginBottom: 32,
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
marginBottom: 16,
backgroundColor: '#F3F4F6',
},
progressBarContainer: {
flexDirection: 'row',
width: '100%',
height: '100%',
},
progressSegment: {
height: '100%',
},
gradientBar: {
flex: 1,
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
legendDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 6,
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
legendText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
fontSize: 13,
fontWeight: '500',
color: '#4B5563',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
borderRadius: 20,
padding: 24,
marginBottom: 32,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 12,
elevation: 3,
},
statsRow: {
flexDirection: 'row',
gap: 20,
marginBottom: 24,
gap: 24,
marginBottom: 32,
},
statItem: {
flex: 1,
},
statTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 8,
fontSize: 15,
fontWeight: '600',
marginBottom: 12,
},
statPercentage: {
fontSize: 36,
fontSize: 32,
fontWeight: '800',
color: '#111827',
marginBottom: 4,
marginBottom: 8,
fontFamily: 'AliBold',
},
statDetails: {
marginBottom: 4,
marginBottom: 8,
},
statRange: {
fontSize: 14,
fontSize: 12,
fontWeight: '600',
color: '#DC2626',
backgroundColor: '#FEE2E2',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
paddingVertical: 4,
borderRadius: 6,
alignSelf: 'flex-start',
overflow: 'hidden',
},
statCount: {
fontSize: 16,
fontWeight: '600',
fontSize: 13,
fontWeight: '500',
color: '#6B7280',
},
bottomContainer: {
paddingHorizontal: 20,
paddingBottom: 34,
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
backgroundColor: 'transparent',
},
continueButton: {
borderRadius: 25,
borderRadius: 28,
overflow: 'hidden',
marginBottom: 8,
marginBottom: 12,
shadowColor: '#8B5CF6',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 16,
elevation: 8,
},
glassButton: {
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 28,
},
buttonGradient: {
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
},
buttonBackground: {
backgroundColor: Colors.light.accentGreen, // 应用主色调
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
buttonText: {
fontSize: 18,
fontWeight: '700',
color: '#192126', // 主色调上的文字颜色
color: '#FFFFFF',
letterSpacing: 0.5,
},
homeIndicator: {
width: 134,
height: 5,
backgroundColor: '#000',
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
borderRadius: 3,
alignSelf: 'center',
},

View File

@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation();
const [hrvValue, setHrvValue] = useState(0)
const [updateTime, setUpdateTime] = useState<Date>(new Date())
useEffect(() => {
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
if (result.hrvData.recordedAt) {
setUpdateTime(new Date(result.hrvData.recordedAt));
}
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: No HRV data obtained');
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
{/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient
colors={['#EF4444', '#FCD34D', '#10B981']}
colors={['#10B981', '#FCD34D', '#EF4444']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal}
onClose={() => setShowStressModal(false)}
hrvValue={hrvValue}
updateTime={new Date()}
updateTime={updateTime}
/>
</>
);
@@ -158,7 +162,8 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
valueSection: {
flexDirection: 'row',
@@ -171,12 +176,14 @@ const styles = StyleSheet.create({
color: '#192126',
lineHeight: 20,
marginTop: 2,
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
fontWeight: '500',
color: '#9AA3AE',
marginLeft: 4,
fontFamily: 'AliRegular',
},
progressContainer: {
height: 6,

View File

@@ -0,0 +1,343 @@
import type { VersionInfo } from '@/services/version';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type VersionUpdateModalProps = {
visible: boolean;
info: VersionInfo | null;
currentVersion: string;
onClose: () => void;
onUpdate: () => void;
strings: {
title: string;
tag: string;
currentVersionLabel: string;
latestVersionLabel: string;
updatesTitle: string;
fallbackNote: string;
remindLater: string;
updateCta: string;
};
};
export function VersionUpdateModal({
visible,
info,
currentVersion,
onClose,
onUpdate,
strings,
}: VersionUpdateModalProps) {
const notes = useMemo(() => {
if (!info) return [];
if (info.releaseNotes && info.releaseNotes.trim().length > 0) {
return info.releaseNotes
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
if (info.updateMessage && info.updateMessage.trim().length > 0) {
return [info.updateMessage.trim()];
}
return [];
}, [info]);
if (!info) return null;
return (
<Modal
animationType="fade"
transparent
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
<View style={styles.cardShadow}>
<LinearGradient
colors={['#0F1B61', '#0F274A', '#0A1A3A']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<LinearGradient
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.03)']}
style={styles.glowOrb}
/>
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.ribbon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerRow}>
<View style={styles.tag}>
<Ionicons name="sparkles" size={14} color="#0F1B61" />
<Text style={styles.tagText}>{strings.tag}</Text>
</View>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={18} color="#E5E7EB" />
</TouchableOpacity>
</View>
<View style={styles.titleBlock}>
<Text style={styles.title}>{strings.title}</Text>
<Text style={styles.subtitle}>
{info.latestVersion ? `v${info.latestVersion}` : ''}
</Text>
</View>
<View style={styles.metaRow}>
<View style={styles.metaChip}>
<Ionicons name="time-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.currentVersionLabel} v{currentVersion}
</Text>
</View>
<View style={styles.metaChip}>
<Ionicons name="arrow-up-circle-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.latestVersionLabel} v{info.latestVersion}
</Text>
</View>
</View>
<View style={styles.noteCard}>
<Text style={styles.noteTitle}>{strings.updatesTitle}</Text>
{notes.length > 0 ? (
notes.map((line, idx) => (
<View key={`${idx}-${line}`} style={styles.noteItem}>
<View style={styles.bullet}>
<Ionicons name="ellipse" size={6} color="#6EE7B7" />
</View>
<Text style={styles.noteText}>{line}</Text>
</View>
))
) : (
<Text style={styles.noteText}>{strings.fallbackNote}</Text>
)}
</View>
<View style={styles.actions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={onClose}
style={styles.secondaryButton}
>
<Text style={styles.secondaryText}>{strings.remindLater}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
onPress={onUpdate}
style={styles.primaryButtonShadow}
>
<LinearGradient
colors={['#6EE7B7', '#3B82F6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.primaryButton}
>
<Ionicons name="cloud-download-outline" size={18} color="#0B1236" />
<Text style={styles.primaryText}>{strings.updateCta}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(7, 11, 34, 0.65)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
cardShadow: {
width: '100%',
maxWidth: 420,
shadowColor: '#0B1236',
shadowOpacity: 0.35,
shadowOffset: { width: 0, height: 16 },
shadowRadius: 30,
elevation: 8,
},
card: {
borderRadius: 24,
padding: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
glowOrb: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
right: -60,
top: -80,
opacity: 0.8,
},
ribbon: {
position: 'absolute',
left: -120,
bottom: -120,
width: 260,
height: 260,
transform: [{ rotate: '-8deg' }],
opacity: 0.6,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#A5B4FC',
},
tagText: {
color: '#0F1B61',
fontWeight: '700',
marginLeft: 6,
fontSize: 12,
letterSpacing: 0.3,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
titleBlock: {
marginTop: 14,
marginBottom: 8,
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#F9FAFB',
letterSpacing: 0.2,
},
subtitle: {
color: '#C7D2FE',
marginTop: 6,
fontSize: 15,
},
metaRow: {
flexDirection: 'row',
marginTop: 10,
gap: 8,
flexWrap: 'wrap',
},
metaChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 8,
},
metaText: {
color: '#E5E7EB',
marginLeft: 6,
fontSize: 12,
},
noteCard: {
marginTop: 16,
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
noteTitle: {
color: '#F9FAFB',
fontWeight: '700',
fontSize: 15,
marginBottom: 8,
},
noteItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 8,
},
bullet: {
width: 18,
alignItems: 'center',
marginTop: 6,
},
noteText: {
flex: 1,
color: '#E5E7EB',
fontSize: 14,
lineHeight: 20,
},
actions: {
marginTop: 18,
flexDirection: 'row',
gap: 10,
},
secondaryButton: {
flex: 1,
height: 48,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.16)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
},
secondaryText: {
color: '#E5E7EB',
fontWeight: '600',
fontSize: 14,
},
primaryButtonShadow: {
flex: 1,
height: 48,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#1E40AF',
shadowOpacity: 0.4,
shadowOffset: { width: 0, height: 12 },
shadowRadius: 14,
elevation: 6,
},
primaryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
primaryText: {
color: '#0B1236',
fontWeight: '800',
fontSize: 15,
},
});
export default VersionUpdateModal;

View File

@@ -309,6 +309,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
addButton: {
borderRadius: 16,
@@ -323,6 +324,7 @@ const styles = StyleSheet.create({
color: '#6366F1',
fontWeight: '700',
lineHeight: 10,
fontFamily: 'AliBold',
},
chartContainer: {
flex: 1,
@@ -363,11 +365,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
targetIntake: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
fontFamily: 'AliRegular',
},
});

View File

@@ -269,6 +269,7 @@ const styles = StyleSheet.create({
fontSize: 16,
color: '#1F2355',
fontWeight: '600',
fontFamily: 'AliBold',
},
addButton: {
width: 28,
@@ -287,6 +288,7 @@ const styles = StyleSheet.create({
fontSize: 20,
color: '#7A8FFF',
marginTop: -2,
fontFamily: 'AliBold',
},
metricsRow: {
flexDirection: 'row',
@@ -310,12 +312,14 @@ const styles = StyleSheet.create({
fontSize: 24,
fontWeight: '700',
color: '#1F2355',
fontFamily: 'AliBold',
},
metricLabel: {
fontSize: 12,
color: '#4A5677',
fontWeight: '500',
marginBottom: 2,
fontFamily: 'AliRegular',
},
detailsRow: {
flexDirection: 'row',
@@ -331,14 +335,17 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#1F2355',
fontWeight: '500',
fontFamily: 'AliRegular',
},
lastWorkoutTime: {
fontSize: 12,
color: '#7C85A3',
fontFamily: 'AliRegular',
},
sourceText: {
fontSize: 11,
color: '#9AA3C0',
fontFamily: 'AliRegular',
},
badgesRow: {
flexDirection: 'row',

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
};
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} 小时`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
console.log('unit', unit);
const { t } = useI18n();
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} ${t('challengeDetail.ranking.hour')}`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
: reportedLabel
? `今日 ${reportedLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
: targetLabel
? `今日目标 ${targetLabel}`
? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
: undefined;
return (

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef } from 'react';
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type HealthProgressRingProps = {
progress: number; // 0-100
size?: number;
strokeWidth?: number;
gradientColors?: string[];
label?: string;
suffix?: string;
title: string;
};
export function HealthProgressRing({
progress,
size = 80,
strokeWidth = 8,
gradientColors = ['#5B4CFF', '#9B8AFB'],
label,
suffix = '%',
title,
}: HealthProgressRingProps) {
const animatedProgress = useRef(new Animated.Value(0)).current;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
useEffect(() => {
Animated.timing(animatedProgress, {
toValue: progress,
duration: 1000,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [progress]);
const strokeDashoffset = animatedProgress.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
extrapolate: 'clamp',
});
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
return (
<View style={styles.container}>
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Svg width={size} height={size}>
<Defs>
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
</LinearGradient>
</Defs>
{/* Background Circle */}
<Circle
cx={center}
cy={center}
r={radius}
stroke="#F3F4F6"
strokeWidth={strokeWidth}
fill="none"
/>
{/* Progress Circle */}
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
<View style={styles.centerContent}>
<View style={styles.valueContainer}>
<Text style={styles.valueText}>{label ?? progress}</Text>
<Text style={styles.suffixText}>{suffix}</Text>
</View>
</View>
</View>
<Text style={styles.titleText}>{title}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
centerContent: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
valueContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
},
valueText: {
fontSize: 20,
fontWeight: 'bold',
color: '#1F2937',
fontFamily: 'AliBold',
lineHeight: 24,
},
suffixText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
marginLeft: 1,
marginBottom: 3,
fontFamily: 'AliRegular',
},
titleText: {
marginTop: 8,
fontSize: 14,
color: '#4B5563', // gray-600
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,151 @@
import { Colors, palette } from '@/constants/Colors';
import { MedicalRecordItem } from '@/services/healthProfile';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MedicalRecordCardProps {
item: MedicalRecordItem;
onPress: (item: MedicalRecordItem) => void;
onDelete: (item: MedicalRecordItem) => void;
}
export const MedicalRecordCard: React.FC<MedicalRecordCardProps> = ({ item, onPress, onDelete }) => {
const firstAttachment = item.images && item.images.length > 0 ? item.images[0] : null;
const isPdf = firstAttachment?.toLowerCase().endsWith('.pdf');
return (
<TouchableOpacity
style={styles.container}
onPress={() => onPress(item)}
activeOpacity={0.8}
>
<View style={styles.thumbnailContainer}>
{firstAttachment ? (
isPdf ? (
<View style={styles.pdfThumbnail}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText}>PDF</Text>
</View>
) : (
<Image
source={{ uri: firstAttachment }}
style={styles.thumbnail}
contentFit="cover"
transition={200}
/>
)
) : (
<View style={styles.placeholderThumbnail}>
<Ionicons name="document-text-outline" size={32} color={palette.gray[300]} />
</View>
)}
{item.images && item.images.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>+{item.images.length - 1}</Text>
</View>
)}
</View>
<View style={styles.content}>
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
<Text style={styles.date}>{dayjs(item.date).format('YYYY-MM-DD')}</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => onDelete(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={16} color={palette.gray[400]} />
</TouchableOpacity>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: palette.gray[200],
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 2,
overflow: 'hidden',
flexDirection: 'row',
height: 100,
},
thumbnailContainer: {
width: 100,
height: '100%',
backgroundColor: palette.gray[50],
position: 'relative',
},
thumbnail: {
width: '100%',
height: '100%',
},
pdfThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
placeholderThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.gray[50],
},
badge: {
position: 'absolute',
right: 8,
bottom: 8,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
},
badgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
content: {
flex: 1,
padding: 12,
justifyContent: 'center',
},
title: {
fontSize: 16,
fontWeight: '600',
color: palette.gray[800],
marginBottom: 4,
fontFamily: 'AliBold',
},
date: {
fontSize: 12,
color: palette.purple[600],
fontWeight: '500',
fontFamily: 'AliRegular',
},
deleteButton: {
position: 'absolute',
top: 8,
right: 8,
padding: 4,
},
});

View File

@@ -0,0 +1,161 @@
import { ROUTES } from '@/constants/Routes';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
type BasicInfoTabProps = {
healthData: {
bmi: string;
height: string;
weight: string;
waist: string;
};
};
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
const { t } = useI18n();
const router = useRouter();
const handleHeightWeightPress = () => {
router.push(ROUTES.PROFILE_EDIT);
};
const handleWaistPress = () => {
router.push('/circumference-detail');
};
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
<View style={styles.metricsGrid}>
{/* BMI - Highlighted */}
<View style={styles.metricItemMain}>
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
<Text style={styles.metricValueMain}>
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
</Text>
</View>
{/* Height - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleHeightWeightPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.height}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
</Text>
</TouchableOpacity>
{/* Weight - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleHeightWeightPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.weight}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
</Text>
</TouchableOpacity>
{/* Waist - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleWaistPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.waist}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 6,
elevation: 1,
},
cardTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 16,
fontFamily: 'AliBold',
},
metricsGrid: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
metricItemMain: {
flex: 1.5,
backgroundColor: '#F5F3FF',
borderRadius: 12,
padding: 12,
marginRight: 12,
alignItems: 'center',
},
metricHeader: {
flexDirection: 'row',
gap: 2,
marginBottom: 8,
},
metricLabelMain: {
fontSize: 14,
color: '#5B4CFF',
fontWeight: 'bold',
marginBottom: 4,
fontFamily: 'AliBold',
},
metricValueMain: {
fontSize: 16,
color: '#5B4CFF',
fontFamily: 'AliRegular',
},
metricItem: {
flex: 1,
alignItems: 'center',
},
metricHeaderSmall: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 2,
},
metricLabel: {
fontSize: 11,
color: '#6B7280',
marginBottom: 4,
fontFamily: 'AliRegular',
},
metricValue: {
fontSize: 14,
color: '#1F2937',
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,49 @@
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function CheckupRecordsTab() {
return (
<View style={styles.card}>
<View style={styles.emptyState}>
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 40,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 6,
elevation: 1,
minHeight: 200,
alignItems: 'center',
justifyContent: 'center',
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
color: '#374151',
fontFamily: 'AliBold',
},
emptySubtext: {
marginTop: 8,
fontSize: 13,
color: '#9CA3AF',
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,788 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { HealthHistoryCategory } from '@/services/healthProfile';
import {
HistoryItemDetail,
fetchHealthHistory,
saveHealthHistoryCategory,
selectHealthLoading,
selectHistoryData,
updateHistoryData,
} from '@/store/healthSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { palette } from '../../../constants/Colors';
// Translation Keys for Recommendations
const RECOMMENDATION_KEYS: Record<string, string[]> = {
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
};
interface HistoryItemProps {
title: string;
categoryKey: string;
data: {
hasHistory: boolean | null;
items: HistoryItemDetail[];
};
onPress?: () => void;
}
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
const { t } = useTranslation();
const translateItemName = (name: string) => {
const keys = RECOMMENDATION_KEYS[categoryKey];
if (keys && keys.includes(name)) {
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
}
return name;
};
const hasItems = data.hasHistory === true && data.items.length > 0;
return (
<TouchableOpacity
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
onPress={onPress}
activeOpacity={0.7}
>
{/* Header Row */}
<View style={styles.itemHeader}>
<View style={styles.itemLeft}>
<LinearGradient
colors={[palette.purple[400], palette.purple[600]]}
style={styles.indicator}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.itemTitle}>{title}</Text>
</View>
{!hasItems && (
<Text style={[
styles.itemStatus,
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
]}>
{data.hasHistory === null
? t('health.tabs.healthProfile.history.pending')
: data.hasHistory === false
? t('health.tabs.healthProfile.history.modal.none')
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
</Text>
)}
</View>
{/* List of Items */}
{hasItems && (
<View style={styles.subListContainer}>
{data.items.map(item => (
<View key={item.id} style={styles.subItemRow}>
<View style={styles.subItemDot} />
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
{item.date && (
<Text style={styles.subItemDate}>
{dayjs(item.date).format('YYYY-MM-DD')}
</Text>
)}
</View>
))}
</View>
)}
</TouchableOpacity>
);
}
export function HealthHistoryTab() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
// 从 Redux store 获取健康史数据和加载状态
const historyData = useAppSelector(selectHistoryData);
const isLoading = useAppSelector(selectHealthLoading);
// Modal State
const [modalVisible, setModalVisible] = useState(false);
const [currentType, setCurrentType] = useState<string | null>(null);
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
const [isSaving, setIsSaving] = useState(false);
// Date Picker State
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
// 初始化时从服务端获取健康史数据(如果父组件未加载)
useEffect(() => {
// 只在数据为空时才主动拉取,避免重复请求
if (!historyData || Object.keys(historyData).length === 0) {
dispatch(fetchHealthHistory());
}
}, [dispatch, historyData]);
const historyItems = [
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
];
// Helper to translate item (try to find key, fallback to item itself)
const translateItem = (type: string, item: string) => {
// Check if item is a predefined key
const keys = RECOMMENDATION_KEYS[type];
if (keys && keys.includes(item)) {
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
}
// Fallback for manual input
return item;
};
// Open Modal
const handleItemPress = (key: string) => {
setCurrentType(key);
const currentData = historyData[key];
setTempHasHistory(currentData.hasHistory);
// Deep copy items to avoid reference issues
setTempItems(currentData.items.map(item => ({ ...item })));
setModalVisible(true);
};
// Close Modal
const handleCloseModal = () => {
setModalVisible(false);
setCurrentType(null);
};
// Save Data
const handleSave = async () => {
if (currentType) {
// Filter out empty items
const validItems = tempItems.filter(item => item.name.trim() !== '');
// If "No" history is selected, clear items
const finalItems = tempHasHistory === false ? [] : validItems;
setIsSaving(true);
try {
// 先乐观更新本地状态
dispatch(updateHistoryData({
type: currentType,
data: {
hasHistory: tempHasHistory,
items: finalItems,
},
}));
// 同步到服务端
await dispatch(saveHealthHistoryCategory({
category: currentType as HealthHistoryCategory,
data: {
hasHistory: tempHasHistory ?? false,
items: finalItems.map(item => ({
name: item.name,
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
isRecommendation: item.isRecommendation,
})),
},
})).unwrap();
handleCloseModal();
} catch (error: any) {
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
Alert.alert(
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
error?.message || '请稍后重试',
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
);
} finally {
setIsSaving(false);
}
}
};
// Add Item (Manual or Recommendation)
const addItem = (name: string = '', isRecommendation: boolean = false) => {
// Avoid duplicates for recommendations if already exists
if (isRecommendation && tempItems.some(item => item.name === name)) {
return;
}
const newItem: HistoryItemDetail = {
id: Date.now().toString() + Math.random().toString(),
name,
isRecommendation
};
setTempItems([...tempItems, newItem]);
};
// Remove Item
const removeItem = (id: string) => {
setTempItems(tempItems.filter(item => item.id !== id));
};
// Update Item Name
const updateItemName = (id: string, text: string) => {
setTempItems(tempItems.map(item =>
item.id === id ? { ...item, name: text } : item
));
};
// Date Picker Handlers
const showDatePicker = (id: string) => {
setCurrentEditingId(id);
setDatePickerVisibility(true);
};
const hideDatePicker = () => {
setDatePickerVisibility(false);
setCurrentEditingId(null);
};
const handleConfirmDate = (date: Date) => {
if (currentEditingId) {
setTempItems(tempItems.map(item =>
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
));
}
hideDatePicker();
};
return (
<View style={styles.container}>
{/* Glow effect background */}
<View style={styles.glowContainer}>
<View style={styles.glow} />
</View>
<View style={styles.card}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
</View>
{/* List */}
<View style={styles.list}>
{historyItems.map((item) => (
<HistoryItem
key={item.key}
title={item.title}
categoryKey={item.key}
data={historyData[item.key]}
onPress={() => handleItemPress(item.key)}
/>
))}
</View>
</View>
{/* Edit Modal */}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible}
onRequestClose={handleCloseModal}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.modalContent}>
{/* Modal Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
</Text>
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
<Ionicons name="close" size={24} color={palette.gray[400]} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Question: Do you have history? */}
<Text style={styles.questionText}>
{t('health.tabs.healthProfile.history.modal.question', {
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
})}
</Text>
<View style={styles.radioGroup}>
<TouchableOpacity
style={[
styles.radioButton,
tempHasHistory === true && styles.radioButtonActive
]}
onPress={() => setTempHasHistory(true)}
>
<Text style={[
styles.radioText,
tempHasHistory === true && styles.radioTextActive
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.radioButton,
tempHasHistory === false && styles.radioButtonActive
]}
onPress={() => setTempHasHistory(false)}
>
<Text style={[
styles.radioText,
tempHasHistory === false && styles.radioTextActive
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
</TouchableOpacity>
</View>
{/* Conditional Content */}
{tempHasHistory === true && currentType && (
<View style={styles.detailsContainer}>
{/* Recommendations */}
{RECOMMENDATION_KEYS[currentType] && (
<View style={styles.recommendationContainer}>
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
<View style={styles.tagsContainer}>
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
<TouchableOpacity
key={index}
style={styles.tag}
onPress={() => addItem(tagKey, true)}
>
<Text style={styles.tagText}>
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
</Text>
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
</TouchableOpacity>
))}
</View>
</View>
)}
{/* History List Items */}
<View style={styles.listContainer}>
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
{tempItems.map((item) => (
<View key={item.id} style={styles.listItemCard}>
<View style={styles.listItemHeader}>
<TextInput
style={styles.listItemNameInput}
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
placeholderTextColor={palette.gray[300]}
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
onChangeText={(text) => updateItemName(item.id, text)}
editable={!item.isRecommendation}
/>
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.datePickerTrigger}
onPress={() => showDatePicker(item.id)}
>
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
<Text style={[
styles.dateText,
!item.date && styles.placeholderText
]}>
{item.date
? dayjs(item.date).format('YYYY-MM-DD')
: t('health.tabs.healthProfile.history.modal.selectDate')}
</Text>
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
</TouchableOpacity>
</View>
))}
{/* Add Button */}
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
{/* Save Button */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isSaving}
>
<LinearGradient
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
style={styles.saveButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
{isSaving ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</View>
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="date"
onConfirm={handleConfirmDate}
onCancel={hideDatePicker}
maximumDate={new Date()} // Cannot select future date for history
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
/>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
position: 'relative',
},
glowContainer: {
position: 'absolute',
top: 20,
left: 20,
right: 20,
bottom: 20,
alignItems: 'center',
justifyContent: 'center',
zIndex: -1,
},
glow: {
width: '90%',
height: '90%',
backgroundColor: palette.purple[200],
opacity: 0.3,
borderRadius: 40,
transform: [{ scale: 1.05 }],
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
shadowColor: palette.purple[100],
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.6,
shadowRadius: 24,
elevation: 4,
borderWidth: 1,
borderColor: '#F5F3FF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
paddingHorizontal: 4,
},
headerTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: palette.gray[900],
fontWeight: '600',
},
list: {
backgroundColor: '#FFFFFF',
},
itemContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 4,
},
itemContainerWithList: {
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
},
itemHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
itemLeft: {
flexDirection: 'row',
alignItems: 'center',
},
indicator: {
width: 4,
height: 14,
borderRadius: 2,
marginRight: 12,
},
itemTitle: {
fontSize: 16,
color: palette.gray[700],
fontFamily: 'AliRegular',
},
itemStatus: {
fontSize: 14,
color: palette.gray[300],
fontFamily: 'AliRegular',
textAlign: 'right',
maxWidth: 150,
},
itemStatusActive: {
color: palette.purple[600],
fontWeight: '500',
},
subListContainer: {
marginTop: 12,
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
},
subItemRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 6,
},
subItemDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: palette.purple[300],
marginRight: 8,
},
subItemName: {
flex: 1,
fontSize: 15,
color: palette.gray[800],
fontFamily: 'AliRegular',
fontWeight: '500',
},
subItemDate: {
fontSize: 13,
color: palette.gray[400],
fontFamily: 'AliRegular',
},
// Modal Styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '100%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 24,
maxHeight: '85%', // Increased height
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontFamily: 'AliBold',
color: palette.gray[900],
fontWeight: '600',
},
closeButton: {
padding: 4,
},
questionText: {
fontSize: 16,
color: palette.gray[700],
marginBottom: 12,
fontFamily: 'AliRegular',
},
radioGroup: {
flexDirection: 'row',
marginBottom: 24,
},
radioButton: {
flex: 1,
paddingVertical: 12,
borderWidth: 1,
borderColor: palette.gray[200],
borderRadius: 12,
alignItems: 'center',
marginRight: 8,
},
radioButtonActive: {
backgroundColor: palette.purple[50],
borderColor: palette.purple[500],
},
radioText: {
fontSize: 16,
color: palette.gray[600],
fontWeight: '500',
},
radioTextActive: {
color: palette.purple[600],
fontWeight: '600',
},
detailsContainer: {
marginTop: 4,
},
sectionLabel: {
fontSize: 14,
color: palette.gray[500],
marginBottom: 12,
marginTop: 8,
fontFamily: 'AliRegular',
},
recommendationContainer: {
marginBottom: 20,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
tag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#F5F7FA',
},
tagText: {
fontSize: 14,
color: palette.gray[600],
fontFamily: 'AliRegular',
},
listContainer: {
marginTop: 8,
},
listItemCard: {
backgroundColor: '#F9FAFB',
borderRadius: 16,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: palette.gray[100],
},
listItemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
listItemNameInput: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: palette.gray[900],
fontFamily: 'AliBold',
padding: 0,
},
deleteButton: {
padding: 4,
},
datePickerTrigger: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: palette.gray[200],
},
dateText: {
marginLeft: 8,
fontSize: 14,
color: palette.gray[900],
fontFamily: 'AliRegular',
},
placeholderText: {
color: palette.gray[400],
},
addItemButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderWidth: 1,
borderColor: palette.purple[200],
borderRadius: 12,
borderStyle: 'dashed',
backgroundColor: palette.purple[25],
marginTop: 4,
marginBottom: 20,
},
addItemText: {
marginLeft: 8,
fontSize: 14,
color: palette.purple[600],
fontWeight: '500',
},
modalFooter: {
marginTop: 8,
},
saveButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
shadowOpacity: 0,
},
saveButtonGradient: {
paddingVertical: 14,
alignItems: 'center',
},
saveButtonText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,666 @@
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
import {
addNewMedicalRecord,
deleteMedicalRecordItem,
fetchMedicalRecords,
selectHealthLoading,
selectMedicalRecords,
} from '@/store/healthSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as DocumentPicker from 'expo-document-picker';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
export function MedicalRecordsTab() {
const dispatch = useAppDispatch();
const medicalRecords = useAppSelector(selectMedicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
const isLoading = useAppSelector(selectHealthLoading);
// COS 上传
const { upload: uploadToCos, uploading: isUploading } = useCosUpload({
prefix: 'images/health/medical-records'
});
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
const [isModalVisible, setModalVisible] = useState(false);
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
// Form State
const [title, setTitle] = useState('');
const [date, setDate] = useState(new Date());
const [images, setImages] = useState<string[]>([]);
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Image Viewer State
const [viewerVisible, setViewerVisible] = useState(false);
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
useEffect(() => {
dispatch(fetchMedicalRecords());
}, [dispatch]);
const currentList = activeTab === 'medical_record' ? records : prescriptions;
const handleTabPress = (tab: MedicalRecordType) => {
setActiveTab(tab);
};
const resetForm = () => {
setTitle('');
setDate(new Date());
setImages([]);
setNote('');
};
const openAddModal = () => {
resetForm();
setModalVisible(true);
};
const handlePickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相册以上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handleTakePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handlePickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
} catch (error) {
console.error('Error picking document:', error);
Alert.alert('错误', '选择文件失败');
}
};
const handleSubmit = async () => {
if (!title.trim()) {
Alert.alert('提示', '请输入标题');
return;
}
if (images.length === 0) {
Alert.alert('提示', '请至少上传一张图片');
return;
}
setIsSubmitting(true);
try {
// 1. 上传所有图片到 COS
const uploadPromises = images.map(async (uri) => {
const result = await uploadToCos({ uri });
return result.url;
});
const uploadedUrls = await Promise.all(uploadPromises);
// 2. 创建就医资料记录
await dispatch(addNewMedicalRecord({
type: activeTab,
title: title.trim(),
date: dayjs(date).format('YYYY-MM-DD'),
images: uploadedUrls,
note: note.trim() || undefined,
})).unwrap();
setModalVisible(false);
resetForm();
} catch (error: any) {
console.error('保存失败:', error);
const errorMessage = error?.message || '保存失败,请重试';
Alert.alert('错误', errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = (item: MedicalRecordItem) => {
Alert.alert(
'确认删除',
'确定要删除这条记录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
},
]
);
};
const handleViewImages = (item: MedicalRecordItem) => {
if (item.images && item.images.length > 0) {
setCurrentViewerImages(item.images.map(uri => ({ uri })));
setViewerVisible(true);
}
};
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
<MedicalRecordCard
item={item}
onPress={handleViewImages}
onDelete={handleDelete}
/>
);
return (
<View style={styles.container}>
{/* Segmented Control */}
<View style={styles.segmentContainer}>
<TouchableOpacity
style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
onPress={() => handleTabPress('medical_record')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
onPress={() => handleTabPress('prescription')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
</View>
{/* Content List */}
<View style={styles.contentContainer}>
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={palette.purple[500]} />
</View>
) : currentList.length > 0 ? (
<FlatList
data={currentList}
renderItem={renderItem}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
scrollEnabled={false} // Since it's inside a parent ScrollView
/>
) : (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
size={48}
color={palette.gray[300]}
/>
</View>
<Text style={styles.emptyText}>
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
</Text>
<Text style={styles.emptySubtext}>
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
</Text>
</View>
)}
</View>
{/* Add Button */}
<TouchableOpacity
style={styles.fab}
onPress={openAddModal}
activeOpacity={0.9}
>
<LinearGradient
colors={[palette.purple[500], palette.purple[700]]}
style={styles.fabGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="add" size={28} color="#FFFFFF" />
</LinearGradient>
</TouchableOpacity>
{/* Add/Edit Modal */}
<Modal
visible={isModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
<Text style={styles.modalCloseText}></Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
</Text>
<TouchableOpacity
onPress={handleSubmit}
style={[styles.modalSaveButton, (isSubmitting || isUploading) && styles.modalSaveButtonDisabled]}
disabled={isSubmitting || isUploading}
>
{(isSubmitting || isUploading) ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.modalSaveText}></Text>
)}
</TouchableOpacity>
</View>
<View style={styles.formContainer}>
{/* Title Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<TextInput
style={styles.input}
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
value={title}
onChangeText={setTitle}
placeholderTextColor={palette.gray[400]}
/>
</View>
{/* Date Picker */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TouchableOpacity
style={styles.dateInput}
onPress={() => setDatePickerVisibility(true)}
>
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
</TouchableOpacity>
</View>
{/* Images */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<View style={styles.imageGrid}>
{images.map((uri, index) => {
const isPdf = uri.toLowerCase().endsWith('.pdf');
return (
<View key={index} style={styles.imagePreviewContainer}>
{isPdf ? (
<View style={[styles.imagePreview, styles.pdfPreview]}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
</View>
) : (
<Image
source={{ uri }}
style={styles.imagePreview}
contentFit="cover"
/>
)}
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => setImages(images.filter((_, i) => i !== index))}
>
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
</TouchableOpacity>
</View>
);
})}
{images.length < 9 && (
<TouchableOpacity style={styles.addImageButton} onPress={() => {
Alert.alert(
'上传文件',
'请选择上传方式',
[
{ text: '拍照', onPress: handleTakePhoto },
{ text: '从相册选择', onPress: handlePickImage },
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
{ text: '取消', style: 'cancel' },
]
);
}}>
<Ionicons name="add" size={32} color={palette.purple[500]} />
<Text style={styles.addImageText}></Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Note */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="添加备注信息..."
value={note}
onChangeText={setNote}
multiline
numberOfLines={4}
placeholderTextColor={palette.gray[400]}
textAlignVertical="top"
/>
</View>
</View>
</View>
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="date"
onConfirm={(d) => {
setDate(d);
setDatePickerVisibility(false);
}}
onCancel={() => setDatePickerVisibility(false)}
maximumDate={new Date()}
locale="zh_CN"
confirmTextIOS="确定"
cancelTextIOS="取消"
/>
</Modal>
<ImageViewing
images={currentViewerImages}
imageIndex={0}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
segmentContainer: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 4,
marginBottom: 16,
},
segmentButton: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 10,
},
segmentButtonActive: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
segmentText: {
fontSize: 14,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
segmentTextActive: {
color: palette.purple[600],
fontWeight: '600',
fontFamily: 'AliBold',
},
contentContainer: {
minHeight: 300,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
listContent: {
paddingBottom: 80,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
marginTop: 40,
paddingHorizontal: 40,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
fontFamily: 'AliBold',
},
emptySubtext: {
fontSize: 13,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 20,
fontFamily: 'AliRegular',
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
fabGradient: {
width: '100%',
height: '100%',
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
// Modal Styles
modalContainer: {
flex: 1,
backgroundColor: '#F9FAFB',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB',
paddingTop: Platform.OS === 'ios' ? 12 : 12,
},
modalCloseButton: {
padding: 8,
},
modalCloseText: {
fontSize: 16,
color: '#6B7280',
fontFamily: 'AliRegular',
},
modalTitle: {
fontSize: 17,
fontWeight: '600',
color: '#111827',
fontFamily: 'AliBold',
},
modalSaveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: palette.purple[600],
borderRadius: 6,
},
modalSaveButtonDisabled: {
opacity: 0.6,
},
modalSaveText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
formContainer: {
padding: 16,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '500',
color: '#374151',
marginBottom: 8,
fontFamily: 'AliRegular',
},
required: {
color: palette.error[500],
},
input: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#111827',
borderWidth: 1,
borderColor: '#E5E7EB',
fontFamily: 'AliRegular',
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
dateInput: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
},
dateText: {
fontSize: 16,
color: '#111827',
fontFamily: 'AliRegular',
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
imagePreviewContainer: {
width: 80,
height: 80,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
imagePreview: {
width: '100%',
height: '100%',
},
pdfPreview: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
removeImageButton: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: 'rgba(255,255,255,0.8)',
borderRadius: 10,
},
addImageButton: {
width: 80,
height: 80,
borderRadius: 8,
borderWidth: 1,
borderColor: palette.purple[200],
borderStyle: 'dashed',
backgroundColor: palette.purple[50],
alignItems: 'center',
justifyContent: 'center',
},
addImageText: {
fontSize: 12,
color: palette.purple[600],
marginTop: 4,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
@@ -25,6 +26,8 @@ interface MedicationPhotoGuideModalProps {
* 展示如何正确拍摄药品照片的说明和示例
*/
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
const { t } = useI18n();
return (
<Modal
visible={visible}
@@ -48,8 +51,12 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
>
{/* 标题部分 */}
<View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideStepBadge}>
{t('medications.aiCamera.guideModal.badge')}
</Text>
<Text style={styles.guideTitle}>
{t('medications.aiCamera.guideModal.title')}
</Text>
</View>
{/* 示例图片 */}
@@ -99,10 +106,10 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
{/* 说明文字 */}
<View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}>
\\
{t('medications.aiCamera.guideModal.description1')}
</Text>
<Text style={styles.guideDescriptionText}>
线
{t('medications.aiCamera.guideModal.description2')}
</Text>
</View>
@@ -124,7 +131,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</GlassView>
) : (
@@ -135,7 +144,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</View>
)}

View File

@@ -2,6 +2,7 @@
import CustomCheckBox from '@/components/ui/CheckBox';
import { USER_AGREEMENT_URL } from '@/constants/Agree';
import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import {
MEMBERSHIP_PLAN_META,
extractMembershipProductsFromOfferings,
@@ -65,51 +66,6 @@ interface BenefitItem {
regular: PermissionConfig;
}
// 权益对比配置
const BENEFIT_COMPARISON: BenefitItem[] = [
{
title: 'AI拍照记录热量',
description: '通过拍照识别食物并自动记录热量',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日3次'
}
},
{
title: 'AI拍照识别包装',
description: '识别食品包装上的营养成分信息',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日5次'
}
},
{
title: '每日健康提醒',
description: '根据个人目标提供个性化健康提醒',
vip: {
type: 'unlimited',
text: '完全支持',
vipText: '智能提醒'
},
regular: {
type: 'unlimited',
text: '基础提醒',
vipText: '基础提醒'
}
},
];
const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = {
lifetime: {
@@ -151,6 +107,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
};
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
const [loading, setLoading] = useState(false);
@@ -165,6 +122,94 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 保存监听器引用,用于移除监听器
const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null);
// 权益对比配置 - Move inside component to use t function
const benefitComparison: BenefitItem[] = [
{
title: t('membershipModal.benefits.items.aiCalories.title'),
description: t('membershipModal.benefits.items.aiCalories.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 3 })
}
},
{
title: t('membershipModal.benefits.items.aiNutrition.title'),
description: t('membershipModal.benefits.items.aiNutrition.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 5 })
}
},
{
title: t('membershipModal.benefits.items.healthReminder.title'),
description: t('membershipModal.benefits.items.healthReminder.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.fullSupport'),
vipText: t('membershipModal.benefits.permissions.smartReminder')
},
regular: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.basicSupport'),
vipText: t('membershipModal.benefits.permissions.basicSupport')
}
},
{
title: t('membershipModal.benefits.items.aiMedication.title'),
description: t('membershipModal.benefits.items.aiMedication.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.fullAnalysis'),
vipText: t('membershipModal.benefits.permissions.fullAnalysis')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
{
title: t('membershipModal.benefits.items.customChallenge.title'),
description: t('membershipModal.benefits.items.customChallenge.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.createUnlimited'),
vipText: t('membershipModal.benefits.permissions.createUnlimited')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
{
title: t('membershipModal.benefits.items.tabBarCustomization.title'),
description: t('membershipModal.benefits.items.tabBarCustomization.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.fullSupport'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
];
// 根据选中的产品生成tips内容
const getTipsContent = (product: PurchasesStoreProduct | null): string => {
if (!product) return '';
@@ -176,11 +221,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
switch (plan.type) {
case 'lifetime':
return '终身陪伴,见证您的每一次健康蜕变';
return t('membershipModal.plans.lifetime.subtitle');
case 'quarterly':
return '3个月科学计划让健康成为生活习惯';
return t('membershipModal.plans.quarterly.subtitle');
case 'weekly':
return '7天体验期感受专业健康指导的力量';
return t('membershipModal.plans.weekly.subtitle');
default:
return '';
}
@@ -326,7 +371,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 显示成功提示
GlobalToast.show({
message: '会员开通成功',
message: t('membershipModal.success.purchase'),
});
}, 1000);
}
@@ -492,11 +537,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否已同意协议
if (!agreementAccepted) {
Alert.alert(
'请阅读并同意相关协议',
'购买前需要同意用户协议、会员协议和自动续费协议',
t('membershipModal.agreements.alert.title'),
t('membershipModal.agreements.alert.message'),
[
{
text: '确定',
text: t('membershipModal.agreements.alert.confirm'),
style: 'default',
}
]
@@ -517,11 +562,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否选择了产品
if (!selectedProduct) {
Alert.alert(
'请选择会员套餐',
t('membershipModal.errors.selectPlan'),
'',
[
{
text: '确定',
text: t('membershipModal.agreements.alert.confirm'),
style: 'default',
}
]
@@ -579,32 +624,32 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
// 用户取消购买
GlobalToast.show({
message: '购买已取消',
message: t('membershipModal.errors.purchaseCancelled'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
// 商品已拥有
GlobalToast.show({
message: '您已拥有此商品',
message: t('membershipModal.errors.alreadyPurchased'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
// 网络错误
GlobalToast.show({
message: '网络连接失败',
message: t('membershipModal.errors.networkError'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
// 支付待处理
GlobalToast.show({
message: '支付正在处理中',
message: t('membershipModal.errors.paymentPending'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
// 凭据无效
GlobalToast.show({
message: '账户验证失败',
message: t('membershipModal.errors.invalidCredentials'),
});
} else {
// 其他错误
GlobalToast.show({
message: '购买失败',
message: t('membershipModal.errors.purchaseFailed'),
});
}
} finally {
@@ -701,7 +746,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onClose?.();
GlobalToast.show({
message: '恢复购买成功',
message: t('membershipModal.errors.restoreSuccess'),
});
} catch (apiError: any) {
@@ -720,7 +765,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录)
// 但不关闭弹窗,让用户知道可能需要重试
GlobalToast.show({
message: '恢复购买部分失败',
message: t('membershipModal.errors.restorePartialFailed'),
});
}
@@ -734,7 +779,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeSubscriptionsCount: activeSubscriptionIds.length
});
GlobalToast.show({
message: '没有找到购买记录',
message: t('membershipModal.errors.noPurchasesFound'),
});
}
} catch (error: any) {
@@ -754,19 +799,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 处理特定的恢复购买错误
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
GlobalToast.show({
message: '恢复购买已取消',
message: t('membershipModal.errors.restoreCancelled'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
GlobalToast.show({
message: '网络错误',
message: t('membershipModal.errors.networkError'),
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
GlobalToast.show({
message: '账户验证失败',
message: t('membershipModal.errors.invalidCredentials'),
});
} else {
GlobalToast.show({
message: '恢复购买失败',
message: t('membershipModal.errors.restoreFailed'),
});
}
} finally {
@@ -780,7 +825,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
const renderPlanCard = (product: PurchasesStoreProduct) => {
const planMeta = getPlanMetaById(product.identifier);
const isSelected = selectedProduct === product;
const displayTitle = resolvePlanDisplayName(product, planMeta);
// 优先使用翻译的标题,如果找不到 meta 则回退到产品标题
let displayTitle = product.title;
let displaySubtitle = planMeta?.subtitle ?? '';
if (planMeta) {
displayTitle = t(`membershipModal.plans.${planMeta.type}.title`);
displaySubtitle = t(`membershipModal.plans.${planMeta.type}.subtitle`);
} else {
// 如果没有 meta尝试使用 resolvePlanDisplayName (虽然这里主要依赖 meta)
displayTitle = resolvePlanDisplayName(product, planMeta);
}
const priceLabel = product.priceString || '';
const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
@@ -797,7 +854,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeOpacity={loading ? 1 : 0.8}
accessible={true}
accessibilityLabel={`${displayTitle} ${priceLabel}`}
accessibilityHint={loading ? '购买进行中,无法切换套餐' : `选择${displayTitle}套餐`}
accessibilityHint={loading ? t('membershipModal.loading.purchase') : t('membershipModal.actions.selectPlan', { plan: displayTitle })}
accessibilityState={{ disabled: loading, selected: isSelected }}
>
<LinearGradient
@@ -809,7 +866,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.planCardTopSection}>
{planMeta?.tag && (
<View style={styles.planTag}>
<Text style={styles.planTagText}>{planMeta.tag}</Text>
<Text style={styles.planTagText}>{t('membershipModal.plans.tag')}</Text>
</View>
)}
<Text style={styles.planCardTitle}>{displayTitle}</Text>
@@ -825,7 +882,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View>
<View style={styles.planCardBottomSection}>
<Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text>
<Text style={styles.planCardDescription}>{displaySubtitle}</Text>
</View>
</LinearGradient>
</TouchableOpacity>
@@ -854,8 +911,8 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={onClose}
activeOpacity={0.7}
accessible={true}
accessibilityLabel="返回"
accessibilityHint="关闭会员购买弹窗"
accessibilityLabel={t('membershipModal.actions.back')}
accessibilityHint={t('membershipModal.actions.close')}
style={styles.floatingBackButtonContainer}
>
{isLiquidGlassAvailable() ? (
@@ -887,14 +944,14 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}>
<Ionicons name="star" size={16} color="#7B2CBF" />
</View>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('membershipModal.sectionTitle.plans')}</Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
<Text style={styles.sectionSubtitle}>{t('membershipModal.sectionTitle.plansSubtitle')}</Text>
{products.length === 0 ? (
<View style={styles.configurationNotice}>
<Text style={styles.configurationText}>
RevenueCat iOS Offering
{t('membershipModal.errors.noProducts')}
</Text>
</View>
) : (
@@ -917,17 +974,17 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}>
<Ionicons name="checkbox" size={16} color="#FF9F0A" />
</View>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('membershipModal.benefits.title')}</Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
<Text style={styles.sectionSubtitle}>{t('membershipModal.benefits.subtitle')}</Text>
<View style={styles.comparisonTable}>
<View style={[styles.tableRow, styles.tableHeader]}>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}></Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>VIP</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}></Text>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}>{t('membershipModal.benefits.table.benefit')}</Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>{t('membershipModal.benefits.table.vip')}</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}>{t('membershipModal.benefits.table.regular')}</Text>
</View>
{BENEFIT_COMPARISON.map((row, index) => (
{benefitComparison.map((row, index) => (
<View
key={row.title}
style={[
@@ -963,39 +1020,46 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View>
<View style={styles.bottomSection}>
<View style={styles.agreementRow}>
<CustomCheckBox
checked={agreementAccepted}
onCheckedChange={setAgreementAccepted}
size={16}
checkedColor="#E91E63"
uncheckedColor="#999"
/>
<Text style={styles.agreementPrefix}></Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(USER_AGREEMENT_URL);
captureMessage('click user agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<Text style={styles.agreementSeparator}>|</Text>
<TouchableOpacity
onPress={() => {
captureMessage('click membership agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<Text style={styles.agreementSeparator}>|</Text>
<TouchableOpacity
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<View style={styles.agreementContainer}>
<View style={styles.checkboxWrapper}>
<CustomCheckBox
checked={agreementAccepted}
onCheckedChange={setAgreementAccepted}
size={16}
checkedColor="#E91E63"
uncheckedColor="#999"
/>
</View>
<Text style={styles.agreementText}>
{t('membershipModal.agreements.prefix')}
<Text
style={styles.agreementLink}
onPress={() => {
Linking.openURL(USER_AGREEMENT_URL);
captureMessage('click user agreement');
}}
>
{t('membershipModal.agreements.userAgreement')}
</Text>
<Text style={styles.agreementSeparator}> | </Text>
<Text
style={styles.agreementLink}
onPress={() => {
captureMessage('click membership agreement');
}}
>
{t('membershipModal.agreements.membershipAgreement')}
</Text>
<Text style={styles.agreementSeparator}> | </Text>
<Text
style={styles.agreementLink}
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
{t('membershipModal.agreements.autoRenewalAgreement')}
</Text>
</Text>
</View>
<TouchableOpacity
@@ -1006,10 +1070,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{restoring ? (
<View style={styles.restoreButtonContent}>
<ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} />
<Text style={styles.restoreButtonText}>...</Text>
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restoring')}</Text>
</View>
) : (
<Text style={styles.restoreButtonText}></Text>
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restore')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1031,15 +1095,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
? t('membershipModal.loading.purchase')
: products.length === 0
? '正在加载会员套餐,请稍候'
? t('membershipModal.loading.products')
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
? t('membershipModal.errors.selectPlan')
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
@@ -1047,10 +1111,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View>
) : (
<Text style={styles.purchaseButtonText}></Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)}
</TouchableOpacity>
</GlassView>
@@ -1066,15 +1130,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
? t('membershipModal.loading.purchase')
: products.length === 0
? '正在加载会员套餐,请稍候'
? t('membershipModal.loading.products')
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
? t('membershipModal.errors.selectPlan')
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
@@ -1082,10 +1146,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View>
) : (
<Text style={styles.purchaseButtonText}></Text>
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1168,12 +1232,14 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
color: '#2B2B2E',
fontFamily: 'AliBold',
},
sectionSubtitle: {
fontSize: 13,
color: '#6B6B73',
marginTop: 6,
marginBottom: 16,
fontFamily: 'AliRegular',
},
configurationNotice: {
borderRadius: 16,
@@ -1185,6 +1251,7 @@ const styles = StyleSheet.create({
color: '#B86A04',
textAlign: 'center',
lineHeight: 20,
fontFamily: 'AliRegular',
},
plansContainer: {
flexDirection: 'row',
@@ -1217,35 +1284,40 @@ const styles = StyleSheet.create({
alignSelf: 'flex-start',
backgroundColor: '#2F2F36',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 4,
paddingHorizontal: 6,
paddingVertical: 6,
marginBottom: 12,
},
planTagText: {
color: '#FFFFFF',
fontSize: 11,
fontSize: 10,
fontWeight: '600',
fontFamily: 'AliBold',
},
planCardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#241F1F',
},
planCardPrice: {
fontSize: 16,
fontWeight: '700',
color: '#241F1F',
fontFamily: 'AliBold',
},
planCardPrice: {
fontSize: 14,
fontWeight: '700',
marginTop: 12,
fontFamily: 'AliBold',
},
planCardOriginalPrice: {
fontSize: 13,
color: '#8E8EA1',
textDecorationLine: 'line-through',
marginTop: 2,
fontFamily: 'AliRegular',
},
planCardDescription: {
fontSize: 12,
color: '#6C6C77',
lineHeight: 17,
fontFamily: 'AliRegular',
},
planCardTopSection: {
flex: 1,
@@ -1275,6 +1347,7 @@ const styles = StyleSheet.create({
color: '#9B6200',
marginLeft: 6,
lineHeight: 16,
fontFamily: 'AliRegular',
},
comparisonTable: {
borderRadius: 16,
@@ -1298,10 +1371,12 @@ const styles = StyleSheet.create({
color: '#575764',
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'AliBold',
},
tableCellText: {
fontSize: 13,
color: '#3E3E44',
fontFamily: 'AliRegular',
},
tableTitleCell: {
flex: 1.5,
@@ -1361,6 +1436,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
fontFamily: 'AliBold',
},
loadingContainer: {
flexDirection: 'row',
@@ -1369,29 +1445,34 @@ const styles = StyleSheet.create({
loadingSpinner: {
marginRight: 8,
},
agreementRow: {
agreementContainer: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'center',
flexWrap: 'nowrap',
marginBottom: 16,
marginBottom: 20,
paddingHorizontal: 4,
},
agreementPrefix: {
fontSize: 10,
checkboxWrapper: {
marginTop: 2, // Align with text line-height
marginRight: 8,
},
agreementText: {
flex: 1,
fontSize: 11,
lineHeight: 16,
color: '#666672',
marginRight: 4,
fontFamily: 'AliRegular',
},
agreementLink: {
fontSize: 10,
fontSize: 11,
color: '#E91E63',
textDecorationLine: 'underline',
fontWeight: '500',
marginHorizontal: 2,
textDecorationLine: 'underline',
fontFamily: 'AliBold',
},
agreementSeparator: {
fontSize: 10,
fontSize: 11,
color: '#A0A0B0',
marginHorizontal: 2,
},
restoreButton: {
alignSelf: 'center',
@@ -1401,6 +1482,7 @@ const styles = StyleSheet.create({
color: '#6F6F7A',
fontSize: 12,
fontWeight: '500',
fontFamily: 'AliBold',
},
disabledRestoreButton: {
opacity: 0.5,
@@ -1422,6 +1504,7 @@ const styles = StyleSheet.create({
color: '#8E8E93',
marginTop: 2,
lineHeight: 14,
fontFamily: 'AliRegular',
},
permissionContainer: {
alignItems: 'center',
@@ -1435,5 +1518,6 @@ const styles = StyleSheet.create({
marginTop: 4,
textAlign: 'center',
lineHeight: 12,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,6 +1,11 @@
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -15,11 +20,11 @@ import {
Text,
TextInput,
TouchableOpacity,
View
View,
} from 'react-native';
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { Colors } from '@/constants/Colors';
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
export interface CreateCustomFoodModalProps {
visible: boolean;
@@ -43,9 +48,10 @@ export function CreateCustomFoodModal({
onClose,
onSave
}: CreateCustomFoodModalProps) {
const { t } = useI18n();
const [foodName, setFoodName] = useState('');
const [defaultAmount, setDefaultAmount] = useState('100');
const [caloriesUnit, setCaloriesUnit] = useState('千卡');
const [caloriesUnit, setCaloriesUnit] = useState(t('createCustomFood.units.kcal'));
const [calories, setCalories] = useState('100');
const [imageUrl, setImageUrl] = useState<string>('');
const [protein, setProtein] = useState('0');
@@ -93,7 +99,7 @@ export function CreateCustomFoodModal({
if (visible) {
setFoodName('');
setDefaultAmount('100');
setCaloriesUnit('千卡');
setCaloriesUnit(t('createCustomFood.units.kcal'));
setCalories('100');
setImageUrl('');
setProtein('0');
@@ -102,16 +108,16 @@ export function CreateCustomFoodModal({
}
}, [visible]);
// 选择热量单位
// 选择图片
const handleSelectImage = async () => {
try {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择照片');
Alert.alert(
t('createCustomFood.alerts.permissionDenied.title'),
t('createCustomFood.alerts.permissionDenied.message')
);
return;
}
@@ -137,11 +143,17 @@ export function CreateCustomFoodModal({
setImageUrl(url);
} catch (e) {
console.warn('上传照片失败', e);
Alert.alert('上传失败', '照片上传失败,请重试');
Alert.alert(
t('createCustomFood.alerts.uploadFailed.title'),
t('createCustomFood.alerts.uploadFailed.message')
);
}
}
} catch (e) {
Alert.alert('发生错误', '选择照片失败,请重试');
Alert.alert(
t('createCustomFood.alerts.error.title'),
t('createCustomFood.alerts.error.message')
);
}
};
@@ -151,12 +163,18 @@ export function CreateCustomFoodModal({
// 保存自定义食物
const handleSave = () => {
if (!foodName.trim()) {
Alert.alert('提示', '请输入食物名称');
Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.nameRequired')
);
return;
}
if (!calories.trim() || parseFloat(calories) <= 0) {
Alert.alert('提示', '请输入有效的热量值');
Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.caloriesRequired')
);
return;
}
@@ -175,75 +193,99 @@ export function CreateCustomFoodModal({
onClose();
};
const isSaveDisabled = !foodName.trim() || !calories.trim();
return (
<Modal
visible={visible}
animationType="fade"
animationType="slide"
transparent={true}
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<View style={styles.overlay}>
<BlurView intensity={20} tint="dark" style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<View style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight,
maxHeight: screenHeight - keyboardHeight,
}
]}>
<TouchableOpacity activeOpacity={1} onPress={onClose} style={styles.dismissArea} />
<View
style={[
styles.modalContainer,
keyboardHeight > 0 && {
height: screenHeight - keyboardHeight - 60,
maxHeight: screenHeight - keyboardHeight - 60,
},
]}
>
<View style={styles.modalHeaderBar}>
<View style={styles.dragIndicator} />
</View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
flexGrow: 1,
paddingBottom: keyboardHeight > 0 ? 20 : 0
paddingBottom: keyboardHeight > 0 ? 20 : 40,
}}
>
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#333" />
<TouchableOpacity onPress={onClose} style={styles.backButton} activeOpacity={0.7}>
<Ionicons name="close-circle" size={32} color="#E2E8F0" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerTitle}>{t('createCustomFood.title')}</Text>
<TouchableOpacity
style={[
styles.saveButton,
(!foodName.trim() || !calories.trim()) && styles.saveButtonDisabled
]}
style={[styles.saveButton, isSaveDisabled && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!foodName.trim() || !calories.trim()}
disabled={isSaveDisabled}
activeOpacity={0.8}
>
<Text style={[
styles.saveButtonText,
(!foodName.trim() || !calories.trim()) && styles.saveButtonTextDisabled
]}></Text>
<LinearGradient
colors={isSaveDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.saveButtonGradient}
>
<Text style={styles.saveButtonText}>{t('createCustomFood.save')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
{/* 效果预览区域 */}
<View style={styles.previewSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.previewCard}>
<LinearGradient
colors={['#ffffff', '#F8F9FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.previewHeader}>
<Text style={styles.sectionTitle}>{t('createCustomFood.preview.title')}</Text>
</View>
<View style={styles.previewContent}>
{imageUrl ? (
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.previewImagePlaceholder}>
<Ionicons name="restaurant" size={20} color="#999" />
</View>
)}
<View style={styles.imageWrapper}>
{imageUrl ? (
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.previewImagePlaceholder}>
<Ionicons name="restaurant" size={24} color="#94A3B8" />
</View>
)}
</View>
<View style={styles.previewInfo}>
<Text style={styles.previewName}>
{foodName || '食物名称'}
</Text>
<Text style={styles.previewCalories}>
{actualCalories}{caloriesUnit}/{defaultAmount}g
<Text style={styles.previewName} numberOfLines={1}>
{foodName || t('createCustomFood.preview.defaultName')}
</Text>
<View style={styles.previewBadge}>
<Ionicons name="flame" size={14} color="#F59E0B" />
<Text style={styles.previewCalories}>
{actualCalories} {caloriesUnit} / {defaultAmount}
{t('createCustomFood.units.g')}
</Text>
</View>
</View>
</View>
</View>
@@ -252,21 +294,21 @@ export function CreateCustomFoodModal({
{/* 基本信息 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('createCustomFood.basicInfo.title')}</Text>
<Text style={styles.requiredIndicator}>*</Text>
</View>
<View style={styles.sectionCard}>
{/* 食物名称和单位 */}
{/* 食物名称 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.name')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={foodName}
onChangeText={setFoodName}
placeholder="例如,汉堡"
placeholderTextColor="#A0A0A0"
placeholder={t('createCustomFood.basicInfo.namePlaceholder')}
placeholderTextColor="#94A3B8"
/>
</View>
</View>
@@ -274,36 +316,36 @@ export function CreateCustomFoodModal({
{/* 默认数量 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.defaultAmount')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={defaultAmount}
onChangeText={setDefaultAmount}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}>g</Text>
<Text style={styles.unitText}>{t('createCustomFood.units.g')}</Text>
</View>
</View>
</View>
{/* 食物热量 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.calories')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={calories}
onChangeText={setCalories}
keyboardType="numeric"
placeholder="100"
placeholderTextColor="#A0A0A0"
placeholder="0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.kcal')}</Text>
</View>
</View>
</View>
@@ -312,23 +354,26 @@ export function CreateCustomFoodModal({
{/* 可选信息 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('createCustomFood.optionalInfo.title')}</Text>
<View style={styles.sectionCard}>
{/* 照片 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.photo')}</Text>
<View style={styles.inputRowContent}>
<TouchableOpacity
style={styles.modernImageSelector}
<TouchableOpacity
style={styles.modernImageSelector}
onPress={handleSelectImage}
disabled={uploading}
activeOpacity={0.8}
>
{imageUrl ? (
<Image style={styles.selectedImage} source={{ uri: imageUrl }} />
) : (
<View style={styles.modernImagePlaceholder}>
<Ionicons name="camera" size={28} color="#A0A0A0" />
<Text style={styles.imagePlaceholderText}></Text>
<Ionicons name="camera-outline" size={28} color="#94A3B8" />
<Text style={styles.imagePlaceholderText}>
{t('createCustomFood.optionalInfo.addPhoto')}
</Text>
</View>
)}
{uploading && (
@@ -342,54 +387,56 @@ export function CreateCustomFoodModal({
{/* 蛋白质 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.protein')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={protein}
onChangeText={setProtein}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
{/* 脂肪 */}
<View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.fat')}</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={fat}
onChangeText={setFat}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
{/* 碳水化合物 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text>
<Text style={styles.inputRowLabel}>
{t('createCustomFood.optionalInfo.carbohydrate')}
</Text>
<View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}>
<View style={styles.modernInputContainer}>
<TextInput
style={styles.modernNumberInput}
value={carbohydrate}
onChangeText={setCarbohydrate}
keyboardType="numeric"
placeholder="0"
placeholderTextColor="#A0A0A0"
placeholderTextColor="#94A3B8"
/>
<Text style={styles.unitText}></Text>
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View>
</View>
</View>
@@ -398,7 +445,7 @@ export function CreateCustomFoodModal({
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</BlurView>
</Modal>
);
}
@@ -408,331 +455,272 @@ const { height: screenHeight } = Dimensions.get('window');
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
keyboardAvoidingView: {
flex: 1,
justifyContent: 'flex-end',
},
dismissArea: {
flex: 1,
},
modalContainer: {
flex: 1,
backgroundColor: '#FFFFFF',
marginTop: 50,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
backgroundColor: '#F1F5F9', // Slate 100
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
height: '90%',
maxHeight: '90%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 20,
overflow: 'hidden',
},
modalHeaderBar: {
width: '100%',
height: 24,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F1F5F9',
},
dragIndicator: {
width: 40,
height: 4,
backgroundColor: '#CBD5E1',
borderRadius: 2,
},
scrollView: {
flex: 1,
backgroundColor: '#F1F5F9',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 16,
paddingHorizontal: 20,
paddingBottom: 20,
},
backButton: {
padding: 4,
marginLeft: -8,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
flex: 1,
fontSize: 20,
fontWeight: '800',
color: '#1E293B',
textAlign: 'center',
marginHorizontal: 20,
fontFamily: 'AliBold',
},
saveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderRadius: 20,
overflow: 'hidden',
},
saveButtonDisabled: {
opacity: 0.5,
opacity: 0.6,
},
saveButtonGradient: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
saveButtonText: {
fontSize: 16,
color: Colors.light.primary,
fontWeight: '500',
},
saveButtonTextDisabled: {
color: Colors.light.textMuted,
fontSize: 14,
color: '#FFFFFF',
fontWeight: '700',
fontFamily: 'AliBold',
},
previewSection: {
paddingHorizontal: 16,
paddingBottom: 16,
paddingHorizontal: 20,
marginBottom: 24,
},
previewCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
borderRadius: 24,
padding: 20,
overflow: 'hidden',
backgroundColor: '#FFFFFF',
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
},
previewHeader: {
marginBottom: 16,
},
previewContent: {
flexDirection: 'row',
alignItems: 'center',
},
imageWrapper: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
previewImage: {
width: 32,
height: 32,
borderRadius: 4,
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#F8FAFC',
},
previewImagePlaceholder: {
width: 32,
height: 32,
borderRadius: 4,
backgroundColor: '#E5E5E5',
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
},
previewInfo: {
flex: 1,
marginLeft: 12,
marginLeft: 16,
justifyContent: 'center',
},
previewName: {
fontSize: 16,
fontWeight: '500',
color: '#333',
marginBottom: 2,
fontSize: 18,
fontWeight: '700',
color: '#1E293B',
marginBottom: 6,
fontFamily: 'AliBold',
},
previewBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFBEB',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
gap: 4,
},
previewCalories: {
fontSize: 14,
color: '#666',
fontSize: 13,
color: '#D97706',
fontWeight: '600',
fontFamily: 'AliRegular',
},
section: {
paddingHorizontal: 16,
paddingVertical: 12,
paddingHorizontal: 20,
marginBottom: 24,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
marginBottom: 12,
paddingHorizontal: 4,
},
sectionTitle: {
fontSize: 14,
color: '#333',
marginLeft: 8
fontWeight: '700',
color: '#64748B',
fontFamily: 'AliBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
requiredIndicator: {
fontSize: 16,
color: '#FF4444',
fontSize: 14,
color: '#EF4444',
marginLeft: 4,
},
inputGroup: {
marginBottom: 20,
},
inputRowGroup: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
inputRowItem: {
flex: 1,
},
inputLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
modernTextInput: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
marginLeft: 20,
color: '#333',
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
numberInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
color: '#333',
textAlign: 'right',
},
unitText: {
fontSize: 14,
color: '#666',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
},
modernSelectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
selectButtonText: {
fontSize: 14,
color: 'gray',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 16,
overflow: 'hidden',
},
selectedImage: {
width: 80,
height: 80,
borderRadius: 16,
},
modernImagePlaceholder: {
width: 80,
height: 80,
borderRadius: 16,
backgroundColor: '#F8F8F8',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 12,
color: '#A0A0A0',
marginTop: 4,
fontWeight: '500',
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
nutritionItem: {
flex: 1,
},
// 保留旧样式以防兼容性问题
textInput: {
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
},
numberInput: {
flex: 1,
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
textAlign: 'right',
},
inputWithUnit: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
inputUnit: {
fontSize: 16,
color: '#666',
minWidth: 30,
},
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
},
imageSelector: {
alignSelf: 'flex-end',
borderRadius: 12,
overflow: 'hidden',
},
imagePlaceholder: {
width: 60,
height: 60,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E5E5E5',
},
disclaimer: {
paddingHorizontal: 16,
paddingVertical: 20,
paddingBottom: 40,
},
disclaimerText: {
fontSize: 12,
color: '#999',
lineHeight: 18,
fontWeight: '700',
},
sectionCard: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 16,
marginTop: 8,
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 10,
elevation: 2,
},
// 新增行布局样式
inputRowContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
marginBottom: 16,
},
inputRowLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
width: 80,
fontSize: 15,
color: '#475569',
fontWeight: '600',
width: 90,
marginRight: 12,
fontFamily: 'AliRegular',
},
inputRowContent: {
flex: 1,
},
imageLoadingOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
modernInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 16,
backgroundColor: '#F8FAFC',
borderWidth: 1,
borderColor: '#E2E8F0',
overflow: 'hidden',
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: '#1E293B',
textAlign: 'right',
fontFamily: 'AliRegular',
},
unitText: {
fontSize: 14,
color: '#94A3B8',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
selectedImage: {
width: 72,
height: 72,
borderRadius: 20,
},
modernImagePlaceholder: {
width: 72,
height: 72,
borderRadius: 20,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 16,
borderWidth: 1,
borderColor: '#E2E8F0',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 11,
color: '#94A3B8',
marginTop: 4,
fontWeight: '600',
textAlign: 'center',
},
imageLoadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 20,
},
});

View File

@@ -11,6 +11,7 @@ import {
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// 睡眠详情数据类型
export type SleepDetailData = {
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
range: string;
isActive?: boolean;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const getGradeColor = (grade: string) => {
switch (grade) {
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' };
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' };
case '良好': return { bg: '#D1FAE5', text: '#065F46' };
case '优秀': return { bg: '#FEF3C7', text: '#92400E' };
case t('sleepDetail.sleepGrades.low'):
case t('sleepDetail.sleepGrades.poor'):
return { bg: '#FECACA', text: '#DC2626' };
case t('sleepDetail.sleepGrades.normal'):
case t('sleepDetail.sleepGrades.fair'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.good'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.excellent'):
return { bg: '#FEF3C7', text: '#92400E' };
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
}
};
@@ -97,6 +105,7 @@ export const InfoModal = ({
type: 'sleep-time' | 'sleep-quality';
sleepData: SleepDetailData;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
@@ -153,26 +162,26 @@ export const InfoModal = ({
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
const sleepTimeGrades = [
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
];
const sleepQualityGrades = [
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
];
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
const getDescription = () => {
if (type === 'sleep-time') {
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。';
return t('sleepDetail.sleepTimeDescription');
} else {
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长还包括睡眠的连续性和各睡眠阶段的平衡。';
return t('sleepDetail.sleepQualityDescription');
}
};

View File

@@ -1,36 +1,47 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export type SleepStageTimelineProps = {
sleepSamples: SleepSample[];
bedtime: string;
wakeupTime: string;
onInfoPress?: () => void;
hideHeader?: boolean;
style?: StyleProp<ViewStyle>;
};
export const SleepStageTimeline = ({
sleepSamples,
bedtime,
wakeupTime,
onInfoPress
onInfoPress,
hideHeader = false,
style
}: SleepStageTimelineProps) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 图表尺寸参数
const containerWidth = 320;
const chartPadding = 25; // 左右边距,为时间标签预留空间
// 图表尺寸参数 - 更宽更现代的设计
const containerWidth = SCREEN_WIDTH - 64; // 留出左右边距
const chartPadding = 24; // 增加左右边距,避免时间轴和标签被截断
const chartWidth = containerWidth - chartPadding * 2;
const chartHeight = 80;
const timelineHeight = 32;
const timelineY = 24;
const timeScaleY = timelineY + timelineHeight + 16;
const chartHeight = 140; // 增加高度以容纳更高的条形图
const timelineHeight = 48; // 更高的条形图
const timelineY = 16;
const timeScaleY = timelineY + timelineHeight + 24;
// 计算时间范围和刻度
const { timelineData, timeLabels } = useMemo(() => {
@@ -56,7 +67,7 @@ export const SleepStageTimeline = ({
const duration = sampleEnd.diff(sampleStart, 'minute');
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
const width = Math.max(3, (duration / totalMinutes) * chartWidth);
return {
x,
@@ -66,29 +77,27 @@ export const SleepStageTimeline = ({
};
});
// 智能生成时间标签,避免重合
// 智能生成时间标签
const labels = [];
const minLabelSpacing = 50; // 最小标签间距(像素)
const minLabelSpacing = 60;
// 总是显示起始时间
// 起始时间标签
labels.push({
time: startTime.format('HH:mm'),
x: chartPadding
});
// 根据睡眠总时长动态调整时间间隔
const sleepDurationHours = totalMinutes / 60;
let timeStepMinutes;
if (sleepDurationHours <= 4) {
timeStepMinutes = 60; // 1小时间隔
timeStepMinutes = 60;
} else if (sleepDurationHours <= 8) {
timeStepMinutes = 120; // 2小时间隔
timeStepMinutes = 120;
} else {
timeStepMinutes = 180; // 3小时间隔
timeStepMinutes = 180;
}
// 添加中间时间标签,确保不重合
let currentTime = startTime;
let stepCount = 1;
@@ -96,7 +105,6 @@ export const SleepStageTimeline = ({
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
// 检查与前一个标签的间距
const lastLabel = labels[labels.length - 1];
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
labels.push({
@@ -108,7 +116,7 @@ export const SleepStageTimeline = ({
stepCount++;
}
// 总是显示结束时间,但要确保与前一个标签有足够间距
// 结束时间标签
const endX = containerWidth - chartPadding;
const lastLabel = labels[labels.length - 1];
if (endX - lastLabel.x >= minLabelSpacing) {
@@ -117,7 +125,6 @@ export const SleepStageTimeline = ({
x: endX
});
} else {
// 如果空间不够,替换最后一个标签为结束时间
labels[labels.length - 1] = {
time: endTime.format('HH:mm'),
x: endX
@@ -130,18 +137,22 @@ export const SleepStageTimeline = ({
// 如果没有数据,显示空状态
if (timelineData.length === 0) {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.noData')}
</Text>
</View>
</View>
@@ -149,67 +160,119 @@ export const SleepStageTimeline = ({
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: 'transparent' }, style]}>
{/* 标题栏 */}
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStages')}
</Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
{/* 睡眠时间范围 */}
{/* 睡眠时间范围 - 更简洁的设计 */}
<View style={styles.timeRange}>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
<Ionicons name="moon" size={16} color="#8B9DC3" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(bedtime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
</View>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
<Ionicons name="sunny" size={16} color="#F59E0B" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(wakeupTime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.sleepDuration')}
</Text>
</View>
</View>
{/* SVG 图表 */}
{/* SVG 图表 - iOS 健康风格 */}
<View style={styles.chartContainer}>
{/* 背景轨道 */}
<View style={[styles.trackBackground, {
left: chartPadding,
right: chartPadding,
width: chartWidth
}]} />
<Svg width={containerWidth} height={chartHeight}>
{/* 绘制睡眠阶段条形图 */}
{timelineData.map((segment, index) => (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={segment.color}
rx={2}
/>
))}
<Defs>
{/* 为每种睡眠阶段定义渐变 */}
<SvgLinearGradient id="gradDeep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#60A5FA" stopOpacity="1" />
<Stop offset="1" stopColor="#3B82F6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradCore" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#A78BFA" stopOpacity="1" />
<Stop offset="1" stopColor="#8B5CF6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradREM" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAwake" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#FCD34D" stopOpacity="1" />
<Stop offset="1" stopColor="#F59E0B" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAsleep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
</Defs>
{/* 绘制时间刻度标签 */}
{/* 绘制睡眠阶段条形图 - 使用渐变和圆角 */}
{timelineData.map((segment, index) => {
const gradientId =
segment.stage === SleepStage.Deep ? 'gradDeep' :
segment.stage === SleepStage.Core ? 'gradCore' :
segment.stage === SleepStage.REM || segment.stage === SleepStage.Asleep ? 'gradREM' :
segment.stage === SleepStage.Awake ? 'gradAwake' : 'gradAsleep';
return (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={`url(#${gradientId})`}
rx={8}
opacity={0.95}
/>
);
})}
{/* 绘制时间刻度标签 - 更细腻的设计 */}
{timeLabels.map((label, index) => (
<React.Fragment key={index}>
{/* 刻度线 */}
<Rect
x={label.x - 0.5}
y={timelineY + timelineHeight}
y={timelineY + timelineHeight + 4}
width={1}
height={6}
fill={colorTokens.border}
height={4}
fill="#D1D5DB"
opacity={0.4}
/>
{/* 时间标签 */}
<SvgText
x={label.x}
y={timeScaleY}
fontSize={11}
fill={colorTokens.textSecondary}
fill="#8B9DC3"
textAnchor="middle"
fontWeight="500"
>
{label.time}
</SvgText>
@@ -218,27 +281,43 @@ export const SleepStageTimeline = ({
</Svg>
</View>
{/* 图例 */}
{/* 图例 - iOS 风格的标签 */}
<View style={styles.legend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#60A5FA', '#3B82F6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.deep')}</Text>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#A78BFA', '#8B5CF6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.core')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#F472B6', '#EC4899']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.rem')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#FCD34D', '#F59E0B']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.awake')}</Text>
</View>
</View>
</View>
@@ -248,14 +327,8 @@ export const SleepStageTimeline = ({
const styles = StyleSheet.create({
container: {
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginHorizontal: 4,
paddingVertical: 20,
paddingHorizontal: 16,
},
header: {
flexDirection: 'row',
@@ -266,31 +339,44 @@ const styles = StyleSheet.create({
title: {
fontSize: 16,
fontWeight: '600',
fontFamily: 'AliBold',
},
infoButton: {
padding: 4,
},
timeRange: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
justifyContent: 'space-around',
marginBottom: 28,
paddingHorizontal: 20,
},
timePoint: {
alignItems: 'center',
gap: 2,
},
timeLabel: {
fontSize: 12,
fontSize: 11,
fontWeight: '500',
marginBottom: 4,
fontFamily: 'AliRegular',
},
timeValue: {
fontSize: 16,
fontSize: 20,
fontWeight: '700',
letterSpacing: -0.2,
fontFamily: 'AliBold',
letterSpacing: -0.5,
},
chartContainer: {
alignItems: 'center',
marginBottom: 16,
marginBottom: 20,
position: 'relative',
},
trackBackground: {
position: 'absolute',
top: 16,
height: 48,
backgroundColor: '#F0F2F9',
borderRadius: 24,
opacity: 0.5,
},
emptyState: {
alignItems: 'center',
@@ -299,27 +385,29 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 14,
fontStyle: 'italic',
fontFamily: 'AliRegular',
},
legend: {
gap: 8,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
flexWrap: 'wrap',
gap: 16,
paddingTop: 8,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
legendPill: {
width: 20,
height: 10,
borderRadius: 5,
},
legendText: {
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
});

View File

@@ -13,6 +13,7 @@ import {
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// Sleep Stages Info Modal 组件
export const SleepStagesInfoModal = ({
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
visible: boolean;
onClose: () => void;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
<View style={styles.sleepStagesModalHeader}>
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStagesInfo.title')}
</Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={24} color={colorTokens.textSecondary} />
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
scrollEnabled={true}
>
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.description')}
</Text>
{/* 清醒时间 */}
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.awake.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.awake.description')}
</Text>
</View>
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.rem.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.rem.description')}
</Text>
</View>
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.core.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.core.description')}
</Text>
</View>
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.deep.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.deep.description')}
</Text>
</View>
</ScrollView>

View File

@@ -207,6 +207,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#192126',
marginBottom: 16,
fontFamily: 'AliBold',
},
measurementsContainer: {
flexDirection: 'row',
@@ -221,6 +222,7 @@ const styles = StyleSheet.create({
color: '#888',
marginBottom: 8,
textAlign: 'center',
fontFamily: 'AliRegular',
},
valueContainer: {
backgroundColor: '#F5F5F7',
@@ -236,6 +238,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#192126',
textAlign: 'center',
fontFamily: 'AliBold',
},
});

View File

@@ -72,6 +72,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
valueContainer: {
flexDirection: 'row',
@@ -81,6 +82,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
@@ -88,6 +90,7 @@ const styles = StyleSheet.create({
marginLeft: 4,
marginBottom: 2,
fontWeight: '500',
fontFamily: 'AliRegular',
},
});

View File

@@ -1,7 +1,8 @@
import { fetchOxygenSaturation } from '@/utils/health';
import { useFocusEffect } from '@react-navigation/native';
import { ensureHealthPermissions, fetchOxygenSaturation } from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import React, { useCallback, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import HealthDataCard from './HealthDataCard';
@@ -15,42 +16,52 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
selectedDate
}) => {
const { t } = useTranslation();
const isFocused = useIsFocused();
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
useFocusEffect(
useCallback(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
useEffect(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
// 防止重复请求
if (loadingRef.current) return;
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setOxygenSaturation(null);
return;
}
try {
loadingRef.current = true;
setLoading(true);
// 防止重复请求
if (loadingRef.current) return;
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
return;
}
};
loadOxygenSaturationData();
}, [selectedDate])
);
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadOxygenSaturationData();
}, [isFocused, selectedDate]);
return (
<HealthDataCard
@@ -62,4 +73,4 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
);
};
export default OxygenSaturationCard;
export default OxygenSaturationCard;

View File

@@ -127,12 +127,14 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
sleepValue: {
fontSize: 16,
color: '#1E40AF',
fontWeight: '700',
marginTop: 8,
fontFamily: 'AliBold',
},
});

View File

@@ -30,6 +30,9 @@ const MAPPING = {
'info.circle': 'info',
'magnifyingglass': 'search',
'xmark': 'close',
'chevron.left': 'chevron-left',
'sparkles': 'auto-awesome',
'arrow.clockwise': 'refresh',
} as IconMapping;
/**

View File

@@ -0,0 +1,454 @@
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
BackHandler,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useI18n } from '../../hooks/useI18n';
import { triggerLightHaptic } from '../../utils/haptics';
const { height: screenHeight } = Dimensions.get('window');
interface MedicationAiSummaryInfoSheetProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
loading?: boolean;
}
/**
* AI 用药总结介绍弹窗组件
* 用于展示 AI 用药总结功能的介绍和说明
*/
export function MedicationAiSummaryInfoSheet({
visible,
onClose,
onConfirm,
loading = false,
}: MedicationAiSummaryInfoSheetProps) {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(screenHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const [showImagePreview, setShowImagePreview] = useState(false);
// 预览图片 - 直接使用 require 资源
const imageSource = require('@/assets/images/medicine/medicine-ai-summary.png');
useEffect(() => {
if (visible) {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
bounciness: 6,
speed: 12,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: screenHeight,
duration: 240,
useNativeDriver: true,
}),
]).start(() => {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
});
}
}, [visible, backdropOpacity, translateY]);
// 处理Android返回键关闭图片预览
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showImagePreview) {
setShowImagePreview(false);
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [showImagePreview]);
// 处理图片预览
const handleImagePreview = useCallback(() => {
triggerLightHaptic();
setShowImagePreview(true);
}, []);
const handleClose = () => {
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
});
onClose();
};
const handleConfirm = () => {
if (loading) return;
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
});
onConfirm();
};
if (!visible) {
return null;
}
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={handleClose}
statusBarTranslucent
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.backdrop,
{
opacity: backdropOpacity,
},
]}
>
<TouchableOpacity style={StyleSheet.absoluteFillObject} activeOpacity={1} onPress={handleClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY }],
paddingBottom: Math.max(insets.bottom, 20),
},
]}
>
<View style={styles.handle} />
{/* 图标和标题 */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={24} color="#8B5CF6" />
</View>
<Text style={styles.title}>{t('medications.aiSummaryInfo.title')}</Text>
</View>
{/* 介绍图片区域 */}
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/medicine/medicine-ai-summary.png')}
style={styles.introImage}
contentFit='contain'
/>
{/* 右上角查看大图提示按钮 */}
<TouchableOpacity
style={styles.viewImageButton}
onPress={handleImagePreview}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.glassViewButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="expand-outline" size={16} color="#333" />
</GlassView>
) : (
<View style={[styles.glassViewButton, styles.fallbackViewButton]}>
<Ionicons name="expand-outline" size={16} color="#333" />
</View>
)}
</TouchableOpacity>
</View>
{/* 功能介绍内容 */}
<View style={styles.contentContainer}>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="analytics" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.intelligent.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.intelligent.description')}
</Text>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="time" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.tracking.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.tracking.description')}
</Text>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="shield-checkmark" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.professional.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.professional.description')}
</Text>
</View>
</View>
</View>
{/* 确认按钮 - 支持 Liquid Glass */}
<View style={styles.actions}>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleConfirm}
disabled={loading}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.confirmButton}
glassEffectStyle="regular"
tintColor="rgba(139, 92, 246, 0.8)"
isInteractive={true}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="arrow-forward" size={20} color="#fff" />
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
</>
)}
</GlassView>
) : (
<View style={[styles.confirmButton, styles.fallbackButton]}>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="arrow-forward" size={20} color="#fff" />
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
</>
)}
</View>
)}
</TouchableOpacity>
</View>
</Animated.View>
</View>
{/* 图片预览 */}
<ImageViewing
images={[imageSource]}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
</TouchableOpacity>
</View>
)}
/>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'transparent',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15, 23, 42, 0.45)',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 24,
paddingTop: 16,
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 16,
shadowOffset: { width: 0, height: -4 },
elevation: 16,
gap: 20,
},
handle: {
width: 50,
height: 4,
borderRadius: 2,
backgroundColor: '#E5E7EB',
alignSelf: 'center',
marginBottom: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F3E8FF',
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
},
imageContainer: {
width: '100%',
height: 380,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#F9FAFB',
},
introImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
contentContainer: {
gap: 16,
paddingVertical: 8,
},
featureItem: {
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
},
featureIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#F3E8FF',
alignItems: 'center',
justifyContent: 'center',
},
featureContent: {
flex: 1,
},
featureTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
featureDescription: {
fontSize: 14,
lineHeight: 20,
color: '#6B7280',
},
actions: {
marginTop: 8,
},
confirmButton: {
height: 56,
borderRadius: 18,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
overflow: 'hidden', // 保证玻璃边界圆角效果
},
fallbackButton: {
backgroundColor: '#8B5CF6',
shadowColor: 'rgba(139, 92, 246, 0.45)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
confirmText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
// 图片预览相关样式
viewImageButton: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 1,
},
glassViewButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackViewButton: {
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
imageViewerFooter: {
position: 'absolute',
bottom: 60,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 1,
},
imageViewerFooterButton: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 20,
},
imageViewerFooterButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
},
});

View File

@@ -9,6 +9,7 @@ import { Ionicons } from '@expo/vector-icons';
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, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -20,18 +21,26 @@ import {
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
import { WeightProgressBar } from './WeightProgressBar';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
const CHART_HEIGHT = 60;
const CARD_WIDTH = screenWidth - 40;
const CHART_WIDTH = CARD_WIDTH - 36;
const CHART_HEIGHT = 70;
const PADDING = 10;
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_PRIMARY = '#1c1f3a';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export function WeightHistoryCard() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const router = useRouter();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
if (isLoggedIn) {
loadWeightHistory();
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
}
};
const navigateToCoach = () => {
// 点击添加按钮 - 需要登录
const handleAddWeight = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
setShowBMIModal(false);
};
// 点击卡片 - 直接跳转,不需要登录
const navigateToWeightRecords = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
router.push(ROUTES.WEIGHT_RECORDS);
};
// Process weight history data
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // Show only the last 7 records
.slice(-7);
// return (
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
// <View style={styles.cardHeader}>
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
// </View>
// 是否有数据
const hasData = sortedHistory.length > 0;
// <View style={styles.emptyContent}>
// <Text style={styles.emptyDescription}>
// No weight records yet, click the button below to start recording
// </Text>
// <TouchableOpacity
// style={styles.recordButton}
// onPress={(e) => {
// e.stopPropagation();
// navigateToCoach();
// }}
// activeOpacity={0.8}
// >
// <Ionicons name="add" size={18} color="#FFFFFF" />
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
// </TouchableOpacity>
// </View>
// </TouchableOpacity>
// );
// }
// 计算减重进度
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
const initialWeight = userProfile?.initialWeight
? parseFloat(userProfile.initialWeight)
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
// 计算进度百分比
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
const totalToLose = initialWeight - targetWeight;
const actualLost = initialWeight - currentWeight;
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
// Generate chart data
const weights = sortedHistory.map(item => parseFloat(item.weight));
const minWeight = Math.min(...weights);
const maxWeight = Math.max(...weights);
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
const minWeight = hasData ? Math.min(...weights) : 0;
const maxWeight = hasData ? Math.max(...weights) : 0;
const weightRange = maxWeight - minWeight || 1;
const points = sortedHistory.map((item, index) => {
const points = hasData ? sortedHistory.map((item, index) => {
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
// Reduce top margin, compress whitespace
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
return { x, y, weight: item.weight, date: item.createdAt };
});
}) : [];
// Generate path
const pathData = points.map((point, index) => {
if (index === 0) return `M ${point.x} ${point.y}`;
return `L ${point.x} ${point.y}`;
}).join(' ');
// 生成平滑曲线路径(使用贝塞尔曲线)
const generateSmoothPath = (pts: typeof points) => {
if (pts.length === 0) return '';
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
// If there's only one data point, display as a horizontal line
const singlePointPath = points.length === 1 ?
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
pathData;
let path = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
}
return path;
};
const smoothPath = generateSmoothPath(points);
const singlePointPath = points.length === 1
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
: smoothPath;
// 空状态下的占位曲线路径(水平虚线效果)
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
</View>
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
{isLgAvaliable ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
handleAddWeight();
}}
activeOpacity={0.8}
>
<GlassView style={styles.addButtonGlass}>
<Ionicons name="add" size={18} color={Colors.light.primary} />
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</GlassView>
</TouchableOpacity>
) : (
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
style={styles.addButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
handleAddWeight();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color={Colors.light.primary} />
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</TouchableOpacity>
)}
</View>
{/* Default chart display */}
{sortedHistory.length > 0 && (
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* Background grid lines */}
{/* 当前体重显示 */}
<View style={styles.currentWeightSection}>
<View style={styles.weightValueContainer}>
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
<Text style={styles.weightUnit}>kg</Text>
</View>
{sortedHistory.length > 1 && (
<View style={[
styles.changeTag,
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
]}>
<Ionicons
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
size={12}
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
/>
<Text style={[
styles.changeText,
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
]}>
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
</Text>
</View>
)}
</View>
{/* More abstract line - reduce line width and display details */}
{/* 图表显示 */}
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
<Defs>
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{hasData ? (
<>
{/* 平滑曲线 */}
<Path
d={singlePointPath}
stroke="url(#lineGradient)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
{/* 外圈光晕 */}
{isLastPoint && (
<Circle
cx={point.x}
cy={point.y}
r={8}
fill={THEME_PRIMARY}
opacity={0.15}
/>
)}
{/* 数据点 */}
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 4 : 2.5}
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
stroke={isLastPoint ? '#ffffff' : 'none'}
strokeWidth={isLastPoint ? 2 : 0}
/>
</React.Fragment>
);
})}
</>
) : (
/* 空状态 - 虚线占位 */
<Path
d={singlePointPath}
stroke={Colors.light.accentGreen}
d={emptyLinePath}
stroke="#E8EAF0"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.8}
strokeDasharray="8,6"
/>
)}
</Svg>
{/* Simplified data points - smaller and more refined */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 3 : 2}
fill={Colors.light.accentGreen}
opacity={0.9}
/>
</React.Fragment>
);
})}
</Svg>
{/* Concise chart information */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
{/* 图表信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
</Text>
</View>
</View>
)}
</View>
{/* 减重进度条 - 始终显示 */}
<WeightProgressBar
progress={weightProgress}
currentWeight={currentWeight}
targetWeight={targetWeight}
initialWeight={initialWeight}
/>
{/* BMI information modal */}
<Modal
@@ -323,32 +401,39 @@ export function WeightHistoryCard() {
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginTop: 16
borderRadius: 24,
padding: 18,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
marginTop: 16,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
},
iconSquare: {
width: 14,
height: 14,
iconContainer: {
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: 'rgba(79, 91, 213, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
marginRight: 10,
},
iconSquare: {
width: 16,
height: 16,
tintColor: THEME_PRIMARY,
},
cardTitle: {
fontSize: 14,
color: '#192126',
fontSize: 15,
color: THEME_TEXT_PRIMARY,
flex: 1,
fontWeight: '600'
fontWeight: '700',
fontFamily: 'AliBold',
},
headerButtons: {
flexDirection: 'row',
@@ -363,19 +448,56 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
},
addButtonGlass: {
width: 28,
height: 28,
borderRadius: 14,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(147, 112, 219, 0.3)',
backgroundColor: 'rgba(79, 91, 213, 0.15)',
},
currentWeightSection: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
gap: 12,
},
weightValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 32,
fontWeight: '800',
color: THEME_TEXT_PRIMARY,
fontFamily: 'AliBold',
},
weightUnit: {
fontSize: 14,
fontWeight: '600',
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginLeft: 4,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
gap: 4,
},
changeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: 'AliBold',
},
emptyContent: {
alignItems: 'center',
@@ -383,12 +505,12 @@ const styles = StyleSheet.create({
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
color: THEME_TEXT_PRIMARY,
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: '#687076',
color: THEME_TEXT_SECONDARY,
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
@@ -396,16 +518,17 @@ const styles = StyleSheet.create({
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.light.accentGreen,
backgroundColor: THEME_PRIMARY,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
color: '#FFFFFF',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
chartContainer: {
width: '100%',
@@ -416,19 +539,25 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
marginTop: -14,
},
infoItem: {
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.06)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 10,
},
infoLabel: {
fontSize: 11,
color: '#687076',
color: THEME_TEXT_SECONDARY,
fontWeight: '500',
fontFamily: 'AliRegular',
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
color: THEME_TEXT_PRIMARY,
},
// BMI modal styles
@@ -446,6 +575,7 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: 24,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
bmiModalIntroSection: {
marginBottom: 32,
@@ -456,6 +586,7 @@ const styles = StyleSheet.create({
lineHeight: 24,
textAlign: 'center',
marginBottom: 16,
fontFamily: 'AliRegular',
},
bmiModalFormulaContainer: {
backgroundColor: '#F3F4F6',
@@ -467,6 +598,7 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#374151',
fontFamily: 'AliBold',
},
bmiModalSectionTitle: {
fontSize: 20,
@@ -474,6 +606,7 @@ const styles = StyleSheet.create({
color: '#111827',
marginBottom: 16,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
bmiModalStatsCard: {
marginBottom: 32,
@@ -493,14 +626,17 @@ const styles = StyleSheet.create({
bmiModalStatTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: 'AliBold',
},
bmiModalStatRange: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
bmiModalStatAdvice: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'AliRegular',
},
bmiModalHealthTips: {
marginBottom: 32,
@@ -520,6 +656,7 @@ const styles = StyleSheet.create({
marginLeft: 12,
flex: 1,
lineHeight: 20,
fontFamily: 'AliRegular',
},
bmiModalDisclaimer: {
flexDirection: 'row',
@@ -535,6 +672,7 @@ const styles = StyleSheet.create({
marginLeft: 8,
flex: 1,
lineHeight: 18,
fontFamily: 'AliRegular',
},
bmiModalBottomContainer: {
padding: 20,
@@ -544,7 +682,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
bmiModalButtonBackground: {
backgroundColor: '#192126',
backgroundColor: THEME_TEXT_PRIMARY,
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
@@ -553,6 +691,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
bmiModalHomeIndicator: {
height: 5,

View File

@@ -0,0 +1,278 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Animated,
Easing,
StyleSheet,
Text,
View,
ViewStyle
} from 'react-native';
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export interface WeightProgressBarProps {
/** 进度值 0-1 */
progress: number;
/** 当前体重 */
currentWeight: number;
/** 目标体重 */
targetWeight: number;
/** 初始体重 */
initialWeight: number;
/** 容器样式 */
style?: ViewStyle;
/** 是否显示顶部分隔线,默认 true */
showTopBorder?: boolean;
}
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
progress,
currentWeight,
targetWeight,
initialWeight,
style,
showTopBorder = true,
}) => {
const { t } = useTranslation();
const animatedProgress = useRef(new Animated.Value(0)).current;
const [barWidth, setBarWidth] = useState(0);
const clampedProgress = Math.min(1, Math.max(0, progress));
const percent = Math.round(clampedProgress * 100);
// 判断是否有有效数据
const hasInitialWeight = initialWeight > 0;
const hasTargetWeight = targetWeight > 0;
const hasCurrentWeight = currentWeight > 0;
// 只要有初始体重和当前体重,就可以显示已减重量
const canShowLost = hasInitialWeight && hasCurrentWeight;
// 需要有目标体重才能显示距离目标和进度
const canShowTarget = hasTargetWeight && hasCurrentWeight;
useEffect(() => {
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
const timer = setTimeout(() => {
Animated.timing(animatedProgress, {
toValue: clampedProgress,
duration: 800,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}, 800);
return () => clearTimeout(timer);
}, [clampedProgress]);
const fillWidth = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [0, barWidth],
});
const sliderPosition = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [-12, barWidth - 12],
});
const weightLost = initialWeight - currentWeight;
const weightToGo = currentWeight - targetWeight;
return (
<View style={[
styles.container,
showTopBorder && styles.topBorder,
style
]}>
{/* 进度信息 */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
</Text>
</View>
<View style={styles.percentContainer}>
<Text style={styles.percentValue}>{percent}</Text>
<Text style={styles.percentSymbol}>%</Text>
</View>
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
</Text>
</View>
</View>
{/* 进度条 */}
<View
style={styles.trackContainer}
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
>
{/* 背景轨道 */}
<View style={styles.track} />
{/* 填充进度 */}
<Animated.View style={[styles.fill, { width: fillWidth }]}>
<LinearGradient
colors={[THEME_PRIMARY, THEME_SECONDARY]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFillObject}
/>
</Animated.View>
{/* 滑块 - 圆角矩形 */}
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
<LinearGradient
colors={['#ffffff', '#f8f9fc']}
style={styles.sliderInner}
>
<View style={styles.sliderLine} />
</LinearGradient>
</Animated.View>
</View>
{/* 起止标签 */}
<View style={styles.labelRow}>
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
<View style={styles.targetBadge}>
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 12,
paddingTop: 10,
marginLeft:12,
marginRight: 12
},
topBorder: {
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.04)',
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginBottom: 2,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
percentContainer: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'center',
},
percentValue: {
fontSize: 24,
fontWeight: '800',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
},
percentSymbol: {
fontSize: 12,
fontWeight: '600',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
marginLeft: 2,
},
trackContainer: {
height: 8,
position: 'relative',
marginBottom: 8,
},
track: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: '#E8EAF0',
borderRadius: 4,
},
fill: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
borderRadius: 4,
overflow: 'hidden',
},
slider: {
position: 'absolute',
top: -8,
width: 24,
height: 24,
borderRadius: 8,
shadowColor: THEME_PRIMARY,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35,
shadowRadius: 6,
elevation: 6,
},
sliderInner: {
width: '100%',
height: '100%',
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2.5,
borderColor: THEME_PRIMARY,
},
sliderLine: {
width: 8,
height: 3,
borderRadius: 1.5,
backgroundColor: THEME_PRIMARY,
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
labelText: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
},
targetBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
gap: 4,
},
targetText: {
fontSize: 11,
color: THEME_PRIMARY,
fontWeight: '600',
fontFamily: 'AliBold',
},
});
export default WeightProgressBar;

View File

@@ -1,10 +1,11 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React, { useRef } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
interface WeightRecordCardProps {
@@ -20,6 +21,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
onDelete,
weightChange = 0
}) => {
const { t } = useI18n();
const swipeableRef = useRef<Swipeable>(null);
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
@@ -27,15 +29,15 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
`确定要删除这条体重记录吗?此操作无法撤销。`,
t('weightRecords.card.deleteConfirmTitle'),
t('weightRecords.card.deleteConfirmMessage'),
[
{
text: '取消',
text: t('weightRecords.card.cancelButton'),
style: 'cancel',
},
{
text: '删除',
text: t('weightRecords.card.deleteButton'),
style: 'destructive',
onPress: () => {
const recordId = record.id || record.createdAt;
@@ -56,124 +58,174 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
);
};
return (
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View
style={[styles.recordCard]}
<View style={styles.cardContainer}>
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
</Text>
<TouchableOpacity
style={styles.recordEditButton}
onPress={() => onPress?.(record)}
>
<Ionicons name="create-outline" size={16} color="#FF9500" />
</TouchableOpacity>
</View>
<View style={styles.recordContent}>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}></Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}kg</Text>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.weightChangeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={12}
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
<View style={styles.recordCard}>
<View style={styles.leftContent}>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={styles.icon}
/>
<Text style={[
styles.weightChangeText,
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<View style={styles.textContent}>
<View style={styles.dateTimeContainer}>
<Text style={styles.dateText}>
{dayjs(record.createdAt).format('MM-DD')}
</Text>
<Text style={styles.timeText}>
{dayjs(record.createdAt).format('HH:mm')}
</Text>
</View>
<View style={styles.weightInfo}>
<Text style={styles.weightValue}>{record.weight}<Text style={styles.unitText}>{t('weightRecords.modal.unit')}</Text></Text>
</View>
</View>
</View>
<View style={styles.rightContent}>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.changeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={10}
color={weightChange < 0 ? '#22C55E' : '#FF9500'}
/>
<Text style={[
styles.changeText,
{ color: weightChange < 0 ? '#22C55E' : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<TouchableOpacity
style={styles.editButton}
onPress={() => onPress?.(record)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="ellipsis-vertical" size={16} color="#9ba3c7" />
</TouchableOpacity>
</View>
</View>
</View>
</Swipeable>
</Swipeable>
</View>
);
};
const styles = StyleSheet.create({
cardContainer: {
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
},
recordCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
padding: 20,
marginBottom: 12,
},
recordHeader: {
borderRadius: 24,
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
recordDateTime: {
fontSize: 14,
color: '#687076',
fontWeight: '500',
},
recordEditButton: {
padding: 6,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
},
recordContent: {
leftContent: {
flexDirection: 'row',
alignItems: 'center',
},
recordWeightLabel: {
fontSize: 16,
color: '#687076',
fontWeight: '500',
},
recordWeightValue: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginLeft: 4,
flex: 1,
},
weightChangeTag: {
iconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#F0F2F5',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
icon: {
width: 24,
height: 24,
tintColor: '#4F5BD5',
},
textContent: {
justifyContent: 'center',
},
dateTimeContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
marginLeft: 12,
marginBottom: 4,
},
weightChangeText: {
fontSize: 12,
dateText: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
marginRight: 8,
fontFamily: 'AliBold',
},
timeText: {
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
weightInfo: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
unitText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
changeText: {
fontSize: 11,
fontWeight: '700',
marginLeft: 2,
fontFamily: 'AliBold',
},
editButton: {
padding: 4,
},
deleteButton: {
backgroundColor: '#EF4444',
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginBottom: 12,
marginLeft: 8,
},
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
width: 70,
borderRadius: 24,
marginLeft: 12,
height: '100%',
},
});

View File

@@ -1,10 +1,11 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
ScrollView,
@@ -15,6 +16,7 @@ import {
View,
} from 'react-native';
import { useI18n } from '@/hooks/useI18n';
import {
HeartRateZoneStat,
WorkoutDetailMetrics,
@@ -59,62 +61,49 @@ export function WorkoutDetailModal({
onRetry,
errorMessage,
}: WorkoutDetailModalProps) {
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
const { t, i18n } = useI18n();
const [isMounted, setIsMounted] = useState(visible);
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
useEffect(() => {
if (visible) {
setIsMounted(true);
Animated.timing(animation, {
toValue: 1,
duration: 280,
useNativeDriver: true,
}).start();
setShouldRenderChart(true);
} else {
Animated.timing(animation, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) {
setIsMounted(false);
}
});
setShouldRenderChart(false);
setIsMounted(false);
setShowIntensityInfo(false);
}
}, [visible, animation]);
const translateY = animation.interpolate({
inputRange: [0, 1],
outputRange: [SHEET_MAX_HEIGHT, 0],
});
const backdropOpacity = animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}, [visible]);
const activityName = workout
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
: '';
const chartWidth = useMemo(
() => Math.max(Dimensions.get('window').width - 96, 240),
[]
);
const dateInfo = useMemo(() => {
if (!workout) {
return { title: '', subtitle: '' };
}
const date = dayjs(workout.startDate || workout.endDate);
const date = dayjs(workout.startDate || workout.endDate).locale(locale);
if (!date.isValid()) {
return { title: '', subtitle: '' };
}
return {
title: date.format('M月D日'),
subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
title: locale === 'en' ? date.format('MMM D') : date.format('M月D日'),
subtitle: locale === 'en'
? date.format('dddd, MMM D, YYYY HH:mm')
: date.format('YYYY年M月D日 dddd HH:mm'),
};
}, [workout]);
}, [locale, workout]);
const heartRateChart = useMemo(() => {
if (!metrics?.heartRateSeries?.length) {
@@ -156,23 +145,16 @@ export function WorkoutDetailModal({
return (
<Modal
transparent
visible={isMounted}
animationType="none"
visible={visible}
animationType='slide'
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={[
styles.sheetContainer,
{
transform: [{ translateY }],
},
]}
>
<View style={styles.sheetContainer}>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
@@ -206,7 +188,7 @@ export function WorkoutDetailModal({
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.summaryCard}>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
@@ -223,32 +205,34 @@ export function WorkoutDetailModal({
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>...</Text>
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<TouchableOpacity
onPress={() => setShowIntensityInfo(true)}
style={styles.metricInfoButton}
@@ -262,9 +246,9 @@ export function WorkoutDetailModal({
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -275,20 +259,20 @@ export function WorkoutDetailModal({
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || '未能获取到完整的锻炼详情'}
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</TouchableOpacity>
) : null}
</View>
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
@@ -299,21 +283,21 @@ export function WorkoutDetailModal({
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -321,67 +305,75 @@ export function WorkoutDetailModal({
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{/* @ts-ignore - react-native-chart-kit types are outdated */}
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
},
],
}}
width={chartWidth}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
paddingRight={48}
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
],
}}
width={Dimensions.get('window').width - 72}
height={220}
fromZero={false}
yAxisSuffix="次/分"
withInnerLines={false}
bezier
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>线</Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}></Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || '未获取到心率数据'}
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
@@ -389,15 +381,15 @@ export function WorkoutDetailModal({
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone)
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}></Text>
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</Animated.View>
</View>
{showIntensityInfo ? (
<Modal
transparent
@@ -410,36 +402,36 @@ export function WorkoutDetailModal({
<TouchableWithoutFeedback onPress={() => { }}>
<View style={styles.intensityInfoSheet}>
<View style={styles.intensityHandle} />
<Text style={styles.intensityInfoTitle}></Text>
<Text style={styles.intensityInfoTitle}>{t('workoutDetail.intensityInfo.title')}</Text>
<Text style={styles.intensityInfoText}>
MET/·
{t('workoutDetail.intensityInfo.description1')}
</Text>
<Text style={styles.intensityInfoText}>
MET 便
{t('workoutDetail.intensityInfo.description2')}
</Text>
<Text style={styles.intensityInfoText}>
3 km/h 2 METs 2
{t('workoutDetail.intensityInfo.description3')}
</Text>
<Text style={styles.intensityInfoText}>
METs 使70
{t('workoutDetail.intensityInfo.description4')}
</Text>
<View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}></Text>
<Text style={styles.intensityFormulaValue}>METs = / ÷ 1 /</Text>
<Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
<Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
</View>
<View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>3 - 6</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</Text>
</View>
</View>
</View>
@@ -511,6 +503,7 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
// 遍历所有点,选择重要点
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
let lastSelectedIndex = 0;
for (let i = 1; i < n - 1; i++) {
const shouldKeep =
@@ -523,11 +516,9 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
if (shouldKeep) {
// 检查与上一个选中点的距离,避免过于密集
const lastSelectedIndex = result.length > 0 ?
series.findIndex(p => p.timestamp === result[result.length - 1].timestamp) : 0;
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
result.push(series[i]);
lastSelectedIndex = i;
}
}
}
@@ -553,7 +544,21 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
return result;
}
function renderHeartRateZone(zone: HeartRateZoneStat) {
function renderHeartRateZone(
zone: HeartRateZoneStat,
t: (key: string, options?: Record<string, any>) => string
) {
const label = t(`workoutDetail.zones.labels.${zone.key}`, {
defaultValue: zone.label,
});
const range = t(`workoutDetail.zones.ranges.${zone.key}`, {
defaultValue: zone.rangeText,
});
const meta = t('workoutDetail.zones.summary', {
minutes: zone.durationMinutes,
range,
});
return (
<View key={zone.key} style={styles.zoneRow}>
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
@@ -568,10 +573,8 @@ function renderHeartRateZone(zone: HeartRateZoneStat) {
/>
</View>
<View style={styles.zoneInfo}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<Text style={styles.zoneMeta}>
{zone.durationMinutes} · {zone.rangeText}
</Text>
<Text style={styles.zoneLabel}>{label}</Text>
<Text style={styles.zoneMeta}>{meta}</Text>
</View>
</View>
);
@@ -666,20 +669,28 @@ const styles = StyleSheet.create({
shadowRadius: 22,
elevation: 8,
},
summaryCardLoading: {
minHeight: 240,
},
summaryHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: 10,
},
activityName: {
fontSize: 24,
fontWeight: '700',
color: '#1E2148',
flex: 1,
flexShrink: 1,
lineHeight: 30,
},
intensityPill: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
alignSelf: 'flex-start',
},
intensityPillText: {
fontSize: 12,
@@ -766,6 +777,12 @@ const styles = StyleSheet.create({
shadowRadius: 20,
elevation: 4,
},
sectionHeartRateLoading: {
minHeight: 360,
},
sectionZonesLoading: {
minHeight: 200,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
@@ -809,11 +826,22 @@ const styles = StyleSheet.create({
color: '#1E2148',
},
chartWrapper: {
alignItems: 'flex-start',
overflow: 'visible',
},
chartLoading: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
},
chartLoadingText: {
marginTop: 8,
fontSize: 12,
color: '#7E86A7',
},
chartStyle: {
marginLeft: -10,
marginRight: -10,
marginLeft: 0,
marginRight: 0,
},
chartEmpty: {
paddingVertical: 32,
@@ -947,4 +975,3 @@ const styles = StyleSheet.create({
height: 40,
},
});

View File

@@ -49,6 +49,8 @@ export const ROUTES = {
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
SLEEP_DETAIL: '/sleep-detail',
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
HEALTH_PROFILE: '/health/profile',
HEALTH_FAMILY_INVITE: '/health/family-invite',
// 饮水相关路由
WATER_DETAIL: '/water/detail',

View File

@@ -0,0 +1,135 @@
import VersionUpdateModal from '@/components/VersionUpdateModal';
import { useToast } from '@/contexts/ToastContext';
import { fetchVersionInfo, getCurrentAppVersion, type VersionInfo } from '@/services/version';
import { log } from '@/utils/logger';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Linking } from 'react-native';
import { useTranslation } from 'react-i18next';
type VersionCheckContextValue = {
isChecking: boolean;
updateInfo: VersionInfo | null;
checkForUpdate: (options?: { manual?: boolean }) => Promise<VersionInfo | null>;
openStore: () => Promise<void>;
};
const VersionCheckContext = createContext<VersionCheckContextValue | undefined>(undefined);
export function VersionCheckProvider({ children }: { children: React.ReactNode }) {
const { showSuccess, showError } = useToast();
const { t } = useTranslation();
const [isChecking, setIsChecking] = useState(false);
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const hasAutoCheckedRef = useRef(false);
const currentVersion = useMemo(() => getCurrentAppVersion(), []);
const openStore = useCallback(async () => {
if (!updateInfo?.appStoreUrl) {
showError(t('personal.versionCheck.missingUrl'));
return;
}
try {
const supported = await Linking.canOpenURL(updateInfo.appStoreUrl);
if (!supported) {
throw new Error('URL not supported');
}
await Linking.openURL(updateInfo.appStoreUrl);
log.info('version-update-open-store', { url: updateInfo.appStoreUrl });
} catch (error) {
log.error('version-update-open-store-failed', error);
showError(t('personal.versionCheck.openStoreFailed'));
}
}, [showError, t, updateInfo]);
const checkForUpdate = useCallback(
async ({ manual = false }: { manual?: boolean } = {}) => {
if (isChecking) {
if (manual) {
showSuccess(t('personal.versionCheck.checking'));
}
return updateInfo;
}
setIsChecking(true);
try {
const info = await fetchVersionInfo();
setUpdateInfo(info);
setModalVisible(Boolean(info?.needsUpdate));
if (info?.needsUpdate && manual) {
showSuccess(
t('personal.versionCheck.updateFound', {
version: info.latestVersion,
})
);
} else if (!info?.needsUpdate && manual) {
showSuccess(t('personal.versionCheck.upToDate'));
}
return info;
} catch (error) {
log.error('version-check-failed', error);
if (manual) {
showError(t('personal.versionCheck.failed'));
}
return null;
} finally {
setIsChecking(false);
}
},
[isChecking, showError, showSuccess, t, updateInfo]
);
useEffect(() => {
if (hasAutoCheckedRef.current) return;
hasAutoCheckedRef.current = true;
checkForUpdate({ manual: false }).catch((error) => {
log.error('auto-version-check-failed', error);
});
}, [checkForUpdate]);
const strings = useMemo(
() => ({
title: t('personal.versionCheck.modalTitle'),
tag: t('personal.versionCheck.modalTag'),
currentVersionLabel: t('personal.versionCheck.currentVersion'),
latestVersionLabel: t('personal.versionCheck.latestVersion'),
updatesTitle: t('personal.versionCheck.releaseNotesTitle'),
fallbackNote: t('personal.versionCheck.fallbackNotes'),
remindLater: t('personal.versionCheck.later'),
updateCta: t('personal.versionCheck.updateNow'),
}),
[t]
);
return (
<VersionCheckContext.Provider
value={{
isChecking,
updateInfo,
checkForUpdate,
openStore,
}}
>
{children}
<VersionUpdateModal
visible={modalVisible && Boolean(updateInfo?.needsUpdate)}
info={updateInfo}
currentVersion={currentVersion}
onClose={() => setModalVisible(false)}
onUpdate={openStore}
strings={strings}
/>
</VersionCheckContext.Provider>
);
}
export function useVersionCheck(): VersionCheckContextValue {
const context = useContext(VersionCheckContext);
if (!context) {
throw new Error('useVersionCheck must be used within VersionCheckProvider');
}
return context;
}

View File

@@ -5,6 +5,7 @@ import { Alert } from 'react-native';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { STORAGE_KEYS, api } from '@/services/api';
import { logout as logoutAction } from '@/store/userSlice';
@@ -21,6 +22,7 @@ export function useAuthGuard() {
const dispatch = useAppDispatch();
const currentPath = usePathname();
const user = useAppSelector(state => state.user);
const { t } = useI18n();
// 判断登录状态:优先使用 token因为 token 是登录的根本凭证
// profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复
@@ -74,28 +76,28 @@ export function useAuthGuard() {
router.push('/auth/login');
} catch (error) {
console.error('退出登录失败:', error);
Alert.alert('错误', '退出登录失败,请稍后重试');
Alert.alert(t('authGuard.logout.error'), t('authGuard.logout.errorMessage'));
}
}, [dispatch, router]);
}, [dispatch, router, t]);
// 带确认对话框的退出登录
const confirmLogout = useCallback(() => {
Alert.alert(
'确认退出',
'确定要退出当前账号吗?',
t('authGuard.confirmLogout.title'),
t('authGuard.confirmLogout.message'),
[
{
text: '取消',
text: t('authGuard.confirmLogout.cancelButton'),
style: 'cancel',
},
{
text: '确定',
text: t('authGuard.confirmLogout.confirmButton'),
style: 'default',
onPress: handleLogout,
},
]
);
}, [handleLogout]);
}, [handleLogout, t]);
// 注销账号功能
const handleDeleteAccount = useCallback(async () => {
@@ -109,38 +111,38 @@ export function useAuthGuard() {
// 执行退出登录逻辑
await dispatch(logoutAction()).unwrap();
Alert.alert('账号已注销', '您的账号已成功注销', [
Alert.alert(t('authGuard.deleteAccount.successTitle'), t('authGuard.deleteAccount.successMessage'), [
{
text: '确定',
text: t('authGuard.deleteAccount.confirmButton'),
onPress: () => router.push('/auth/login'),
},
]);
} catch (error: any) {
console.error('注销账号失败:', error);
const message = error?.message || '注销失败,请稍后重试';
Alert.alert('注销失败', message);
const message = error?.message || t('authGuard.deleteAccount.errorMessage');
Alert.alert(t('authGuard.deleteAccount.errorTitle'), message);
}
}, [dispatch, router]);
}, [dispatch, router, t]);
// 带确认对话框的注销账号
const confirmDeleteAccount = useCallback(() => {
Alert.alert(
'确认注销账号',
'此操作不可恢复,将删除您的账号及相关数据。确定继续吗?',
t('authGuard.confirmDeleteAccount.title'),
t('authGuard.confirmDeleteAccount.message'),
[
{
text: '取消',
text: t('authGuard.confirmDeleteAccount.cancelButton'),
style: 'cancel',
},
{
text: '确认注销',
text: t('authGuard.confirmDeleteAccount.confirmButton'),
style: 'destructive',
onPress: handleDeleteAccount,
},
],
{ cancelable: true }
);
}, [handleDeleteAccount]);
}, [handleDeleteAccount, t]);
return {
isLoggedIn,

View File

@@ -1,6 +1,8 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { WaterRecordSource } from '@/services/waterRecords';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
@@ -81,6 +83,9 @@ export const useWaterData = () => {
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
// Redux dispatch
const dispatch = useAppDispatch();
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
@@ -196,6 +201,15 @@ export const useWaterData = () => {
return false;
}
// 同步到服务端(后台执行,不阻塞 UI
dispatch(createWaterRecordAction({
amount,
recordedAt: recordTime,
source: WaterRecordSource.Manual,
})).catch((err) => {
console.warn('同步饮水记录到服务端失败:', err);
});
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(date);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
@@ -225,7 +239,7 @@ export const useWaterData = () => {
return false;
}
},
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加
@@ -554,6 +568,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const dispatch = useAppDispatch();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
@@ -567,6 +582,15 @@ export const useWaterDataByDate = (targetDate?: string) => {
return false;
}
// 同步到服务端(后台执行,不阻塞 UI
dispatch(createWaterRecordAction({
amount,
recordedAt: recordTime,
source: WaterRecordSource.Manual,
})).catch((err) => {
console.warn('同步饮水记录到服务端失败:', err);
});
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
@@ -596,7 +620,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
return false;
}
},
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
);
// 更新喝水记录

303
i18n/en/challenge.ts Normal file
View File

@@ -0,0 +1,303 @@
export const challengeDetail = {
title: 'Challenge Details',
notFound: 'Challenge not found, please try again later.',
loading: 'Loading challenge details…',
retry: 'Reload',
share: {
generating: 'Generating share card...',
failed: 'Share failed, please try again later',
messageJoined: 'I\'m participating in "{{title}}" challenge, completed {{completed}}/{{target}} days! Join me!',
messageNotJoined: 'Found an amazing challenge "{{title}}", let\'s join together!',
},
dateRange: {
format: '{{start}} - {{end}}',
monthDay: 'Month {{month}} Day {{day}}',
ongoing: 'Ongoing updates',
},
participants: {
count: '{{count}} participants',
ongoing: 'Ongoing updates',
more: 'More',
},
detail: {
requirement: 'Daily check-in auto accumulates',
viewAllRanking: 'View All',
},
checkIn: {
title: 'Challenge Check-in',
todayChecked: 'Checked in today',
subtitle: 'Daily check-ins accumulate progress towards goal',
subtitleChecked: 'Today\'s progress recorded, keep it up tomorrow',
button: {
checkIn: 'Check In Now',
checking: 'Checking in…',
checked: 'Checked in today',
notJoined: 'Join to check in',
upcoming: 'Not started yet',
expired: 'Challenge ended',
},
toast: {
alreadyChecked: 'Already checked in today',
notStarted: 'Challenge not started yet, check in after it begins',
expired: 'Challenge has ended, cannot check in',
mustJoin: 'Join the challenge to check in',
success: 'Check-in successful, keep going!',
failed: 'Check-in failed, please try again',
},
},
cta: {
join: 'Join Challenge',
joining: 'Joining…',
leave: 'Leave Challenge',
leaving: 'Leaving…',
delete: 'Delete Challenge',
deleting: 'Deleting…',
upcoming: 'Starting Soon',
expired: 'Challenge Ended',
},
highlight: {
join: {
title: 'Join Challenge Now',
subtitle: 'Invite friends to persist together, achieve more easily',
},
leave: {
title: 'Don\'t leave just yet',
subtitle: 'Keep going, the next milestone is around the corner',
},
upcoming: {
title: 'Challenge Starting Soon',
subtitle: 'Starts on {{date}}, stay tuned',
subtitleFallback: 'Challenge coming soon, stay tuned',
},
expired: {
title: 'Challenge Ended',
subtitle: 'Ended on {{date}}, look forward to the next one',
subtitleFallback: 'This round has ended, look forward to the next challenge',
},
},
alert: {
leaveConfirm: {
title: 'Confirm leaving challenge?',
message: 'You will need to rejoin to continue.',
cancel: 'Cancel',
confirm: 'Leave Challenge',
},
joinFailed: 'Failed to join challenge',
leaveFailed: 'Failed to leave challenge',
archiveConfirm: {
title: 'Delete this challenge?',
message: 'This cannot be undone and participants will lose access.',
cancel: 'Cancel',
confirm: 'Delete Challenge',
},
archiveFailed: 'Failed to delete challenge',
archiveSuccess: 'Challenge deleted',
},
ranking: {
title: 'Leaderboard',
description: '',
empty: 'Leaderboard opening soon, grab your spot.',
today: 'Today',
todayGoal: 'Today\'s Goal',
hour: 'hrs',
},
leaderboard: {
title: 'Leaderboard',
loading: 'Loading leaderboard…',
notFound: 'Challenge not found.',
loadFailed: 'Unable to load leaderboard, please try again later.',
empty: 'Leaderboard opening soon, grab your spot.',
loadMore: 'Loading more…',
loadMoreFailed: 'Failed to load more, pull to refresh and retry',
},
shareCard: {
footer: 'Out Live · Beyond Life',
progress: {
label: 'My Progress',
days: '{{completed}} / {{target}} days',
completed: '🎉 Challenge Completed!',
remaining: '{{remaining}} days to complete',
},
info: {
checkInDaily: 'Daily check-in',
joinUs: 'Join us!',
},
shareCode: {
copied: 'Share code copied',
},
},
shareCode: {
copied: 'Share code copied',
},
};
export const badges = {
title: 'Badge Gallery',
subtitle: 'Celebrate every effort',
hero: {
highlight: 'Keep checking in to unlock rarer badges.',
earnedLabel: 'Earned',
totalLabel: 'Total',
progressLabel: 'Progress',
},
categories: {
all: 'All',
sleep: 'Sleep',
exercise: 'Exercise',
diet: 'Nutrition',
challenge: 'Challenge',
social: 'Social',
special: 'Special',
},
rarities: {
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
epic: 'Epic',
legendary: 'Legendary',
},
status: {
earned: 'Unlocked',
locked: 'Locked',
earnedAt: 'Unlocked on {{date}}',
},
legend: 'Rarity legend',
filterLabel: 'Badge categories',
empty: {
title: 'No badges yet',
description: 'Complete sleep, workout, or challenge tasks to earn your first badge.',
action: 'Explore plans',
},
};
export const challenges = {
title: 'Challenges',
subtitle: 'Join challenges to stay consistent',
loading: 'Loading challenges…',
loadFailed: 'Failed to load challenges, please try again later.',
retry: 'Retry',
empty: 'No challenges yet. Join one to get started.',
customChallenges: 'Custom Challenges',
officialChallengesTitle: 'Official Challenges',
officialChallenges: 'Official challenges launching soon.',
join: 'Join',
joined: 'Joined',
invalidInviteCode: 'Please enter a valid invite code',
joinSuccess: 'Joined challenge successfully',
joinFailed: 'Failed to join challenge',
joinModal: {
title: 'Join via invite code',
description: 'Enter the invite code to join a challenge',
confirm: 'Join',
joining: 'Joining…',
cancel: 'Cancel',
placeholder: 'Enter invite code',
},
statusLabels: {
upcoming: 'Upcoming',
ongoing: 'Ongoing',
expired: 'Ended',
},
createCustom: {
title: 'Create Challenge',
editTitle: 'Edit Challenge',
yourChallenge: 'Your challenge',
basicInfo: 'Basic Info',
challengeSettings: 'Challenge Settings',
displayInteraction: 'Display & Interaction',
durationDays: '{{days}} days',
durationDaysChallenge: '{{days}}-day challenge',
dayUnit: 'days',
defaultTitle: 'Custom Challenge',
rankingDescription: 'Leaderboard updates daily',
typeLabels: {
water: 'Hydration',
exercise: 'Exercise',
diet: 'Diet',
sleep: 'Sleep',
mood: 'Mood',
weight: 'Weight',
custom: 'Custom',
},
fields: {
title: 'Challenge title',
titlePlaceholder: 'e.g., 21-day hydration',
coverImage: 'Cover image',
uploadCover: 'Upload cover',
challengeDescription: 'Challenge description',
descriptionPlaceholder: 'Describe the goal and check-in rules',
challengeType: 'Challenge type',
challengeTypeHelper: 'Pick the category closest to your goal',
timeRange: 'Time range',
start: 'Start date',
end: 'End date',
duration: 'Duration',
periodLabel: 'Period label',
periodLabelPlaceholder: 'e.g., 21-day sprint',
dailyTargetAndUnit: 'Daily target & unit',
dailyTargetPlaceholder: 'Daily target value',
unitPlaceholder: 'Unit (cups / mins / steps...)',
unitHelper: 'Optional, shown after the daily target',
minimumCheckInDays: 'Minimum check-in days',
minimumCheckInDaysPlaceholder: 'Cannot exceed total duration',
maxParticipants: 'Max participants',
noLimit: 'No limit',
isPublic: 'Allow public join',
publicDescription: 'Others can join with the invite code when enabled.',
},
floatingCTA: {
title: 'Generate invite code',
subtitle: 'Create a challenge and share it with friends',
editTitle: 'Save changes',
editSubtitle: 'Update the challenge for all participants',
},
buttons: {
createAndGenerateCode: 'Create & generate code',
creating: 'Creating…',
updateAndSave: 'Save changes',
updating: 'Saving…',
},
datePicker: {
confirm: 'Confirm',
cancel: 'Cancel',
},
alerts: {
titleRequired: 'Please enter a challenge title',
endTimeError: 'End date must be after start date',
targetValueError: 'Daily target must be between 1 and 1000',
minimumDaysError: 'Minimum check-in days must be between 1 and 365',
minimumDaysExceedError: 'Minimum check-in days cannot exceed total duration',
participantsError: 'Participants must be between 2 and 10000 or leave empty',
createFailed: 'Failed to create challenge',
createSuccess: 'Challenge created',
updateSuccess: 'Challenge updated',
},
imageUpload: {
selectSource: 'Choose cover',
selectMessage: 'Take a photo or pick from album',
camera: 'Camera',
album: 'Album',
cancel: 'Cancel',
cameraPermission: 'Camera permission required',
cameraPermissionMessage: 'Enable camera access to take a photo.',
albumPermissionMessage: 'Enable photo access to choose from library.',
cameraFailed: 'Failed to open camera',
cameraFailedMessage: 'Please try again or choose from album.',
selectFailed: 'Selection failed',
selectFailedMessage: 'Could not select an image, please try again.',
uploadFailed: 'Upload failed',
uploadFailedMessage: 'Cover upload failed, please retry.',
uploading: 'Uploading…',
clear: 'Remove cover',
helper: 'Use a 16:9 cover under 2MB for better results.',
},
shareModal: {
title: 'Invite code generated',
subtitle: 'Share this code so others can join your challenge',
generatingCode: 'Generating…',
copyCode: 'Copy code',
viewChallenge: 'View challenge',
later: 'Share later',
},
},
};

14
i18n/en/common.ts Normal file
View File

@@ -0,0 +1,14 @@
export const dateSelector = {
backToToday: 'Back to Today',
cancel: 'Cancel',
confirm: 'Confirm',
};
export const common = {
alert: 'Alert',
success: 'Success',
error: 'Error',
delete: 'Delete',
confirm: 'Confirm',
cancel: 'Cancel',
};

551
i18n/en/diet.ts Normal file
View File

@@ -0,0 +1,551 @@
export const nutritionRecords = {
title: 'Nutrition Records',
listTitle: 'Today\'s Meals',
recordCount: '{{count}} records',
empty: {
title: 'No records today',
action: 'Add Record',
},
footer: {
end: '- No more records -',
loadMore: 'Load More',
},
delete: {
title: 'Confirm Delete',
message: 'Are you sure you want to delete this nutrition record? This action cannot be undone.',
cancel: 'Cancel',
confirm: 'Delete',
},
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
other: 'Other',
},
nutrients: {
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
unit: 'g',
caloriesUnit: 'kcal',
},
overlay: {
title: 'Record Method',
scan: 'AI Scan',
foodLibrary: 'Food Library',
voiceRecord: 'Voice Log',
},
chart: {
remaining: 'Remaining',
formula: 'Remaining = Metabolism + Exercise - Diet',
metabolism: 'Metabolism',
exercise: 'Exercise',
diet: 'Diet',
},
};
export const foodCamera = {
title: 'Food Camera',
hint: 'Keep food within the frame',
permission: {
title: 'Camera Permission Required',
description: 'Camera access is needed to capture food for AI recognition',
button: 'Allow Access',
},
guide: {
title: 'Shooting Guide',
description: 'Please upload or take clear photos of food to improve recognition accuracy',
button: 'Got it',
good: 'Good lighting, clear subject',
bad: 'Blurry, poor lighting',
},
buttons: {
album: 'Album',
capture: 'Capture',
help: 'Help',
},
alerts: {
captureFailed: {
title: 'Capture Failed',
message: 'Please try again',
},
pickFailed: {
title: 'Selection Failed',
message: 'Please try again',
},
},
};
export const foodRecognition = {
title: 'Food Recognition',
header: {
confirm: 'Confirm Food',
recognizing: 'AI Recognizing',
},
errors: {
noImage: 'Image not found',
generic: 'Food recognition failed, please try again',
unknown: 'Unknown error',
noFoodDetected: 'Recognition failed: No food detected',
processError: 'Error during recognition process',
},
logs: {
uploading: '📤 Uploading image to cloud...',
uploadSuccess: '✅ Image upload completed',
analyzing: '🤖 AI model analyzing...',
analysisSuccess: '✅ AI analysis completed',
confidence: '🎯 Confidence: {{value}}%',
itemsFound: '🍽️ Detected {{count}} food items',
failed: '❌ Recognition failed: No food detected',
error: '❌ Error during recognition process',
},
status: {
idle: {
title: 'Ready',
subtitle: 'Please wait...',
},
uploading: {
title: 'Uploading Image',
subtitle: 'Uploading image to cloud server...',
},
recognizing: {
title: 'AI Analyzing',
subtitle: 'AI model is analyzing food ingredients...',
},
completed: {
title: 'Success',
subtitle: 'Redirecting to analysis results...',
},
failed: {
title: 'Failed',
subtitle: 'Please check network or try again later',
},
processing: {
title: 'Processing...',
subtitle: 'Please wait...',
},
},
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
unknown: 'Unknown',
},
info: {
title: 'Smart Food Recognition',
description: 'AI will analyze the photo, identify food types, estimate nutrition, and generate a detailed report.',
},
actions: {
start: 'Start Recognition',
retry: 'Retry',
logs: 'Process Logs',
logsPlaceholder: 'Ready to start...',
},
alerts: {
recognizing: {
title: 'Recognition in progress',
message: 'Recognition is not complete. Are you sure you want to go back?',
continue: 'Continue',
back: 'Go Back',
},
},
};
export const foodAnalysisResult = {
title: 'Analysis Result',
error: {
notFound: 'Image or recognition result not found',
},
placeholder: 'Nutrition Record',
nutrients: {
caloriesUnit: 'kcal',
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
unit: 'g',
},
sections: {
recognitionResult: 'Recognition Result',
foodIntake: 'Food Intake',
},
nonFood: {
title: 'No Food Detected',
suggestions: {
title: 'Suggestions:',
item1: '• Ensure food is in the frame',
item2: '• Try a clearer angle',
item3: '• Avoid blur or poor lighting',
},
},
actions: {
retake: 'Retake',
record: 'Record',
close: 'Close',
},
mealSelector: {
title: 'Select Meal',
},
editModal: {
title: 'Edit Food Info',
fields: {
name: 'Food Name',
namePlaceholder: 'Enter food name',
amount: 'Weight (g)',
amountPlaceholder: 'Enter weight',
calories: 'Calories (kcal)',
caloriesPlaceholder: 'Enter calories',
},
actions: {
cancel: 'Cancel',
save: 'Save',
},
},
confidence: 'Confidence: {{value}}%',
dateFormats: {
today: 'MMM D, YYYY',
full: 'MMM D, YYYY HH:mm',
},
};
export const foodLibrary = {
title: 'Food Library',
custom: 'Custom',
search: {
placeholder: 'Search food...',
loading: 'Searching...',
empty: 'No relevant food found',
noData: 'No food data',
},
loading: 'Loading food library...',
retry: 'Retry',
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
},
actions: {
record: 'Record',
selectMeal: 'Select Meal',
},
alerts: {
deleteFailed: {
title: 'Delete Failed',
message: 'Error occurred while deleting food, please try again later',
},
createFailed: {
title: 'Create Failed',
message: 'Error occurred while creating custom food, please try again later',
},
},
};
export const createCustomFood = {
title: 'Create Custom Food',
save: 'Save',
preview: {
title: 'Preview',
defaultName: 'Food Name',
},
basicInfo: {
title: 'Basic Info',
name: 'Food Name',
namePlaceholder: 'e.g. Hamburger',
defaultAmount: 'Default Amount',
calories: 'Calories',
},
optionalInfo: {
title: 'Optional Info',
photo: 'Photo',
addPhoto: 'Add Photo',
protein: 'Protein',
fat: 'Fat',
carbohydrate: 'Carbs',
},
units: {
kcal: 'kcal',
g: 'g',
gram: 'g',
},
alerts: {
permissionDenied: {
title: 'Permission Denied',
message: 'Photo library permission is required to select photos',
},
uploadFailed: {
title: 'Upload Failed',
message: 'Photo upload failed, please try again',
},
error: {
title: 'Error',
message: 'Failed to select photo, please try again',
},
validation: {
title: 'Notice',
nameRequired: 'Please enter food name',
caloriesRequired: 'Please enter valid calories',
},
},
};
export const voiceRecord = {
title: 'Voice Log',
intro: {
description: 'Describe your meal with voice, AI will intelligently analyze nutrition and calories',
},
status: {
idle: 'Tap microphone to start recording',
listening: 'Listening... Please start speaking...',
processing: 'AI is processing voice content...',
analyzing: 'AI model is deeply analyzing nutritional components...',
result: 'Voice recognition completed, please confirm the result',
},
hints: {
listening: 'Tell us about the food you want to record',
},
examples: {
title: 'Recording Examples:',
items: [
'This morning I had two fried eggs, a slice of whole wheat bread and a glass of milk',
'For lunch I had about 150g of braised pork, a small bowl of rice and a serving of vegetables',
'For dinner I had steamed egg custard, seaweed egg drop soup and a bowl of millet porridge',
],
},
analysis: {
progress: 'Analysis Progress: {{progress}}%',
hint: 'AI is deeply analyzing your food description...',
},
result: {
label: 'Recognition Result:',
},
actions: {
retry: 'Retry Recording',
confirm: 'Confirm & Use',
},
alerts: {
noVoiceInput: 'No voice input detected, please try again',
networkError: 'Network connection error, please check network and try again',
voiceError: 'Voice recognition problem occurred, please try again',
noValidContent: 'No valid content recognized, please record again',
pleaseRecordFirst: 'Please perform voice recognition first',
recordingFailed: 'Recording Failed',
recordingPermissionError: 'Unable to start voice recognition, please check microphone permission settings',
analysisFailed: 'Analysis Failed',
},
};
export const nutritionLabelAnalysis = {
title: 'Nutrition Label Analysis',
camera: {
permissionDenied: 'Permission Denied',
permissionMessage: 'Camera permission is required to take nutrition label photos',
},
actions: {
takePhoto: 'Take Photo',
selectFromAlbum: 'Select from Album',
startAnalysis: 'Start Analysis',
close: 'Close',
},
placeholder: {
text: 'Take or select a nutrition label photo',
},
status: {
uploading: 'Uploading image...',
analyzing: 'Analyzing nutrition label...',
},
errors: {
analysisFailed: {
title: 'Analysis Failed',
message: 'Error occurred while analyzing the image, please try again',
defaultMessage: 'Analysis service is temporarily unavailable',
},
cannotRecognize: 'Unable to recognize nutrition label, please try taking a clearer photo',
cameraPermissionDenied: 'Camera permission is required to take nutrition label photos',
},
results: {
title: 'Detailed Nutrition Analysis',
detailedAnalysis: 'Detailed Nutrition Analysis',
},
imageViewer: {
close: 'Close',
dateFormat: 'MMM D, YYYY HH:mm',
},
};
export const nutritionAnalysisHistory = {
title: 'History',
dateFormat: 'MMM D, YYYY HH:mm',
recognized: 'Recognized {{count}} nutrients',
loadingMore: 'Loading more...',
loading: 'Loading history...',
filter: {
all: 'All',
},
filters: {
all: 'All',
success: 'Success',
failed: 'Failed',
},
status: {
success: 'Success',
failed: 'Failed',
processing: 'Processing',
unknown: 'Unknown',
},
nutrients: {
energy: 'Energy',
protein: 'Protein',
carbs: 'Carbs',
fat: 'Fat',
},
delete: {
confirmTitle: 'Confirm Delete',
confirmMessage: 'Are you sure you want to delete this record?',
cancel: 'Cancel',
delete: 'Delete',
successTitle: 'Deleted Successfully',
successMessage: 'Record has been deleted successfully',
},
actions: {
expand: 'Expand Details',
collapse: 'Collapse Details',
expandDetails: 'Expand Details',
collapseDetails: 'Collapse Details',
confirmDelete: 'Confirm Delete',
delete: 'Delete',
cancel: 'Cancel',
retry: 'Retry',
},
empty: {
title: 'No History Records',
subtitle: 'Start recognizing nutrition labels',
},
errors: {
error: 'Error',
loadFailed: 'Load Failed',
unknownError: 'Unknown Error',
fetchFailed: 'Failed to fetch history records',
fetchFailedRetry: 'Failed to fetch history records, please retry',
deleteFailed: 'Delete failed, please try again later',
},
loadingState: {
records: 'Loading history...',
more: 'Loading more...',
},
details: {
title: 'Detailed Nutrition Information',
nutritionDetails: 'Detailed Nutrition Information',
aiModel: 'AI Model',
provider: 'Service Provider',
serviceProvider: 'Service Provider',
},
records: {
nutritionCount: 'Recognized {{count}} nutrients',
},
imageViewer: {
close: 'Close',
},
};
export const waterDetail = {
title: 'Water Details',
waterRecord: 'Water Records',
today: 'Today',
total: 'Total: ',
goal: 'Goal: ',
noRecords: 'No water records',
noRecordsSubtitle: 'Tap "Add Record" to start tracking water intake',
deleteConfirm: {
title: 'Confirm Delete',
message: 'Are you sure you want to delete this water record? This action cannot be undone.',
cancel: 'Cancel',
confirm: 'Delete',
},
deleteButton: 'Delete',
water: 'Water',
loadingUserPreferences: 'Failed to load user preferences',
};
export const waterSettings = {
title: 'Water Settings',
sections: {
dailyGoal: 'Daily Water Goal',
quickAdd: 'Quick Add Default',
reminder: 'Water Reminder',
},
descriptions: {
quickAdd: 'Set the default water amount when clicking the "+" button',
reminder: 'Set periodic reminders to replenish water',
},
labels: {
ml: 'ml',
disabled: 'Disabled',
},
alerts: {
goalSuccess: {
title: 'Settings Saved',
message: 'Daily water goal has been set to {{amount}}ml',
},
quickAddSuccess: {
title: 'Settings Saved',
message: 'Quick add default has been set to {{amount}}ml',
},
quickAddFailed: {
title: 'Save Failed',
message: 'Unable to save quick add default, please try again',
},
},
buttons: {
cancel: 'Cancel',
confirm: 'Confirm',
},
status: {
reminderEnabled: '{{startTime}}-{{endTime}}, every {{interval}} minutes',
},
};
export const waterReminderSettings = {
title: 'Water Reminder',
sections: {
notifications: 'Push Notifications',
timeRange: 'Reminder Time Range',
interval: 'Reminder Interval',
},
descriptions: {
notifications: 'Enable to receive periodic water reminders during specified time periods',
timeRange: 'Only send reminders during specified time periods to avoid disturbing your rest',
interval: 'Choose the reminder frequency, recommended 30-120 minutes',
},
labels: {
startTime: 'Start Time',
endTime: 'End Time',
interval: 'Reminder Interval',
saveSettings: 'Save Settings',
hours: 'Hours',
timeRangePreview: 'Time Range Preview',
minutes: 'Minutes',
},
alerts: {
timeValidation: {
title: 'Time Setting Tip',
startTimeInvalid: 'Start time cannot be later than or equal to end time, please select again',
endTimeInvalid: 'End time cannot be earlier than or equal to start time, please select again',
},
success: {
enabled: 'Settings Saved',
enabledMessage: 'Water reminder has been enabled\n\nTime range: {{timeRange}}\nReminder interval: {{interval}}\n\nWe will periodically remind you to drink water during the specified time period',
disabled: 'Settings Saved',
disabledMessage: 'Water reminder has been disabled',
},
error: {
title: 'Save Failed',
message: 'Unable to save water reminder settings, please try again',
},
},
buttons: {
confirm: 'Confirm',
cancel: 'Cancel',
},
};

850
i18n/en/health.ts Normal file
View File

@@ -0,0 +1,850 @@
export const 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 statistics = {
title: 'Out Live',
aiReport: {
button: 'Report',
generating: 'Generating your AI health report, this may take 1030s…',
generatingShort: 'Generating',
success: 'Report ready',
failed: 'Failed to generate report, please try again',
missing: 'Report is not ready yet, please try again',
permission: 'Media permission is required to save the report',
saved: 'Saved to Photos',
saveFailed: 'Save failed, please try again',
save: 'Save',
saving: 'Saving…',
share: 'Share',
sharing: 'Sharing…',
shareFailed: 'Share failed, please try again',
shareTitle: 'AI Health Report',
shareMessage: 'Here is my AI health report—take a look!',
close: 'Close',
galleryTitle: 'AI Report Gallery',
gallerySubtitle: 'Browse and keep your immersive reports',
bannerDesc: 'Tap generate on the top right, takes about 1030s',
loadFailed: 'Failed to load report history',
emptyHistory: 'No reports yet',
emptyHistoryHint: 'Tap the top right to generate your first report',
generated: 'generated',
},
sections: {
bodyMetrics: 'Body Metrics',
},
components: {
diet: {
title: 'Diet Analysis',
loading: 'Loading...',
updated: 'Updated: {{time}}',
remaining: 'Can Still Eat',
calories: 'Calories',
protein: 'Protein',
carb: 'Carbs',
fat: 'Fat',
fiber: 'Fiber',
sodium: 'Sodium',
basal: 'Basal',
exercise: 'Exercise',
diet: 'Diet',
kcal: 'kcal',
aiRecognition: 'AI Scan',
foodLibrary: 'Food Library',
voiceRecord: 'Voice Log',
nutritionLabel: 'Nutrition Label',
},
fitness: {
kcal: 'kcal',
minutes: 'min',
hours: 'hrs',
},
steps: {
title: 'Steps',
},
mood: {
title: 'Mood',
empty: 'Tap to record mood',
},
stress: {
title: 'Stress',
unit: 'ms',
},
water: {
title: 'Water',
unit: 'ml',
addButton: '+ {{amount}}ml',
},
metabolism: {
title: 'Metabolism',
loading: 'Loading...',
unit: 'kcal/day',
status: {
high: 'High',
normal: 'Normal',
low: 'Low',
veryLow: 'Very Low',
unknown: 'Unknown',
},
},
sleep: {
title: 'Sleep',
loading: 'Loading...',
},
oxygen: {
title: 'Blood Oxygen',
},
circumference: {
title: 'Circumference (cm)',
setTitle: 'Set {{label}}',
confirm: 'Confirm',
measurements: {
chest: 'Chest',
waist: 'Waist',
hip: 'Hip',
arm: 'Arm',
thigh: 'Thigh',
calf: 'Calf',
},
},
workout: {
title: 'Recent Workout',
minutes: 'min',
kcal: 'kcal',
noData: 'No workout data',
syncing: 'Syncing...',
sourceWaiting: 'Source: Syncing...',
sourceUnknown: 'Source: Unknown',
sourceFormat: 'Source: {{source}}',
sourceFormatMultiple: 'Source: {{source}} et al.',
lastWorkout: 'Latest Workout',
updated: 'Updated',
},
weight: {
title: 'Weight Records',
addButton: 'Record Weight',
bmi: 'BMI',
weight: 'Weight',
days: 'days',
range: 'Range',
unit: 'kg',
progress: {
lost: 'Lost',
toGo: 'To go',
},
demo: 'Demo',
bmiModal: {
title: 'BMI Index Explanation',
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
formula: 'Formula: weight(kg) ÷ height²(m)',
classificationTitle: 'BMI Classification Standards',
healthTipsTitle: 'Health Tips',
tips: {
nutrition: 'Maintain a balanced diet and control calorie intake',
exercise: 'At least 150 minutes of moderate-intensity exercise per week',
sleep: 'Ensure 7-9 hours of adequate sleep',
monitoring: 'Regularly monitor weight changes and adjust promptly',
},
disclaimer: 'BMI is for reference only and cannot reflect muscle mass, bone density, etc. If you have health concerns, please consult a professional doctor.',
continueButton: 'Continue',
},
},
fitnessRings: {
title: 'Fitness Rings',
activeCalories: 'Active Calories',
exerciseMinutes: 'Exercise Minutes',
standHours: 'Stand Hours',
goal: '/{{goal}}',
ringLabels: {
active: 'Active',
exercise: 'Exercise',
stand: 'Stand',
},
},
},
activityHeatMap: {
subtitle: 'Active {{days}} days in the last 6 months',
activeRate: '{{rate}}%',
popover: {
title: 'Accumulated energy can be redeemed for AI-related benefits',
subtitle: 'How to earn',
rules: {
login: '1. Daily login earns energy +1',
mood: '2. Daily mood record earns energy +1',
diet: '3. Diet record earns energy +1',
goal: '4. Complete a goal earns energy +1',
},
},
months: {
1: 'Jan',
2: 'Feb',
3: 'Mar',
4: 'Apr',
5: 'May',
6: 'Jun',
7: 'Jul',
8: 'Aug',
9: 'Sep',
10: 'Oct',
11: 'Nov',
12: 'Dec',
},
legend: {
less: 'Less',
more: 'More',
},
},
};
export const sleepDetail = {
title: 'Sleep Details',
loading: 'Loading sleep data...',
today: 'Today',
sleepScore: 'Sleep Score',
noData: 'No sleep data available',
noDataRecommendation: 'Please ensure you are running on a real iOS device with authorized health data access, or wait until you have sleep data to view.',
sleepDuration: 'Sleep Duration',
sleepQuality: 'Sleep Quality',
sleepStages: 'Sleep Stages',
learnMore: 'Learn More',
awake: 'Awake',
rem: 'REM',
core: 'Core Sleep',
deep: 'Deep Sleep',
unknown: 'Unknown',
rawData: 'Raw Data',
rawDataDescription: 'Contains {{count}} HealthKit sleep sample records',
infoModalTitles: {
sleepTime: 'Sleep Time',
sleepQuality: 'Sleep Quality',
},
sleepGrades: {
low: 'Low',
normal: 'Normal',
good: 'Good',
excellent: 'Excellent',
poor: 'Poor',
fair: 'Fair',
},
sleepTimeDescription: 'Sleep is most important - it accounts for more than half of your sleep score. Longer sleep can reduce sleep debt, but regular sleep times are crucial for quality rest.',
sleepQualityDescription: 'Sleep quality comprehensively evaluates multiple indicators such as your sleep efficiency, deep sleep duration, REM sleep ratio, etc. High-quality sleep depends not only on duration but also on sleep continuity and balance of sleep stages.',
sleepStagesInfo: {
title: 'Understand Your Sleep Stages',
description: 'People have many misconceptions about sleep stages and sleep quality. Some people may need more deep sleep, while others may not. Scientists and doctors are still exploring the role of different sleep stages and their effects on the body. By tracking sleep stages and paying attention to how you feel each morning, you may gain deeper insights into your own sleep.',
awake: {
title: 'Awake Time',
description: 'During a sleep period, you may wake up several times. Occasional waking is normal. You may fall back asleep immediately and not remember waking up during the night.',
},
rem: {
title: 'REM Sleep',
description: 'This sleep stage may have some impact on learning and memory. During this stage, your muscles are most relaxed and your eyes move rapidly left and right. This is also the stage where most of your dreams occur.',
},
core: {
title: 'Core Sleep',
description: 'This stage is sometimes called light sleep and is as important as other stages. This stage usually occupies most of your sleep time each night. Brain waves that are crucial for cognition are generated during this stage.',
},
deep: {
title: 'Deep Sleep',
description: 'Due to the characteristics of brain waves, this stage is also called slow-wave sleep. During this stage, body tissues are repaired and important hormones are released. It usually occurs in the first half of sleep and lasts longer. During deep sleep, the body is very relaxed, so you may find it harder to wake up during this stage compared to other stages.',
},
},
};
export const sleepQuality = {
excellent: {
description: 'You feel refreshed and energized',
recommendation: 'Congratulations on getting quality sleep! If you feel energized, consider moderate exercise to maintain a healthy lifestyle and further reduce stress for optimal sleep.'
},
good: {
description: 'Good sleep quality, decent mental state',
recommendation: 'Your sleep quality is decent but has room for improvement.建议 maintaining regular sleep schedules, avoiding electronic devices before bed, and creating a quiet, comfortable sleep environment.'
},
fair: {
description: 'Fair sleep quality, may affect daytime performance',
recommendation: 'Your sleep needs improvement.建议 establishing a fixed bedtime routine, limiting caffeine intake, ensuring appropriate bedroom temperature, and considering light exercise to improve sleep quality.'
},
poor: {
description: 'Poor sleep quality, attention to sleep health recommended',
recommendation: 'Your sleep quality needs serious attention.建议 consulting a doctor or sleep specialist to check for sleep disorders, while improving sleep environment and habits, avoiding stimulating activities before bed.'
}
};
export const stepsDetail = {
title: 'Steps Details',
loading: 'Loading...',
stats: {
totalSteps: 'Total Steps',
averagePerHour: 'Average Per Hour',
mostActiveTime: 'Most Active Time',
},
chart: {
title: 'Hourly Steps Distribution',
averageLabel: 'Average {{steps}} steps',
},
activityLevel: {
currentActivity: 'Your activity level today is',
levels: {
inactive: 'Inactive',
light: 'Lightly Active',
moderate: 'Moderately Active',
very_active: 'Very Active',
},
progress: {
current: 'Current',
nextLevel: 'Next: {{level}}',
highestLevel: 'Highest Level',
},
},
timeLabels: {
midnight: '0:00',
noon: '12:00',
nextDay: '24:00',
},
};
export const fitnessRingsDetail = {
title: 'Fitness Rings Detail',
loading: 'Loading...',
weekDays: {
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
},
dateFormats: {
header: 'MMM D, YYYY',
},
cards: {
activeCalories: {
title: 'Active Calories',
unit: 'kcal',
},
exerciseMinutes: {
title: 'Exercise Minutes',
unit: 'minutes',
info: {
title: 'Exercise Minutes:',
description: 'Exercise at an intensity of at least "brisk walking" will accumulate corresponding exercise minutes.',
recommendation: 'WHO recommends adults to maintain at least 30 minutes of moderate to high-intensity exercise daily.',
knowButton: 'Got it',
},
},
standHours: {
title: 'Stand Hours',
unit: 'hours',
},
},
stats: {
weeklyClosedRings: 'Weekly Closed Rings',
daysUnit: 'days',
},
datePicker: {
cancel: 'Cancel',
confirm: 'Confirm',
},
errors: {
loadExerciseInfoPreference: 'Failed to load exercise minutes info preference',
saveExerciseInfoPreference: 'Failed to save exercise minutes info preference',
},
};
export const circumferenceDetail = {
title: 'Circumference Statistics',
loading: 'Loading...',
error: 'Loading failed',
retry: 'Retry',
noData: 'No data available',
noDataSelected: 'Please select circumference data to display',
tabs: {
week: 'By Week',
month: 'By Month',
year: 'By Year',
},
measurements: {
chest: 'Chest',
waist: 'Waist',
upperHip: 'Upper Hip',
arm: 'Arm',
thigh: 'Thigh',
calf: 'Calf',
},
modal: {
title: 'Set {{label}}',
defaultTitle: 'Set Circumference',
confirm: 'Confirm',
},
chart: {
weekLabel: 'Week {{week}}',
monthLabel: '{{month}}',
empty: 'No data available',
noSelection: 'Please select circumference data to display',
},
};
export const basalMetabolismDetail = {
title: 'Metabolism',
currentData: {
title: '{{date}} Basal Metabolism',
unit: 'kcal',
normalRange: 'Normal range: {{min}}-{{max}} kcal',
noData: '--',
},
stats: {
title: 'Basal Metabolism Statistics',
tabs: {
week: 'By Week',
month: 'By Month',
},
},
chart: {
loading: 'Loading...',
loadingText: 'Loading...',
error: {
text: 'Loading failed: {{error}}',
retry: 'Retry',
fetchFailed: 'Failed to fetch data',
},
empty: 'No data available',
yAxisSuffix: 'kcal',
weekLabel: 'Week {{week}}',
},
modal: {
title: 'Basal Metabolism',
closeButton: '×',
description: 'Basal metabolism, also known as Basal Metabolic Rate (BMR), refers to the minimum energy consumption required for the human body to maintain basic life functions (heartbeat, breathing, body temperature regulation, etc.) in a completely resting state, usually measured in calories.',
sections: {
importance: {
title: 'Why is it important?',
content: 'Basal metabolism accounts for 60-75% of total energy consumption and is the foundation of energy balance. Understanding your basal metabolism helps develop scientific nutrition plans, optimize weight management strategies, and assess metabolic health status.',
},
normalRange: {
title: 'Normal Range',
formulas: {
male: 'Male: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age + 5',
female: 'Female: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age - 161',
},
userRange: 'Your normal range: {{min}}-{{max}} kcal/day',
rangeNote: '(Within 15% above or below the calculated value is considered normal)',
userInfo: 'Based on your information: {{gender}}, {{age}} years old, {{height}}cm, {{weight}}kg',
incompleteInfo: 'Please complete basic information to calculate your metabolic rate',
},
strategies: {
title: 'Strategies to Boost Metabolism',
subtitle: 'Scientific research supports the following methods:',
items: [
'1. Increase muscle mass (2-3 strength training sessions per week)',
'2. High-intensity interval training (HIIT)',
'3. Adequate protein intake (1.6-2.2g per kg of body weight)',
'4. Ensure adequate sleep (7-9 hours per night)',
'5. Avoid excessive calorie restriction (not less than 80% of BMR)',
],
},
},
},
gender: {
male: 'Male',
female: 'Female',
},
comments: {
reloadData: 'Reload data',
},
};
export const workoutTypes = {
americanfootball: 'American Football',
archery: 'Archery',
australianfootball: 'Australian Football',
badminton: 'Badminton',
baseball: 'Baseball',
basketball: 'Basketball',
bowling: 'Bowling',
boxing: 'Boxing',
climbing: 'Climbing',
cricket: 'Cricket',
crosstraining: 'Cross Training',
curling: 'Curling',
cycling: 'Cycling',
dance: 'Dance',
danceinspiredtraining: 'Dance Inspired Training',
elliptical: 'Elliptical',
equestriansports: 'Equestrian Sports',
fencing: 'Fencing',
fishing: 'Fishing',
functionalstrengthtraining: 'Functional Strength Training',
golf: 'Golf',
gymnastics: 'Gymnastics',
handball: 'Handball',
hiking: 'Hiking',
hockey: 'Hockey',
hunting: 'Hunting',
lacrosse: 'Lacrosse',
martialarts: 'Martial Arts',
mindandbody: 'Mind and Body',
mixedmetaboliccardiotraining: 'Mixed Metabolic Cardio Training',
paddlesports: 'Paddle Sports',
play: 'Play',
preparationandrecovery: 'Preparation & Recovery',
racquetball: 'Racquetball',
rowing: 'Rowing',
rugby: 'Rugby',
running: 'Running',
sailing: 'Sailing',
skatingsports: 'Skating Sports',
snowsports: 'Snow Sports',
soccer: 'Soccer',
softball: 'Softball',
squash: 'Squash',
stairclimbing: 'Stair Climbing',
surfingsports: 'Surfing Sports',
swimming: 'Swimming',
tabletennis: 'Table Tennis',
tennis: 'Tennis',
trackandfield: 'Track and Field',
traditionalstrengthtraining: 'Traditional Strength Training',
volleyball: 'Volleyball',
walking: 'Walking',
waterfitness: 'Water Fitness',
waterpolo: 'Water Polo',
watersports: 'Water Sports',
wrestling: 'Wrestling',
yoga: 'Yoga',
barre: 'Barre',
coretraining: 'Core Training',
crosscountryskiing: 'Cross-Country Skiing',
downhillskiing: 'Downhill Skiing',
flexibility: 'Flexibility',
highintensityintervaltraining: 'High-Intensity Interval Training',
jumprope: 'Jump Rope',
kickboxing: 'Kickboxing',
pilates: 'Pilates',
snowboarding: 'Snowboarding',
stairs: 'Stairs',
steptraining: 'Step Training',
wheelchairwalkpace: 'Wheelchair Walk Pace',
wheelchairrunpace: 'Wheelchair Run Pace',
taichi: 'Tai Chi',
mixedcardio: 'Mixed Cardio',
handcycling: 'Hand Cycling',
discsports: 'Disc Sports',
fitnessgaming: 'Fitness Gaming',
cardiodance: 'Cardio Dance',
socialdance: 'Social Dance',
pickleball: 'Pickleball',
cooldown: 'Cooldown',
swimbikerun: 'Swim Bike Run',
transition: 'Transition',
underwaterdiving: 'Underwater Diving',
other: 'Other',
};
export const workoutDetail = {
loading: 'Loading workout details...',
retry: 'Retry',
errors: {
loadFailed: 'Failed to load workout details',
noHeartRateData: 'No heart rate data available',
noZoneStats: 'No heart rate zone data',
},
metrics: {
duration: 'Duration',
calories: 'Calories',
caloriesUnit: 'kcal',
intensity: 'Intensity',
averageHeartRate: 'Average Heart Rate',
heartRateUnit: 'bpm',
},
sections: {
heartRateRange: 'Heart Rate Range',
averageHeartRate: 'Average',
maximumHeartRate: 'Maximum',
minimumHeartRate: 'Minimum',
heartRateUnit: 'bpm',
heartRateZones: 'Heart Rate Zones',
},
chart: {
unavailable: 'Chart unavailable',
noData: 'No heart rate chart data yet',
},
intensityInfo: {
title: 'About workout intensity (METs)',
description1: 'METs (metabolic equivalent) reflect energy cost; resting equals 1 MET.',
description2: '3-6 METs is moderate intensity, above 6 METs is high intensity.',
description3: 'Higher values mean more energy burned per minute—adjust to your fitness level.',
description4: 'Warm up and cool down before and after sustained high-intensity sessions.',
formula: {
title: 'Formula',
value: 'METs = Exercise VO₂ ÷ Resting VO₂',
},
legend: {
low: '2-3 METs',
lowLabel: 'Low intensity',
medium: '3-6 METs',
mediumLabel: 'Moderate',
high: '>6 METs',
highLabel: 'High intensity',
},
},
zones: {
summary: '{{minutes}} min · {{range}}',
labels: {
warmup: 'Warm-up',
fatburn: 'Fat burn',
aerobic: 'Aerobic',
anaerobic: 'Anaerobic',
max: 'Max effort',
},
ranges: {
warmup: '<100 bpm',
fatburn: '100-119 bpm',
aerobic: '120-149 bpm',
anaerobic: '150-169 bpm',
max: '≥170 bpm',
},
},
};
export const sleepNotification = {
// Notification body template
body: 'You slept {{duration}} last night with {{efficiency}}% efficiency. Score: {{score}} 🎯',
// Sleep quality titles - warm and encouraging tone
quality: {
excellent: 'Amazing! You slept great',
good: 'Nice! Good sleep quality',
fair: 'Not bad, tomorrow will be better',
poor: 'Hang in there, rest well tonight',
veryPoor: 'Take care of yourself',
default: 'Sleep analysis complete',
},
// Sleep duration formatting
duration: {
hoursOnly: '{{hours}} hours',
hoursAndMinutes: '{{hours}}h {{minutes}}m',
},
// Sleep tips - encouraging tone
tips: {
excellent: {
keepItUp: 'Keep it up, you\'re doing amazing!',
greatJob: 'Your body thanks you for the great care!',
energized: 'You\'ll be full of energy today!',
proud: 'Give yourself a pat on the back!',
},
suggestions: {
shortSleep: 'Try hitting the pillow earlier - 7-9 hours will boost your energy!',
longSleep: 'Too much sleep can be tiring too - try a consistent wake time!',
lowDeepSleep: 'Put your phone away before bed for deeper rest~',
lowRemSleep: 'A regular schedule helps you dream better!',
lowEfficiency: 'A cozy bedroom environment can work wonders~',
},
general: 'Every night is a fresh start - take care of yourself!',
},
};
export const workoutHistory = {
title: 'Workout Summary',
loading: 'Loading workout records...',
error: {
permissionDenied: 'Health data permission not granted',
loadFailed: 'Failed to load workout records, please try again later',
detailLoadFailed: 'Failed to load workout details, please try again later',
},
retry: 'Retry',
monthlyStats: {
title: 'Workout Time',
periodText: 'Statistics period: 1st - {{day}} (This month)',
overviewWithStats: 'As of {{date}}, you have completed {{count}} workouts, totaling {{duration}}.',
overviewEmpty: 'No workout records this month yet, start moving to collect your first one!',
emptyData: 'No workout data this month',
},
intensity: {
low: 'Low Intensity',
medium: 'Medium Intensity',
high: 'High Intensity',
},
historyCard: {
calories: '{{calories}} kcal · {{minutes}} min',
activityTime: '{{activity}}, {{time}}',
},
empty: {
title: 'No Workout Records',
subtitle: 'Complete a workout to view detailed history here',
},
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
};
export const familyGroup = {
joinTitle: 'Join Family Group',
joinDescription: 'Enter the invite code shared by your family member to join health management',
inviteCodePlaceholder: 'Enter invite code',
relationshipLabel: 'Relationship to creator',
relationshipPlaceholder: 'Select relationship',
joinButton: 'Join',
joining: 'Joining...',
cancel: 'Cancel',
errors: {
emptyCode: 'Please enter invite code',
emptyRelationship: 'Please select relationship',
},
success: 'Successfully joined family group',
relationships: {
spouse: 'Spouse',
father: 'Father',
mother: 'Mother',
son: 'Son',
daughter: 'Daughter',
grandfather: 'Grandfather',
grandmother: 'Grandmother',
grandson: 'Grandson',
granddaughter: 'Granddaughter',
brother: 'Brother',
sister: 'Sister',
uncle: 'Uncle',
aunt: 'Aunt',
nephew: 'Nephew',
niece: 'Niece',
cousin: 'Cousin',
other: 'Other',
},
};
export const health = {
tabs: {
health: 'Health',
medications: 'Meds',
fasting: 'Fasting',
challenges: 'Challenges',
personal: 'Me',
healthProfile: {
title: 'Health Profile',
subtitle: 'Invite family to join health management for timely anomaly alerts',
privacyNotice: 'Profile content is visible only to you. We strictly protect your privacy.',
basicInfo: 'Basic Info',
healthHistory: 'History',
medicalRecords: 'Records',
checkupRecords: 'Checkups',
medicineBox: 'Medications',
basicInfoCard: {
title: 'Basic Information',
noData: 'No data',
bmi: 'BMI',
height: 'Height',
heightUnit: 'CM',
weight: 'Weight',
weightUnit: 'KG',
waist: 'Waist',
waistUnit: 'CM',
},
history: {
allergy: 'Allergies',
disease: 'Conditions',
surgery: 'Surgeries',
familyDisease: 'Family History',
pending: 'To be added',
edit: 'Edit',
modal: {
question: 'Do you have {{type}}?',
yes: 'Yes',
no: 'No',
addDetails: 'Add Details',
enterSpecific: 'Enter specific condition...',
recommendations: 'Recommendations',
save: 'Save',
none: 'None',
yesNoDetails: 'Yes (No details)',
diagnosisDate: 'Diagnosis Date',
namePlaceholder: 'Condition Name',
addItem: 'Add Record',
selectDate: 'Select Date'
},
recommendationItems: {
allergy: {
penicillin: 'Penicillin',
sulfonamides: 'Sulfonamides',
peanuts: 'Peanuts',
seafood: 'Seafood',
pollen: 'Pollen',
dustMites: 'Dust Mites',
alcohol: 'Alcohol',
mango: 'Mango'
},
disease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
asthma: 'Asthma',
heartDisease: 'Heart Disease',
gastritis: 'Gastritis',
migraine: 'Migraine'
},
surgery: {
appendectomy: 'Appendectomy',
cesareanSection: 'Cesarean Section',
tonsillectomy: 'Tonsillectomy',
fractureRepair: 'Fracture Repair',
none: 'None'
},
familyDisease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
cancer: 'Cancer',
heartDisease: 'Heart Disease',
stroke: 'Stroke',
alzheimers: 'Alzheimer\'s'
}
}
}
}
}
};

20
i18n/en/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as Challenge from './challenge';
import * as Common from './common';
import * as Diet from './diet';
import * as Health from './health';
import * as Medication from './medication';
import * as Mood from './mood';
import * as Personal from './personal';
import * as Weight from './weight';
export default {
...Personal,
...Health,
...Diet,
...Medication,
...Weight,
...Challenge,
...Mood,
...Common,
...Common.common, // 确保通用翻译被正确导出
};

543
i18n/en/medication.ts Normal file
View File

@@ -0,0 +1,543 @@
export const medications = {
greeting: 'Hello, {{name}}',
welcome: 'Welcome to Medication Assistant!',
todayMedications: 'Today\'s Medications',
filters: {
all: 'All',
taken: 'Taken',
missed: 'Missed',
},
emptyState: {
title: 'No medications scheduled for today',
subtitle: 'No medication plans added yet. Let\'s add some.',
},
stack: {
completed: 'Completed ({{count}})',
},
dateFormats: {
today: 'Today, {{date}}',
other: '{{date}}',
},
// MedicationCard
card: {
status: {
missed: 'Missed',
timeToTake: 'Time to take',
remaining: '{{time}} remaining',
},
action: {
takeNow: 'Take Now',
taken: 'Taken',
skipped: 'Skipped',
skip: 'Skip',
submitting: 'Submitting...',
},
skipAlert: {
title: 'Confirm Skip',
message: 'Are you sure you want to skip this medication?\n\nIt will not be recorded as taken.',
cancel: 'Cancel',
confirm: 'Confirm Skip',
},
earlyTakeAlert: {
title: 'Not yet time to take medication',
message: 'This medication is scheduled for {{time}}, which is more than 1 hour from now.\n\nHave you already taken this medication?',
cancel: 'Cancel',
confirm: 'Confirm Taken',
},
takeError: {
title: 'Operation Failed',
message: 'An error occurred while recording medication, please try again later',
confirm: 'OK',
},
skipError: {
title: 'Operation Failed',
message: 'Skip operation failed, please try again later',
confirm: 'OK',
},
},
// Add Medication Page
add: {
title: 'Add Medication',
steps: {
name: 'Medication Name',
dosage: 'Dosage & Form',
frequency: 'Frequency',
time: 'Reminder Time',
note: 'Notes',
},
descriptions: {
name: 'Name the medication and upload package photo for easy identification',
dosage: 'Select tablet type and fill in dosage per administration',
frequency: 'Set medication frequency and daily times',
time: 'Add and manage daily reminder times',
note: 'Fill in notes or doctor instructions (optional)',
},
name: {
placeholder: 'Enter or search medication name',
},
photo: {
title: 'Upload Medication Photo',
subtitle: 'Take a photo or select from album to help identify medication packaging',
selectTitle: 'Select Image',
selectMessage: 'Please select image source',
camera: 'Camera',
album: 'From Album',
cancel: 'Cancel',
retake: 'Retake',
uploading: 'Uploading...',
uploadingText: 'Uploading',
remove: 'Remove',
cameraPermission: 'Camera permission is required to take medication photos',
albumPermission: 'Album permission is required to select medication photos',
uploadFailed: 'Upload Failed',
uploadFailedMessage: 'Image upload failed, please try again later',
cameraFailed: 'Camera Failed',
cameraFailedMessage: 'Unable to open camera, please try again later',
selectFailed: 'Selection Failed',
selectFailedMessage: 'Unable to open album, please try again later',
},
dosage: {
label: 'Dosage per administration',
placeholder: '0.5',
type: 'Type',
unitSelector: 'Select dosage unit',
},
frequency: {
label: 'Times per day',
value: '{{count}} times/day',
period: 'Medication period',
start: 'Start',
end: 'End',
longTerm: 'Long-term',
startDateInvalid: 'Invalid date',
startDateInvalidMessage: 'Start date cannot be earlier than today',
endDateInvalid: 'Invalid date',
endDateInvalidMessage: 'End date cannot be earlier than start date',
},
time: {
label: 'Daily reminder times',
addTime: 'Add Time',
editTime: 'Edit Reminder Time',
addTimeButton: 'Add Time',
},
note: {
label: 'Notes',
placeholder: 'Record precautions, doctor instructions or custom reminders',
voiceNotSupported: 'Voice-to-text is not supported on this device, you can type notes directly',
voiceError: 'Voice recognition unavailable',
voiceErrorMessage: 'Unable to use voice input, please check permission settings and try again',
voiceStartError: 'Unable to start voice input',
voiceStartErrorMessage: 'Please check microphone and voice recognition permissions and try again',
},
actions: {
previous: 'Previous',
next: 'Next',
complete: 'Complete',
},
success: {
title: 'Added Successfully',
message: 'Successfully added medication "{{name}}"',
confirm: 'OK',
},
error: {
title: 'Add Failed',
message: 'An error occurred while creating medication, please try again later',
confirm: 'OK',
},
datePickers: {
startDate: 'Select Start Date',
endDate: 'Select End Date',
time: 'Select Time',
cancel: 'Cancel',
confirm: 'Confirm',
},
pickers: {
timesPerDay: 'Select Times Per Day',
dosageUnit: 'Select Dosage Unit',
cancel: 'Cancel',
confirm: 'Confirm',
},
},
// Medication Management Page
manage: {
title: 'Medication Management',
subtitle: 'Manage status and reminders for all medications',
filters: {
all: 'All',
active: 'Active',
inactive: 'Inactive',
},
loading: 'Loading medication information...',
empty: {
title: 'No Medications',
subtitle: 'No medication records yet, click the top right to add',
},
deactivate: {
title: 'Deactivate {{name}}?',
description: 'After deactivation, medication plans generated for the day will be deleted and cannot be recovered.',
confirm: 'Confirm Deactivation',
cancel: 'Cancel',
error: {
title: 'Operation Failed',
message: 'An error occurred while deactivating medication, please try again later.',
},
},
toggleError: {
title: 'Operation Failed',
message: 'An error occurred while toggling medication status, please try again later.',
},
formLabels: {
capsule: 'Capsule',
pill: 'Tablet',
tablet: 'Tablet',
injection: 'Injection',
spray: 'Spray',
drop: 'Drops',
syrup: 'Syrup',
other: 'Other',
ointment: 'Ointment',
},
frequency: {
daily: 'Daily',
weekly: 'Weekly',
custom: 'Custom',
},
cardMeta: 'Started {{date}} Reminder: {{reminder}}',
reminderNotSet: 'Not set',
unknownDate: 'Unknown date',
},
// Medication Detail Page
detail: {
title: 'Medication Details',
notFound: {
title: 'Medication information not found',
subtitle: 'Please re-enter this page from the medication list.',
},
loading: 'Loading...',
error: {
title: 'Unable to retrieve medication information at this time, please try again later.',
subtitle: 'Please check your network and try again, or return to the previous page.',
},
sections: {
plan: 'Medication Plan',
dosage: 'Dosage & Form',
note: 'Notes',
overview: 'Medication Overview',
aiAnalysis: 'AI Medication Analysis',
},
plan: {
period: 'Medication Period',
time: 'Medication Time',
frequency: 'Frequency',
expiryDate: 'Expiry Date',
longTerm: 'Long-term',
periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}',
longTermPlan: 'Medication plan: Long-term medication',
timeMessage: 'Set times: {{times}}',
dateFormat: 'MMM D, YYYY',
periodRange: 'From {{startDate}} to {{endDate}}',
periodLongTerm: 'From {{startDate}} until indefinitely',
expiryStatus: {
notSet: 'Set Expiry',
expired: 'Expired',
expiresToday: 'Expires today',
expiresInDays: 'Expires in {{days}} days',
},
},
dosage: {
label: 'Dosage per administration',
form: 'Form',
selectDosage: 'Select Dosage',
selectForm: 'Select Form',
dosageValue: 'Dosage Value',
unit: 'Unit',
},
note: {
label: 'Medication Notes',
placeholder: 'Record precautions, doctor instructions or custom reminders',
edit: 'Edit Notes',
noNote: 'No notes',
voiceNotSupported: 'Voice-to-text is not supported on this device, you can type notes directly',
save: 'Save',
saveError: {
title: 'Save Failed',
message: 'An error occurred while submitting notes, please try again later.',
},
},
overview: {
calculating: 'Calculating...',
takenCount: 'Taken {{count}} times in total',
calculatingDays: 'Calculating adherence days',
startedDays: 'Adhered for {{days}} days',
startDate: 'Started {{date}}',
noStartDate: 'No start date',
},
aiAnalysis: {
analyzing: 'Analyzing medication information...',
analyzingButton: 'Analyzing...',
reanalyzeButton: 'Reanalyze',
getAnalysisButton: 'Get AI Analysis',
button: 'AI Analysis',
status: {
generated: 'Generated',
memberExclusive: 'Member Exclusive',
pending: 'Pending',
},
title: 'Analysis Results',
recommendation: 'AI Recommended',
placeholder: 'Get AI analysis to quickly understand suitable populations, ingredient safety, and usage recommendations.',
categories: {
suitableFor: 'Suitable For',
unsuitableFor: 'Unsuitable For',
sideEffects: 'Possible Side Effects',
storageAdvice: 'Storage Advice',
healthAdvice: 'Health/Usage Advice',
},
membershipCard: {
title: 'Member Exclusive AI In-depth Analysis',
subtitle: 'Unlock complete medication analysis and unlimited usage',
},
error: {
title: 'Analysis Failed',
message: 'AI analysis failed, please try again later',
networkError: 'Failed to initiate analysis request, please check network connection',
unauthorized: 'Please log in first',
forbidden: 'No access to this medication',
notFound: 'Medication not found',
},
},
aiDraft: {
reshoot: 'Reshoot',
saveAndCreate: 'Save & Create',
saveError: {
title: 'Save Failed',
message: 'An error occurred while creating medication, please try again later',
},
},
status: {
enabled: 'Reminders Enabled',
disabled: 'Reminders Disabled',
},
delete: {
title: 'Delete {{name}}?',
description: 'After deletion, reminders and history related to this medication will be cleared and cannot be recovered.',
confirm: 'Delete',
cancel: 'Cancel',
error: {
title: 'Delete Failed',
message: 'An error occurred while removing this medication, please try again later.',
},
},
deactivate: {
title: 'Deactivate {{name}}?',
description: 'After deactivation, medication plans generated for the day will be deleted and cannot be recovered.',
confirm: 'Confirm Deactivation',
cancel: 'Cancel',
error: {
title: 'Operation Failed',
message: 'An error occurred while deactivating medication, please try again later.',
},
},
toggleError: {
title: 'Operation Failed',
message: 'An error occurred while toggling reminder status, please try again later.',
},
updateErrors: {
dosage: 'Update Failed',
dosageMessage: 'An error occurred while updating dosage, please try again later.',
form: 'Update Failed',
formMessage: 'An error occurred while updating form, please try again later.',
expiryDate: 'Update Failed',
expiryDateMessage: 'Failed to update expiry date, please try again later.',
},
imageViewer: {
close: 'Close',
},
pickers: {
cancel: 'Cancel',
confirm: 'Confirm',
},
},
// Edit Frequency Page
editFrequency: {
title: 'Edit Medication Frequency',
missingParams: 'Missing required parameters',
medicationName: 'Editing: {{name}}',
sections: {
frequency: 'Medication Frequency',
frequencyDescription: 'Set daily medication frequency',
time: 'Daily Reminder Times',
timeDescription: 'Add and manage daily reminder times',
},
frequency: {
repeatPattern: 'Repeat Pattern',
timesPerDay: 'Times Per Day',
daily: 'Daily',
weekly: 'Weekly',
custom: 'Custom',
timesLabel: '{{count}} times',
summary: '{{pattern}} {{count}} times',
},
time: {
addTime: 'Add Time',
editTime: 'Edit Reminder Time',
addTimeButton: 'Add Time',
},
actions: {
save: 'Save Changes',
},
error: {
title: 'Update Failed',
message: 'An error occurred while updating medication frequency, please try again later.',
},
pickers: {
cancel: 'Cancel',
confirm: 'Confirm',
},
},
aiProgress: {
title: 'Analyzing',
steps: {
analyzing_product: 'Analyzing product...',
analyzing_suitability: 'Checking suitability...',
analyzing_ingredients: 'Evaluating ingredients...',
analyzing_effects: 'Generating safety advice...',
completed: 'Completed, loading details...',
},
errors: {
default: 'Recognition failed, please retake photo',
queryFailed: 'Query failed, please try again later',
},
modal: {
title: 'Retake Required',
retry: 'Retake Photo',
},
},
aiCamera: {
title: 'AI Scan',
steps: {
front: {
title: 'Front',
subtitle: 'Ensure medication name is clearly visible',
},
side: {
title: 'Back',
subtitle: 'Include specs and ingredients info',
},
aux: {
title: 'Side',
subtitle: 'Add more details to improve accuracy',
},
stepProgress: 'Step {{current}} / {{total}}',
optional: '(Optional)',
notTaken: 'Empty',
},
buttons: {
flip: 'Flip',
capture: 'Snap',
complete: 'Done',
album: 'Album',
},
permission: {
title: 'Camera Permission Required',
description: 'Allow access to capture medication packaging for automatic recognition',
button: 'Allow Camera Access',
},
alerts: {
pickFailed: {
title: 'Selection Failed',
message: 'Please try again or choose another image',
},
captureFailed: {
title: 'Capture Failed',
message: 'Please try again',
},
insufficientPhotos: {
title: 'Photos Missing',
message: 'Please capture at least front and back sides',
},
taskFailed: {
title: 'Task Creation Failed',
defaultMessage: 'Please check network and try again',
},
},
guideModal: {
badge: 'Guide',
title: 'Keep Photos Clear',
description1: 'Please capture the product name and description on the front/back of the medication.',
description2: 'Ensure good lighting, avoid glare, and keep text legible. Photo clarity affects recognition accuracy.',
button: 'Got it!',
},
},
aiSummary: {
title: 'AI Medication Summary',
headerBadge: 'AI Insight',
subtitle: 'Adherence and safety overview',
overviewTitle: 'Adherence snapshot',
keyInsights: 'AI key insight',
refresh: 'DeepSeek is analyzing, please wait...',
stats: {
activePlans: 'Active plans',
plannedDoses: 'Planned doses',
takenDoses: 'Taken doses',
completion: 'Overall completion',
avgCompletion: 'Avg adherence',
activeDays: 'Planned days',
},
badges: {
adherence: 'Adherence',
safety: 'Monitoring',
},
doseSummary: 'Completed {{taken}} / {{planned}}',
daysLabel: '{{days}} day plan • {{times}} times/day',
completionLabel: '{{value}}% completed',
emptyTitle: 'No active medication plans',
emptyDescription: 'Activate or add a plan to generate the AI summary.',
error403: 'Free AI quota is used up, please upgrade to continue.',
genericError: 'Unable to load AI summary, please try again later.',
keyInsightPlaceholder: 'No AI insight available yet.',
listTitle: 'Plan breakdown',
updatedAt: 'Updated {{time}}',
pillChip: 'Professional advice',
retry: 'Retry',
infoModal: {
badge: 'Info',
title: 'Refresh & Adherence',
point1: '• Daily Generation: Based on active medication plans and actual check-in data, generated daily.',
point2: '• Refresh Effect: Retrieves the latest plan vs. actual completion and AI analysis, no extra quota used.',
point3: '• Adherence: Degree of following the plan (completion rate). Higher means better compliance and lower risk.',
point4: '• Statistics: Only counts plans with isActive=true and not deleted; completion only counts records with status "taken".',
button: 'Got it',
},
completionInfoModal: {
badge: 'Calculation',
title: 'Completion Calculation Logic',
point1: '• Overall completion = (Total actual doses taken ÷ Total planned doses) × 100%',
point2: '• Actual doses taken: Number of medication records marked as "taken"',
point3: '• Planned doses: Total doses calculated from the plan start date to current date based on daily frequency',
point4: '• Detailed calculation: (Current date - Start date + 1) × Daily doses, e.g.: Day 5 with 2 daily doses = 10 total planned doses',
point5: '• Individual plan completion = (Actual doses taken for that plan ÷ Planned doses for that plan) × 100%',
button: 'Understood',
},
},
aiSummaryInfo: {
title: 'AI Medication Summary',
placeholderImage: 'Intro Image',
viewImage: 'View Full Image',
features: {
intelligent: {
title: 'Intelligent Analysis',
description: 'AI deeply analyzes your medication records to provide personalized health recommendations',
},
tracking: {
title: 'Trend Tracking',
description: 'Long-term tracking of medication effects to help optimize treatment plans',
},
professional: {
title: 'Professional & Reliable',
description: 'Based on medical knowledge base, providing safe and reliable health analysis',
},
},
confirmButton: 'Subscribe Now',
},
};

95
i18n/en/mood.ts Normal file
View File

@@ -0,0 +1,95 @@
export const mood = {
calendar: {
title: 'Mood Calendar',
weekDays: {
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
},
months: {
january: 'Jan',
february: 'Feb',
march: 'Mar',
april: 'Apr',
may: 'May',
june: 'Jun',
july: 'Jul',
august: 'Aug',
september: 'Sep',
october: 'Oct',
november: 'Nov',
december: 'Dec',
},
selectedDate: {
selectDate: 'Please select a date',
record: 'Record',
noRecord: 'No mood records',
noRecordHint: 'Click the "Record" button in the top right to add mood',
noDateSelected: 'Please select a date first',
noDateSelectedHint: 'Click a date in the calendar, then click the "Record" button to add mood',
intensity: 'Intensity',
dateFormat: 'MMMM D, YYYY',
},
errors: {
loadMonthDataFailed: 'Failed to load monthly mood data',
loadDailyDataFailed: 'Failed to load mood records',
},
},
types: {
happy: 'Happy',
excited: 'Excited',
thrilled: 'Thrilled',
calm: 'Calm',
anxious: 'Anxious',
sad: 'Sad',
lonely: 'Lonely',
wronged: 'Wronged',
angry: 'Angry',
tired: 'Tired',
},
edit: {
title: 'Record Mood',
editTitle: 'Edit Mood',
selectMood: 'Select Mood',
intensity: 'Mood Intensity',
intensityLow: 'Low',
intensityHigh: 'High',
diary: 'Mood Diary',
diarySubtitle: 'Record your feelings and cherish beautiful memories',
placeholder: `How are you feeling today?
Did you experience anything special?
What made you happy?
Or, what's bothering you?
Write down your feelings and let these moments become your precious memories...`,
save: 'Save Mood',
update: 'Update Mood',
saving: 'Saving...',
dateFormat: 'MMMM D, YYYY',
alerts: {
selectMood: 'Please select a mood',
saveSuccess: 'Mood record saved',
updateSuccess: 'Mood record updated',
deleteSuccess: 'Mood record deleted',
saveError: 'Failed to save mood, please try again',
deleteError: 'Failed to delete mood, please try again',
confirmDelete: 'Are you sure you want to delete this mood record?',
confirmDeleteTitle: 'Confirm Delete',
},
},
history: {
title: 'Mood Records',
noRecords: 'No mood records',
totalRecords: 'Total Records',
averageIntensity: 'Average Intensity',
mostFrequent: 'Most Frequent',
recentRecords: 'Recent Records',
intensity: 'Intensity',
dateTimeFormat: 'MM/DD HH:mm',
},
};

437
i18n/en/personal.ts Normal file
View File

@@ -0,0 +1,437 @@
export const personal = {
edit: 'Edit',
login: 'Log in',
memberNumber: 'Member ID: {{number}}',
aiUsage: 'Free AI credits: {{value}}',
aiUsageUnlimited: 'Unlimited',
fishRecord: 'Energy log',
badgesPreview: {
title: 'My badges',
subtitle: 'Celebrate every milestone',
cta: 'View all',
loading: 'Syncing your badges…',
empty: 'Complete sleep or challenge tasks to unlock your first badge.',
lockedHint: 'Keep building the habit to unlock more.',
},
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',
},
membershipBanner: {
title: 'Unlock Premium Access',
subtitle: 'Get unlimited access to AI features & custom plans',
cta: 'Upgrade Now',
},
sections: {
notifications: 'Notifications',
developer: 'Developer',
other: 'Other',
account: 'Account & Security',
language: 'Language',
healthData: 'Health data permissions',
medicalSources: 'Medical Advice Sources',
customization: 'Customization',
},
healthProfile: {
title: 'Health Profile',
subtitle: 'Manage your personal health data and family profile',
},
versionCheck: {
sectionTitle: 'Updates',
menuTitle: 'Check for updates',
checking: 'Checking for updates...',
upToDate: 'You are on the latest version',
updateBadge: 'v{{version}} available',
failed: 'Update check failed, please try again later',
updateFound: 'New version v{{version}}',
modalTitle: 'Update available',
modalTag: 'New',
currentVersion: 'Current',
latestVersion: 'Latest',
releaseNotesTitle: "What's new",
fallbackNotes: 'Performance improvements and fixes to keep things smooth.',
later: 'Remind me later',
updateNow: 'Update now',
missingUrl: 'Store link is not ready yet',
openStoreFailed: 'Could not open the store, please try again',
},
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',
whoSource: 'World Health Organization (WHO)',
tabBarConfig: 'Tab Bar Settings',
},
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',
},
},
},
tabBarConfig: {
title: 'Tab Bar Settings',
subtitle: 'Customize your bottom navigation',
description: 'Use toggles to show or hide tabs',
resetButton: 'Reset',
cannotDisable: 'Cannot be disabled',
vipOnly: 'VIP members only',
resetConfirm: {
title: 'Reset to Default?',
message: 'This will reset all tab bar settings and visibility',
cancel: 'Cancel',
confirm: 'Confirm',
},
resetSuccess: 'Settings reset to default',
},
};
export const editProfile = {
title: 'Edit Profile',
fields: {
name: 'Nickname',
gender: 'Gender',
height: 'Height',
weight: 'Weight',
activityLevel: 'Activity Level',
birthDate: 'Birth Date',
maxHeartRate: 'Max Heart Rate',
},
gender: {
male: 'Male',
female: 'Female',
notSet: 'Not set',
},
height: {
unit: 'cm',
placeholder: '170cm',
},
weight: {
unit: 'kg',
placeholder: '55kg',
},
activityLevels: {
1: 'Sedentary',
2: 'Lightly active',
3: 'Moderately active',
4: 'Very active',
descriptions: {
1: 'Rarely exercise',
2: 'Exercise 1-3 times per week',
3: 'Exercise 3-5 times per week',
4: 'Exercise 6-7 times per week',
},
},
birthDate: {
placeholder: 'January 1, 1995',
format: '{{month}} {{day}}, {{year}}',
},
maxHeartRate: {
unit: 'bpm',
notAvailable: 'Not available',
alert: {
title: 'Notice',
message: 'Max heart rate data is automatically retrieved from Health app',
},
},
alerts: {
notLoggedIn: {
title: 'Not logged in',
message: 'Please log in before trying to save',
},
saveFailed: {
title: 'Save failed',
message: 'Please try again later',
},
avatarPermissions: {
title: 'Insufficient permissions',
message: 'Photo album permission is required to select avatar',
},
avatarUploadFailed: {
title: 'Upload failed',
message: 'Avatar upload failed, please try again',
},
avatarError: {
title: 'Error occurred',
message: 'Failed to select avatar, please try again',
},
avatarSuccess: {
title: 'Success',
message: 'Avatar updated successfully',
},
},
modals: {
cancel: 'Cancel',
confirm: 'Confirm',
save: 'Save',
input: {
namePlaceholder: 'Enter nickname',
weightPlaceholder: 'Enter weight',
weightUnit: 'kg',
},
selectHeight: 'Select Height',
selectGender: 'Select Gender',
selectActivityLevel: 'Select Activity Level',
female: 'Female',
male: 'Male',
},
defaultValues: {
name: 'TonightEatMeat',
height: 170,
weight: 55,
birthDate: '1995-01-01',
activityLevel: 1,
},
};
export const login = {
title: 'Log In',
subtitle: 'Healthy living, freedom through self-discipline',
appleLogin: 'Sign in with Apple',
loggingIn: 'Logging in...',
agreement: {
readAndAgree: 'I have read and agree to ',
privacyPolicy: 'Privacy Policy',
and: ' and ',
userAgreement: 'User Agreement',
alert: {
title: 'Please read and agree',
message: 'Please read and check the "Privacy Policy" and "User Agreement" before continuing. Clicking "Agree and Continue" will automatically check the box and proceed.',
cancel: 'Cancel',
confirm: 'Agree and Continue',
},
},
errors: {
appleIdentityTokenMissing: 'Failed to get Apple identity token',
loginFailed: 'Login failed, please try again later',
loginFailedTitle: 'Login Failed',
},
success: {
loginSuccess: 'Login Successful',
},
};
export const authGuard = {
logout: {
error: 'Logout Failed',
errorMessage: 'Failed to logout, please try again later',
},
confirmLogout: {
title: 'Confirm Logout',
message: 'Are you sure you want to logout of your current account?',
cancelButton: 'Cancel',
confirmButton: 'Confirm',
},
deleteAccount: {
successTitle: 'Account Deleted',
successMessage: 'Your account has been successfully deleted',
confirmButton: 'OK',
errorTitle: 'Deletion Failed',
errorMessage: 'Failed to delete account, please try again later',
},
confirmDeleteAccount: {
title: 'Confirm Account Deletion',
message: 'This action cannot be undone. Your account and all related data will be permanently deleted. Are you sure you want to continue?',
cancelButton: 'Cancel',
confirmButton: 'Confirm Deletion',
},
};
export const membershipModal = {
plans: {
lifetime: {
title: 'Lifetime',
subtitle: 'Lifetime companion, witnessing every health transformation',
},
quarterly: {
title: 'Quarterly',
subtitle: '3-month scientific plan, making health a habit',
},
weekly: {
title: 'Weekly',
subtitle: '7-day trial, experience the power of professional guidance',
},
unknown: 'Unknown Plan',
tag: 'Best Value',
},
benefits: {
title: 'Benefits Comparison',
subtitle: 'Core benefits at a glance, choose with confidence',
table: {
benefit: 'Benefit',
vip: 'VIP',
regular: 'Regular',
},
items: {
aiCalories: {
title: 'AI Calorie Tracking',
description: 'Photo recognition for automatic calorie tracking',
},
aiNutrition: {
title: 'AI Nutrition Label',
description: 'Identify nutrition facts from food packaging',
},
healthReminder: {
title: 'Daily Health Reminder',
description: 'Personalized health reminders based on goals',
},
aiMedication: {
title: 'AI Medication Manager',
description: 'Deep analysis of interactions & personalized schedules',
},
customChallenge: {
title: 'Unlimited Custom Challenges',
description: 'Create exclusive challenges & invite friends to join the journey',
},
},
permissions: {
unlimited: 'Unlimited',
limited: 'Limited',
dailyLimit: '{{count}} times/day',
fullSupport: 'Full Support',
basicSupport: 'Basic',
smartReminder: 'Smart',
fullAnalysis: 'Deep Analysis',
createUnlimited: 'Unlimited',
notSupported: 'Not Supported',
},
},
sectionTitle: {
plans: 'Membership Plans',
plansSubtitle: 'Flexible choices, improve at your own pace',
},
actions: {
subscribe: 'Subscribe Now',
processing: 'Processing...',
restore: 'Restore Purchase',
restoring: 'Restoring...',
back: 'Back',
close: 'Close membership modal',
selectPlan: 'Select {{plan}} plan',
purchaseHint: 'Click to purchase {{plan}} membership',
},
agreements: {
prefix: 'By subscribing, you agree to',
userAgreement: 'User Agreement',
membershipAgreement: 'Membership Agreement',
autoRenewalAgreement: 'Auto-Renewal Agreement',
alert: {
title: 'Please read and agree',
message: 'Please agree to User Agreement, Membership Agreement and Auto-Renewal Agreement before purchasing',
confirm: 'OK',
},
},
errors: {
noProducts: 'No membership products found. Please configure iOS products in RevenueCat and sync to current Offering.',
purchaseCancelled: 'Purchase cancelled',
alreadyPurchased: 'You already own this item',
networkError: 'Network connection failed',
paymentPending: 'Payment is processing',
invalidCredentials: 'Account verification failed',
purchaseFailed: 'Purchase failed',
restoreSuccess: 'Restore successful',
restoreFailed: 'Restore failed',
restoreCancelled: 'Restore cancelled',
restorePartialFailed: 'Restore partially failed',
noPurchasesFound: 'No purchase records found',
selectPlan: 'Please select a plan',
},
loading: {
products: 'Loading membership plans, please wait',
purchase: 'Purchase in progress, please wait',
},
success: {
purchase: 'Membership activated successfully',
},
};
export const notificationSettings = {
title: 'Notification Settings',
loading: 'Loading...',
sections: {
notifications: 'Notification Settings',
medicationReminder: 'Medication Reminder',
nutritionReminder: 'Nutrition Reminder',
moodReminder: 'Mood Reminder',
description: 'Description',
},
items: {
pushNotifications: {
title: 'Push Notifications',
description: 'Receive app notifications when enabled',
},
medicationReminder: {
title: 'Medication Reminder',
description: 'Receive reminder notifications at medication time',
},
nutritionReminder: {
title: 'Nutrition Record Reminder',
description: 'Receive nutrition record reminders at meal times',
},
moodReminder: {
title: 'Mood Record Reminder',
description: 'Receive mood record reminders in the evening',
},
},
description: {
text: '• Push notifications is the master switch for all notifications\n• Various reminders require push notifications to be enabled\n• You can manage notification permissions in system settings\n• Disabling push notifications will stop all app notifications',
},
alerts: {
permissionDenied: {
title: 'Permission Denied',
message: 'Please enable notification permission in system settings, then try to enable push notifications',
cancel: 'Cancel',
goToSettings: 'Go to Settings',
},
error: {
title: 'Error',
message: 'Failed to request notification permission',
saveFailed: 'Failed to save settings',
medicationReminderFailed: 'Failed to set medication reminder',
nutritionReminderFailed: 'Failed to set nutrition reminder',
moodReminderFailed: 'Failed to set mood reminder',
},
notificationsEnabled: {
title: 'Notifications Enabled',
body: 'You will receive app notifications and reminders',
},
medicationReminderEnabled: {
title: 'Medication Reminder Enabled',
body: 'You will receive reminder notifications at medication time',
},
nutritionReminderEnabled: {
title: 'Nutrition Reminder Enabled',
body: 'You will receive nutrition record reminders at meal times',
},
moodReminderEnabled: {
title: 'Mood Reminder Enabled',
body: 'You will receive mood record reminders in the evening',
},
},
};

31
i18n/en/weight.ts Normal file
View File

@@ -0,0 +1,31 @@
export const weightRecords = {
title: 'Weight Records',
pageSubtitle: 'Track your weight progress and trends',
loadingHistory: 'Failed to load weight history',
history: 'Records',
historyMonthFormat: '{{year}}.{{month}}',
stats: {
currentWeight: 'Current Weight',
initialWeight: 'Starting Weight',
targetWeight: 'Target Weight',
},
empty: {
title: 'No weight records',
subtitle: 'Tap the + button to add your first record.',
},
modal: {
recordWeight: 'Record Weight',
editInitialWeight: 'Edit Starting Weight',
editTargetWeight: 'Edit Target Weight',
editRecord: 'Edit Record',
inputPlaceholder: 'Enter weight',
unit: 'kg',
quickSelection: 'Quick pick',
confirm: 'Save',
},
alerts: {
deleteFailed: 'Failed to delete record',
invalidWeight: 'Please enter a valid weight between 0 and 500 kg',
saveFailed: 'Failed to save weight, please try again',
},
};

File diff suppressed because it is too large Load Diff

303
i18n/zh/challenge.ts Normal file
View File

@@ -0,0 +1,303 @@
export const challengeDetail = {
title: '挑战详情',
notFound: '未找到该挑战,稍后再试试吧。',
loading: '加载挑战详情中…',
retry: '重新加载',
share: {
generating: '正在生成分享卡片...',
failed: '分享失败,请稍后重试',
messageJoined: '我正在参与「{{title}}」挑战,已完成 {{completed}}/{{target}} 天!一起加入吧!',
messageNotJoined: '发现一个很棒的挑战「{{title}}」,一起来参与吧!',
},
dateRange: {
format: '{{start}} - {{end}}',
monthDay: '{{month}}月{{day}}日',
ongoing: '持续更新中',
},
participants: {
count: '{{count}} 人正在参与',
ongoing: '持续更新中',
more: '更多',
},
detail: {
requirement: '按日打卡自动累计',
viewAllRanking: '查看全部',
},
checkIn: {
title: '挑战打卡',
todayChecked: '今日已打卡',
subtitle: '每日打卡会累计进度,达成目标天数',
subtitleChecked: '已记录今日进度,明天继续保持',
button: {
checkIn: '立即打卡',
checking: '打卡中…',
checked: '今日已打卡',
notJoined: '加入后打卡',
upcoming: '挑战未开始',
expired: '挑战已结束',
},
toast: {
alreadyChecked: '今日已打卡',
notStarted: '挑战未开始,开始后再来打卡',
expired: '挑战已结束,无法打卡',
mustJoin: '加入挑战后才能打卡',
success: '打卡成功,继续坚持!',
failed: '打卡失败,请稍后再试',
},
},
cta: {
join: '立即加入挑战',
joining: '加入中…',
leave: '退出挑战',
leaving: '退出中…',
delete: '删除挑战',
deleting: '删除中…',
upcoming: '挑战即将开始',
expired: '挑战已结束',
},
highlight: {
join: {
title: '立即加入挑战',
subtitle: '邀请好友一起坚持,更容易收获成果',
},
leave: {
title: '先别急着离开',
subtitle: '再坚持一下,下一个里程碑就要出现了',
},
upcoming: {
title: '挑战即将开始',
subtitle: '{{date}} 开始,敬请期待',
subtitleFallback: '挑战即将开启,敬请期待',
},
expired: {
title: '挑战已结束',
subtitle: '{{date}} 已截止,期待下一次挑战',
subtitleFallback: '本轮挑战已结束,期待下一次挑战',
},
},
alert: {
leaveConfirm: {
title: '确认退出挑战?',
message: '退出后需要重新加入才能继续坚持。',
cancel: '取消',
confirm: '退出挑战',
},
joinFailed: '加入挑战失败',
leaveFailed: '退出挑战失败',
archiveConfirm: {
title: '确认删除该挑战?',
message: '删除后将无法恢复,参与者也将无法再访问此挑战。',
cancel: '取消',
confirm: '删除挑战',
},
archiveFailed: '删除挑战失败',
archiveSuccess: '挑战已删除',
},
ranking: {
title: '排行榜',
description: '',
empty: '榜单即将开启,快来抢占席位。',
today: '今日',
todayGoal: '今日目标',
hour: '小时',
},
leaderboard: {
title: '排行榜',
loading: '加载榜单中…',
notFound: '未找到该挑战。',
loadFailed: '暂时无法加载榜单,请稍后再试。',
empty: '榜单即将开启,快来抢占席位。',
loadMore: '加载更多…',
loadMoreFailed: '加载更多失败,请下拉刷新重试',
},
shareCard: {
footer: 'Out Live · 超越生命',
progress: {
label: '我的坚持进度',
days: '{{completed}} / {{target}} 天',
completed: '🎉 已完成挑战!',
remaining: '还差 {{remaining}} 天完成挑战',
},
info: {
checkInDaily: '按日打卡',
joinUs: '快来一起坚持吧',
},
shareCode: {
copied: '分享码已复制',
},
},
shareCode: {
copied: '分享码已复制',
},
};
export const badges = {
title: '勋章馆',
subtitle: '点亮每一次坚持',
hero: {
highlight: '保持连续打卡即可解锁更多稀有勋章',
earnedLabel: '已获得',
totalLabel: '总数',
progressLabel: '解锁进度',
},
categories: {
all: '全部',
sleep: '睡眠',
exercise: '运动',
diet: '饮食',
challenge: '挑战',
social: '社交',
special: '特别',
},
rarities: {
common: '普通',
uncommon: '少见',
rare: '稀有',
epic: '史诗',
legendary: '传说',
},
status: {
earned: '已获得',
locked: '待解锁',
earnedAt: '{{date}} 获得',
},
legend: '稀有度说明',
filterLabel: '勋章分类',
empty: {
title: '还没有勋章',
description: '完成睡眠、运动、挑战等任务即可点亮你的第一枚勋章。',
action: '去探索计划',
},
};
export const challenges = {
title: '挑战',
subtitle: '加入官方或自定义挑战,一起坚持',
loading: '加载挑战中…',
loadFailed: '暂时无法获取挑战,请稍后再试。',
retry: '重新加载',
empty: '暂时没有挑战,先去创建或加入一个吧。',
customChallenges: '自定义挑战',
officialChallengesTitle: '官方挑战',
officialChallenges: '官方挑战即将上线。',
join: '加入',
joined: '已加入',
invalidInviteCode: '请输入有效的分享码',
joinSuccess: '加入挑战成功',
joinFailed: '加入失败,请稍后再试',
joinModal: {
title: '输入分享码加入',
description: '输入好友分享码即可加入挑战',
confirm: '加入挑战',
joining: '加入中…',
cancel: '取消',
placeholder: '请输入分享码',
},
statusLabels: {
upcoming: '即将开始',
ongoing: '进行中',
expired: '已结束',
},
createCustom: {
title: '创建挑战',
editTitle: '编辑挑战',
yourChallenge: '你的挑战',
basicInfo: '基础信息',
challengeSettings: '挑战设置',
displayInteraction: '展示与互动',
durationDays: '{{days}} 天',
durationDaysChallenge: '{{days}} 天挑战',
dayUnit: '天',
defaultTitle: '自定义挑战',
rankingDescription: '榜单每日更新',
typeLabels: {
water: '饮水',
exercise: '运动',
diet: '饮食',
sleep: '睡眠',
mood: '心情',
weight: '体重',
custom: '自定义',
},
fields: {
title: '挑战名称',
titlePlaceholder: '例如 21 天早睡',
coverImage: '封面图',
uploadCover: '上传封面',
challengeDescription: '挑战简介',
descriptionPlaceholder: '写下挑战目标和打卡方式',
challengeType: '挑战类型',
challengeTypeHelper: '选择最贴近目标的类型',
timeRange: '时间范围',
start: '开始日期',
end: '结束日期',
duration: '持续时间',
periodLabel: '周期标签',
periodLabelPlaceholder: '例如 21 天养成计划',
dailyTargetAndUnit: '每日目标与单位',
dailyTargetPlaceholder: '每日目标数值',
unitPlaceholder: '单位(杯/分钟/步数等)',
unitHelper: '选填,展示在每日目标后',
minimumCheckInDays: '最少打卡天数',
minimumCheckInDaysPlaceholder: '不能超过总天数',
maxParticipants: '参与人数上限',
noLimit: '不限制',
isPublic: '允许他人通过分享码加入',
publicDescription: '开启后他人可凭分享码加入;关闭则仅自己可见',
},
floatingCTA: {
title: '生成分享码',
subtitle: '创建挑战并分享给好友一起打卡',
editTitle: '保存更改',
editSubtitle: '更新挑战信息并同步给参与者',
},
buttons: {
createAndGenerateCode: '创建并生成分享码',
creating: '创建中…',
updateAndSave: '保存修改',
updating: '保存中…',
},
datePicker: {
confirm: '确认',
cancel: '取消',
},
alerts: {
titleRequired: '请输入挑战名称',
endTimeError: '结束时间需要晚于开始时间',
targetValueError: '每日目标需在 1-1000 之间',
minimumDaysError: '最少打卡天数需在 1-365 之间',
minimumDaysExceedError: '最少打卡天数不能超过挑战总天数',
participantsError: '人数需在 2-10000 之间或留空',
createFailed: '创建挑战失败',
createSuccess: '挑战已创建',
updateSuccess: '挑战已更新',
},
imageUpload: {
selectSource: '选择封面',
selectMessage: '拍照或从相册选择封面',
camera: '拍照',
album: '相册',
cancel: '取消',
cameraPermission: '需要相机权限',
cameraPermissionMessage: '请开启相机权限以拍摄封面',
albumPermissionMessage: '请开启相册权限以选择图片',
cameraFailed: '打开相机失败',
cameraFailedMessage: '请重试或从相册选择',
selectFailed: '选择失败',
selectFailedMessage: '暂时无法选择图片,请重试',
uploadFailed: '上传失败',
uploadFailedMessage: '封面上传失败,请稍后重试',
uploading: '上传中…',
clear: '移除封面',
helper: '推荐使用 16:9 的高清图片,大小 2MB 内',
},
shareModal: {
title: '分享码已生成',
subtitle: '分享给好友即可一起参与挑战',
generatingCode: '生成中…',
copyCode: '复制分享码',
viewChallenge: '查看挑战',
later: '稍后再说',
},
},
};

14
i18n/zh/common.ts Normal file
View File

@@ -0,0 +1,14 @@
export const dateSelector = {
backToToday: '回到今天',
cancel: '取消',
confirm: '确定',
};
export const common = {
alert: '提示',
success: '成功',
error: '错误',
delete: '删除',
confirm: '确定',
cancel: '取消',
};

551
i18n/zh/diet.ts Normal file
View File

@@ -0,0 +1,551 @@
export const nutritionRecords = {
title: '营养记录',
listTitle: '今日餐食',
recordCount: '{{count}} 条记录',
empty: {
title: '今天还没有记录',
action: '记一笔',
},
footer: {
end: '- 已经到底啦 -',
loadMore: '加载更多',
},
delete: {
title: '确认删除',
message: '确定要删除这条营养记录吗?此操作无法撤销。',
cancel: '取消',
confirm: '删除',
},
mealTypes: {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
other: '其他',
},
nutrients: {
protein: '蛋白质',
fat: '脂肪',
carbs: '碳水',
unit: 'g',
caloriesUnit: '千卡',
},
overlay: {
title: '记录方式',
scan: 'AI识别',
foodLibrary: '食物库',
voiceRecord: '一句话记录',
},
chart: {
remaining: '还能吃',
formula: '还能吃 = 代谢 + 运动 - 饮食',
metabolism: '代谢',
exercise: '运动',
diet: '饮食',
},
};
export const foodCamera = {
title: '食物拍摄',
hint: '确保食物在取景框内',
permission: {
title: '需要相机权限',
description: '为了拍摄食物并进行AI识别需要访问您的相机',
button: '授权访问',
},
guide: {
title: '拍摄示例',
description: '请上传或拍摄清晰的食物照片,有助于提高识别准确率',
button: '知道了',
good: '光线充足,主体清晰',
bad: '模糊不清,光线昏暗',
},
buttons: {
album: '相册',
capture: '拍照',
help: '帮助',
},
alerts: {
captureFailed: {
title: '拍照失败',
message: '请重试',
},
pickFailed: {
title: '选择失败',
message: '请重试',
},
},
};
export const foodRecognition = {
title: '食物识别',
header: {
confirm: '确认食物',
recognizing: 'AI 识别中',
},
errors: {
noImage: '未找到图片',
generic: '食物识别失败,请重试',
unknown: '未知错误',
noFoodDetected: '识别失败:未检测到食物',
processError: '识别过程出错',
},
logs: {
uploading: '📤 正在上传图片到云端...',
uploadSuccess: '✅ 图片上传完成',
analyzing: '🤖 AI大模型分析中...',
analysisSuccess: '✅ AI分析完成',
confidence: '🎯 识别置信度: {{value}}%',
itemsFound: '🍽️ 识别到 {{count}} 种食物',
failed: '❌ 识别失败:未检测到食物',
error: '❌ 识别过程出错',
},
status: {
idle: {
title: '准备就绪',
subtitle: '请稍候...',
},
uploading: {
title: '上传图片',
subtitle: '正在上传图片到云端服务器...',
},
recognizing: {
title: 'AI 分析中',
subtitle: '智能模型正在分析食物成分...',
},
completed: {
title: '识别成功',
subtitle: '即将跳转到分析结果页面',
},
failed: {
title: '识别失败',
subtitle: '请检查网络或稍后重试',
},
processing: {
title: '处理中...',
subtitle: '请稍候...',
},
},
mealTypes: {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
unknown: '未知',
},
info: {
title: '智能食物识别',
description: 'AI 将分析照片,自动识别食物种类并估算营养成分,生成详细报告。',
},
actions: {
start: '开始识别',
retry: '返回重试',
logs: '处理日志',
logsPlaceholder: '准备开始...',
},
alerts: {
recognizing: {
title: '正在识别中',
message: '识别过程尚未完成,确定要返回吗?',
continue: '继续识别',
back: '返回',
},
},
};
export const foodAnalysisResult = {
title: '分析结果',
error: {
notFound: '未找到图片或识别结果',
},
placeholder: '营养记录',
nutrients: {
caloriesUnit: '千卡',
protein: '蛋白质',
fat: '脂肪',
carbs: '碳水',
unit: '克',
},
sections: {
recognitionResult: '识别结果',
foodIntake: '食物摄入',
},
nonFood: {
title: '未识别到食物',
suggestions: {
title: '建议:',
item1: '• 确保图片中包含食物',
item2: '• 尝试更清晰的照片角度',
item3: '• 避免过度模糊或光线不足',
},
},
actions: {
retake: '重新拍照',
record: '记录',
close: '关闭',
},
mealSelector: {
title: '选择餐次',
},
editModal: {
title: '编辑食物信息',
fields: {
name: '食物名称',
namePlaceholder: '输入食物名称',
amount: '重量 (克)',
amountPlaceholder: '输入重量',
calories: '卡路里 (千卡)',
caloriesPlaceholder: '输入卡路里',
},
actions: {
cancel: '取消',
save: '保存',
},
},
confidence: '置信度: {{value}}%',
dateFormats: {
today: 'YYYY年M月D日',
full: 'YYYY年M月D日 HH:mm',
},
};
export const foodLibrary = {
title: '食物库',
custom: '自定义',
search: {
placeholder: '搜索食物...',
loading: '搜索中...',
empty: '未找到相关食物',
noData: '暂无食物数据',
},
loading: '加载食物库中...',
retry: '重试',
mealTypes: {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
},
actions: {
record: '记录',
selectMeal: '选择餐次',
},
alerts: {
deleteFailed: {
title: '删除失败',
message: '删除食物时发生错误,请稍后重试',
},
createFailed: {
title: '创建失败',
message: '创建自定义食物时发生错误,请稍后重试',
},
},
};
export const createCustomFood = {
title: '创建自定义食物',
save: '保存',
preview: {
title: '效果预览',
defaultName: '食物名称',
},
basicInfo: {
title: '基本信息',
name: '食物名称',
namePlaceholder: '例如,汉堡',
defaultAmount: '默认数量',
calories: '食物热量',
},
optionalInfo: {
title: '可选信息',
photo: '照片',
addPhoto: '添加照片',
protein: '蛋白质',
fat: '脂肪',
carbohydrate: '碳水化合物',
},
units: {
kcal: '千卡',
g: 'g',
gram: '克',
},
alerts: {
permissionDenied: {
title: '权限不足',
message: '需要相册权限以选择照片',
},
uploadFailed: {
title: '上传失败',
message: '照片上传失败,请重试',
},
error: {
title: '发生错误',
message: '选择照片失败,请重试',
},
validation: {
title: '提示',
nameRequired: '请输入食物名称',
caloriesRequired: '请输入有效的热量值',
},
},
};
export const voiceRecord = {
title: '一句话记录',
intro: {
description: '通过语音描述您的饮食内容AI将智能分析营养成分和卡路里',
},
status: {
idle: '轻触麦克风开始录音',
listening: '正在聆听中,请开始说话...',
processing: 'AI正在处理语音内容...',
analyzing: 'AI大模型深度分析营养成分中...',
result: '语音识别完成,请确认结果',
},
hints: {
listening: '说出您想记录的食物内容',
},
examples: {
title: '记录示例:',
items: [
'今早吃了两个煎蛋、一片全麦面包和一杯牛奶',
'午饭吃了红烧肉约150克、米饭一小碗、青菜一份',
'晚饭吃了蒸蛋羹、紫菜蛋花汤、小米粥一碗',
],
},
analysis: {
progress: '分析进度: {{progress}}%',
hint: 'AI正在深度分析您的食物描述...',
},
result: {
label: '识别结果:',
},
actions: {
retry: '重新录音',
confirm: '确认使用',
},
alerts: {
noVoiceInput: '没有检测到语音输入,请重试',
networkError: '网络连接异常,请检查网络后重试',
voiceError: '语音识别出现问题,请重试',
noValidContent: '未识别到有效内容,请重新录音',
pleaseRecordFirst: '请先进行语音识别',
recordingFailed: '录音失败',
recordingPermissionError: '无法启动语音识别,请检查麦克风权限设置',
analysisFailed: '分析失败',
},
};
export const nutritionLabelAnalysis = {
title: '成分表分析',
camera: {
permissionDenied: '权限不足',
permissionMessage: '需要相机权限才能拍摄成分表',
},
actions: {
takePhoto: '拍摄',
selectFromAlbum: '相册',
startAnalysis: '开始分析',
close: '关闭',
},
placeholder: {
text: '拍摄或选择成分表照片',
},
status: {
uploading: '正在上传图片...',
analyzing: '正在分析成分表...',
},
errors: {
analysisFailed: {
title: '分析失败',
message: '分析图片时发生错误,请重试',
defaultMessage: '分析服务暂时不可用',
},
cannotRecognize: '无法识别成分表,请尝试拍摄更清晰的照片',
cameraPermissionDenied: '需要相机权限才能拍摄成分表',
},
results: {
title: '营养成分详细分析',
detailedAnalysis: '营养成分详细分析',
},
imageViewer: {
close: '关闭',
dateFormat: 'YYYY年M月D日 HH:mm',
},
};
export const nutritionAnalysisHistory = {
title: '历史记录',
dateFormat: 'YYYY年M月D日 HH:mm',
recognized: '识别 {{count}} 项营养素',
loadingMore: '加载更多...',
loading: '加载历史记录...',
filter: {
all: '全部',
},
filters: {
all: '全部',
success: '成功',
failed: '失败',
},
status: {
success: '成功',
failed: '失败',
processing: '处理中',
unknown: '未知',
},
nutrients: {
energy: '热量',
protein: '蛋白质',
carbs: '碳水',
fat: '脂肪',
},
delete: {
confirmTitle: '确认删除',
confirmMessage: '确定要删除这条记录吗?',
cancel: '取消',
delete: '删除',
successTitle: '删除成功',
successMessage: '记录已成功删除',
},
actions: {
expand: '展开详情',
collapse: '收起详情',
expandDetails: '展开详情',
collapseDetails: '收起详情',
confirmDelete: '确认删除',
delete: '删除',
cancel: '取消',
retry: '重试',
},
empty: {
title: '暂无历史记录',
subtitle: '开始识别营养成分表吧',
},
errors: {
error: '错误',
loadFailed: '加载失败',
unknownError: '未知错误',
fetchFailed: '获取历史记录失败',
fetchFailedRetry: '获取历史记录失败,请重试',
deleteFailed: '删除失败,请稍后重试',
},
loadingState: {
records: '加载历史记录...',
more: '加载更多...',
},
details: {
title: '详细营养成分',
nutritionDetails: '详细营养成分',
aiModel: 'AI 模型',
provider: '服务提供商',
serviceProvider: '服务提供商',
},
records: {
nutritionCount: '识别 {{count}} 项营养素',
},
imageViewer: {
close: '关闭',
},
};
export const waterDetail = {
title: '饮水详情',
waterRecord: '饮水记录',
today: '今日',
total: '总计:',
goal: '目标:',
noRecords: '暂无饮水记录',
noRecordsSubtitle: '点击"添加记录"开始记录饮水量',
deleteConfirm: {
title: '确认删除',
message: '确定要删除这条饮水记录吗?此操作无法撤销。',
cancel: '取消',
confirm: '删除',
},
deleteButton: '删除',
water: '水',
loadingUserPreferences: '加载用户偏好设置失败',
};
export const waterSettings = {
title: '饮水设置',
sections: {
dailyGoal: '每日饮水目标',
quickAdd: '快速添加默认值',
reminder: '喝水提醒',
},
descriptions: {
quickAdd: '设置点击"+"按钮时添加的默认饮水量',
reminder: '设置定时提醒您补充水分',
},
labels: {
ml: 'ml',
disabled: '已关闭',
},
alerts: {
goalSuccess: {
title: '设置成功',
message: '每日饮水目标已设置为 {{amount}}ml',
},
quickAddSuccess: {
title: '设置成功',
message: '快速添加默认值已设置为 {{amount}}ml',
},
quickAddFailed: {
title: '设置失败',
message: '无法保存快速添加默认值,请重试',
},
},
buttons: {
cancel: '取消',
confirm: '确定',
},
status: {
reminderEnabled: '{{startTime}}-{{endTime}}, 每{{interval}}分钟',
},
};
export const waterReminderSettings = {
title: '喝水提醒',
sections: {
notifications: '推送提醒',
timeRange: '提醒时间段',
interval: '提醒间隔',
},
descriptions: {
notifications: '开启后将在指定时间段内定期推送喝水提醒',
timeRange: '只在指定时间段内发送提醒,避免打扰您的休息',
interval: '选择提醒的频率建议30-120分钟为宜',
},
labels: {
startTime: '开始时间',
endTime: '结束时间',
interval: '提醒间隔',
saveSettings: '保存设置',
hours: '小时',
timeRangePreview: '时间段预览',
minutes: '分钟',
},
alerts: {
timeValidation: {
title: '时间设置提示',
startTimeInvalid: '开始时间不能晚于或等于结束时间,请重新选择',
endTimeInvalid: '结束时间不能早于或等于开始时间,请重新选择',
},
success: {
enabled: '设置成功',
enabledMessage: '喝水提醒已开启\n\n时间段{{timeRange}}\n提醒间隔{{interval}}\n\n我们将在指定时间段内定期提醒您喝水',
disabled: '设置成功',
disabledMessage: '喝水提醒已关闭',
},
error: {
title: '保存失败',
message: '无法保存喝水提醒设置,请重试',
},
},
buttons: {
confirm: '确定',
cancel: '取消',
},
};

Some files were not shown because too many files have changed in this diff Show More