10 Commits

50 changed files with 8953 additions and 3814 deletions

View File

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

View File

@@ -36,6 +36,7 @@
},
"plugins": [
"expo-router",
"expo-updates",
[
"expo-splash-screen",
{
@@ -70,8 +71,16 @@
"experiments": {
"typedRoutes": true
},
"runtimeVersion": {
"policy": "appVersion"
},
"android": {
"package": "com.anonymous.digitalpilates"
},
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
}
}
}

View File

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

View File

@@ -1,13 +1,16 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';
@@ -56,6 +59,8 @@ type LanguageOption = {
};
export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
@@ -70,6 +75,11 @@ export default function PersonalScreen() {
const [refreshing, setRefreshing] = useState(false);
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
code: 'zh' as AppLanguage,
@@ -260,25 +270,6 @@ export default function PersonalScreen() {
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}${t('personal.stats.ageSuffix')}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
@@ -369,25 +360,6 @@ export default function PersonalScreen() {
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -454,27 +426,33 @@ export default function PersonalScreen() {
);
};
// 数据统计部分
const StatsSection = () => (
// 健康档案入口组件
const HealthProfileEntry = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'transparent'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
<TouchableOpacity
style={styles.healthProfileCard}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
>
<LinearGradient
colors={['#FFFFFF', '#F0F4FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.healthProfileGradient}
>
<View style={styles.healthProfileContent}>
<View style={styles.healthProfileLeft}>
<View style={styles.healthProfileTitleRow}>
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
</View>
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
</View>
<View style={styles.healthProfileRight}>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
@@ -793,15 +771,13 @@ export default function PersonalScreen() {
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
colors={gradientColors}
style={StyleSheet.absoluteFillObject}
/>
<ScrollView
@@ -823,8 +799,8 @@ export default function PersonalScreen() {
}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<HealthProfileEntry />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
@@ -855,14 +831,6 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
scrollView: {
flex: 1,
@@ -889,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
@@ -1315,4 +1278,60 @@ const styles = StyleSheet.create({
color: '#9370DB',
fontFamily: 'AliBold',
},
// 健康档案入口样式
healthProfileCard: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
backgroundColor: '#FFFFFF',
},
healthProfileGradient: {
padding: 16,
},
healthProfileContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
healthProfileLeft: {
flex: 1,
marginRight: 16,
},
healthProfileTitleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
healthProfileTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginRight: 8,
fontFamily: 'AliBold',
},
healthStatusBadge: {
backgroundColor: '#ECFDF5',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
borderWidth: 1,
borderColor: '#A7F3D0',
},
healthStatusText: {
fontSize: 10,
color: '#059669',
fontWeight: '600',
},
healthProfileSubtitle: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular',
},
healthProfileRight: {
justifyContent: 'center',
},
});

View File

@@ -14,7 +14,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { syncHealthKitToServer } from '@/services/healthKitSync';
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { updateUserProfile } from '@/store/userSlice';
@@ -64,7 +64,9 @@ export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
@@ -293,6 +295,7 @@ export default function ExploreScreen() {
try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 1. 同步个人资料 (身高、体重、出生日期)
// 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer(
async (data) => {
@@ -302,20 +305,36 @@ export default function ExploreScreen() {
);
if (success) {
logger.info('HealthKit 数据同步到服务端成功');
logger.info('HealthKit 个人资料同步到服务端成功');
} else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
}
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
// 传入今日饮水量
const waterIntake = todayWaterStats?.totalAmount;
logger.info('开始同步每日健康数据报表...', { waterIntake });
const reportSuccess = await syncDailyHealthReport(waterIntake);
if (reportSuccess) {
logger.info('每日健康数据报表同步成功');
} else {
logger.info('每日健康数据报表同步跳过(无变化)或失败');
}
} catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error);
}
}, [isLoggedIn, dispatch, userProfile]);
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
// 初始加载时执行数据加载和同步
useEffect(() => {
loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
const syncTimer = setTimeout(() => {
syncHealthDataToServer();
}, 1000);
@@ -634,9 +653,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 3,
},
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: {
fontSize: 12,
fontFamily: 'AliRegular',

View File

@@ -1,14 +1,7 @@
import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import '@/i18n';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications';
@@ -26,8 +19,14 @@ import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
@@ -485,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
getPrivacyAgreed();
}, []);
// 使用 ref 确保更新检查只执行一次
// const updateCheckRequestedRef = React.useRef(false);
// useEffect(() => {
// // 如果已经执行过更新检查,直接返回
// if (updateCheckRequestedRef.current) {
// return;
// }
// updateCheckRequestedRef.current = true;
// async function checkUpdate() {
// try {
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
// // 只有在 expo-updates 启用时才检查更新
// // 开发环境或 Updates 未启用时跳过
// if (__DEV__ || !Updates.isEnabled) {
// logger.info('Skipping update check: dev mode or updates not enabled');
// return;
// }
// const update = await Updates.checkForUpdateAsync();
// logger.info("Update check:", update);
// if (update.isAvailable) {
// logger.info("Update available, fetching...");
// const result = await Updates.fetchUpdateAsync();
// logger.info("Fetch result:", result);
// if (result.isNew) {
// logger.info("Reloading app to apply update...");
// await Updates.reloadAsync();
// }
// }
// } catch (e) {
// logger.error("Update error:", e);
// }
// }
// setTimeout(() => {
// checkUpdate();
// }, 5000);
// }, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);

View File

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

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

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

View File

