- Introduced new translation files for medication, personal, and weight management in Chinese. - Updated the main index file to include the new translation modules. - Enhanced the medication type definitions to include 'ointment'. - Refactored workout type labels to utilize i18n for better localization support. - Improved sleep quality descriptions and recommendations with i18n integration.
522 lines
15 KiB
TypeScript
522 lines
15 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
import { Colors, palette } from '@/constants/Colors';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import { getMedicationRecognitionStatus } from '@/services/medications';
|
|
import { MedicationRecognitionTask } from '@/types/medication';
|
|
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 { router, useLocalSearchParams } from 'expo-router';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
|
|
const STEP_KEYS: MedicationRecognitionTask['status'][] = [
|
|
'analyzing_product',
|
|
'analyzing_suitability',
|
|
'analyzing_ingredients',
|
|
'analyzing_effects',
|
|
];
|
|
|
|
export default function MedicationAiProgressScreen() {
|
|
const { t } = useI18n();
|
|
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
|
const insets = useSafeAreaInsets();
|
|
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
const navigatingRef = useRef(false);
|
|
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// 动画值:上下浮动和透明度
|
|
const floatAnim = useRef(new Animated.Value(0)).current;
|
|
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
|
|
|
const steps = useMemo(() => STEP_KEYS.map(key => ({
|
|
key,
|
|
label: t(`medications.aiProgress.steps.${key}`)
|
|
})), [t]);
|
|
|
|
const currentStepIndex = useMemo(() => {
|
|
if (!task) return 0;
|
|
const idx = STEP_KEYS.indexOf(task.status as any);
|
|
if (idx >= 0) return idx;
|
|
if (task.status === 'completed') return STEP_KEYS.length;
|
|
return 0;
|
|
}, [task]);
|
|
|
|
const fetchStatus = async () => {
|
|
if (!taskId || navigatingRef.current) return;
|
|
try {
|
|
const data = await getMedicationRecognitionStatus(taskId as string);
|
|
setTask(data);
|
|
setError(null);
|
|
|
|
// 识别成功,跳转到详情页
|
|
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
|
navigatingRef.current = true;
|
|
// 清除轮询
|
|
if (pollingTimerRef.current) {
|
|
clearInterval(pollingTimerRef.current);
|
|
pollingTimerRef.current = null;
|
|
}
|
|
router.replace({
|
|
pathname: '/medications/[medicationId]',
|
|
params: {
|
|
medicationId: 'ai-draft',
|
|
aiTaskId: data.taskId,
|
|
cover: (cover as string) || data.result.photoUrl || '',
|
|
},
|
|
});
|
|
}
|
|
|
|
// 识别失败,停止轮询并显示错误弹窗
|
|
if (data.status === 'failed' && !navigatingRef.current) {
|
|
navigatingRef.current = true;
|
|
// 清除轮询
|
|
if (pollingTimerRef.current) {
|
|
clearInterval(pollingTimerRef.current);
|
|
pollingTimerRef.current = null;
|
|
}
|
|
// 显示错误提示弹窗
|
|
setErrorMessage(data.errorMessage || t('medications.aiProgress.errors.default'));
|
|
setShowErrorModal(true);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('[MEDICATION_AI] status failed', err);
|
|
setError(err?.message || t('medications.aiProgress.errors.queryFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 处理重新拍摄
|
|
const handleRetry = () => {
|
|
setShowErrorModal(false);
|
|
router.back();
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
|
return () => {
|
|
if (pollingTimerRef.current) {
|
|
clearInterval(pollingTimerRef.current);
|
|
pollingTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [taskId]);
|
|
|
|
// 启动浮动和闪烁动画 - 更快的动画速度
|
|
useEffect(() => {
|
|
// 上下浮动动画 - 加快速度
|
|
const floatAnimation = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(floatAnim, {
|
|
toValue: -10,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(floatAnim, {
|
|
toValue: 0,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
])
|
|
);
|
|
|
|
// 透明度闪烁动画 - 加快速度,增加对比度
|
|
const opacityAnimation = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(opacityAnim, {
|
|
toValue: 1,
|
|
duration: 800,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacityAnim, {
|
|
toValue: 0.4,
|
|
duration: 800,
|
|
useNativeDriver: true,
|
|
}),
|
|
])
|
|
);
|
|
|
|
floatAnimation.start();
|
|
opacityAnimation.start();
|
|
|
|
return () => {
|
|
floatAnimation.stop();
|
|
opacityAnimation.stop();
|
|
};
|
|
}, []);
|
|
|
|
const progress = task?.progress ?? Math.min(100, (currentStepIndex / steps.length) * 100 + 10);
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<LinearGradient colors={[palette.gray[25], palette.gray[50]]} style={StyleSheet.absoluteFill} />
|
|
<HeaderBar title={t('medications.aiProgress.title')} onBack={() => router.back()} transparent />
|
|
<View style={{ height: insets.top }} />
|
|
|
|
<View style={styles.heroCard}>
|
|
<View style={styles.heroImageWrapper}>
|
|
{cover ? (
|
|
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
|
) : (
|
|
<View style={styles.heroPlaceholder} />
|
|
)}
|
|
|
|
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
|
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
|
<>
|
|
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
|
<View style={styles.overlayMask} />
|
|
|
|
{/* 渐变蒙版边框,增加视觉层次 */}
|
|
<LinearGradient
|
|
colors={[Colors.light.primary + '4D', Colors.light.accentPurple + '33', 'transparent']}
|
|
style={styles.gradientBorder}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
|
|
{/* 点阵网格动画 */}
|
|
<Animated.View
|
|
style={[
|
|
styles.dottedGrid,
|
|
{
|
|
transform: [{ translateY: floatAnim }],
|
|
opacity: opacityAnim,
|
|
}
|
|
]}
|
|
>
|
|
{Array.from({ length: 11 }).map((_, idx) => (
|
|
<View key={idx} style={styles.dotRow}>
|
|
{Array.from({ length: 11 }).map((__, jdx) => (
|
|
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
|
))}
|
|
</View>
|
|
))}
|
|
</Animated.View>
|
|
</>
|
|
)}
|
|
</View>
|
|
<View style={styles.progressRow}>
|
|
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
|
</View>
|
|
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
|
</View>
|
|
|
|
<View style={styles.stepList}>
|
|
{steps.map((step, index) => {
|
|
const active = index === currentStepIndex;
|
|
const done = index < currentStepIndex;
|
|
return (
|
|
<View key={step.key} style={styles.stepRow}>
|
|
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
|
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
|
{step.label}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
{task?.status === 'completed' && (
|
|
<View style={styles.stepRow}>
|
|
<View style={[styles.bullet, styles.bulletDone]} />
|
|
<Text style={[styles.stepLabel, styles.stepLabelDone]}>{t('medications.aiProgress.steps.completed')}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.loadingBox}>
|
|
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
|
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
|
</View>
|
|
|
|
{/* 识别提示弹窗 */}
|
|
<Modal
|
|
visible={showErrorModal}
|
|
transparent={true}
|
|
animationType="fade"
|
|
onRequestClose={handleRetry}
|
|
>
|
|
<TouchableOpacity
|
|
style={styles.modalOverlay}
|
|
activeOpacity={1}
|
|
onPress={handleRetry}
|
|
>
|
|
<TouchableOpacity
|
|
activeOpacity={1}
|
|
onPress={(e) => e.stopPropagation()}
|
|
style={styles.errorModalContainer}
|
|
>
|
|
<View style={styles.errorModalContent}>
|
|
|
|
{/* 标题 */}
|
|
<Text style={styles.errorModalTitle}>{t('medications.aiProgress.modal.title')}</Text>
|
|
|
|
{/* 提示信息 */}
|
|
<View style={styles.errorMessageBox}>
|
|
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
|
</View>
|
|
|
|
{/* 重新拍摄按钮 */}
|
|
<TouchableOpacity
|
|
onPress={handleRetry}
|
|
activeOpacity={0.8}
|
|
style={{ width: '100%' }}
|
|
>
|
|
{isLiquidGlassAvailable() ? (
|
|
<GlassView
|
|
style={styles.retryButton}
|
|
glassEffectStyle="regular"
|
|
tintColor={Colors.light.primary}
|
|
isInteractive={true}
|
|
>
|
|
<LinearGradient
|
|
colors={[Colors.light.primary, Colors.light.accentPurple]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={styles.retryButtonGradient}
|
|
>
|
|
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
|
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
|
|
</LinearGradient>
|
|
</GlassView>
|
|
) : (
|
|
<View style={styles.retryButton}>
|
|
<LinearGradient
|
|
colors={[Colors.light.primary, Colors.light.accentPurple]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={styles.retryButtonGradient}
|
|
>
|
|
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
|
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
|
|
</LinearGradient>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
</Modal>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
heroCard: {
|
|
marginHorizontal: 20,
|
|
marginTop: 24,
|
|
borderRadius: 24,
|
|
backgroundColor: Colors.light.card,
|
|
padding: 16,
|
|
shadowColor: Colors.light.text,
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 18,
|
|
shadowOffset: { width: 0, height: 10 },
|
|
},
|
|
heroImageWrapper: {
|
|
height: 230,
|
|
borderRadius: 18,
|
|
overflow: 'hidden',
|
|
backgroundColor: palette.gray[50],
|
|
},
|
|
heroImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
heroPlaceholder: {
|
|
flex: 1,
|
|
backgroundColor: palette.gray[50],
|
|
},
|
|
// 深色蒙版层,让点阵更清晰可见
|
|
overlayMask: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
|
},
|
|
// 渐变边框效果
|
|
gradientBorder: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
borderRadius: 18,
|
|
},
|
|
// 点阵网格容器
|
|
dottedGrid: {
|
|
position: 'absolute',
|
|
left: 16,
|
|
right: 16,
|
|
top: 16,
|
|
bottom: 16,
|
|
justifyContent: 'space-between',
|
|
},
|
|
dotRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
},
|
|
// 单个点样式 - 更明亮和更大的发光效果
|
|
dot: {
|
|
width: 5,
|
|
height: 5,
|
|
borderRadius: 2.5,
|
|
backgroundColor: Colors.light.background,
|
|
shadowColor: Colors.light.primary,
|
|
shadowOpacity: 0.9,
|
|
shadowRadius: 6,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
},
|
|
progressRow: {
|
|
height: 8,
|
|
backgroundColor: palette.gray[50],
|
|
borderRadius: 10,
|
|
marginTop: 14,
|
|
overflow: 'hidden',
|
|
},
|
|
progressBar: {
|
|
height: '100%',
|
|
borderRadius: 10,
|
|
backgroundColor: Colors.light.primary,
|
|
},
|
|
progressText: {
|
|
marginTop: 8,
|
|
fontSize: 14,
|
|
fontWeight: '700',
|
|
color: Colors.light.text,
|
|
textAlign: 'right',
|
|
},
|
|
stepList: {
|
|
marginTop: 24,
|
|
marginHorizontal: 24,
|
|
gap: 14,
|
|
},
|
|
stepRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
},
|
|
bullet: {
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 7,
|
|
backgroundColor: palette.gray[50],
|
|
},
|
|
bulletActive: {
|
|
backgroundColor: Colors.light.primary,
|
|
},
|
|
bulletDone: {
|
|
backgroundColor: Colors.light.success,
|
|
},
|
|
stepLabel: {
|
|
fontSize: 15,
|
|
color: Colors.light.textMuted,
|
|
},
|
|
stepLabelActive: {
|
|
color: Colors.light.text,
|
|
fontWeight: '700',
|
|
},
|
|
stepLabelDone: {
|
|
color: Colors.light.successDark,
|
|
fontWeight: '700',
|
|
},
|
|
loadingBox: {
|
|
marginTop: 30,
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
},
|
|
errorText: {
|
|
color: Colors.light.danger,
|
|
fontSize: 14,
|
|
},
|
|
// Modal 样式
|
|
modalOverlay: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
|
},
|
|
errorModalContainer: {
|
|
width: SCREEN_WIDTH - 48,
|
|
backgroundColor: Colors.light.card,
|
|
borderRadius: 28,
|
|
overflow: 'hidden',
|
|
shadowColor: Colors.light.primary,
|
|
shadowOpacity: 0.15,
|
|
shadowRadius: 24,
|
|
shadowOffset: { width: 0, height: 8 },
|
|
elevation: 8,
|
|
},
|
|
errorModalContent: {
|
|
padding: 32,
|
|
alignItems: 'center',
|
|
},
|
|
errorIconContainer: {
|
|
marginBottom: 24,
|
|
},
|
|
errorIconCircle: {
|
|
width: 96,
|
|
height: 96,
|
|
borderRadius: 48,
|
|
backgroundColor: palette.purple[50],
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
errorModalTitle: {
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
color: Colors.light.text,
|
|
marginBottom: 16,
|
|
textAlign: 'center',
|
|
},
|
|
errorMessageBox: {
|
|
backgroundColor: palette.purple[25],
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
marginBottom: 28,
|
|
width: '100%',
|
|
borderWidth: 1,
|
|
borderColor: palette.purple[200],
|
|
},
|
|
errorMessageText: {
|
|
fontSize: 15,
|
|
lineHeight: 24,
|
|
color: Colors.light.textSecondary,
|
|
textAlign: 'center',
|
|
},
|
|
retryButton: {
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
shadowColor: Colors.light.primary,
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 12,
|
|
shadowOffset: { width: 0, height: 6 },
|
|
elevation: 6,
|
|
},
|
|
retryButtonGradient: {
|
|
paddingVertical: 16,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
retryButtonText: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
color: Colors.light.onPrimary,
|
|
},
|
|
});
|