feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
626
app/medications/ai-camera.tsx
Normal file
626
app/medications/ai-camera.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
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 { 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,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
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);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage
|
||||
if (!hasSeenGuide) {
|
||||
setShowGuideModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const currentStep = captureSteps[currentStepIndex];
|
||||
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
|
||||
const allRequiredCaptured = Boolean(shots.front && shots.side);
|
||||
|
||||
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
<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>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleTakePicture}
|
||||
disabled={creatingTask}
|
||||
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>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleCamera}
|
||||
disabled={creatingTask}
|
||||
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>
|
||||
</View>
|
||||
|
||||
{/* 只要正面和背面都有照片就显示识别按钮 */}
|
||||
{allRequiredCaptured && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleStartRecognition}
|
||||
disabled={creatingTask || uploading}
|
||||
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
{creatingTask || uploading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
|
||||
开始识别
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</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',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 43,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user