Files
digital-pilates/app/health/profile.tsx

618 lines
20 KiB
TypeScript

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',
},
});