Files
digital-pilates/app/medications/ai-camera.tsx
richarjiang fbe0c92f0f feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
2025-11-27 17:54:36 +08:00

1008 lines
29 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 { useI18n } from '@/hooks/useI18n';
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', mandatory: true },
{ key: 'side', mandatory: true },
{ key: 'aux', mandatory: false },
] as const;
type CaptureKey = (typeof captureSteps)[number]['key'];
type Shot = {
uri: string;
};
export default function MedicationAiCameraScreen() {
const { t } = useI18n();
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(
() =>
t('medications.aiCamera.steps.stepProgress', {
current: currentStepIndex + 1,
total: captureSteps.length,
}),
[currentStepIndex, t]
);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
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(
t('medications.aiCamera.alerts.pickFailed.title'),
t('medications.aiCamera.alerts.pickFailed.message')
);
}
};
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(
t('medications.aiCamera.alerts.captureFailed.title'),
t('medications.aiCamera.alerts.captureFailed.message')
);
}
};
const goNextStep = () => {
if (currentStepIndex < captureSteps.length - 1) {
setCurrentStepIndex((prev) => prev + 1);
}
};
const handleStartRecognition = async () => {
// 检查必需照片是否完成
if (!allRequiredCaptured) {
Alert.alert(
t('medications.aiCamera.alerts.insufficientPhotos.title'),
t('medications.aiCamera.alerts.insufficientPhotos.message')
);
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(
t('medications.aiCamera.alerts.taskFailed.title'),
error?.message || t('medications.aiCamera.alerts.taskFailed.defaultMessage')
);
} 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}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</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}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</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}>
{t('medications.aiCamera.buttons.complete')}
</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}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</>
)}
</View>
)}
</TouchableOpacity>
</Animated.View>
</>
)}
</Animated.View>
);
};
if (!permission) {
return null;
}
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
/>
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}>
{t('medications.aiCamera.permission.title')}
</Text>
<Text style={styles.permissionTip}>
{t('medications.aiCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('medications.aiCamera.permission.button')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<>
{/* 引导说明弹窗 - 移到最外层 */}
<MedicationPhotoGuideModal
visible={showGuideModal}
onClose={() => setShowGuideModal(false)}
/>
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
right={
<TouchableOpacity
onPress={() => setShowGuideModal(true)}
activeOpacity={0.7}
accessibilityLabel={t('medications.aiCamera.guideModal.title')}
>
{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}>
{t(`medications.aiCamera.steps.${currentStep.key}.title`)}
</Text>
<Text style={styles.metaSubtitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.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]}>
{t(`medications.aiCamera.steps.${step.key}.title`)}
{!step.mandatory
? ` ${t('medications.aiCamera.steps.optional')}`
: ''}
</Text>
{shot ? (
<Image
source={{ uri: shot.uri }}
style={styles.shotThumb}
contentFit="cover"
/>
) : (
<View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}>
{t('medications.aiCamera.steps.notTaken')}
</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}>
{t('medications.aiCamera.buttons.album')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</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',
},
});