@@ -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 (
<View style={styles.container}>
{/* 背景 */}
@@ -245,9 +265,9 @@ export default function WeightRecordsPage() {
<Text style={styles.mainStatUnit}>kg</Text>
</View>
<View style={styles.totalLossTag}>
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Ionicons name={totalWeightLoss > 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Text style={styles.totalLossText}>
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
{totalWeightLoss > 0 ? '-' : totalWeightLoss < 0 ? '+' : ''}{Math.abs(totalWeightLoss).toFixed(1)} kg
</Text>
</View>
</View>
@@ -295,6 +315,19 @@ export default function WeightRecordsPage() {
</View>
</View>
{/* 减重进度条 - 仅在设置了目标体重时显示 */}
{hasTargetWeight && (
<View style={styles.progressContainer}>
<WeightProgressBar
progress={weightProgress}
currentWeight={currentWeight}
targetWeight={targetWeight}
initialWeight={initialWeight}
showTopBorder={false}
/>
</View>
)}
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
<View style={styles.historySection}>
@@ -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,

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation();
const [hrvValue, setHrvValue] = useState(0)
const [updateTime, setUpdateTime] = useState<Date>(new Date())
useEffect(() => {
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
if (result.hrvData.recordedAt) {
setUpdateTime(new Date(result.hrvData.recordedAt));
}
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: No HRV data obtained');
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
{/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient
colors={['#EF4444', '#FCD34D', '#10B981']}
colors={['#10B981', '#FCD34D', '#EF4444']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal}
onClose={() => setShowStressModal(false)}
hrvValue={hrvValue}
updateTime={new Date()}
updateTime={updateTime}
/>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}}%',
@@ -656,6 +654,45 @@ export const workoutDetail = {
},
};
export const sleepNotification = {
// Notification body template
body: 'You slept {{duration}} last night with {{efficiency}}% efficiency. Score: {{score}} 🎯',
// Sleep quality titles - warm and encouraging tone
quality: {
excellent: 'Amazing! You slept great',
good: 'Nice! Good sleep quality',
fair: 'Not bad, tomorrow will be better',
poor: 'Hang in there, rest well tonight',
veryPoor: 'Take care of yourself',
default: 'Sleep analysis complete',
},
// Sleep duration formatting
duration: {
hoursOnly: '{{hours}} hours',
hoursAndMinutes: '{{hours}}h {{minutes}}m',
},
// Sleep tips - encouraging tone
tips: {
excellent: {
keepItUp: 'Keep it up, you\'re doing amazing!',
greatJob: 'Your body thanks you for the great care!',
energized: 'You\'ll be full of energy today!',
proud: 'Give yourself a pat on the back!',
},
suggestions: {
shortSleep: 'Try hitting the pillow earlier - 7-9 hours will boost your energy!',
longSleep: 'Too much sleep can be tiring too - try a consistent wake time!',
lowDeepSleep: 'Put your phone away before bed for deeper rest~',
lowRemSleep: 'A regular schedule helps you dream better!',
lowEfficiency: 'A cozy bedroom environment can work wonders~',
},
general: 'Every night is a fresh start - take care of yourself!',
},
};
export const workoutHistory = {
title: 'Workout Summary',
loading: 'Loading workout records...',
@@ -687,3 +724,127 @@ export const workoutHistory = {
},
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
};
export const familyGroup = {
joinTitle: 'Join Family Group',
joinDescription: 'Enter the invite code shared by your family member to join health management',
inviteCodePlaceholder: 'Enter invite code',
relationshipLabel: 'Relationship to creator',
relationshipPlaceholder: 'Select relationship',
joinButton: 'Join',
joining: 'Joining...',
cancel: 'Cancel',
errors: {
emptyCode: 'Please enter invite code',
emptyRelationship: 'Please select relationship',
},
success: 'Successfully joined family group',
relationships: {
spouse: 'Spouse',
father: 'Father',
mother: 'Mother',
son: 'Son',
daughter: 'Daughter',
grandfather: 'Grandfather',
grandmother: 'Grandmother',
grandson: 'Grandson',
granddaughter: 'Granddaughter',
brother: 'Brother',
sister: 'Sister',
uncle: 'Uncle',
aunt: 'Aunt',
nephew: 'Nephew',
niece: 'Niece',
cousin: 'Cousin',
other: 'Other',
},
};
export const health = {
tabs: {
health: 'Health',
medications: 'Meds',
fasting: 'Fasting',
challenges: 'Challenges',
personal: 'Me',
healthProfile: {
title: 'Health Profile',
subtitle: 'Invite family to join health management for timely anomaly alerts',
privacyNotice: 'Profile content is visible only to you. We strictly protect your privacy.',
basicInfo: 'Basic Info',
healthHistory: 'History',
medicalRecords: 'Records',
checkupRecords: 'Checkups',
medicineBox: 'Medications',
basicInfoCard: {
title: 'Basic Information',
noData: 'No data',
bmi: 'BMI',
height: 'Height',
heightUnit: 'CM',
weight: 'Weight',
weightUnit: 'KG',
waist: 'Waist',
waistUnit: 'CM',
},
history: {
allergy: 'Allergies',
disease: 'Conditions',
surgery: 'Surgeries',
familyDisease: 'Family History',
pending: 'To be added',
edit: 'Edit',
modal: {
question: 'Do you have {{type}}?',
yes: 'Yes',
no: 'No',
addDetails: 'Add Details',
enterSpecific: 'Enter specific condition...',
recommendations: 'Recommendations',
save: 'Save',
none: 'None',
yesNoDetails: 'Yes (No details)',
diagnosisDate: 'Diagnosis Date',
namePlaceholder: 'Condition Name',
addItem: 'Add Record',
selectDate: 'Select Date'
},
recommendationItems: {
allergy: {
penicillin: 'Penicillin',
sulfonamides: 'Sulfonamides',
peanuts: 'Peanuts',
seafood: 'Seafood',
pollen: 'Pollen',
dustMites: 'Dust Mites',
alcohol: 'Alcohol',
mango: 'Mango'
},
disease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
asthma: 'Asthma',
heartDisease: 'Heart Disease',
gastritis: 'Gastritis',
migraine: 'Migraine'
},
surgery: {
appendectomy: 'Appendectomy',
cesareanSection: 'Cesarean Section',
tonsillectomy: 'Tonsillectomy',
fractureRepair: 'Fracture Repair',
none: 'None'
},
familyDisease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
cancer: 'Cancer',
heartDisease: 'Heart Disease',
stroke: 'Stroke',
alzheimers: 'Alzheimer\'s'
}
}
}
}
}
};

View File

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

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: 'No expiry',
dateFormat: 'YYYY-MM-DD',
},
membershipBanner: {
title: 'Unlock Premium Access',
subtitle: 'Get unlimited access to AI features & custom plans',
cta: 'Upgrade Now',
},
sections: {
notifications: 'Notifications',
developer: 'Developer',
@@ -37,6 +42,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',

View File

@@ -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}}%',
@@ -657,6 +655,45 @@ export const workoutDetail = {
},
};
export const sleepNotification = {
// 通知正文模板
body: '昨晚睡了 {{duration}},睡眠效率 {{efficiency}}%,得分 {{score}} 分 🎯',
// 睡眠质量标题 - 更温暖鼓励的语气
quality: {
excellent: '太棒了!睡得真好',
good: '不错哦!睡眠质量良好',
fair: '还行,明天会更好',
poor: '辛苦了,今晚早点休息',
veryPoor: '抱抱,好好照顾自己',
default: '睡眠分析完成啦',
},
// 睡眠时长格式化
duration: {
hoursOnly: '{{hours}} 小时',
hoursAndMinutes: '{{hours}} 小时 {{minutes}} 分钟',
},
// 睡眠建议 - 更鼓励的语气
tips: {
excellent: {
keepItUp: '继续保持,你真的很棒!',
greatJob: '身体一定很感谢你的照顾~',
energized: '今天一定精力满满!',
proud: '为自己的好习惯点赞!',
},
suggestions: {
shortSleep: '试着早点上床吧7-9 小时的睡眠会让你更有活力哦~',
longSleep: '睡太久也会累哦,试试固定起床时间~',
lowDeepSleep: '睡前放下手机,让大脑好好休息~',
lowRemSleep: '规律作息能帮助你做更多好梦~',
lowEfficiency: '调整一下卧室环境,会睡得更香哦~',
},
general: '每一晚都是新的开始,照顾好自己~',
},
};
export const workoutHistory = {
title: '锻炼总结',
loading: '正在加载锻炼记录...',
@@ -688,3 +725,127 @@ export const workoutHistory = {
},
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
};
export const familyGroup = {
joinTitle: '加入家庭组',
joinDescription: '输入家人分享的邀请码,加入家庭健康管理',
inviteCodePlaceholder: '请输入邀请码',
relationshipLabel: '与创建者的关系',
relationshipPlaceholder: '请选择关系',
joinButton: '加入',
joining: '加入中...',
cancel: '取消',
errors: {
emptyCode: '请输入邀请码',
emptyRelationship: '请选择与创建者的关系',
},
success: '成功加入家庭组',
relationships: {
spouse: '配偶',
father: '父亲',
mother: '母亲',
son: '儿子',
daughter: '女儿',
grandfather: '爷爷/外公',
grandmother: '奶奶/外婆',
grandson: '孙子/外孙',
granddaughter: '孙女/外孙女',
brother: '兄弟',
sister: '姐妹',
uncle: '叔叔/舅舅',
aunt: '阿姨/姑姑',
nephew: '侄子/外甥',
niece: '侄女/外甥女',
cousin: '表/堂兄弟姐妹',
other: '其他',
},
};
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: '阿尔茨海默病'
}
}
}
}
}
};

View File

