feat: 添加相机和相册权限请求功能

- 在 AI 体态评估页面中集成相机和相册权限请求逻辑
- 更新 app.json 和 Info.plist,添加相应的权限说明
- 修改布局以支持照片上传功能,用户可上传正面、侧面和背面照片
- 更新 package.json 和 package-lock.json,添加 expo-image-picker 依赖
This commit is contained in:
richarjiang
2025-08-12 17:30:26 +08:00
parent e84ad0857c
commit 2fac3f899c
9 changed files with 705 additions and 365 deletions

View File

@@ -1,9 +1,13 @@
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 from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Image,
ImageBackground,
Linking,
Platform,
ScrollView,
StyleSheet,
Text,
@@ -14,49 +18,157 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
type Exercise = {
id: string;
title: string;
duration: string;
imageUri: string;
type PoseView = 'front' | 'side' | 'back';
type UploadState = {
front?: string | null;
side?: string | null;
back?: string | null;
};
const EXERCISES: Exercise[] = [
{
id: '1',
title: 'Jumping Jacks',
duration: '00:30',
imageUri:
'https://images.unsplash.com/photo-1546483875-ad9014c88eba?q=80&w=400&auto=format&fit=crop',
},
{
id: '2',
title: 'Squats',
duration: '00:45',
imageUri:
'https://images.unsplash.com/photo-1583454110551-21f2fa2f36f0?q=80&w=400&auto=format&fit=crop',
},
{
id: '3',
title: 'Backward Lunge',
duration: '00:40',
imageUri:
'https://images.unsplash.com/photo-1597074866923-5c3bfa3b6c46?q=80&w=400&auto=format&fit=crop',
},
{
id: '4',
title: 'High Knees',
duration: '00:30',
imageUri:
'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?q=80&w=400&auto=format&fit=crop',
},
];
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 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 }]}>
@@ -69,7 +181,7 @@ export default function AIPostureAssessmentScreen() {
>
<Ionicons name="chevron-back" size={24} color="#ECEDEE" />
</TouchableOpacity>
<Text style={styles.headerTitle}>AI体态评</Text>
<Text style={styles.headerTitle}>AI体态</Text>
<View style={{ width: 32 }} />
</View>
@@ -77,104 +189,142 @@ export default function AIPostureAssessmentScreen() {
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
showsVerticalScrollIndicator={false}
>
{/* Hero */}
<View style={styles.heroContainer}>
<ImageBackground
source={{
uri:
'https://images.unsplash.com/photo-1594737625785-c6683fc87c73?q=80&w=1200&auto=format&fit=crop',
}}
style={styles.heroImage}
imageStyle={{ borderRadius: 28 }}
>
<View style={styles.heroOverlay} />
</ImageBackground>
{/* 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>
)
)}
{/* Floating stats */}
<View style={styles.statsFloating}>
<StatCard
icon={<Ionicons name="time-outline" size={18} color="#192126" />}
label="Time"
value="20 min"
/>
<View style={styles.divider} />
<StatCard
icon={<Ionicons name="flame-outline" size={18} color="#192126" />}
label="Burn"
value="95 kcal"
/>
</View>
</View>
{/* Title & description */}
<View style={styles.contentSection}>
<Text style={styles.title}>Lower Body Training</Text>
{/* Intro */}
<View style={styles.introBox}>
<Text style={styles.title}>姿</Text>
<Text style={styles.description}>
The lower abdomen and hips are the most difficult areas of the body to reduce when we are on
a diet. Even so, in this area, especially the legs as a whole, you can reduce weight even if
you don't use tools.
线
</Text>
</View>
{/* Rounds header */}
<View style={styles.roundsHeader}>
<Text style={styles.roundsTitle}>Rounds</Text>
<Text style={styles.roundsCount}>1/8</Text>
</View>
{/* Upload sections */}
<UploadTile
label="正面"
value={uploadState.front}
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
/>
{/* Exercise list */}
<View style={{ gap: 14, marginHorizontal: 20 }}>
{EXERCISES.map((item) => (
<ExerciseItem key={item.id} exercise={item} />)
)}
</View>
<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
activeOpacity={0.9}
onPress={() => { }}
style={[styles.bottomCta, { backgroundColor: theme.primary }]}
disabled={!canStart}
activeOpacity={1}
onPress={handleStart}
style={[
styles.bottomCta,
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
]}
>
<Text style={styles.bottomCtaText}>Lets Workout</Text>
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
{canStart ? '开始测评' : '请先完成三视角上传'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function StatCard({
icon,
function UploadTile({
label,
value,
onPickCamera,
onPickLibrary,
samples,
}: {
icon: React.ReactNode;
label: string;
value: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
samples: Sample[];
}) {
return (
<View style={styles.statCard}>
<View style={styles.statIconWrap}>{icon}</View>
<View style={{ marginLeft: 8 }}>
<Text style={styles.statLabel}>{label}</Text>
<Text style={styles.statValue}>{value}</Text>
<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>
</View>
);
}
function ExerciseItem({ exercise }: { exercise: Exercise }) {
return (
<View style={styles.exerciseItem}>
<Image source={{ uri: exercise.imageUri }} style={styles.exerciseThumb} />
<View style={{ flex: 1, marginHorizontal: 12 }}>
<Text style={styles.exerciseTitle}>{exercise.title}</Text>
<Text style={styles.exerciseDuration}>{exercise.duration}</Text>
</View>
<TouchableOpacity style={styles.exercisePlayButton}>
<Ionicons name="play" size={18} color="#BBF246" />
<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>
);
}
@@ -183,6 +333,57 @@ 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',
@@ -202,73 +403,13 @@ const styles = StyleSheet.create({
color: '#ECEDEE',
fontWeight: '700',
},
heroContainer: {
marginTop: 14,
marginHorizontal: 16,
},
heroImage: {
height: 260,
borderRadius: 28,
overflow: 'hidden',
justifyContent: 'flex-end',
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.18)',
borderRadius: 28,
},
statsFloating: {
position: 'absolute',
left: 22,
right: 22,
bottom: -26,
height: 72,
borderRadius: 20,
backgroundColor: '#1E262C',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 14,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
divider: {
width: 1,
height: 36,
backgroundColor: 'rgba(255,255,255,0.08)',
},
statCard: {
flexDirection: 'row',
alignItems: 'center',
},
statIconWrap: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#BBF246',
alignItems: 'center',
justifyContent: 'center',
},
statLabel: {
color: 'rgba(255,255,255,0.75)',
fontSize: 12,
marginBottom: 2,
},
statValue: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '700',
},
contentSection: {
marginTop: 46,
introBox: {
marginTop: 12,
paddingHorizontal: 20,
gap: 12,
gap: 10,
},
title: {
fontSize: 28,
fontSize: 26,
color: '#ECEDEE',
fontWeight: '800',
},
@@ -277,53 +418,98 @@ const styles = StyleSheet.create({
lineHeight: 22,
color: 'rgba(255,255,255,0.75)',
},
roundsHeader: {
marginTop: 18,
paddingHorizontal: 20,
section: {
marginTop: 16,
paddingHorizontal: 16,
gap: 12,
},
sectionHeader: {
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
roundsTitle: {
sectionTitle: {
color: '#ECEDEE',
fontSize: 22,
fontSize: 18,
fontWeight: '700',
},
roundsCount: {
color: 'rgba(255,255,255,0.6)',
fontSize: 16,
},
exerciseItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#1E262C',
padding: 12,
borderRadius: 18,
},
exerciseThumb: {
width: 56,
height: 56,
borderRadius: 12,
},
exerciseTitle: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '600',
},
exerciseDuration: {
color: 'rgba(255,255,255,0.6)',
marginTop: 4,
retakeHint: {
color: 'rgba(255,255,255,0.55)',
fontSize: 13,
},
exercisePlayButton: {
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,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#192126',
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',
@@ -338,10 +524,8 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
bottomCtaText: {
color: '#192126',
fontSize: 18,
fontWeight: '800',
},
});