feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
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',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
});
|
||||
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
785
components/health/tabs/HealthHistoryTab.tsx
Normal file
@@ -0,0 +1,785 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { HealthHistoryCategory } from '@/services/healthProfile';
|
||||
import {
|
||||
HistoryItemDetail,
|
||||
fetchHealthHistory,
|
||||
saveHealthHistoryCategory,
|
||||
selectHealthLoading,
|
||||
selectHistoryData,
|
||||
updateHistoryData,
|
||||
} from '@/store/healthSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import { palette } from '../../../constants/Colors';
|
||||
|
||||
// Translation Keys for Recommendations
|
||||
const RECOMMENDATION_KEYS: Record<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(() => {
|
||||
dispatch(fetchHealthHistory());
|
||||
}, [dispatch]);
|
||||
|
||||
const historyItems = [
|
||||
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
|
||||
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
|
||||
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
|
||||
];
|
||||
|
||||
// Helper to translate item (try to find key, fallback to item itself)
|
||||
const translateItem = (type: string, item: string) => {
|
||||
// Check if item is a predefined key
|
||||
const keys = RECOMMENDATION_KEYS[type];
|
||||
if (keys && keys.includes(item)) {
|
||||
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
|
||||
}
|
||||
// Fallback for manual input
|
||||
return item;
|
||||
};
|
||||
|
||||
// Open Modal
|
||||
const handleItemPress = (key: string) => {
|
||||
setCurrentType(key);
|
||||
const currentData = historyData[key];
|
||||
setTempHasHistory(currentData.hasHistory);
|
||||
// Deep copy items to avoid reference issues
|
||||
setTempItems(currentData.items.map(item => ({ ...item })));
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Close Modal
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
setCurrentType(null);
|
||||
};
|
||||
|
||||
// Save Data
|
||||
const handleSave = async () => {
|
||||
if (currentType) {
|
||||
// Filter out empty items
|
||||
const validItems = tempItems.filter(item => item.name.trim() !== '');
|
||||
|
||||
// If "No" history is selected, clear items
|
||||
const finalItems = tempHasHistory === false ? [] : validItems;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// 先乐观更新本地状态
|
||||
dispatch(updateHistoryData({
|
||||
type: currentType,
|
||||
data: {
|
||||
hasHistory: tempHasHistory,
|
||||
items: finalItems,
|
||||
},
|
||||
}));
|
||||
|
||||
// 同步到服务端
|
||||
await dispatch(saveHealthHistoryCategory({
|
||||
category: currentType as HealthHistoryCategory,
|
||||
data: {
|
||||
hasHistory: tempHasHistory ?? false,
|
||||
items: finalItems.map(item => ({
|
||||
name: item.name,
|
||||
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
|
||||
isRecommendation: item.isRecommendation,
|
||||
})),
|
||||
},
|
||||
})).unwrap();
|
||||
|
||||
handleCloseModal();
|
||||
} catch (error: any) {
|
||||
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
|
||||
Alert.alert(
|
||||
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
|
||||
error?.message || '请稍后重试',
|
||||
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add Item (Manual or Recommendation)
|
||||
const addItem = (name: string = '', isRecommendation: boolean = false) => {
|
||||
// Avoid duplicates for recommendations if already exists
|
||||
if (isRecommendation && tempItems.some(item => item.name === name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: HistoryItemDetail = {
|
||||
id: Date.now().toString() + Math.random().toString(),
|
||||
name,
|
||||
isRecommendation
|
||||
};
|
||||
setTempItems([...tempItems, newItem]);
|
||||
};
|
||||
|
||||
// Remove Item
|
||||
const removeItem = (id: string) => {
|
||||
setTempItems(tempItems.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
// Update Item Name
|
||||
const updateItemName = (id: string, text: string) => {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === id ? { ...item, name: text } : item
|
||||
));
|
||||
};
|
||||
|
||||
// Date Picker Handlers
|
||||
const showDatePicker = (id: string) => {
|
||||
setCurrentEditingId(id);
|
||||
setDatePickerVisibility(true);
|
||||
};
|
||||
|
||||
const hideDatePicker = () => {
|
||||
setDatePickerVisibility(false);
|
||||
setCurrentEditingId(null);
|
||||
};
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (currentEditingId) {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
|
||||
));
|
||||
}
|
||||
hideDatePicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<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',
|
||||
},
|
||||
});
|
||||
49
components/health/tabs/MedicalRecordsTab.tsx
Normal file
49
components/health/tabs/MedicalRecordsTab.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 MedicalRecordsTab() {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user