@@ -238,7 +238,7 @@ export const medications = {
periodRange: '从 {{startDate}} 至 {{endDate}}',
periodLongTerm: '从 {{startDate}} 至长期',
expiryStatus: {
notSet: '未设置',
notSet: '未设置(过期预警)',
expired: '已过期',
expiresToday: '今天到期',
expiresInDays: '{{days}}天后到期',

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: '长期有效',
dateFormat: 'YYYY年MM月DD日',
},
membershipBanner: {
title: '解锁尊享会员权益',
subtitle: '无限次使用 AI 功能,定制专属健康计划',
cta: '立即升级',
},
sections: {
notifications: '通知',
developer: '开发者',
@@ -37,6 +42,10 @@ export const personal = {
medicalSources: '医学建议来源',
customization: '个性化',
},
healthProfile: {
title: '健康档案',
subtitle: '管理您的个人健康数据与家庭档案',
},
versionCheck: {
sectionTitle: '版本与更新',
menuTitle: '检查更新',

View File

@@ -15,6 +15,7 @@
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */; };
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */; };
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */ = {isa = PBXBuildFile; fileRef = 7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */; };
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
@@ -68,6 +69,8 @@
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppStoreReviewManager.m; sourceTree = "<group>"; };
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReviewManager.swift; sourceTree = "<group>"; };
7981D9902EDFC0B5008D5F2D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
7981D9932EDFC0B8008D5F2D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -140,6 +143,7 @@
13B07FAE1A68108700A75B9A /* OutLive */ = {
isa = PBXGroup;
children = (
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
@@ -309,10 +313,11 @@
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
"zh-Hans",
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
@@ -399,6 +404,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
@@ -407,6 +413,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
@@ -422,6 +429,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
@@ -430,6 +438,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
@@ -500,6 +509,7 @@
79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */,
79E80C002EC5E127004425BE /* WidgetManager.m in Sources */,
79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */,
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */,
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
@@ -530,6 +540,18 @@
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
7981D9902EDFC0B5008D5F2D /* zh-Hans */,
7981D9932EDFC0B8008D5F2D /* en */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -620,7 +642,7 @@
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = medicine/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = medicine;
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
@@ -670,7 +692,7 @@
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = medicine/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = medicine;
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (

View File

@@ -41,7 +41,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -2,11 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesURL</key>
<string>https://pilate.richarjiang.com/api/expo-updates/manifest</string>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>1.1.4</string>
</dict>
</plist>

View File

@@ -1,4 +1,6 @@
PODS:
- EASClient (1.0.7):
- ExpoModulesCore
- EXApplication (7.0.7):
- ExpoModulesCore
- EXConstants (18.0.10):
@@ -6,9 +8,12 @@ PODS:
- EXImageLoader (6.0.0):
- ExpoModulesCore
- React-Core
- EXNotifications (0.32.12):
- EXJSONUtils (0.15.0)
- EXManifests (1.0.9):
- ExpoModulesCore
- Expo (54.0.25):
- EXNotifications (0.32.13):
- ExpoModulesCore
- Expo (54.0.26):
- ExpoModulesCore
- hermes-engine
- RCTRequired
@@ -37,7 +42,7 @@ PODS:
- ExpoModulesCore
- ExpoAsset (12.0.10):
- ExpoModulesCore
- ExpoBackgroundTask (1.0.8):
- ExpoBackgroundTask (1.0.9):
- ExpoModulesCore
- ExpoBlur (15.0.7):
- ExpoModulesCore
@@ -47,6 +52,8 @@ PODS:
- ZXingObjC/PDF417
- ExpoClipboard (8.0.7):
- ExpoModulesCore
- ExpoDocumentPicker (14.0.7):
- ExpoModulesCore
- ExpoFileSystem (19.0.19):
- ExpoModulesCore
- ExpoFont (14.0.9):
@@ -55,7 +62,7 @@ PODS:
- ExpoModulesCore
- ExpoHaptics (15.0.7):
- ExpoModulesCore
- ExpoHead (6.0.15):
- ExpoHead (6.0.16):
- ExpoModulesCore
- RNScreens
- ExpoImage (3.0.10):
@@ -78,7 +85,7 @@ PODS:
- ExpoMediaLibrary (18.2.0):
- ExpoModulesCore
- React-Core
- ExpoModulesCore (3.0.26):
- ExpoModulesCore (3.0.27):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -105,7 +112,7 @@ PODS:
- ExpoModulesCore
- ExpoSplashScreen (31.0.11):
- ExpoModulesCore
- ExpoSQLite (16.0.8):
- ExpoSQLite (16.0.9):
- ExpoModulesCore
- ExpoSymbols (1.0.7):
- ExpoModulesCore
@@ -113,11 +120,42 @@ PODS:
- ExpoModulesCore
- ExpoUI (0.2.0-beta.7):
- ExpoModulesCore
- ExpoWebBrowser (15.0.8):
- ExpoWebBrowser (15.0.9):
- ExpoModulesCore
- EXStructuredHeaders (5.0.0)
- EXTaskManager (14.0.8):
- ExpoModulesCore
- UMAppLoader
- EXUpdates (29.0.14):
- EASClient
- EXManifests
- ExpoModulesCore
- EXStructuredHeaders
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- ReachabilitySwift
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- EXUpdatesInterface (2.0.0):
- ExpoModulesCore
- FBLazyVector (0.81.5)
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
@@ -163,14 +201,15 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- PurchasesHybridCommon (17.19.1):
- RevenueCat (= 5.48.0)
- PurchasesHybridCommon (17.21.2):
- RevenueCat (= 5.49.2)
- RCTDeprecation (0.81.5)
- RCTRequired (0.81.5)
- RCTTypeSafety (0.81.5):
- FBLazyVector (= 0.81.5)
- RCTRequired (= 0.81.5)
- React-Core (= 0.81.5)
- ReachabilitySwift (5.2.4)
- React (0.81.5):
- React-Core (= 0.81.5)
- React-Core/DevSupport (= 0.81.5)
@@ -1446,7 +1485,7 @@ PODS:
- ReactNativeDependencies
- react-native-render-html (6.3.4):
- React-Core
- react-native-safe-area-context (5.6.1):
- react-native-safe-area-context (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1458,8 +1497,8 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-safe-area-context/common (= 5.6.1)
- react-native-safe-area-context/fabric (= 5.6.1)
- react-native-safe-area-context/common (= 5.6.2)
- react-native-safe-area-context/fabric (= 5.6.2)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@@ -1470,7 +1509,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/common (5.6.1):
- react-native-safe-area-context/common (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1492,7 +1531,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/fabric (5.6.1):
- react-native-safe-area-context/fabric (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1911,7 +1950,7 @@ PODS:
- React-utils (= 0.81.5)
- ReactNativeDependencies
- ReactNativeDependencies (0.81.5)
- RevenueCat (5.48.0)
- RevenueCat (5.49.2)
- RNCAsyncStorage (2.2.0):
- hermes-engine
- RCTRequired
@@ -2024,10 +2063,10 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNPurchases (9.6.7):
- PurchasesHybridCommon (= 17.19.1)
- RNPurchases (9.6.9):
- PurchasesHybridCommon (= 17.21.2)
- React-Core
- RNReanimated (4.1.5):
- RNReanimated (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2049,10 +2088,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated (= 4.1.5)
- RNReanimated/reanimated (= 4.1.6)
- RNWorklets
- Yoga
- RNReanimated/reanimated (4.1.5):
- RNReanimated/reanimated (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2074,10 +2113,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.1.5)
- RNReanimated/reanimated/apple (= 4.1.6)
- RNWorklets
- Yoga
- RNReanimated/reanimated/apple (4.1.5):
- RNReanimated/reanimated/apple (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2217,7 +2256,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets (0.6.1):
- RNWorklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2239,9 +2278,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets (= 0.6.1)
- RNWorklets/worklets (= 0.7.1)
- Yoga
- RNWorklets/worklets (0.6.1):
- RNWorklets/worklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2263,9 +2302,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets/apple (= 0.6.1)
- RNWorklets/worklets/apple (= 0.7.1)
- Yoga
- RNWorklets/worklets/apple (0.6.1):
- RNWorklets/worklets/apple (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2288,9 +2327,9 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- SDWebImage (5.21.4):
- SDWebImage/Core (= 5.21.4)
- SDWebImage/Core (5.21.4)
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
- SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10)
@@ -2309,9 +2348,12 @@ PODS:
- ZXingObjC/Core
DEPENDENCIES:
- EASClient (from `../node_modules/expo-eas-client/ios`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
- EXManifests (from `../node_modules/expo-manifests/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
@@ -2320,6 +2362,7 @@ DEPENDENCIES:
- ExpoBlur (from `../node_modules/expo-blur/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
@@ -2340,7 +2383,10 @@ DEPENDENCIES:
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
- EXTaskManager (from `../node_modules/expo-task-manager/ios`)
- EXUpdates (from `../node_modules/expo-updates/ios`)
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- lottie-react-native (from `../node_modules/lottie-react-native`)
@@ -2436,6 +2482,7 @@ SPEC REPOS:
- libwebp
- lottie-ios
- PurchasesHybridCommon
- ReachabilitySwift
- RevenueCat
- SDWebImage
- SDWebImageAVIFCoder
@@ -2445,12 +2492,18 @@ SPEC REPOS:
- ZXingObjC
EXTERNAL SOURCES:
EASClient:
:path: "../node_modules/expo-eas-client/ios"
EXApplication:
:path: "../node_modules/expo-application/ios"
EXConstants:
:path: "../node_modules/expo-constants/ios"
EXImageLoader:
:path: "../node_modules/expo-image-loader/ios"
EXJSONUtils:
:path: "../node_modules/expo-json-utils/ios"
EXManifests:
:path: "../node_modules/expo-manifests/ios"
EXNotifications:
:path: "../node_modules/expo-notifications/ios"
Expo:
@@ -2467,6 +2520,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-camera/ios"
ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios"
ExpoDocumentPicker:
:path: "../node_modules/expo-document-picker/ios"
ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
@@ -2507,8 +2562,14 @@ EXTERNAL SOURCES:
:path: "../node_modules/@expo/ui/ios"
ExpoWebBrowser:
:path: "../node_modules/expo-web-browser/ios"
EXStructuredHeaders:
:path: "../node_modules/expo-structured-headers/ios"
EXTaskManager:
:path: "../node_modules/expo-task-manager/ios"
EXUpdates:
:path: "../node_modules/expo-updates/ios"
EXUpdatesInterface:
:path: "../node_modules/expo-updates-interface/ios"
FBLazyVector:
:path: "../node_modules/react-native/Libraries/FBLazyVector"
hermes-engine:
@@ -2684,129 +2745,137 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
EASClient: de38c20c1dd9af7ff435afdc8b2c9b7c20d46767
EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094
EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd
EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: eaf327418721c4a7482576c579d215516d36319c
EXNotifications: 5edf06b6b41a52d8b5b3eccf0e951dc8a880a50b
Expo: a429ae95cbae859785ece75d42ff768d6176099f
ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8
ExpoAsset: ee515c16290e521de1870dcdee66d78173fbc533
ExpoBackgroundTask: e0efb5999d30593cbc0a2c443db70cc9310ec51c
ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76
ExpoCamera: d1894aad960492f4881c1f83967167963365d937
ExpoClipboard: 99109306a2d9ed2fbd16f6b856e6267b2afa8472
ExpoDocumentPicker: dbe4eeeb771891209fee044a6b0bafbd1fdcc4fa
ExpoFileSystem: 73a9f3f2e0affc61eba5b9326153f64870438af1
ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f
ExpoGlassEffect: 3d147d753d3bfe1a5d6b7920560e206e3e98c19e
ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5
ExpoHead: 4ad178d6b19c2bf786b0d1889a870ba3187c18ef
ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5
ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8
ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a
ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588
ExpoLinking: 50a65cd7beb6051ffc82f84ffb33961dd5e55a5b
ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c
ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31
ExpoModulesCore: bf3cd6f1f564c47031bcb1ee61b6aed1e0909eed
ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58
ExpoSplashScreen: f46795cd52cdad65d30e54043f04c86401c4f85d
ExpoSQLite: 36b8723223f322148606473507da446b1912500c
ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a
ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79
ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae
ExpoWebBrowser: 35223474c19e261c0661ecc7caa513d054244ab5
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684
EXUpdates: 04853b77f4a405c5b85039b9a2ccf1c71438028c
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d
lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1
react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0
react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005
RNCPicker: e0149590451d5eae242cf686014a6f6d808f93c7
RNDateTimePicker: 5e0a759109b63ebc661a4714712361d2d07142fe
RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59
RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162
RNPurchases: 8a21795ad71f932dca9f48e9560924a3c64878c6
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
RNSentry: 54f8041cd06d7ccf484171edde72f1b07323fb2e
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
RNWorklets: e2b9423934700160135e216d8a2e6cb7b194846c
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380

View File

@@ -0,0 +1,8 @@
//
// InfoPlist.strings
// OutLive
//
// Created by richard on 2025/12/3.
//
CFBundleDisplayName = "OutLive";

View File

@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>用药计划</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -0,0 +1,8 @@
//
// InfoPlist.strings
// OutLive
//
// Created by richard on 2025/12/3.
//
CFBundleDisplayName = "OutLive";

5265
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,21 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "2.11.4",
"@react-native-voice/voice": "^3.2.4",
"@react-navigation/bottom-tabs": "^7.8.6",
"@react-navigation/elements": "^2.8.3",
"@react-navigation/native": "^7.1.21",
"@react-navigation/bottom-tabs": "^7.8.11",
"@react-navigation/elements": "^2.9.1",
"@react-navigation/native": "^7.1.24",
"@reduxjs/toolkit": "^2.11.0",
"@sentry/react-native": "~7.7.0",
"@types/lodash": "^4.17.21",
"dayjs": "^1.11.19",
"expo": "54.0.25",
"expo": "54.0.26",
"expo-apple-authentication": "~8.0.7",
"expo-background-task": "~1.0.8",
"expo-background-task": "~1.0.9",
"expo-blur": "~15.0.7",
"expo-clipboard": "~8.0.7",
"expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.10",
"expo-document-picker": "~14.0.7",
"expo-font": "~14.0.9",
"expo-glass-effect": "~0.1.7",
"expo-haptics": "~15.0.7",
@@ -49,13 +50,14 @@
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "^29.0.14",
"expo-web-browser": "~15.0.7",
"i18next": "^25.6.2",
"lodash": "^4.17.21",
"lottie-react-native": "^7.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.3.0",
"react-i18next": "^16.3.5",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4",
@@ -67,7 +69,7 @@
"react-native-purchases": "^9.2.2",
"react-native-reanimated": "~4.1.0",
"react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "~5.6.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-toast-message": "^2.3.3",
@@ -85,4 +87,4 @@
"typescript": "~5.9.2"
},
"private": true
}
}

View File

@@ -8,16 +8,28 @@
*/
import {
fetchActiveEnergyBurned,
fetchBasalEnergyBurned,
fetchCompleteSleepData,
fetchHourlyExerciseMinutesForDate,
fetchHourlyStandHoursForDate,
fetchOxygenSaturation,
fetchPersonalHealthData,
fetchSmartHRVData,
fetchStepCount,
saveHeight,
saveWeight
} from '@/utils/health';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { convertHrvToStressIndex } from '@/utils/stress';
import dayjs from 'dayjs';
import { DailyHealthDataDto, updateDailyHealthData } from './users';
// 同步状态存储键
const SYNC_STATUS_KEY = '@healthkit_sync_status';
const SYNC_LOCK_KEY = '@healthkit_sync_lock';
const DAILY_HEALTH_SYNC_KEY = '@daily_health_sync_status';
// 同步状态类型
interface SyncStatus {
@@ -30,6 +42,13 @@ interface SyncStatus {
};
}
// 每日健康数据同步状态
interface DailyHealthSyncStatus {
lastSyncTime: number;
lastSyncDate: string; // YYYY-MM-DD
data: DailyHealthDataDto;
}
// 同步锁(防止并发同步)
let syncLock = false;
@@ -444,4 +463,153 @@ export async function clearSyncStatus(): Promise<void> {
*/
export async function getSyncStatusInfo(): Promise<SyncStatus | null> {
return getLastSyncStatus();
}
/**
* 同步每日健康数据报表到服务端
* @param waterIntake - 当日饮水量(从应用内部获取,因为 HealthKit 可能不包含应用内记录)
*/
export async function syncDailyHealthReport(waterIntake?: number): Promise<boolean> {
try {
const today = new Date();
const dateStr = dayjs(today).format('YYYY-MM-DD');
// 1. 获取各项健康数据
// 并行获取以提高性能
const [
activeEnergy,
basalEnergy,
sleepData,
exerciseMinutesData,
standHoursData,
oxygenSaturation,
hrvData,
stepCount
] = await Promise.all([
// 卡路里
fetchActiveEnergyBurned({
startDate: dayjs(today).startOf('day').toISOString(),
endDate: dayjs(today).endOf('day').toISOString()
}),
// 基础代谢
fetchBasalEnergyBurned({
startDate: dayjs(today).startOf('day').toISOString(),
endDate: dayjs(today).endOf('day').toISOString()
}),
// 睡眠数据 (需要完整数据来获取分钟数)
fetchCompleteSleepData(today),
// 锻炼分钟数 (按小时聚合)
fetchHourlyExerciseMinutesForDate(today),
// 站立小时 (按小时聚合)
fetchHourlyStandHoursForDate(today),
// 血氧
fetchOxygenSaturation({
startDate: dayjs(today).startOf('day').toISOString(),
endDate: dayjs(today).endOf('day').toISOString()
}),
// HRV (用于计算压力)
fetchSmartHRVData(today),
// 步数
fetchStepCount(today)
]);
// 2. 数据处理与计算
// 计算总锻炼分钟数
const totalExerciseMinutes = exerciseMinutesData.reduce((sum, item) => sum + item.minutes, 0);
// 计算总站立时间 (分钟) - 注意 HealthKit 返回的是小时是否有站立我们这里估算每小时站立1分钟或者直接用小时数
// 根据 API 要求 "standingMinutes"HealthKit 的 standHours 是指有多少个小时有站立活动
// 通常 Apple Watch 判定一小时内站立至少1分钟即计为1个站立小时
// 为了符合 API 语义,我们这里统计有多少个小时达标,转换成分钟可能不太准确,但 API 字段叫 minutes
// 策略:如果有 HealthKit 数据,我们用 达标小时数 * 60 作为估算,或者直接传小时数?
// 文档说 "standingMinutes: 站立时间(分钟)"。
// HealthKit 的 appleStandHours 是 count比如 12。
// 如果我们传 12 分钟显然不对。如果我们传 12 * 60 = 720 分钟也不太对,因为并不是站了这么久。
// 实际上 Apple 的 Stand Hours 是 "Hours with >1 min standing".
// 这里我们统计有多少小时是有站立的,并乘以一个系数?或者直接传小时数让后端理解?
// 鉴于字段名是 minutes我们统计所有有站立的小时数。
// 实际上,我们应该直接使用 HealthKit 的 appleStandTime (如果可用) 或者通过 appleStandHours 估算。
// 这里的 fetchHourlyStandHoursForDate 返回的是每小时是否有站立(0或1)。
const standHoursCount = standHoursData.filter(h => h.hasStood > 0).length;
// 暂时策略:将小时数转换为分钟数传递,虽然这代表的是跨度
const standingMinutes = standHoursCount * 60;
// 计算睡眠分钟数
const sleepMinutes = sleepData ? Math.round(sleepData.totalSleepTime) : 0;
// 计算压力值
let stressLevel = 0;
if (hrvData && hrvData.value > 0) {
const stressIndex = convertHrvToStressIndex(hrvData.value);
if (stressIndex !== null) {
stressLevel = stressIndex;
}
}
// 3. 构建 DTO
const healthData: DailyHealthDataDto = {
date: dateStr,
// 只有当数据有效时才包含字段
...(waterIntake !== undefined && { waterIntake }),
...(totalExerciseMinutes > 0 && { exerciseMinutes: Math.round(totalExerciseMinutes) }),
...(activeEnergy > 0 && { caloriesBurned: Math.round(activeEnergy) }),
...(standingMinutes > 0 && { standingMinutes }),
...(basalEnergy > 0 && { basalMetabolism: Math.round(basalEnergy) }),
...(sleepMinutes > 0 && { sleepMinutes }),
...(oxygenSaturation !== null && oxygenSaturation > 0 && { bloodOxygen: oxygenSaturation }),
...(stressLevel > 0 && { stressLevel }),
...(stepCount > 0 && { steps: Math.round(stepCount) })
};
logger.info('准备同步每日健康数据:', healthData);
// 4. 检查是否需要同步 (与上次同步的数据比较)
const lastSyncStatusStr = await AsyncStorage.getItem(DAILY_HEALTH_SYNC_KEY);
if (lastSyncStatusStr) {
const lastSyncStatus: DailyHealthSyncStatus = JSON.parse(lastSyncStatusStr);
// 如果是同一天,检查数据差异
if (lastSyncStatus.lastSyncDate === dateStr) {
const lastData = lastSyncStatus.data;
const isDifferent =
healthData.waterIntake !== lastData.waterIntake ||
healthData.exerciseMinutes !== lastData.exerciseMinutes ||
healthData.caloriesBurned !== lastData.caloriesBurned ||
healthData.standingMinutes !== lastData.standingMinutes ||
healthData.basalMetabolism !== lastData.basalMetabolism ||
healthData.sleepMinutes !== lastData.sleepMinutes ||
healthData.bloodOxygen !== lastData.bloodOxygen ||
healthData.stressLevel !== lastData.stressLevel ||
healthData.steps !== lastData.steps;
if (!isDifferent) {
console.log('每日健康数据无变化,跳过同步');
return true;
}
}
}
// 5. 调用 API
if (Object.keys(healthData).length > 1) { // 至少包含 date 以外的一个字段
await updateDailyHealthData(healthData);
console.log('每日健康数据同步成功');
// 6. 保存同步状态
const newSyncStatus: DailyHealthSyncStatus = {
lastSyncTime: Date.now(),
lastSyncDate: dateStr,
data: healthData
};
await AsyncStorage.setItem(DAILY_HEALTH_SYNC_KEY, JSON.stringify(newSyncStatus));
return true;
} else {
console.log('没有有效的健康数据需要同步');
return false;
}
} catch (error) {
console.error('同步每日健康报表失败:', error);
return false;
}
}

274
services/healthProfile.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* 健康档案 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<HealthHistoryCategory, boolean>;
}
// 更新健康史请求
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<HealthProfileOverview> {
return api.get<HealthProfileOverview>('/api/health-profiles/overview');
}
// ==================== 健康史 API ====================
/**
* 获取完整健康史
*/
export async function getHealthHistory(): Promise<HealthHistoryData> {
return api.get<HealthHistoryData>('/api/health-profiles/history');
}
/**
* 更新指定分类的健康史
* @param category 分类: allergy | disease | surgery | familyDisease
* @param data 更新数据
*/
export async function updateHealthHistory(
category: HealthHistoryCategory,
data: UpdateHealthHistoryRequest
): Promise<HealthHistoryCategoryData> {
return api.put<HealthHistoryCategoryData>(
`/api/health-profiles/history/${category}`,
data
);
}
/**
* 获取健康史完成度
*/
export async function getHealthHistoryProgress(): Promise<HealthHistoryProgress> {
return api.get<HealthHistoryProgress>('/api/health-profiles/history/progress');
}
// ==================== 就医资料 API ====================
export type MedicalRecordType = 'medical_record' | 'prescription';
export interface MedicalRecordItem {
id: string;
type: MedicalRecordType;
title: string;
date: string; // YYYY-MM-DD
images: string[]; // Image URLs
note?: string;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}
export interface MedicalRecordsData {
records: MedicalRecordItem[];
prescriptions: MedicalRecordItem[];
}
/**
* 获取就医资料列表
*/
export async function getMedicalRecords(): Promise<MedicalRecordsData> {
return api.get<MedicalRecordsData>('/api/health-profiles/medical-records');
}
/**
* 添加就医资料
*/
export async function addMedicalRecord(data: Omit<MedicalRecordItem, 'id' | 'createdAt' | 'updatedAt'>): Promise<MedicalRecordItem> {
return api.post<MedicalRecordItem>('/api/health-profiles/medical-records', data);
}
/**
* 删除就医资料
*/
export async function deleteMedicalRecord(id: string): Promise<void> {
return api.delete<void>(`/api/health-profiles/medical-records/${id}`);
}
// ==================== 家庭健康管理 API ====================
/**
* 获取用户所属家庭组
*/
export async function getFamilyGroup(): Promise<FamilyGroup | null> {
try {
return await api.get<FamilyGroup>('/api/health-profiles/family/group');
} catch (error: any) {
// 如果用户没有家庭组,返回 null
if (error?.status === 404) {
return null;
}
throw error;
}
}
/**
* 生成家庭组邀请码
* @param expiresInHours 过期时间小时默认24小时
*/
export async function generateInviteCode(expiresInHours: number = 24): Promise<InviteCode> {
return api.post<InviteCode>('/api/health-profiles/family/group/invite', { expiresInHours });
}
/**
* 通过邀请码加入家庭组
* @param inviteCode 邀请码
* @param relationship 与创建者的关系(如:配偶、父母、子女等)
*/
export async function joinFamilyGroup(
inviteCode: string,
relationship: string
): Promise<FamilyGroup> {
return api.post<FamilyGroup>('/api/health-profiles/family/group/join', {
inviteCode,
relationship,
});
}
/**
* 获取家庭成员列表
*/
export async function getFamilyMembers(): Promise<FamilyMember[]> {
return api.get<FamilyMember[]>('/api/health-profiles/family/members');
}
/**
* 更新家庭成员权限(仅 owner/admin 可操作)
* @param memberId 成员ID
* @param permissions 权限设置
*/
export async function updateFamilyMember(
memberId: string,
permissions: UpdateMemberPermissionsRequest
): Promise<FamilyMember> {
return api.put<FamilyMember>(
`/api/health-profiles/family/members/${memberId}`,
permissions
);
}
/**
* 移除家庭成员(仅 owner/admin 可操作)
* @param memberId 成员ID
*/
export async function removeFamilyMember(memberId: string): Promise<void> {
return api.delete(`/api/health-profiles/family/members/${memberId}`);
}
/**
* 退出家庭组(非 owner 成员使用)
*/
export async function leaveFamilyGroup(): Promise<void> {
return api.post('/api/health-profiles/family/leave');
}

View File

@@ -265,6 +265,15 @@ export class NotificationService {
console.log('用户点击了 HRV 压力通知', data);
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
router.push(targetUrl as any);
} else if (data?.type === NotificationTypes.SLEEP_ANALYSIS || data?.type === NotificationTypes.SLEEP_REMINDER) {
// 处理睡眠分析通知
console.log('用户点击了睡眠分析通知', data);
// 从通知数据中获取日期,如果没有则使用今天
const sleepDate = data?.date as string || new Date().toISOString().split('T')[0];
router.push({
pathname: ROUTES.SLEEP_DETAIL,
params: { date: sleepDate },
} as any);
}
}
@@ -607,6 +616,8 @@ export const NotificationTypes = {
FASTING_START: 'fasting_start',
FASTING_END: 'fasting_end',
HRV_STRESS_ALERT: 'hrv_stress_alert',
SLEEP_ANALYSIS: 'sleep_analysis',
SLEEP_REMINDER: 'sleep_reminder',
} as const;
// 便捷方法

View File

@@ -4,10 +4,14 @@
* 负责在睡眠分析完成后发送通知,提供睡眠质量评估和建议
*/
import i18n from '@/i18n';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import * as Notifications from 'expo-notifications';
import { SleepAnalysisData } from './sleepMonitor';
const t = (key: string, options?: Record<string, unknown>) => i18n.t(`sleepNotification.${key}`, options);
/**
* 分析睡眠数据并发送通知
*/
@@ -51,12 +55,22 @@ function buildSleepNotification(analysis: SleepAnalysisData): Notifications.Noti
// 构建通知正文
const sleepDuration = formatSleepDuration(totalSleepHours);
const efficiencyText = `睡眠效率 ${sleepEfficiency.toFixed(0)}%`;
const body = `您昨晚睡了 ${sleepDuration}${efficiencyText}。评分:${sleepScore}`;
const body = t('body', {
duration: sleepDuration,
efficiency: sleepEfficiency.toFixed(0),
score: sleepScore
});
// 获取建议
const suggestion = getSleepSuggestion(analysis);
// 计算睡眠日期
// 睡眠详情页面使用的日期逻辑是传入的日期会查询从前一天18:00到当天12:00的数据
// 所以我们应该传递醒来的日期sessionEnd这样用户点击通知后能看到正确的睡眠数据
const sleepDate = analysis.sessionEnd
? dayjs(analysis.sessionEnd).format('YYYY-MM-DD')
: dayjs().format('YYYY-MM-DD');
return {
title,
body: `${body}\n${suggestion}`,
@@ -64,6 +78,7 @@ function buildSleepNotification(analysis: SleepAnalysisData): Notifications.Noti
type: 'sleep_analysis',
score: sleepScore,
quality,
date: sleepDate, // 添加日期参数,用于点击通知后跳转
analysis: JSON.stringify(analysis),
url: '/sleep-detail', // 点击通知跳转到睡眠详情页
},
@@ -79,32 +94,32 @@ function getQualityConfig(quality: string): {
emoji: string;
title: string;
} {
const configs = {
const configs: Record<string, { emoji: string; title: string }> = {
excellent: {
emoji: '😴',
title: '睡眠质量优秀',
emoji: '🥳',
title: t('quality.excellent'),
},
good: {
emoji: '😊',
title: '睡眠质量良好',
emoji: '☀️',
title: t('quality.good'),
},
fair: {
emoji: '😐',
title: '睡眠质量一般',
emoji: '🌤️',
title: t('quality.fair'),
},
poor: {
emoji: '😟',
title: '睡眠质量较差',
emoji: '🌛',
title: t('quality.poor'),
},
very_poor: {
emoji: '😰',
title: '睡眠质量很差',
emoji: '🫂',
title: t('quality.veryPoor'),
},
};
return configs[quality as keyof typeof configs] || {
emoji: '💤',
title: '睡眠分析完成',
return configs[quality] || {
emoji: '🛏️',
title: t('quality.default'),
};
}
@@ -116,9 +131,9 @@ function formatSleepDuration(hours: number): string {
const m = Math.round((hours - h) * 60);
if (m === 0) {
return `${h}小时`;
return t('duration.hoursOnly', { hours: h });
}
return `${h}小时${m}分钟`;
return t('duration.hoursAndMinutes', { hours: h, minutes: m });
}
/**
@@ -127,35 +142,36 @@ function formatSleepDuration(hours: number): string {
function getSleepSuggestion(analysis: SleepAnalysisData): string {
const { quality, totalSleepHours, deepSleepPercentage, remSleepPercentage, sleepEfficiency } = analysis;
// 优秀或良好的睡眠
// 优秀或良好的睡眠 - 给予鼓励
if (quality === 'excellent' || quality === 'good') {
const tips = [
'继续保持良好的睡眠习惯!',
'坚持规律作息,身体会感谢你!',
'优质睡眠让你精力充沛!',
t('tips.excellent.keepItUp'),
t('tips.excellent.greatJob'),
t('tips.excellent.energized'),
t('tips.excellent.proud'),
];
return tips[Math.floor(Math.random() * tips.length)];
return `${tips[Math.floor(Math.random() * tips.length)]}`;
}
// 根据具体问题给出建议
// 根据具体问题给出温暖的建议
const suggestions: string[] = [];
if (totalSleepHours < 7) {
suggestions.push('建议增加睡眠时间至7-9小时');
suggestions.push(t('tips.suggestions.shortSleep'));
} else if (totalSleepHours > 9) {
suggestions.push('睡眠时间偏长,注意睡眠质量');
suggestions.push(t('tips.suggestions.longSleep'));
}
if (deepSleepPercentage < 13) {
suggestions.push('深度睡眠不足,睡前避免使用电子设备');
suggestions.push(t('tips.suggestions.lowDeepSleep'));
}
if (remSleepPercentage < 20) {
suggestions.push('REM睡眠不足保持规律的作息时间');
suggestions.push(t('tips.suggestions.lowRemSleep'));
}
if (sleepEfficiency < 85) {
suggestions.push('睡眠效率较低,改善睡眠环境');
suggestions.push(t('tips.suggestions.lowEfficiency'));
}
// 如果有具体建议,返回第一条;否则返回通用建议
@@ -163,29 +179,5 @@ function getSleepSuggestion(analysis: SleepAnalysisData): string {
return `💡 ${suggestions[0]}`;
}
return '建议关注睡眠质量,保持良好作息';
return `💡 ${t('tips.general')}`;
}
/**
* 发送简单的睡眠提醒(用于测试)
*/
export async function sendSimpleSleepReminder(userName: string = '朋友'): Promise<void> {
try {
await Notifications.scheduleNotificationAsync({
content: {
title: '😴 睡眠质量分析',
body: `${userName},您的睡眠数据已更新,点击查看详细分析`,
data: {
type: 'sleep_reminder',
url: '/sleep-detail',
},
sound: 'default',
},
trigger: null,
});
logger.info('简单睡眠提醒已发送');
} catch (error) {
logger.error('发送简单睡眠提醒失败:', error);
}
}

View File

@@ -54,4 +54,25 @@ export async function updateBodyMeasurements(dto: BodyMeasurementsDto): Promise<
return await api.put('/users/body-measurements', dto);
}
export type DailyHealthDataDto = {
date?: string; // YYYY-MM-DD
waterIntake?: number; // ml
exerciseMinutes?: number; // minutes
caloriesBurned?: number; // kcal
standingMinutes?: number; // minutes
basalMetabolism?: number; // kcal
sleepMinutes?: number; // minutes
bloodOxygen?: number; // % (0-100)
stressLevel?: number; // ms (based on HRV)
steps?: number; // 步数
};
export async function updateDailyHealthData(dto: DailyHealthDataDto): Promise<{
code: number;
message: string;
data: any;
}> {
return await api.put('/users/daily-health', dto);
}

303
store/familyHealthSlice.ts Normal file
View File

@@ -0,0 +1,303 @@
/**
* 家庭健康管理 Redux Slice
*/
import * as healthProfileApi from '@/services/healthProfile';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
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 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(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;

View File

@@ -1,5 +1,13 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import * as healthProfileApi from '@/services/healthProfile';
import { AppDispatch, RootState } from './index';
import {
HistoryData,
HistoryItemDetail,
MedicalRecordItem,
MedicalRecordsData,
MedicalRecordType,
} from '@/services/healthProfile';
// 健康数据类型定义
export interface FitnessRingsData {
@@ -22,10 +30,31 @@ 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<string, HealthData>;
// 健康史数据
historyData: HistoryData;
// 就医资料数据
medicalRecords: MedicalRecordsData;
// 加载状态
loading: boolean;
error: string | null;
@@ -37,6 +66,16 @@ export interface HealthState {
// 初始状态
const initialState: HealthState = {
dataByDate: {},
historyData: {
allergy: { hasHistory: null, items: [] },
disease: { hasHistory: null, items: [] },
surgery: { hasHistory: null, items: [] },
familyDisease: { hasHistory: null, items: [] },
},
medicalRecords: {
records: [],
prescriptions: [],
},
loading: false,
error: null,
lastUpdateTime: null,
@@ -82,6 +121,158 @@ 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<HistoryData>) => {
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: [] },
};
},
// 更新就医资料列表
setMedicalRecords: (state, action: PayloadAction<MedicalRecordsData>) => {
state.medicalRecords = action.payload;
state.lastUpdateTime = new Date().toISOString();
},
// 添加就医资料项
addMedicalRecordItem: (state, action: PayloadAction<MedicalRecordItem>) => {
const item = action.payload;
if (item.type === 'medical_record') {
state.medicalRecords.records.unshift(item);
} else {
state.medicalRecords.prescriptions.unshift(item);
}
state.lastUpdateTime = new Date().toISOString();
},
// 删除就医资料项
removeMedicalRecordItem: (state, action: PayloadAction<{ id: string; type: MedicalRecordType }>) => {
const { id, type } = action.payload;
if (type === 'medical_record') {
state.medicalRecords.records = state.medicalRecords.records.filter(item => item.id !== id);
} else {
state.medicalRecords.prescriptions = state.medicalRecords.prescriptions.filter(item => item.id !== id);
}
state.lastUpdateTime = new Date().toISOString();
},
},
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;
})
// 获取就医资料
.addCase(fetchMedicalRecords.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchMedicalRecords.fulfilled, (state, action) => {
state.loading = false;
state.medicalRecords = action.payload;
state.lastUpdateTime = new Date().toISOString();
})
.addCase(fetchMedicalRecords.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// 添加就医资料
.addCase(addNewMedicalRecord.fulfilled, (state, action) => {
const item = action.payload;
if (item.type === 'medical_record') {
state.medicalRecords.records.unshift(item);
} else {
state.medicalRecords.prescriptions.unshift(item);
}
state.lastUpdateTime = new Date().toISOString();
})
// 删除就医资料
.addCase(deleteMedicalRecordItem.fulfilled, (state, action) => {
const { id, type } = action.payload;
if (type === 'medical_record') {
state.medicalRecords.records = state.medicalRecords.records.filter(item => item.id !== id);
} else {
state.medicalRecords.prescriptions = state.medicalRecords.prescriptions.filter(item => item.id !== id);
}
state.lastUpdateTime = new Date().toISOString();
});
},
});
@@ -92,6 +283,12 @@ export const {
setHealthData,
clearHealthDataForDate,
clearAllHealthData,
updateHistoryData,
setHistoryData,
clearHistoryData,
setMedicalRecords,
addMedicalRecordItem,
removeMedicalRecordItem,
} = healthSlice.actions;
// Thunk action to fetch and set health data for a specific date
@@ -112,10 +309,132 @@ 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 ?? '获取健康史进度失败');
}
}
);
// ==================== 就医资料 API Thunks ====================
/**
* 获取就医资料
*/
export const fetchMedicalRecords = createAsyncThunk(
'health/fetchMedicalRecords',
async (_, { rejectWithValue }) => {
try {
const data = await healthProfileApi.getMedicalRecords();
return data;
} catch (err: any) {
return rejectWithValue(err?.message ?? '获取就医资料失败');
}
}
);
/**
* 添加就医资料
*/
export const addNewMedicalRecord = createAsyncThunk(
'health/addMedicalRecord',
async (data: Omit<MedicalRecordItem, 'id' | 'createdAt' | 'updatedAt'>, { rejectWithValue }) => {
try {
const result = await healthProfileApi.addMedicalRecord(data);
return result;
} catch (err: any) {
return rejectWithValue(err?.message ?? '添加就医资料失败');
}
}
);
/**
* 删除就医资料
*/
export const deleteMedicalRecordItem = createAsyncThunk(
'health/deleteMedicalRecord',
async ({ id, type }: { id: string; type: MedicalRecordType }, { rejectWithValue }) => {
try {
await healthProfileApi.deleteMedicalRecord(id);
return { id, type };
} 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;
export const selectMedicalRecords = (state: RootState) => state.health.medicalRecords;
// 计算健康史完成度的 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;

View File

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

View File

@@ -93,7 +93,7 @@ export class SimpleEventEmitter {
* @param event 事件名称
* @returns 监听器数组的副本
*/
listeners(event: string): ((...args: any[]) => void)[] {
getListeners(event: string): ((...args: any[]) => void)[] {
return this.listeners[event] ? [...this.listeners[event]] : [];
}

View File

@@ -1,3 +1,4 @@
import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs';
import { AppState, AppStateStatus, NativeModules } from 'react-native';
import i18n from '../i18n';
@@ -599,7 +600,7 @@ async function fetchHourlyStandHours(date: Date): Promise<number[]> {
}
}
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
export async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
try {
const result = await HealthKitManager.getActiveEnergyBurned(options);
@@ -1484,10 +1485,14 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlySt
const hourlyStandData = await fetchHourlyStandHours(date);
return hourlyStandData.map((hasStood, hour) => ({
hour,
hasStood
hasStood: typeof hasStood === 'number' ? hasStood : (hasStood ? 1 : 0)
}));
}
// 导出获取完整睡眠数据的函数 (代理到 sleepHealthKit)
export { fetchCompleteSleepData };
export type { CompleteSleepData };
// 专门为活动圆环详情页获取精简的数据
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
try {
@@ -1740,6 +1745,48 @@ export async function fetchSmartHRVData(date: Date): Promise<HRVData | null> {
}
}
// 获取指定时间范围内的所有HRV样本
export async function fetchHRVSamples(startDate: Date, endDate: Date): Promise<HRVData[]> {
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 [];
}
}
// === 锻炼记录相关方法 ===
// 获取最近锻炼记录

View File

@@ -1,136 +0,0 @@
import { HourlyStepData, TodayHealthData } from './health';
// Mock的每小时步数数据模拟真实的一天活动模式
export const mockHourlySteps: HourlyStepData[] = [
{ hour: 0, steps: 0 }, // 午夜
{ hour: 1, steps: 0 }, // 凌晨
{ hour: 2, steps: 0 },
{ hour: 3, steps: 0 },
{ hour: 4, steps: 0 },
{ hour: 5, steps: 0 },
{ hour: 6, steps: 120 }, // 早晨起床
{ hour: 7, steps: 450 }, // 晨练/上班准备
{ hour: 8, steps: 680 }, // 上班通勤
{ hour: 9, steps: 320 }, // 工作时间
{ hour: 10, steps: 180 }, // 办公室内活动
{ hour: 11, steps: 280 }, // 会议/活动
{ hour: 12, steps: 520 }, // 午餐时间
{ hour: 13, steps: 150 }, // 午休
{ hour: 14, steps: 240 }, // 下午工作
{ hour: 15, steps: 300 }, // 工作活动
{ hour: 16, steps: 380 }, // 会议/外出
{ hour: 17, steps: 480 }, // 下班通勤
{ hour: 18, steps: 620 }, // 晚餐/活动
{ hour: 19, steps: 350 }, // 晚间活动
{ hour: 20, steps: 280 }, // 散步
{ hour: 21, steps: 150 }, // 休闲时间
{ hour: 22, steps: 80 }, // 准备睡觉
{ hour: 23, steps: 30 }, // 睡前
];
// Mock的完整健康数据
export const mockHealthData: TodayHealthData = {
steps: 6140, // 总步数
hourlySteps: mockHourlySteps,
activeEnergyBurned: 420,
basalEnergyBurned: 1680,
sleepDuration: 480, // 8小时
hrv: 45,
activeCalories: 420,
activeCaloriesGoal: 350,
exerciseMinutes: 32,
exerciseMinutesGoal: 30,
standHours: 8,
standHoursGoal: 12,
oxygenSaturation: 98.2,
heartRate: 72,
};
// 生成随机的每小时步数数据(用于测试不同的数据模式)
export const generateRandomHourlySteps = (): HourlyStepData[] => {
return Array.from({ length: 24 }, (_, hour) => {
let steps = 0;
// 模拟真实的活动模式
if (hour >= 6 && hour <= 22) {
if (hour >= 7 && hour <= 9) {
// 早晨高峰期
steps = Math.floor(Math.random() * 600) + 200;
} else if (hour >= 12 && hour <= 13) {
// 午餐时间
steps = Math.floor(Math.random() * 400) + 300;
} else if (hour >= 17 && hour <= 19) {
// 晚间活跃期
steps = Math.floor(Math.random() * 500) + 250;
} else if (hour >= 6 && hour <= 22) {
// 白天正常活动
steps = Math.floor(Math.random() * 300) + 50;
}
} else {
// 夜间很少活动
steps = Math.floor(Math.random() * 50);
}
return { hour, steps };
});
};
// 不同活动模式的预设数据
export const activityPatterns = {
// 久坐办公族
sedentary: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour >= 7 && hour <= 18 ? Math.floor(Math.random() * 200) + 50 :
hour >= 19 && hour <= 21 ? Math.floor(Math.random() * 300) + 100 :
Math.floor(Math.random() * 20)
})),
// 活跃用户
active: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour >= 6 && hour <= 8 ? Math.floor(Math.random() * 800) + 400 :
hour >= 12 && hour <= 13 ? Math.floor(Math.random() * 600) + 300 :
hour >= 17 && hour <= 20 ? Math.floor(Math.random() * 900) + 500 :
hour >= 9 && hour <= 16 ? Math.floor(Math.random() * 400) + 100 :
Math.floor(Math.random() * 50)
})),
// 健身爱好者
fitness: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour === 6 ? Math.floor(Math.random() * 1200) + 800 : // 晨跑
hour === 18 ? Math.floor(Math.random() * 1000) + 600 : // 晚间锻炼
hour >= 7 && hour <= 17 ? Math.floor(Math.random() * 300) + 100 :
Math.floor(Math.random() * 50)
})),
};
// 用于快速切换测试数据的函数
export const getTestHealthData = (pattern: 'mock' | 'random' | 'sedentary' | 'active' | 'fitness' = 'mock'): TodayHealthData => {
let hourlySteps: HourlyStepData[];
switch (pattern) {
case 'random':
hourlySteps = generateRandomHourlySteps();
break;
case 'sedentary':
hourlySteps = activityPatterns.sedentary;
break;
case 'active':
hourlySteps = activityPatterns.active;
break;
case 'fitness':
hourlySteps = activityPatterns.fitness;
break;
default:
hourlySteps = mockHourlySteps;
}
const totalSteps = hourlySteps.reduce((sum, data) => sum + data.steps, 0);
return {
...mockHealthData,
steps: totalSteps,
hourlySteps,
};
};

