Files
digital-pilates/app/ai-posture-assessment.tsx
richarjiang d76ba48424 feat(ui): 统一应用主题色为天空蓝并优化渐变背景
将应用主色调从 '#BBF246' 更改为 '#87CEEB'(天空蓝),并更新所有相关组件和页面中的颜色引用。同时为多个页面添加统一的渐变背景,提升视觉效果和用户体验。新增压力分析模态框组件,并优化压力计组件的交互与显示逻辑。更新应用图标和启动图资源。
2025-08-20 09:38:25 +08:00

587 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
Linking,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useCosUpload } from '@/hooks/useCosUpload';
type PoseView = 'front' | 'side' | 'back';
type UploadState = {
front?: string | null;
side?: string | null;
back?: string | null;
};
type Sample = { uri: string; correct: boolean };
const SAMPLES: Record<PoseView, Sample[]> = {
front: [
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
],
side: [
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
],
back: [
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
],
};
export default function AIPostureAssessmentScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = Colors.light;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
[uploadState]
);
const { upload, uploading } = useCosUpload();
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
const [cameraCanAsk, setCameraCanAsk] = useState<boolean | null>(null);
const [libraryCanAsk, setLibraryCanAsk] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
const cam = await ImagePicker.getCameraPermissionsAsync();
const lib = await ImagePicker.getMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
})();
}, []);
async function requestAllPermissions() {
try {
const cam = await ImagePicker.requestCameraPermissionsAsync();
const lib = await ImagePicker.requestMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
const libGranted = lib.status === 'granted' || (lib as any).accessPrivileges === 'limited';
if (cam.status !== 'granted' || !libGranted) {
Alert.alert(
'权限未完全授予',
'请在系统设置中授予相机与相册权限以完成上传',
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
}
} catch { }
}
async function requestPermissionAndPick(source: 'camera' | 'library', key: PoseView) {
try {
if (source === 'camera') {
const resp = await ImagePicker.requestCameraPermissionsAsync();
setCameraPerm(resp.status);
setCameraCanAsk(resp.canAskAgain);
if (resp.status !== 'granted') {
Alert.alert(
'权限不足',
'需要相机权限以拍摄照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
// 设置正在上传状态
setUploadingKey(key);
try {
// 上传到 COS
const { url } = await upload(
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
{ prefix: 'posture-assessment/' }
);
// 上传成功,更新状态
setUploadState((s) => ({ ...s, [key]: url }));
} catch (uploadError) {
console.warn('上传图片失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
// 上传失败,清除状态
setUploadState((s) => ({ ...s, [key]: null }));
} finally {
// 清除上传状态
setUploadingKey(null);
}
}
} else {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
setLibraryPerm(resp.status);
setLibraryAccess(
(resp as any).accessPrivileges ?? (resp.status === 'granted' ? 'all' : 'none')
);
setLibraryCanAsk(resp.canAskAgain);
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert(
'权限不足',
'需要相册权限以选择照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
// 设置正在上传状态
setUploadingKey(key);
try {
// 上传到 COS
const { url } = await upload(
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
{ prefix: 'posture-assessment/' }
);
// 上传成功,更新状态
setUploadState((s) => ({ ...s, [key]: url }));
} catch (uploadError) {
console.warn('上传图片失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
// 上传失败,清除状态
setUploadState((s) => ({ ...s, [key]: null }));
} finally {
// 清除上传状态
setUploadingKey(null);
}
}
}
} catch (e) {
Alert.alert('发生错误', '选择图片失败,请重试');
}
}
function handleStart() {
if (!canStart) return;
// 进入评估中间页面
router.push('/ai-posture-processing');
}
return (
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
showsVerticalScrollIndicator={false}
>
{/* Permissions Banner (iOS 优先提示) */}
{Platform.OS === 'ios' && (
(cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && (
<BlurView intensity={18} tint="dark" style={styles.permBanner}>
<Text style={styles.permTitle}></Text>
<Text style={styles.permDesc}>
AI体态测评
</Text>
<View style={styles.permActions}>
{((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? (
<TouchableOpacity style={styles.permPrimary} onPress={requestAllPermissions}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.permPrimary} onPress={() => Linking.openSettings()}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.permSecondary} onPress={() => requestPermissionAndPick('library', 'front')}>
<Text style={styles.permSecondaryText}></Text>
</TouchableOpacity>
</View>
</BlurView>
)
)}
{/* Intro */}
<View style={styles.introBox}>
<Text style={[styles.title, { color: '#192126' }]}>姿</Text>
<Text style={[styles.description, { color: '#5E6468' }]}>线</Text>
</View>
{/* Upload sections */}
<UploadTile
label="正面"
value={uploadState.front}
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
uploading={uploading && uploadingKey === 'front'}
/>
<UploadTile
label="侧面"
value={uploadState.side}
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
samples={SAMPLES.side}
uploading={uploading && uploadingKey === 'side'}
/>
<UploadTile
label="背面"
value={uploadState.back}
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
samples={SAMPLES.back}
uploading={uploading && uploadingKey === 'back'}
/>
</ScrollView>
{/* Bottom CTA */}
<View style={[styles.bottomCtaWrap, { paddingBottom: insets.bottom + 10 }]}>
<TouchableOpacity
disabled={!canStart}
activeOpacity={1}
onPress={handleStart}
style={[
styles.bottomCta,
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
]}
>
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
{canStart ? '开始测评' : '请先完成三视角上传'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function UploadTile({
label,
value,
onPickCamera,
onPickLibrary,
samples,
uploading,
}: {
label: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
samples: Sample[];
uploading?: boolean;
}) {
const [viewerVisible, setViewerVisible] = React.useState(false);
const [viewerIndex, setViewerIndex] = React.useState(0);
const imagesForViewer = React.useMemo(() => samples.map((s) => ({ uri: s.uri })), [samples]);
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{label}</Text>
{value ? (
<Text style={styles.retakeHint}></Text>
) : (
<Text style={styles.retakeHint}></Text>
)}
</View>
<TouchableOpacity
activeOpacity={0.95}
onLongPress={onPickLibrary}
onPress={onPickCamera}
style={styles.uploader}
disabled={uploading}
>
{uploading ? (
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
<ActivityIndicator size="large" color={Colors.light.accentGreen} />
<Text style={styles.placeholderTitle}>...</Text>
</View>
) : value ? (
<Image source={{ uri: value }} style={styles.preview} />
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
</View>
)}
</TouchableOpacity>
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
<Text style={styles.sampleTitle}></Text>
<View style={styles.sampleRow}>
{samples.map((s, idx) => (
<View key={idx} style={styles.sampleItem}>
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
</TouchableOpacity>
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
</View>
</View>
))}
</View>
</BlurView>
<ImageViewing
images={imagesForViewer}
imageIndex={viewerIndex}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
permBanner: {
marginTop: 12,
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(25,33,38,0.06)'
},
permTitle: {
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: '#5E6468',
marginTop: 6,
fontSize: 13,
},
permActions: {
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
permPrimary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
backgroundColor: Colors.light.accentGreen,
},
permPrimaryText: {
color: '#192126',
fontSize: 14,
fontWeight: '800',
},
permSecondary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(25,33,38,0.14)',
},
permSecondaryText: {
color: '#384046',
fontSize: 14,
fontWeight: '700',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
headerTitle: {
fontSize: 22,
color: '#ECEDEE',
fontWeight: '700',
},
introBox: {
marginTop: 12,
paddingHorizontal: 20,
gap: 10,
},
title: {
fontSize: 26,
color: '#ECEDEE',
fontWeight: '800',
},
description: {
fontSize: 15,
lineHeight: 22,
color: 'rgba(255,255,255,0.75)',
},
section: {
marginTop: 16,
paddingHorizontal: 16,
gap: 12,
},
sectionHeader: {
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
color: '#192126',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: '#888F92',
fontSize: 13,
},
uploader: {
height: 220,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(25,33,38,0.14)',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
preview: {
width: '100%',
height: '100%',
},
placeholder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
plusBadge: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: Colors.light.accentGreen,
},
placeholderTitle: {
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: '#888F92',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.72)',
},
sampleTitle: {
color: '#192126',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
},
sampleRow: {
flexDirection: 'row',
gap: 10,
},
sampleItem: {
flex: 1,
},
sampleImg: {
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#F2F4F5',
},
sampleTag: {
alignSelf: 'flex-start',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
marginTop: 6,
},
sampleTagText: {
color: '#192126',
fontSize: 12,
fontWeight: '700',
},
bottomCtaWrap: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
},
bottomCta: {
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
},
bottomCtaText: {
fontSize: 18,
fontWeight: '800',
},
});