Compare commits
10 Commits
main
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
| 409f125db1 | |||
|
|
eef0134ddc | ||
|
|
0013dc3266 | ||
|
|
37a0687456 | ||
|
|
74b49efe23 | ||
|
|
3d08721474 | ||
|
|
f3d4264b53 | ||
|
|
a254af92c7 | ||
|
|
e713ffbace | ||
|
|
02b2de3ea3 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
},
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
"kiroAgent.configureMCP": "Enabled",
|
||||
"codingcopilot.enableCompletionLanguage": {}
|
||||
}
|
||||
|
||||
9
app.json
9
app.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
620
app/health/family-invite.tsx
Normal file
620
app/health/family-invite.tsx
Normal 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}>最多邀请6人,分享二维码有效期24小时</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
617
app/health/profile.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
176
components/MembershipBanner.tsx
Normal file
176
components/MembershipBanner.tsx
Normal 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' }]
|
||||
}
|
||||
});
|
||||
@@ -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}>最近30天HRV情况</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',
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
132
components/health/HealthProgressRing.tsx
Normal file
132
components/health/HealthProgressRing.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
151
components/health/MedicalRecordCard.tsx
Normal file
151
components/health/MedicalRecordCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
161
components/health/tabs/BasicInfoTab.tsx
Normal file
161
components/health/tabs/BasicInfoTab.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
49
components/health/tabs/CheckupRecordsTab.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
788
components/health/tabs/HealthHistoryTab.tsx
Normal file
788
components/health/tabs/HealthHistoryTab.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
666
components/health/tabs/MedicalRecordsTab.tsx
Normal file
666
components/health/tabs/MedicalRecordsTab.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
278
components/weight/WeightProgressBar.tsx
Normal file
278
components/weight/WeightProgressBar.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
// 更新喝水记录
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '阿尔茨海默病'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -238,7 +238,7 @@ export const medications = {
|
||||
periodRange: '从 {{startDate}} 至 {{endDate}}',
|
||||
periodLongTerm: '从 {{startDate}} 至长期',
|
||||
expiryStatus: {
|
||||
notSet: '未设置',
|
||||
notSet: '未设置(过期预警)',
|
||||
expired: '已过期',
|
||||
expiresToday: '今天到期',
|
||||
expiresInDays: '{{days}}天后到期',
|
||||
|
||||
@@ -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: '检查更新',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -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>
|
||||
343
ios/Podfile.lock
343
ios/Podfile.lock
@@ -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
|
||||
|
||||
8
ios/en.lproj/InfoPlist.strings
Normal file
8
ios/en.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// InfoPlist.strings
|
||||
// OutLive
|
||||
//
|
||||
// Created by richard on 2025/12/3.
|
||||
//
|
||||
|
||||
CFBundleDisplayName = "OutLive";
|
||||
@@ -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>
|
||||
|
||||
8
ios/zh-Hans.lproj/InfoPlist.strings
Normal file
8
ios/zh-Hans.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// InfoPlist.strings
|
||||
// OutLive
|
||||
//
|
||||
// Created by richard on 2025/12/3.
|
||||
//
|
||||
|
||||
CFBundleDisplayName = "OutLive";
|
||||
5265
package-lock.json
generated
5265
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
274
services/healthProfile.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// 便捷方法
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
303
store/familyHealthSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] : [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// === 锻炼记录相关方法 ===
|
||||
|
||||
// 获取最近锻炼记录
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user