From a254af92c7c5f666273bfcffebacc14e8b5fda4c Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 4 Dec 2025 17:56:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AE=B6=E5=BA=AD=E9=82=80=E8=AF=B7=E4=B8=8E=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 +- app/(tabs)/_layout.tsx | 10 +- app/(tabs)/personal.tsx | 121 ++- app/(tabs)/statistics.tsx | 3 - app/health/family-invite.tsx | 626 +++++++++++++++ app/health/profile.tsx | 343 ++++++++ app/weight-records.tsx | 57 +- components/StressAnalysisModal.tsx | 454 ++++++++--- components/StressMeter.tsx | 8 +- components/health/HealthProgressRing.tsx | 132 ++++ components/health/tabs/BasicInfoTab.tsx | 161 ++++ components/health/tabs/CheckupRecordsTab.tsx | 49 ++ components/health/tabs/HealthHistoryTab.tsx | 785 +++++++++++++++++++ components/health/tabs/MedicalRecordsTab.tsx | 49 ++ components/weight/WeightHistoryCard.tsx | 386 ++++++--- components/weight/WeightProgressBar.tsx | 278 +++++++ constants/Routes.ts | 2 + i18n/en/health.ts | 101 ++- i18n/en/medication.ts | 2 +- i18n/en/personal.ts | 4 + i18n/zh/health.ts | 101 ++- i18n/zh/medication.ts | 2 +- i18n/zh/personal.ts | 4 + services/healthProfile.ts | 241 ++++++ store/familyHealthSlice.ts | 332 ++++++++ store/healthSlice.ts | 194 ++++- store/index.ts | 2 + utils/health.ts | 42 + 28 files changed, 4177 insertions(+), 315 deletions(-) create mode 100644 app/health/family-invite.tsx create mode 100644 app/health/profile.tsx create mode 100644 components/health/HealthProgressRing.tsx create mode 100644 components/health/tabs/BasicInfoTab.tsx create mode 100644 components/health/tabs/CheckupRecordsTab.tsx create mode 100644 components/health/tabs/HealthHistoryTab.tsx create mode 100644 components/health/tabs/MedicalRecordsTab.tsx create mode 100644 components/weight/WeightProgressBar.tsx create mode 100644 services/healthProfile.ts create mode 100644 store/familyHealthSlice.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index fdee6f3..6348559 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "source.organizeImports": "explicit", "source.sortMembers": "explicit" }, - "kiroAgent.configureMCP": "Enabled" + "kiroAgent.configureMCP": "Enabled", + "codingcopilot.enableCompletionLanguage": {} } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 332387c..637e1b1 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -23,11 +23,11 @@ type TabConfig = { }; const TAB_CONFIGS: Record = { - 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() { diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index af61a3e..123f6ce 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -260,25 +260,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'); @@ -454,27 +435,33 @@ export default function PersonalScreen() { ); }; - // 数据统计部分 - const StatsSection = () => ( + // 健康档案入口组件 + const HealthProfileEntry = () => ( - - - - {formatHeight()} - {t('personal.stats.height')} + router.push(ROUTES.HEALTH_PROFILE)} + > + + + + + {t('personal.healthProfile.title') || '健康档案'} + + {t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'} + + + + - - {formatWeight()} - {t('personal.stats.weight')} - - - {formatAge()} - {t('personal.stats.age')} - - - + + ); @@ -824,7 +811,7 @@ export default function PersonalScreen() { > {userProfile.isVip ? : } - + {/* { + dispatch(fetchFamilyGroup()); + }, [dispatch]); + + // 处理邀请按钮点击 + const handleInvite = async () => { + try { + // 如果没有家庭组,先创建一个 + if (!familyGroup) { + await dispatch(createFamilyGroup('我的家庭')).unwrap(); + } + + // 生成邀请码 + 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 ( + + + + + + + + {/* Header Title Area */} + + 家庭健康管理 + 保障全家健康 + + + + 全家互相督促,让关爱不遗漏 + + + + {/* Hero Image / House Icon Area */} + + {/* Floating Labels */} + + 实时管理 + + + + + 守护家庭健康 + + + {/* Main 3D House Icon Placeholder */} + + + + + + + + {/* Features Grid */} + + + + + + 数据共享 + 家人档案共同维护 + + + + + + + 异常提醒 + 数据异常实时提醒 + + + + + + + 用药监督 + 用药情况远程监督 + + + + {/* Steps Section */} + + 简单3步,帮家人管理档案 + + 最多邀请6人,分享二维码有效期24小时 + + + + + 1 + 分享二维码邀请 + + + + + + + + + 2 + 家人下载登录App + + + + + + + + + 3 + 扫二维码加入 + + + + + + + + {/* Bottom Spacing */} + + + + {/* Bottom Action Area */} + + setAgreed(!agreed)} + activeOpacity={0.8} + > + + + 申请对方同意我查看并管理其健康档案,有数据异常预警我 + + + + + {isLoading ? ( + + ) : ( + 立即邀请 + )} + + + + {/* QR Code Modal */} + setShowQRModal(false)} + > + + + + 邀请家人加入 + setShowQRModal(false)}> + + + + + {isInviteLoading ? ( + + + + ) : inviteCode ? ( + <> + + {/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */} + + + {inviteCode.inviteCode} + 请让家人在 App 中输入此邀请码 + + + + + 邀请码 + {inviteCode.inviteCode} + + + + 有效期至:{new Date(inviteCode.expiresAt).toLocaleString()} + + + + + 分享邀请 + + + ) : null} + + + + + ); +} + +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, + }, +}); diff --git a/app/health/profile.tsx b/app/health/profile.tsx new file mode 100644 index 0000000..d5fe6b3 --- /dev/null +++ b/app/health/profile.tsx @@ -0,0 +1,343 @@ +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 { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { ROUTES } from '@/constants/Routes'; +import { useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { useI18n } from '@/hooks/useI18n'; +import { selectHealthHistoryProgress } from '@/store/healthSlice'; +import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Stack, useRouter } from 'expo-router'; +import React, { useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Text, 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 [activeTab, setActiveTab] = useState(0); + + // 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]); + + 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 === 4) { + // Handle Medicine Box tab specially + router.push('/medications/manage-medications'); + return; + } + setActiveTab(index); + }; + + const renderActiveTab = () => { + switch (activeTab) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } + }; + + return ( + + + + + + + + + + } + /> + + + {/* Top Section with Avatar and Status */} + + + + + {displayName} + + + + + + + {/* Action Buttons - Replaced with HealthProgressRing */} + + + + + + + + {/* Family Invite Banner */} + router.push(ROUTES.HEALTH_FAMILY_INVITE)} + > + + + + + {t('health.tabs.healthProfile.subtitle')} + + + + + {/* Tab/Segment Control */} + + {tabs.map((tab, index) => ( + handleTabPress(index)} + activeOpacity={0.7} + > + + + + {tab} + + ))} + + + {/* Active Tab Content */} + {renderActiveTab()} + + {/* Privacy Notice Footer */} + + + + + + {t('health.tabs.healthProfile.privacyNotice')} + + + + + + {/* Privacy Warning Footer - Removed as requested */} + + ); +} + +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, + }, + +}); diff --git a/app/weight-records.tsx b/app/weight-records.tsx index 037038e..836c89e 100644 --- a/app/weight-records.tsx +++ b/app/weight-records.tsx @@ -1,9 +1,11 @@ import NumberKeyboard from '@/components/NumberKeyboard'; import { HeaderBar } from '@/components/ui/HeaderBar'; +import { WeightProgressBar } from '@/components/weight/WeightProgressBar'; import { WeightRecordCard } from '@/components/weight/WeightRecordCard'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; 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'; @@ -39,14 +41,16 @@ export default function WeightRecordsPage() { const colorScheme = useColorScheme(); const themeColors = Colors[colorScheme ?? 'light']; + const { isLoggedIn, ensureLoggedIn } = useAuthGuard(); const loadWeightHistory = useCallback(async () => { + if (!isLoggedIn) return; try { await dispatch(fetchWeightHistory() as any); } catch (error) { console.error(t('weightRecords.loadingHistory'), error); } - }, [dispatch]); + }, [dispatch, isLoggedIn]); useEffect(() => { loadWeightHistory(); @@ -56,28 +60,36 @@ export default function WeightRecordsPage() { setInputWeight(weight.toString()); }; - const handleAddWeight = () => { + const handleAddWeight = async () => { + const ok = await ensureLoggedIn(); + if (!ok) return; setPickerType('current'); const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0; initializeInput(weight); setShowWeightPicker(true); }; - const handleEditInitialWeight = () => { + const handleEditInitialWeight = async () => { + const ok = await ensureLoggedIn(); + if (!ok) return; setPickerType('initial'); const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0'; initializeInput(parseFloat(initialWeight)); setShowWeightPicker(true); }; - const handleEditTargetWeight = () => { + const handleEditTargetWeight = async () => { + const ok = await ensureLoggedIn(); + if (!ok) return; setPickerType('target'); const targetWeight = userProfile?.targetWeight || '60.0'; initializeInput(parseFloat(targetWeight)); setShowWeightPicker(true); }; - const handleEditWeightRecord = (record: WeightHistoryItem) => { + const handleEditWeightRecord = async (record: WeightHistoryItem) => { + const ok = await ensureLoggedIn(); + if (!ok) return; setPickerType('edit'); setEditingRecord(record); initializeInput(parseFloat(record.weight)); @@ -85,6 +97,8 @@ export default function WeightRecordsPage() { }; const handleDeleteWeightRecord = async (id: string) => { + const ok = await ensureLoggedIn(); + if (!ok) return; try { await dispatch(deleteWeightRecord(id) as any); await loadWeightHistory(); @@ -180,6 +194,12 @@ export default function WeightRecordsPage() { const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0; const totalWeightLoss = initialWeight - currentWeight; + // 计算减重进度 + const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight; + const totalToLose = initialWeight - targetWeight; + const actualLost = initialWeight - currentWeight; + const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0; + return ( {/* 背景 */} @@ -295,6 +315,19 @@ export default function WeightRecordsPage() { + {/* 减重进度条 - 仅在设置了目标体重时显示 */} + {hasTargetWeight && ( + + + + )} + {/* Monthly Records */} {Object.keys(groupedHistory).length > 0 ? ( @@ -628,6 +661,20 @@ const styles = StyleSheet.create({ marginLeft: 2, }, + // Progress Container + progressContainer: { + marginHorizontal: 24, + marginBottom: 24, + backgroundColor: '#ffffff', + borderRadius: 24, + padding: 20, + shadowColor: 'rgba(30, 41, 59, 0.06)', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 3, + }, + // History Section historySection: { paddingHorizontal: 24, diff --git a/components/StressAnalysisModal.tsx b/components/StressAnalysisModal.tsx index 12baef3..4456fce 100644 --- a/components/StressAnalysisModal.tsx +++ b/components/StressAnalysisModal.tsx @@ -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({ + 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 ( - {/* 标题 */} - 压力情况分析 + {/* 标题区域 */} + 压力分析 + + {/* 当前状态卡片 */} + + + 当前状态 + 更新于 {dayjs(updateTime).format('HH:mm')} + + + + + + {stressInfo.label} + + {stressInfo.description} + + + HRV + {Math.round(hrvValue)}ms + + + {/* 最近30天HRV情况 */} - 最近30天HRV情况 + 最近30天压力分布 - {/* 彩色横条图 */} - - - - - - - - 鸭梨山大 - - - - 活力满满 - - - - 好事发生 - - - - - {/* 数据统计卡片 */} - - {/* 好事发生 & 活力满满 */} - - - 好事发生 - {hrvData.goodEvents.percentage}% - - ❤️ {hrvData.goodEvents.range} + {loading ? ( + + ) : ( + <> + {/* 彩色横条图 */} + + + {historyData.totalSamples > 0 ? ( + + {historyData.stressed.percentage > 0 && ( + + )} + {historyData.energetic.percentage > 0 && ( + + )} + {historyData.goodEvents.percentage > 0 && ( + + )} + + ) : ( + + )} - {hrvData.goodEvents.count}次 - - - - 活力满满 - {hrvData.energetic.percentage}% - - ❤️ {hrvData.energetic.range} + + + + + 鸭梨山大 + + + + 活力满满 + + + + 好事发生 + - {hrvData.energetic.count}次 - - {/* 鸭梨山大 */} - - 鸭梨山大 - {hrvData.stressed.percentage}% - - ❤️ {hrvData.stressed.range} + {/* 数据统计卡片 */} + + {/* 好事发生 & 活力满满 */} + + + 好事发生 + {historyData.goodEvents.percentage}% + + + HRV {historyData.goodEvents.range} + + + {historyData.goodEvents.count}次 + + + + 活力满满 + {historyData.energetic.percentage}% + + + HRV {historyData.energetic.range} + + + {historyData.energetic.count}次 + + + + {/* 鸭梨山大 */} + + 鸭梨山大 + {historyData.stressed.percentage}% + + + HRV {historyData.stressed.range} + + + {historyData.stressed.count}次 + - {hrvData.stressed.count}次 - - + + )} {/* 底部继续按钮 */} - - - 继续 - + + {isLiquidGlassAvailable() ? ( + + 继续 + + ) : ( + + 继续 + + )} @@ -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', }, diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index c059f06..3610af1 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) { const { t } = useTranslation(); const [hrvValue, setHrvValue] = useState(0) + const [updateTime, setUpdateTime] = useState(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) { {/* 渐变背景进度条 */} setShowStressModal(false)} hrvValue={hrvValue} - updateTime={new Date()} + updateTime={updateTime} /> ); diff --git a/components/health/HealthProgressRing.tsx b/components/health/HealthProgressRing.tsx new file mode 100644 index 0000000..d9f7a7c --- /dev/null +++ b/components/health/HealthProgressRing.tsx @@ -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 ( + + + + + + + + + + + {/* Background Circle */} + + + {/* Progress Circle */} + + + + + + {label ?? progress} + {suffix} + + + + + {title} + + ); +} + +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', + }, +}); diff --git a/components/health/tabs/BasicInfoTab.tsx b/components/health/tabs/BasicInfoTab.tsx new file mode 100644 index 0000000..e01a57c --- /dev/null +++ b/components/health/tabs/BasicInfoTab.tsx @@ -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 ( + + {t('health.tabs.healthProfile.basicInfoCard.title')} + + {/* BMI - Highlighted */} + + {t('health.tabs.healthProfile.basicInfoCard.bmi')} + + {healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi} + + + + {/* Height - Clickable */} + + + {healthData.height} + + + + {t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')} + + + + {/* Weight - Clickable */} + + + {healthData.weight} + + + + {t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')} + + + + {/* Waist - Clickable */} + + + {healthData.waist} + + + + {t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')} + + + + + ); +} + +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', + }, +}); diff --git a/components/health/tabs/CheckupRecordsTab.tsx b/components/health/tabs/CheckupRecordsTab.tsx new file mode 100644 index 0000000..d9c6924 --- /dev/null +++ b/components/health/tabs/CheckupRecordsTab.tsx @@ -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 ( + + + + 暂无体检记录 + 记录并追踪您的体检数据变化 + + + ); +} + +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', + }, +}); diff --git a/components/health/tabs/HealthHistoryTab.tsx b/components/health/tabs/HealthHistoryTab.tsx new file mode 100644 index 0000000..8429fd4 --- /dev/null +++ b/components/health/tabs/HealthHistoryTab.tsx @@ -0,0 +1,785 @@ +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 = { + 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 ( + + {/* Header Row */} + + + + {title} + + + {!hasItems && ( + + {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')} + + )} + + + {/* List of Items */} + {hasItems && ( + + {data.items.map(item => ( + + + {translateItemName(item.name)} + {item.date && ( + + {dayjs(item.date).format('YYYY-MM-DD')} + + )} + + ))} + + )} + + ); +} + +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(null); + const [tempHasHistory, setTempHasHistory] = useState(null); + const [tempItems, setTempItems] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + // Date Picker State + const [isDatePickerVisible, setDatePickerVisibility] = useState(false); + const [currentEditingId, setCurrentEditingId] = useState(null); + + // 初始化时从服务端获取健康史数据 + useEffect(() => { + dispatch(fetchHealthHistory()); + }, [dispatch]); + + 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 ( + + {/* Glow effect background */} + + + + + + {/* Header */} + + {t('health.tabs.healthProfile.healthHistory')} + {isLoading && } + + + {/* List */} + + {historyItems.map((item) => ( + handleItemPress(item.key)} + /> + ))} + + + + {/* Edit Modal */} + + + + {/* Modal Header */} + + + {currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''} + + + + + + + + {/* Question: Do you have history? */} + + {t('health.tabs.healthProfile.history.modal.question', { + type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : '' + })} + + + + setTempHasHistory(true)} + > + {t('health.tabs.healthProfile.history.modal.yes')} + + + setTempHasHistory(false)} + > + {t('health.tabs.healthProfile.history.modal.no')} + + + + {/* Conditional Content */} + {tempHasHistory === true && currentType && ( + + + {/* Recommendations */} + {RECOMMENDATION_KEYS[currentType] && ( + + {t('health.tabs.healthProfile.history.modal.recommendations')} + + {RECOMMENDATION_KEYS[currentType].map((tagKey, index) => ( + addItem(tagKey, true)} + > + + {t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)} + + + + ))} + + + )} + + {/* History List Items */} + + {t('health.tabs.healthProfile.history.modal.addDetails')} + + {tempItems.map((item) => ( + + + updateItemName(item.id, text)} + editable={!item.isRecommendation} + /> + removeItem(item.id)} style={styles.deleteButton}> + + + + + showDatePicker(item.id)} + > + + + {item.date + ? dayjs(item.date).format('YYYY-MM-DD') + : t('health.tabs.healthProfile.history.modal.selectDate')} + + + + + ))} + + {/* Add Button */} + addItem()}> + + {t('health.tabs.healthProfile.history.modal.addItem')} + + + + + + )} + + + {/* Save Button */} + + + + {isSaving ? ( + + ) : ( + {t('health.tabs.healthProfile.history.modal.save')} + )} + + + + + + + + + + + ); +} + +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', + }, +}); diff --git a/components/health/tabs/MedicalRecordsTab.tsx b/components/health/tabs/MedicalRecordsTab.tsx new file mode 100644 index 0000000..a5c7cf6 --- /dev/null +++ b/components/health/tabs/MedicalRecordsTab.tsx @@ -0,0 +1,49 @@ +import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +export function MedicalRecordsTab() { + return ( + + + + 暂无就医资料 + 上传您的病历、处方单等资料 + + + ); +} + +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', + }, +}); diff --git a/components/weight/WeightHistoryCard.tsx b/components/weight/WeightHistoryCard.tsx index 9f21c42..ee9cffc 100644 --- a/components/weight/WeightHistoryCard.tsx +++ b/components/weight/WeightHistoryCard.tsx @@ -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 ( - // - // - // {t('statistics.components.weight.title')} - // + // 是否有数据 + const hasData = sortedHistory.length > 0; - // - // - // No weight records yet, click the button below to start recording - // - // { - // e.stopPropagation(); - // navigateToCoach(); - // }} - // activeOpacity={0.8} - // > - // - // {t('statistics.components.weight.addButton')} - // - // - // - // ); - // } + // 计算减重进度 + 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 ( - + + + {t('statistics.components.weight.title')} {isLgAvaliable ? ( { e.stopPropagation(); - navigateToCoach(); + handleAddWeight(); }} activeOpacity={0.8} > - + ) : ( @@ -153,68 +174,125 @@ export function WeightHistoryCard() { style={styles.addButton} onPress={(e) => { e.stopPropagation(); - navigateToCoach(); + handleAddWeight(); }} activeOpacity={0.8} > - + )} - {/* Default chart display */} - {sortedHistory.length > 0 && ( - - - {/* Background grid lines */} + {/* 当前体重显示 */} + + + {hasWeight ? currentWeight.toFixed(1) : '--'} + kg + + {sortedHistory.length > 1 && ( + = 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' } + ]}> + = 0 ? 'trending-down' : 'trending-up'} + size={12} + color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'} + /> + = 0 ? THEME_SUCCESS : '#FF6B6B' } + ]}> + {actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg + + + )} + - {/* More abstract line - reduce line width and display details */} + {/* 图表显示 */} + + + + + + + + + + {hasData ? ( + <> + {/* 平滑曲线 */} + + + {/* 数据点 */} + {points.map((point, index) => { + const isLastPoint = index === points.length - 1; + + return ( + + {/* 外圈光晕 */} + {isLastPoint && ( + + )} + {/* 数据点 */} + + + ); + })} + + ) : ( + /* 空状态 - 虚线占位 */ + )} + - {/* Simplified data points - smaller and more refined */} - {points.map((point, index) => { - const isLastPoint = index === points.length - 1; - - return ( - - - - ); - })} - - - - - {/* Concise chart information */} - - - {userProfile.weight}kg - - - {sortedHistory.length}{t('statistics.components.weight.days')} - - - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg - - + {/* 图表信息 */} + + + {hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')} + + + + {hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'} + - )} + + + {/* 减重进度条 - 始终显示 */} + {/* BMI information modal */} = ({ + 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 ( + + {/* 进度信息 */} + + + {t('statistics.components.weight.progress.lost')} + = 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}> + {canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'} + + + + {percent} + % + + + {t('statistics.components.weight.progress.toGo')} + + {canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'} + + + + + {/* 进度条 */} + setBarWidth(e.nativeEvent.layout.width)} + > + {/* 背景轨道 */} + + + {/* 填充进度 */} + + + + + {/* 滑块 - 圆角矩形 */} + + + + + + + + {/* 起止标签 */} + + {hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'} + + + {hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'} + + + + ); +}; + +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; diff --git a/constants/Routes.ts b/constants/Routes.ts index 85fa029..f4056b1 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -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', diff --git a/i18n/en/health.ts b/i18n/en/health.ts index 3e10652..efc57c5 100644 --- a/i18n/en/health.ts +++ b/i18n/en/health.ts @@ -176,6 +176,11 @@ export const statistics = { 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', @@ -205,13 +210,6 @@ export const statistics = { }, }, }, - tabs: { - health: 'Health', - medications: 'Meds', - fasting: 'Fasting', - challenges: 'Challenges', - personal: 'Me', - }, activityHeatMap: { subtitle: 'Active {{days}} days in the last 6 months', activeRate: '{{rate}}%', @@ -726,3 +724,92 @@ export const workoutHistory = { }, monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.', }; + +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' + } + } + } + } + } +}; diff --git a/i18n/en/medication.ts b/i18n/en/medication.ts index af7f7b2..8b411ba 100644 --- a/i18n/en/medication.ts +++ b/i18n/en/medication.ts @@ -238,7 +238,7 @@ export const medications = { periodRange: 'From {{startDate}} to {{endDate}}', periodLongTerm: 'From {{startDate}} until indefinitely', expiryStatus: { - notSet: 'Not set', + notSet: 'Set Expiry', expired: 'Expired', expiresToday: 'Expires today', expiresInDays: 'Expires in {{days}} days', diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts index d21dd03..eaf917b 100644 --- a/i18n/en/personal.ts +++ b/i18n/en/personal.ts @@ -37,6 +37,10 @@ export const personal = { 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', diff --git a/i18n/zh/health.ts b/i18n/zh/health.ts index 4f6a50c..e27e37a 100644 --- a/i18n/zh/health.ts +++ b/i18n/zh/health.ts @@ -177,6 +177,11 @@ export const statistics = { days: '天', range: '范围', unit: 'kg', + progress: { + lost: '已减', + toGo: '距目标', + }, + demo: '示例数据', bmiModal: { title: 'BMI 指数说明', description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标', @@ -206,13 +211,6 @@ export const statistics = { }, }, }, - tabs: { - health: '健康', - medications: '用药', - fasting: '断食', - challenges: '挑战', - personal: '个人', - }, activityHeatMap: { subtitle: '最近6个月活跃 {{days}} 天', activeRate: '{{rate}}%', @@ -727,3 +725,92 @@ export const workoutHistory = { }, monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。', }; + +export const health = { + tabs: { + health: '健康', + medications: '用药', + fasting: '断食', + challenges: '挑战', + personal: '个人', + healthProfile: { + title: '健康档案', + subtitle: '邀请家人加入家庭健康管理,异常及时提醒', + privacyNotice: '档案内容仅供本人查看,我们将严格保护您的隐私', + basicInfo: '基础信息', + healthHistory: '健康史', + medicalRecords: '就医资料', + checkupRecords: '体检记录', + medicineBox: '药品管理', + basicInfoCard: { + title: '基础信息', + noData: '暂无数据', + bmi: 'BMI', + height: '身高', + heightUnit: 'CM', + weight: '体重', + weightUnit: 'KG', + waist: '腰围', + waistUnit: 'CM', + }, + history: { + allergy: '过敏史', + disease: '疾病史', + surgery: '手术史', + familyDisease: '家族疾病史', + pending: '待补充', + edit: '编辑', + modal: { + question: '您是否有{{type}}?', + yes: '有', + no: '没有', + addDetails: '添加详情', + enterSpecific: '请输入具体情况...', + recommendations: '推荐选项', + save: '保存', + none: '无', + yesNoDetails: '有 (未填写详情)', + diagnosisDate: '确诊时间', + namePlaceholder: '疾病/手术名称', + addItem: '添加记录', + selectDate: '选择日期' + }, + recommendationItems: { + 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: '阿尔茨海默病' + } + } + } + } + } +}; diff --git a/i18n/zh/medication.ts b/i18n/zh/medication.ts index aec5fa7..2403fc4 100644 --- a/i18n/zh/medication.ts +++ b/i18n/zh/medication.ts @@ -238,7 +238,7 @@ export const medications = { periodRange: '从 {{startDate}} 至 {{endDate}}', periodLongTerm: '从 {{startDate}} 至长期', expiryStatus: { - notSet: '未设置', + notSet: '未设置(过期预警)', expired: '已过期', expiresToday: '今天到期', expiresInDays: '{{days}}天后到期', diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts index 7777a4c..d342a29 100644 --- a/i18n/zh/personal.ts +++ b/i18n/zh/personal.ts @@ -37,6 +37,10 @@ export const personal = { medicalSources: '医学建议来源', customization: '个性化', }, + healthProfile: { + title: '健康档案', + subtitle: '管理您的个人健康数据与家庭档案', + }, versionCheck: { sectionTitle: '版本与更新', menuTitle: '检查更新', diff --git a/services/healthProfile.ts b/services/healthProfile.ts new file mode 100644 index 0000000..5848859 --- /dev/null +++ b/services/healthProfile.ts @@ -0,0 +1,241 @@ +/** + * 健康档案 API 服务 + * Base URL: /api/health-profiles + */ + +import { api } from './api'; + +// ==================== 类型定义 ==================== + +// 健康史分类 +export type HealthHistoryCategory = 'allergy' | 'disease' | 'surgery' | 'familyDisease'; + +// 健康史条目 +export interface HealthHistoryItem { + id: string; + name: string; + diagnosisDate?: string; // YYYY-MM-DD + isRecommendation?: boolean; + note?: string; +} + +// 健康史分类数据 +export interface HealthHistoryCategoryData { + hasHistory: boolean | null; + items: HealthHistoryItem[]; +} + +// 完整健康史数据 +export interface HealthHistoryData { + allergy: HealthHistoryCategoryData; + disease: HealthHistoryCategoryData; + surgery: HealthHistoryCategoryData; + familyDisease: HealthHistoryCategoryData; +} + +// 健康档案概览 +export interface HealthProfileOverview { + basicInfo: { + progress: number; + data: { + height: string; + weight: string; + bmi: string; + waistCircumference: number | null; + }; + }; + healthHistory: { + progress: number; + answeredCategories: HealthHistoryCategory[]; + pendingCategories: HealthHistoryCategory[]; + }; + medications: { + activeCount: number; + todayCompletionRate: number; + }; +} + +// 健康史进度 +export interface HealthHistoryProgress { + progress: number; + details: Record; +} + +// 更新健康史请求 +export interface UpdateHealthHistoryRequest { + hasHistory: boolean; + items?: Array<{ + name: string; + date?: string; + isRecommendation?: boolean; + note?: string; + }>; +} + +// ==================== 家庭健康管理类型 ==================== + +// 家庭成员角色 +export type FamilyRole = 'owner' | 'admin' | 'member'; + +// 家庭组 +export interface FamilyGroup { + id: string; + name: string; + ownerId: string; + memberCount: number; + maxMembers: number; + createdAt: string; +} + +// 家庭成员 +export interface FamilyMember { + id: string; + userId: string; + nickname: string; + avatar: string; + role: FamilyRole; + relationship: string | null; + canViewHealthData: boolean; + canManageHealthData: boolean; + receiveAlerts: boolean; + joinedAt: string; +} + +// 邀请码 +export interface InviteCode { + inviteCode: string; + expiresAt: string; +} + +// 更新成员权限请求 +export interface UpdateMemberPermissionsRequest { + role?: 'admin' | 'member'; + canViewHealthData?: boolean; + canManageHealthData?: boolean; + receiveAlerts?: boolean; +} + +// ==================== 健康档案概览 API ==================== + +/** + * 获取健康档案概览 + */ +export async function getHealthProfileOverview(): Promise { + return api.get('/api/health-profiles/overview'); +} + +// ==================== 健康史 API ==================== + +/** + * 获取完整健康史 + */ +export async function getHealthHistory(): Promise { + return api.get('/api/health-profiles/history'); +} + +/** + * 更新指定分类的健康史 + * @param category 分类: allergy | disease | surgery | familyDisease + * @param data 更新数据 + */ +export async function updateHealthHistory( + category: HealthHistoryCategory, + data: UpdateHealthHistoryRequest +): Promise { + return api.put( + `/api/health-profiles/history/${category}`, + data + ); +} + +/** + * 获取健康史完成度 + */ +export async function getHealthHistoryProgress(): Promise { + return api.get('/api/health-profiles/history/progress'); +} + +// ==================== 家庭健康管理 API ==================== + +/** + * 获取用户所属家庭组 + */ +export async function getFamilyGroup(): Promise { + try { + return await api.get('/api/health-profiles/family/group'); + } catch (error: any) { + // 如果用户没有家庭组,返回 null + if (error?.status === 404) { + return null; + } + throw error; + } +} + +/** + * 创建家庭组 + * @param name 家庭组名称 + */ +export async function createFamilyGroup(name: string): Promise { + return api.post('/api/health-profiles/family/group', { name }); +} + +/** + * 生成家庭组邀请码 + * @param expiresInHours 过期时间(小时),默认24小时 + */ +export async function generateInviteCode(expiresInHours: number = 24): Promise { + return api.post('/api/health-profiles/family/group/invite', { expiresInHours }); +} + +/** + * 通过邀请码加入家庭组 + * @param inviteCode 邀请码 + * @param relationship 与创建者的关系(如:配偶、父母、子女等) + */ +export async function joinFamilyGroup( + inviteCode: string, + relationship: string +): Promise { + return api.post('/api/health-profiles/family/group/join', { + inviteCode, + relationship, + }); +} + +/** + * 获取家庭成员列表 + */ +export async function getFamilyMembers(): Promise { + return api.get('/api/health-profiles/family/members'); +} + +/** + * 更新家庭成员权限(仅 owner/admin 可操作) + * @param memberId 成员ID + * @param permissions 权限设置 + */ +export async function updateFamilyMember( + memberId: string, + permissions: UpdateMemberPermissionsRequest +): Promise { + return api.put( + `/api/health-profiles/family/members/${memberId}`, + permissions + ); +} + +/** + * 移除家庭成员(仅 owner/admin 可操作) + * @param memberId 成员ID + */ +export async function removeFamilyMember(memberId: string): Promise { + return api.delete(`/api/health-profiles/family/members/${memberId}`); +} + +/** + * 退出家庭组(非 owner 成员使用) + */ +export async function leaveFamilyGroup(): Promise { + return api.post('/api/health-profiles/family/leave'); +} diff --git a/store/familyHealthSlice.ts b/store/familyHealthSlice.ts new file mode 100644 index 0000000..2fad17e --- /dev/null +++ b/store/familyHealthSlice.ts @@ -0,0 +1,332 @@ +/** + * 家庭健康管理 Redux Slice + */ + +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import * as healthProfileApi from '@/services/healthProfile'; +import { RootState } from './index'; + +// ==================== State 类型定义 ==================== + +export interface FamilyHealthState { + // 家庭组信息 + familyGroup: healthProfileApi.FamilyGroup | null; + + // 家庭成员列表 + members: healthProfileApi.FamilyMember[]; + + // 邀请码信息 + inviteCode: healthProfileApi.InviteCode | null; + + // 加载状态 + loading: boolean; + membersLoading: boolean; + inviteLoading: boolean; + + // 错误信息 + error: string | null; +} + +// ==================== 初始状态 ==================== + +const initialState: FamilyHealthState = { + familyGroup: null, + members: [], + inviteCode: null, + loading: false, + membersLoading: false, + inviteLoading: false, + error: null, +}; + +// ==================== Async Thunks ==================== + +/** + * 获取用户所属家庭组 + */ +export const fetchFamilyGroup = createAsyncThunk( + 'familyHealth/fetchGroup', + async (_, { rejectWithValue }) => { + try { + const data = await healthProfileApi.getFamilyGroup(); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取家庭组失败'); + } + } +); + +/** + * 创建家庭组 + */ +export const createFamilyGroup = createAsyncThunk( + 'familyHealth/createGroup', + async (name: string, { rejectWithValue }) => { + try { + const data = await healthProfileApi.createFamilyGroup(name); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '创建家庭组失败'); + } + } +); + +/** + * 生成邀请码 + */ +export const generateInviteCode = createAsyncThunk( + 'familyHealth/generateInvite', + async (expiresInHours: number = 24, { rejectWithValue }) => { + try { + const data = await healthProfileApi.generateInviteCode(expiresInHours); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '生成邀请码失败'); + } + } +); + +/** + * 加入家庭组 + */ +export const joinFamilyGroup = createAsyncThunk( + 'familyHealth/joinGroup', + async ( + { inviteCode, relationship }: { inviteCode: string; relationship: string }, + { rejectWithValue } + ) => { + try { + const data = await healthProfileApi.joinFamilyGroup(inviteCode, relationship); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '加入家庭组失败'); + } + } +); + +/** + * 获取家庭成员列表 + */ +export const fetchFamilyMembers = createAsyncThunk( + 'familyHealth/fetchMembers', + async (_, { rejectWithValue }) => { + try { + const data = await healthProfileApi.getFamilyMembers(); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取家庭成员失败'); + } + } +); + +/** + * 更新家庭成员权限 + */ +export const updateFamilyMember = createAsyncThunk( + 'familyHealth/updateMember', + async ( + { + memberId, + permissions, + }: { + memberId: string; + permissions: healthProfileApi.UpdateMemberPermissionsRequest; + }, + { rejectWithValue } + ) => { + try { + const data = await healthProfileApi.updateFamilyMember(memberId, permissions); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '更新成员权限失败'); + } + } +); + +/** + * 移除家庭成员 + */ +export const removeFamilyMember = createAsyncThunk( + 'familyHealth/removeMember', + async (memberId: string, { rejectWithValue }) => { + try { + await healthProfileApi.removeFamilyMember(memberId); + return memberId; + } catch (err: any) { + return rejectWithValue(err?.message ?? '移除成员失败'); + } + } +); + +/** + * 退出家庭组 + */ +export const leaveFamilyGroup = createAsyncThunk( + 'familyHealth/leaveGroup', + async (_, { rejectWithValue }) => { + try { + await healthProfileApi.leaveFamilyGroup(); + return true; + } catch (err: any) { + return rejectWithValue(err?.message ?? '退出家庭组失败'); + } + } +); + +// ==================== Slice ==================== + +const familyHealthSlice = createSlice({ + name: 'familyHealth', + initialState, + reducers: { + // 清除错误 + clearError: (state) => { + state.error = null; + }, + + // 清除邀请码 + clearInviteCode: (state) => { + state.inviteCode = null; + }, + + // 重置状态(用于登出时) + resetFamilyHealth: () => initialState, + }, + extraReducers: (builder) => { + builder + // 获取家庭组 + .addCase(fetchFamilyGroup.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchFamilyGroup.fulfilled, (state, action) => { + state.loading = false; + state.familyGroup = action.payload; + }) + .addCase(fetchFamilyGroup.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // 创建家庭组 + .addCase(createFamilyGroup.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(createFamilyGroup.fulfilled, (state, action) => { + state.loading = false; + state.familyGroup = action.payload; + }) + .addCase(createFamilyGroup.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // 生成邀请码 + .addCase(generateInviteCode.pending, (state) => { + state.inviteLoading = true; + state.error = null; + }) + .addCase(generateInviteCode.fulfilled, (state, action) => { + state.inviteLoading = false; + state.inviteCode = action.payload; + }) + .addCase(generateInviteCode.rejected, (state, action) => { + state.inviteLoading = false; + state.error = action.payload as string; + }) + + // 加入家庭组 + .addCase(joinFamilyGroup.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(joinFamilyGroup.fulfilled, (state, action) => { + state.loading = false; + state.familyGroup = action.payload; + }) + .addCase(joinFamilyGroup.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // 获取家庭成员 + .addCase(fetchFamilyMembers.pending, (state) => { + state.membersLoading = true; + state.error = null; + }) + .addCase(fetchFamilyMembers.fulfilled, (state, action) => { + state.membersLoading = false; + state.members = action.payload; + }) + .addCase(fetchFamilyMembers.rejected, (state, action) => { + state.membersLoading = false; + state.error = action.payload as string; + }) + + // 更新成员权限 + .addCase(updateFamilyMember.fulfilled, (state, action) => { + const updatedMember = action.payload; + const index = state.members.findIndex((m) => m.id === updatedMember.id); + if (index !== -1) { + state.members[index] = updatedMember; + } + }) + .addCase(updateFamilyMember.rejected, (state, action) => { + state.error = action.payload as string; + }) + + // 移除成员 + .addCase(removeFamilyMember.fulfilled, (state, action) => { + const memberId = action.payload; + state.members = state.members.filter((m) => m.id !== memberId); + if (state.familyGroup) { + state.familyGroup.memberCount = Math.max(0, state.familyGroup.memberCount - 1); + } + }) + .addCase(removeFamilyMember.rejected, (state, action) => { + state.error = action.payload as string; + }) + + // 退出家庭组 + .addCase(leaveFamilyGroup.fulfilled, (state) => { + state.familyGroup = null; + state.members = []; + state.inviteCode = null; + }) + .addCase(leaveFamilyGroup.rejected, (state, action) => { + state.error = action.payload as string; + }); + }, +}); + +// ==================== Actions ==================== + +export const { clearError, clearInviteCode, resetFamilyHealth } = familyHealthSlice.actions; + +// ==================== Selectors ==================== + +export const selectFamilyGroup = (state: RootState) => state.familyHealth.familyGroup; +export const selectFamilyMembers = (state: RootState) => state.familyHealth.members; +export const selectInviteCode = (state: RootState) => state.familyHealth.inviteCode; +export const selectFamilyHealthLoading = (state: RootState) => state.familyHealth.loading; +export const selectFamilyMembersLoading = (state: RootState) => state.familyHealth.membersLoading; +export const selectInviteLoading = (state: RootState) => state.familyHealth.inviteLoading; +export const selectFamilyHealthError = (state: RootState) => state.familyHealth.error; + +// 判断当前用户是否是家庭组 owner +export const selectIsOwner = (state: RootState) => { + const currentUserId = state.user.profile?.id; + const familyGroup = state.familyHealth.familyGroup; + return currentUserId && familyGroup && familyGroup.ownerId === currentUserId; +}; + +// 判断当前用户是否是管理员(owner 或 admin) +export const selectIsAdmin = (state: RootState) => { + const currentUserId = state.user.profile?.id; + const members = state.familyHealth.members; + const currentMember = members.find((m) => m.userId === currentUserId); + return currentMember && (currentMember.role === 'owner' || currentMember.role === 'admin'); +}; + +export default familyHealthSlice.reducer; diff --git a/store/healthSlice.ts b/store/healthSlice.ts index 85dfac4..78d3d6d 100644 --- a/store/healthSlice.ts +++ b/store/healthSlice.ts @@ -1,4 +1,5 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import * as healthProfileApi from '@/services/healthProfile'; import { AppDispatch, RootState } from './index'; // 健康数据类型定义 @@ -22,10 +23,28 @@ export interface HealthData { standHoursGoal: number; } +// 健康史数据类型定义 +export interface HistoryItemDetail { + id: string; + name: string; + date?: string; // ISO Date string + isRecommendation?: boolean; +} + +export interface HistoryData { + [key: string]: { + hasHistory: boolean | null; + items: HistoryItemDetail[]; + }; +} + export interface HealthState { // 按日期存储的历史数据 dataByDate: Record; + // 健康史数据 + historyData: HistoryData; + // 加载状态 loading: boolean; error: string | null; @@ -37,6 +56,12 @@ export interface HealthState { // 初始状态 const initialState: HealthState = { dataByDate: {}, + historyData: { + allergy: { hasHistory: null, items: [] }, + disease: { hasHistory: null, items: [] }, + surgery: { hasHistory: null, items: [] }, + familyDisease: { hasHistory: null, items: [] }, + }, loading: false, error: null, lastUpdateTime: null, @@ -82,6 +107,96 @@ const healthSlice = createSlice({ state.error = null; state.lastUpdateTime = null; }, + + // 更新健康史数据(本地更新,用于乐观更新或离线模式) + updateHistoryData: (state, action: PayloadAction<{ + type: string; + data: { + hasHistory: boolean | null; + items: HistoryItemDetail[]; + }; + }>) => { + const { type, data } = action.payload; + state.historyData[type] = data; + state.lastUpdateTime = new Date().toISOString(); + }, + + // 设置完整的健康史数据(从服务端同步) + setHistoryData: (state, action: PayloadAction) => { + state.historyData = action.payload; + state.lastUpdateTime = new Date().toISOString(); + }, + + // 清除健康史数据 + clearHistoryData: (state) => { + state.historyData = { + allergy: { hasHistory: null, items: [] }, + disease: { hasHistory: null, items: [] }, + surgery: { hasHistory: null, items: [] }, + familyDisease: { hasHistory: null, items: [] }, + }; + }, + }, + extraReducers: (builder) => { + builder + // 获取健康史 + .addCase(fetchHealthHistory.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchHealthHistory.fulfilled, (state, action) => { + state.loading = false; + // 转换服务端数据格式到本地格式 + const serverData = action.payload; + const categories = ['allergy', 'disease', 'surgery', 'familyDisease'] as const; + + categories.forEach(category => { + if (serverData[category]) { + state.historyData[category] = { + hasHistory: serverData[category].hasHistory, + items: serverData[category].items.map(item => ({ + id: item.id, + name: item.name, + date: item.diagnosisDate, + isRecommendation: item.isRecommendation, + })), + }; + } + }); + state.lastUpdateTime = new Date().toISOString(); + }) + .addCase(fetchHealthHistory.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // 保存健康史分类 + .addCase(saveHealthHistoryCategory.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(saveHealthHistoryCategory.fulfilled, (state, action) => { + state.loading = false; + const { category, data } = action.payload; + // 更新对应分类的数据 + state.historyData[category] = { + hasHistory: data.hasHistory, + items: data.items.map(item => ({ + id: item.id, + name: item.name, + date: item.diagnosisDate, + isRecommendation: item.isRecommendation, + })), + }; + state.lastUpdateTime = new Date().toISOString(); + }) + .addCase(saveHealthHistoryCategory.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // 获取健康史进度 + .addCase(fetchHealthHistoryProgress.rejected, (state, action) => { + state.error = action.payload as string; + }); }, }); @@ -92,6 +207,9 @@ export const { setHealthData, clearHealthDataForDate, clearAllHealthData, + updateHistoryData, + setHistoryData, + clearHistoryData, } = healthSlice.actions; // Thunk action to fetch and set health data for a specific date @@ -112,10 +230,84 @@ export const fetchHealthDataForDate = (date: Date) => { }; }; +// ==================== 健康史 API Thunks ==================== + +/** + * 从服务端获取完整健康史数据 + */ +export const fetchHealthHistory = createAsyncThunk( + 'health/fetchHistory', + async (_, { rejectWithValue }) => { + try { + const data = await healthProfileApi.getHealthHistory(); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取健康史失败'); + } + } +); + +/** + * 保存健康史分类到服务端 + */ +export const saveHealthHistoryCategory = createAsyncThunk( + 'health/saveHistoryCategory', + async ( + { + category, + data, + }: { + category: healthProfileApi.HealthHistoryCategory; + data: healthProfileApi.UpdateHealthHistoryRequest; + }, + { rejectWithValue } + ) => { + try { + const result = await healthProfileApi.updateHealthHistory(category, data); + return { category, data: result }; + } catch (err: any) { + return rejectWithValue(err?.message ?? '保存健康史失败'); + } + } +); + +/** + * 获取健康史完成进度 + */ +export const fetchHealthHistoryProgress = createAsyncThunk( + 'health/fetchHistoryProgress', + async (_, { rejectWithValue }) => { + try { + const data = await healthProfileApi.getHealthHistoryProgress(); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取健康史进度失败'); + } + } +); + // Selectors export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date]; export const selectHealthLoading = (state: RootState) => state.health.loading; export const selectHealthError = (state: RootState) => state.health.error; export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime; +export const selectHistoryData = (state: RootState) => state.health.historyData; + +// 计算健康史完成度的 selector +export const selectHealthHistoryProgress = (state: RootState) => { + const historyData = state.health.historyData; + const categories = ['allergy', 'disease', 'surgery', 'familyDisease']; + + let answeredCount = 0; + categories.forEach(category => { + const data = historyData[category]; + // 只要回答了是否有历史(hasHistory !== null),就算已完成 + if (data && data.hasHistory !== null) { + answeredCount++; + } + }); + + return Math.round((answeredCount / categories.length) * 100); +}; export default healthSlice.reducer; \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index b952150..edfb4cf 100644 --- a/store/index.ts +++ b/store/index.ts @@ -5,6 +5,7 @@ import challengesReducer from './challengesSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import circumferenceReducer from './circumferenceSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; +import familyHealthReducer from './familyHealthSlice'; import fastingReducer, { clearActiveSchedule, completeActiveSchedule, @@ -101,6 +102,7 @@ export const store = configureStore({ checkin: checkinReducer, circumference: circumferenceReducer, health: healthReducer, + familyHealth: familyHealthReducer, mood: moodReducer, nutrition: nutritionReducer, trainingPlan: trainingPlanReducer, diff --git a/utils/health.ts b/utils/health.ts index c1c39a7..519bd1f 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1745,6 +1745,48 @@ export async function fetchSmartHRVData(date: Date): Promise { } } +// 获取指定时间范围内的所有HRV样本 +export async function fetchHRVSamples(startDate: Date, endDate: Date): Promise { + try { + const options = { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + limit: 1000 // 获取足够多的样本 + }; + + const result = await HealthKitManager.getHeartRateVariabilitySamples(options); + + if (result && result.data && Array.isArray(result.data)) { + const samples: HRVData[] = []; + + for (const sample of result.data) { + const validatedValue = validateHRVValue(sample.value); + if (validatedValue !== null) { + samples.push({ + value: validatedValue, + recordedAt: sample.startDate, + endDate: sample.endDate, + source: { + name: sample.source?.name || 'Unknown', + bundleIdentifier: sample.source?.bundleIdentifier || '' + }, + isManualMeasurement: sample.isManualMeasurement || false, + qualityScore: sample.qualityScore, + sampleId: sample.id + }); + } + } + + return samples; + } + + return []; + } catch (error) { + console.error('获取HRV样本列表失败:', error); + return []; + } +} + // === 锻炼记录相关方法 === // 获取最近锻炼记录