Files
digital-pilates/app/medications/ai-camera.tsx
richarjiang 6f2b7eb45e feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机
- 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮
- 添加相机引导提示本地存储,避免重复显示
- 修复相机页面布局跳动问题,固定相机高度
- 为医疗免责声明组件添加触觉反馈错误处理
- 实现活动热力图的国际化支持,包括月份标签和统计文本
2025-11-25 14:09:24 +08:00

944 lines
28 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 { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Animated, {
Easing,
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 本地存储的 key用于记录用户是否已经看过拍摄引导
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
] as const;
type CaptureKey = (typeof captureSteps)[number]['key'];
type Shot = {
uri: string;
};
export default function MedicationAiCameraScreen() {
const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const { ensureLoggedIn } = useAuthGuard();
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const [facing, setFacing] = useState<'back' | 'front'>('back');
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
front: null,
side: null,
aux: null,
});
const [creatingTask, setCreatingTask] = useState(false);
const [showGuideModal, setShowGuideModal] = useState(false);
// 动画控制0 = 圆形拍摄按钮1 = 展开为两个按钮
const expandAnimation = useSharedValue(0);
// 首次进入时显示引导弹窗
useEffect(() => {
const checkAndShowGuide = async () => {
try {
// 从本地存储读取是否已经看过引导
const hasSeenGuide = await getItem(MEDICATION_GUIDE_SEEN_KEY);
// 如果没有看过(返回 null 或 undefined则显示引导弹窗
if (!hasSeenGuide) {
setShowGuideModal(true);
// 标记为已看过,下次进入不再自动显示
await setItem(MEDICATION_GUIDE_SEEN_KEY, 'true');
}
} catch (error) {
console.error('[MEDICATION_AI] 检查引导状态失败', error);
// 出错时为了更好的用户体验,还是显示引导
setShowGuideModal(true);
}
};
checkAndShowGuide();
}, []);
const currentStep = captureSteps[currentStepIndex];
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
const allRequiredCaptured = Boolean(shots.front && shots.side);
// 当必需照片都拍摄完成后,触发展开动画
useEffect(() => {
if (allRequiredCaptured) {
expandAnimation.value = withTiming(1, {
duration: 350,
easing: Easing.out(Easing.cubic),
});
} else {
expandAnimation.value = withTiming(0, {
duration: 300,
easing: Easing.inOut(Easing.cubic),
});
}
}, [allRequiredCaptured]);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
const cameraHeight = useMemo(() => {
const { height: screenHeight } = Dimensions.get('window');
// 计算固定占用的高度(使用最大值确保布局稳定)
const headerHeight = insets.top + 40; // HeaderBar 高度
const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域padding + badge + title + subtitle + gap
const shotsRowHeight = 12 + 88; // shotsRow 区域paddingTop + shotCard 高度
// 固定使用展开状态的高度,确保布局不会跳动
const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域(不包含动态变化部分)
const margins = 12 + 12; // cameraCard 的上下边距
// 可用于相机的高度 = 屏幕高度 - 所有固定元素高度
const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins;
// 确保最小高度为 300最大不超过屏幕的 50%
return Math.max(300, Math.min(availableHeight, screenHeight * 0.5));
}, [insets.top, insets.bottom]);
const handleToggleCamera = () => {
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
};
const handlePickFromAlbum = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
});
if (!result.canceled && result.assets?.length) {
const asset = result.assets[0];
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
goNextStep();
}, 300);
}
}
} catch (error) {
console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片');
}
};
const handleTakePicture = async () => {
if (!cameraRef.current) return;
try {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
if (photo?.uri) {
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
goNextStep();
}, 300);
}
}
} catch (error) {
console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试');
}
};
const goNextStep = () => {
if (currentStepIndex < captureSteps.length - 1) {
setCurrentStepIndex((prev) => prev + 1);
}
};
const handleStartRecognition = async () => {
// 检查必需照片是否完成
if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
return;
}
await startRecognition();
};
const startRecognition = async () => {
if (!shots.front || !shots.side) return;
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
try {
setCreatingTask(true);
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
]);
const task = await createMedicationRecognitionTask({
frontImageUrl: frontUpload.url,
sideImageUrl: sideUpload.url,
auxiliaryImageUrl: auxUpload?.url,
});
router.replace({
pathname: '/medications/ai-progress',
params: {
taskId: task.taskId,
cover: frontUpload.url,
},
});
} catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
} finally {
setCreatingTask(false);
}
};
// 动画翻转按钮组件
const AnimatedToggleButton = ({
expandAnimation,
onPress,
disabled,
}: {
expandAnimation: SharedValue<number>;
onPress: () => void;
disabled: boolean;
}) => {
// 翻转按钮的位置动画 - 展开时向右移出
const toggleButtonStyle = useAnimatedStyle(() => {
const translateX = interpolate(
expandAnimation.value,
[0, 1],
[0, 100], // 向右移出屏幕
Extrapolation.CLAMP
);
const opacity = interpolate(
expandAnimation.value,
[0, 0.3],
[1, 0],
Extrapolation.CLAMP
);
return {
opacity,
transform: [{ translateX }],
};
});
return (
<Animated.View style={toggleButtonStyle}>
<TouchableOpacity
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
};
// 动画拍摄按钮组件
const AnimatedCaptureButton = ({
allRequiredCaptured,
expandAnimation,
onCapture,
onComplete,
disabled,
loading,
}: {
allRequiredCaptured: boolean;
expandAnimation: SharedValue<number>;
onCapture: () => void;
onComplete: () => void;
disabled: boolean;
loading: boolean;
}) => {
// 单个拍摄按钮的缩放和透明度动画
const singleButtonStyle = useAnimatedStyle(() => ({
opacity: interpolate(
expandAnimation.value,
[0, 0.3],
[1, 0],
Extrapolation.CLAMP
),
transform: [{
scale: interpolate(
expandAnimation.value,
[0, 0.3],
[1, 0.8],
Extrapolation.CLAMP
)
}],
}));
// 左侧按钮的位置和透明度动画
const leftButtonStyle = useAnimatedStyle(() => {
const translateX = interpolate(
expandAnimation.value,
[0, 1],
[0, -70], // 向左移动更多距离
Extrapolation.CLAMP
);
const opacity = interpolate(
expandAnimation.value,
[0.4, 1],
[0, 1],
Extrapolation.CLAMP
);
const scale = interpolate(
expandAnimation.value,
[0.4, 1],
[0.8, 1],
Extrapolation.CLAMP
);
return {
opacity,
transform: [{ translateX }, { scale }],
};
});
// 右侧按钮的位置和透明度动画
const rightButtonStyle = useAnimatedStyle(() => {
const translateX = interpolate(
expandAnimation.value,
[0, 1],
[0, 70], // 向右移动更多距离
Extrapolation.CLAMP
);
const opacity = interpolate(
expandAnimation.value,
[0.4, 1],
[0, 1],
Extrapolation.CLAMP
);
const scale = interpolate(
expandAnimation.value,
[0.4, 1],
[0.8, 1],
Extrapolation.CLAMP
);
return {
opacity,
transform: [{ translateX }, { scale }],
};
});
// 容器整体向右平移的动画
const containerStyle = useAnimatedStyle(() => {
const translateX = interpolate(
expandAnimation.value,
[0, 1],
[0, 60], // 整体向右移动更多,与相册按钮保持距离
Extrapolation.CLAMP
);
return {
transform: [{ translateX }],
};
});
return (
<Animated.View style={[styles.captureButtonContainer, containerStyle]}>
{/* 未展开状态:圆形拍摄按钮 */}
{!allRequiredCaptured && (
<Animated.View style={[styles.singleCaptureWrapper, singleButtonStyle]}>
<TouchableOpacity
onPress={onCapture}
disabled={disabled}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.captureBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.8)"
isInteractive={true}
>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</GlassView>
) : (
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</View>
)}
</TouchableOpacity>
</Animated.View>
)}
{/* 展开状态:两个分离的按钮 */}
{allRequiredCaptured && (
<>
{/* 左侧:拍照按钮 */}
<Animated.View style={[styles.splitButtonWrapper, leftButtonStyle]}>
<TouchableOpacity
onPress={onCapture}
disabled={disabled}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.splitButton}
glassEffectStyle="clear"
tintColor="rgba(14, 165, 233, 0.2)"
isInteractive={true}
>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
{/* 右侧:完成按钮 */}
<Animated.View style={[styles.splitButtonWrapper, rightButtonStyle]}>
<TouchableOpacity
onPress={onComplete}
disabled={disabled || loading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.splitButton}
glassEffectStyle="clear"
tintColor="rgba(16, 185, 129, 0.2)"
isInteractive={true}
>
{loading ? (
<ActivityIndicator size="small" color="#10b981" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
</>
)}
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
{loading ? (
<ActivityIndicator size="small" color="#10b981" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
</>
)}
</View>
)}
</TouchableOpacity>
</Animated.View>
</>
)}
</Animated.View>
);
};
if (!permission) {
return null;
}
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}></Text>
<Text style={styles.permissionTip}></Text>
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={styles.permissionBtnText}>访</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<>
{/* 引导说明弹窗 - 移到最外层 */}
<MedicationPhotoGuideModal
visible={showGuideModal}
onClose={() => setShowGuideModal(false)}
/>
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title="AI 用药识别"
onBack={() => router.back()}
transparent
right={
<TouchableOpacity
onPress={() => setShowGuideModal(true)}
activeOpacity={0.7}
accessibilityLabel="查看拍摄说明"
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.infoButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="information-circle-outline" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
<Ionicons name="information-circle-outline" size={24} color="#333" />
</View>
)}
</TouchableOpacity>
}
/>
<View style={{ height: insets.top + 40 }} />
<View style={styles.topMeta}>
<View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
</View>
<Text style={styles.metaTitle}>{currentStep.title}</Text>
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
</View>
<View style={styles.cameraCard}>
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.08)']}
style={styles.cameraOverlay}
/>
{coverPreview ? (
<View style={styles.previewBadge}>
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
</View>
) : null}
</View>
</View>
<View style={styles.shotsRow}>
{captureSteps.map((step, index) => {
const active = step.key === currentStep.key;
const shot = shots[step.key];
return (
<TouchableOpacity
key={step.key}
onPress={() => setCurrentStepIndex(index)}
activeOpacity={0.7}
style={[styles.shotCard, active && styles.shotCardActive]}
>
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title}
{!step.mandatory ? '(可选)' : ''}
</Text>
{shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
) : (
<View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
<View style={styles.bottomActions}>
<TouchableOpacity
onPress={handlePickFromAlbum}
disabled={creatingTask || uploading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
<AnimatedCaptureButton
allRequiredCaptured={allRequiredCaptured}
expandAnimation={expandAnimation}
onCapture={handleTakePicture}
onComplete={handleStartRecognition}
disabled={creatingTask}
loading={creatingTask || uploading}
/>
<AnimatedToggleButton
expandAnimation={expandAnimation}
onPress={handleToggleCamera}
disabled={creatingTask}
/>
</View>
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
topMeta: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 6,
},
metaBadge: {
alignSelf: 'flex-start',
backgroundColor: '#e0f2fe',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
metaBadgeText: {
color: '#0369a1',
fontWeight: '700',
fontSize: 12,
},
metaTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
},
metaSubtitle: {
fontSize: 14,
color: '#475569',
},
cameraCard: {
marginHorizontal: 20,
marginTop: 12,
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.12,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
},
cameraFrame: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: '#0b172a',
height: 360,
},
cameraView: {
flex: 1,
},
cameraOverlay: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 80,
},
previewBadge: {
position: 'absolute',
right: 12,
bottom: 12,
width: 90,
height: 90,
borderRadius: 12,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#fff',
},
previewImage: {
width: '100%',
height: '100%',
},
shotsRow: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingTop: 12,
gap: 10,
},
shotCard: {
flex: 1,
borderRadius: 14,
backgroundColor: '#f8fafc',
padding: 10,
gap: 8,
borderWidth: 1,
borderColor: '#e2e8f0',
},
shotCardActive: {
borderColor: '#38bdf8',
backgroundColor: '#ecfeff',
},
shotLabel: {
fontSize: 12,
color: '#475569',
fontWeight: '600',
},
shotLabelActive: {
color: '#0ea5e9',
},
shotThumb: {
width: '100%',
height: 70,
borderRadius: 12,
},
shotPlaceholder: {
height: 70,
borderRadius: 12,
backgroundColor: '#e2e8f0',
alignItems: 'center',
justifyContent: 'center',
},
shotPlaceholderText: {
color: '#94a3b8',
fontSize: 12,
},
bottomBar: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 10,
},
bottomActions: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
captureButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
height: 64,
},
singleCaptureWrapper: {
position: 'absolute',
},
captureBtn: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
fallbackCaptureBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderWidth: 2,
borderColor: 'rgba(14, 165, 233, 0.2)',
},
captureOuterRing: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#fff',
shadowColor: '#0ea5e9',
shadowOpacity: 0.4,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
},
splitButtonWrapper: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
splitButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingHorizontal: 20,
paddingVertical: 11,
borderRadius: 16,
overflow: 'hidden',
width: 110,
height: 48,
shadowColor: '#0f172a',
shadowOpacity: 0.1,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fallbackSplitButton: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderWidth: 1,
borderColor: 'rgba(15, 23, 42, 0.1)',
},
splitButtonLabel: {
fontSize: 13,
fontWeight: '600',
color: '#0f172a',
},
secondaryBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
fallbackSecondaryBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(15, 23, 42, 0.1)',
},
secondaryBtnText: {
color: '#0f172a',
fontWeight: '600',
fontSize: 14,
},
primaryCta: {
marginTop: 6,
borderRadius: 16,
paddingVertical: 14,
alignItems: 'center',
shadowColor: '#0f172a',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
},
primaryText: {
fontSize: 16,
fontWeight: '700',
},
skipBtn: {
alignSelf: 'center',
paddingVertical: 6,
paddingHorizontal: 12,
},
skipText: {
color: '#475569',
fontSize: 13,
},
infoButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackInfoButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
permissionCard: {
marginHorizontal: 24,
borderRadius: 18,
padding: 20,
backgroundColor: '#fff',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
alignItems: 'center',
gap: 10,
},
permissionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0f172a',
},
permissionTip: {
fontSize: 14,
color: '#475569',
textAlign: 'center',
lineHeight: 20,
},
permissionBtn: {
marginTop: 6,
borderRadius: 14,
paddingHorizontal: 18,
paddingVertical: 12,
},
permissionBtnText: {
color: '#fff',
fontWeight: '700',
},
});