Files
digital-pilates/app/ai-posture-assessment.tsx
richarjiang 2fac3f899c feat: 添加相机和相册权限请求功能
- 在 AI 体态评估页面中集成相机和相册权限请求逻辑
- 更新 app.json 和 Info.plist,添加相应的权限说明
- 修改布局以支持照片上传功能,用户可上传正面、侧面和背面照片
- 更新 package.json 和 package-lock.json,添加 expo-image-picker 依赖
2025-08-12 17:30:26 +08:00

532 lines
16 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 {
Alert,
Image,
Linking,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
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://images.unsplash.com/photo-1594737625785-c6683fc87c73?w=400&q=80&auto=format', 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://images.unsplash.com/photo-1554463529-e27854014799?w=400&q=80&auto=format', 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://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=400&q=80&auto=format', 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.dark;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
[uploadState]
);
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) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? 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) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
}
}
} catch (e) {
Alert.alert('发生错误', '选择图片失败,请重试');
}
}
function handleStart() {
if (!canStart) return;
// TODO: 调用后端或进入分析页面
Alert.alert('开始测评', '已收集三视角照片准备开始AI体态分析');
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
{/* Header */}
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="chevron-back" size={24} color="#ECEDEE" />
</TouchableOpacity>
<Text style={styles.headerTitle}>AI体态测评</Text>
<View style={{ width: 32 }} />
</View>
<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}>姿</Text>
<Text style={styles.description}>
线
</Text>
</View>
{/* Upload sections */}
<UploadTile
label="正面"
value={uploadState.front}
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
/>
<UploadTile
label="侧面"
value={uploadState.side}
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
samples={SAMPLES.side}
/>
<UploadTile
label="背面"
value={uploadState.back}
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
samples={SAMPLES.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,
}: {
label: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
samples: Sample[];
}) {
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}
>
{value ? (
<Image source={{ uri: value }} style={styles.preview} />
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color="#192126" />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
</View>
)}
</TouchableOpacity>
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
<Text style={styles.sampleTitle}></Text>
<View style={styles.sampleRow}>
{samples.map((s, idx) => (
<View key={idx} style={styles.sampleItem}>
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
</View>
</View>
))}
</View>
</BlurView>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
permBanner: {
marginTop: 12,
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.04)'
},
permTitle: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: 'rgba(255,255,255,0.75)',
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: '#BBF246',
},
permPrimaryText: {
color: '#192126',
fontSize: 14,
fontWeight: '800',
},
permSecondary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
},
permSecondaryText: {
color: 'rgba(255,255,255,0.85)',
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: '#ECEDEE',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: 'rgba(255,255,255,0.55)',
fontSize: 13,
},
uploader: {
height: 220,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(255,255,255,0.18)',
backgroundColor: '#1E262C',
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: '#BBF246',
},
placeholderTitle: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: 'rgba(255,255,255,0.65)',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
},
sampleTitle: {
color: 'rgba(255,255,255,0.8)',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
},
sampleRow: {
flexDirection: 'row',
gap: 10,
},
sampleItem: {
flex: 1,
},
sampleImg: {
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#111',
},
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',
},
});