View File

@@ -1,198 +0,0 @@
import { workoutMonitorService } from '@/services/workoutMonitor';
import { WorkoutData } from '@/utils/health';
/**
* 锻炼测试工具
* 用于开发和测试锻炼监听功能
*/
export class WorkoutTestHelper {
/**
* 模拟一个锻炼完成事件
*/
static async simulateWorkoutCompletion(): Promise<void> {
console.log('=== 开始模拟锻炼完成事件 ===');
try {
// 手动触发锻炼检查
await workoutMonitorService.manualCheck();
console.log('✅ 锻炼检查已手动触发');
} catch (error) {
console.error('❌ 模拟锻炼完成事件失败:', error);
}
}
/**
* 获取锻炼监听服务状态
*/
static getWorkoutMonitorStatus(): any {
return workoutMonitorService.getStatus();
}
/**
* 创建测试用的模拟锻炼数据
*/
static createMockWorkout(type: string = 'running'): WorkoutData {
const now = new Date();
const duration = 30 * 60; // 30分钟
const startTime = new Date(now.getTime() - duration * 1000);
const workoutTypes: Record<string, number> = {
running: 37,
cycling: 13,
swimming: 46,
yoga: 57,
functionalstrengthtraining: 20,
traditionalstrengthtraining: 50,
highintensityintervaltraining: 63,
walking: 52,
};
return {
id: `mock-workout-${Date.now()}`,
startDate: startTime.toISOString(),
endDate: now.toISOString(),
duration: duration,
workoutActivityType: workoutTypes[type] || 37,
workoutActivityTypeString: type,
totalEnergyBurned: Math.round(Math.random() * 300 + 100), // 100-400千卡
totalDistance: type === 'running' || type === 'cycling' ? Math.round(Math.random() * 10000 + 1000) : undefined,
averageHeartRate: Math.round(Math.random() * 50 + 120), // 120-170次/分
source: {
name: 'Test App',
bundleIdentifier: 'com.test.app'
},
metadata: {
HKAverageMETs: Math.random() * 10 + 5 // 5-15 METs
}
};
}
/**
* 测试不同类型的锻炼通知
*/
static async testWorkoutNotifications(): Promise<void> {
console.log('=== 开始测试不同类型锻炼通知 ===');
const workoutTypes = ['running', 'cycling', 'swimming', 'yoga', 'functionalstrengthtraining', 'highintensityintervaltraining'];
for (const type of workoutTypes) {
console.log(`--- 测试 ${type} 锻炼通知 ---`);
try {
// 这里需要导入通知服务来直接测试
const { analyzeWorkoutAndSendNotification } = await import('@/services/workoutNotificationService');
const mockWorkout = this.createMockWorkout(type);
await analyzeWorkoutAndSendNotification(mockWorkout);
console.log(`${type} 锻炼通知测试成功`);
// 等待一段时间再测试下一个
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error(`${type} 锻炼通知测试失败:`, error);
}
}
console.log('=== 锻炼通知测试完成 ===');
}
/**
* 测试偏好设置功能
*/
static async testPreferences(): Promise<void> {
console.log('=== 开始测试偏好设置功能 ===');
try {
const {
getWorkoutNotificationPreferences,
saveWorkoutNotificationPreferences,
isNotificationTimeAllowed,
isWorkoutTypeEnabled
} = await import('@/utils/workoutPreferences');
// 获取当前设置
const currentPrefs = await getWorkoutNotificationPreferences();
console.log('当前偏好设置:', currentPrefs);
// 测试时间检查
const timeAllowed = await isNotificationTimeAllowed();
console.log('当前时间是否允许通知:', timeAllowed);
// 测试锻炼类型检查
const runningEnabled = await isWorkoutTypeEnabled('running');
console.log('跑步通知是否启用:', runningEnabled);
// 临时修改设置
await saveWorkoutNotificationPreferences({
enabled: true,
startTimeHour: 9,
endTimeHour: 21,
enabledWorkoutTypes: ['running', 'cycling']
});
console.log('临时设置已保存');
// 恢复原始设置
await saveWorkoutNotificationPreferences(currentPrefs);
console.log('原始设置已恢复');
console.log('✅ 偏好设置功能测试完成');
} catch (error) {
console.error('❌ 偏好设置功能测试失败:', error);
}
}
/**
* 运行完整的测试套件
*/
static async runFullTestSuite(): Promise<void> {
console.log('🧪 开始运行锻炼监听功能完整测试套件');
try {
// 1. 检查服务状态
console.log('\n1. 检查服务状态...');
const status = this.getWorkoutMonitorStatus();
console.log('服务状态:', status);
// 2. 测试偏好设置
console.log('\n2. 测试偏好设置...');
await this.testPreferences();
// 3. 测试通知功能
console.log('\n3. 测试通知功能...');
await this.testWorkoutNotifications();
// 4. 测试手动触发
console.log('\n4. 测试手动触发...');
await this.simulateWorkoutCompletion();
console.log('\n🎉 完整测试套件运行完成!');
} catch (error) {
console.error('\n❌ 测试套件运行失败:', error);
}
}
}
/**
* 开发者调试函数
* 可以在开发者控制台中调用
*/
declare global {
interface Window {
testWorkoutNotifications: () => Promise<void>;
testWorkoutPreferences: () => Promise<void>;
simulateWorkoutCompletion: () => Promise<void>;
runWorkoutTestSuite: () => Promise<void>;
}
}
// 在开发环境中暴露调试函数
if (__DEV__) {
// 这些函数可以在开发者控制台中调用
// 例如: window.testWorkoutNotifications()
// 注意:这些函数需要在实际运行环境中绑定
// 可以在应用的初始化代码中添加:
// window.testWorkoutNotifications = WorkoutTestHelper.testWorkoutNotifications;
}