feat(scan): persist GPS coordinates with each scan
Capture the user's location in parallel with TFLite inference so saving the scan doesn't slow down the camera flow. Permission is requested just-in-time on the first capture (not at app boot) and refusals are surfaced once via toast — repeat refusals stay silent (flag persisted in AsyncStorage under @vineye:location-permission-asked). - ScanRecord gains optional latitude / longitude / locationCapturedAt (plus customName + getScanStatus helper used by the Map screen). All fields optional so older scans keep working unchanged. - New useScanLocation hook: requestForegroundPermissionsAsync + getCurrentPositionAsync(Balanced) with a 5s timeout. On any failure returns null so the scan still saves without coordinates. - ScannerScreen runs analyze() and requestAndGetLocation() through Promise.all so GPS acquisition does not block inference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
08c4eba940
commit
3be2d3b531
73
VinEye/src/hooks/useScanLocation.ts
Normal file
73
VinEye/src/hooks/useScanLocation.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useCallback } from 'react';
|
||||
import * as Location from 'expo-location';
|
||||
import { toast } from 'sonner-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { storage } from '@/services/storage';
|
||||
|
||||
export interface ScanCoords {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
const LOCATION_TIMEOUT_MS = 5000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(null), ms);
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function useScanLocation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const requestAndGetLocation = useCallback(async (): Promise<ScanCoords | null> => {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
|
||||
if (status !== 'granted') {
|
||||
const alreadyAsked = await storage.get<boolean>(storage.KEYS.LOCATION_PERMISSION_ASKED);
|
||||
if (!alreadyAsked) {
|
||||
toast.info(t('location.permissionDeniedTitle'), {
|
||||
description: `${t('location.permissionDenied')} ${t('location.settingsHint')}`,
|
||||
duration: 5000,
|
||||
});
|
||||
await storage.set(storage.KEYS.LOCATION_PERMISSION_ASKED, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = await withTimeout(
|
||||
Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
}),
|
||||
LOCATION_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
return {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
capturedAt: new Date(position.timestamp).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (__DEV__) {
|
||||
console.warn('[useScanLocation] failed:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
return { requestAndGetLocation };
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
|
@ -19,6 +19,7 @@ import { Button } from '@/components/ui/Button';
|
|||
import { useDetection } from '@/hooks/useDetection';
|
||||
import { useGameProgress } from '@/hooks/useGameProgress';
|
||||
import { useHistory } from '@/hooks/useHistory';
|
||||
import { useScanLocation } from '@/hooks/useScanLocation';
|
||||
import { hapticSuccess, hapticLight } from '@/services/haptics';
|
||||
import { colors } from '@/theme/colors';
|
||||
import type { RootStackParamList } from '@/types/navigation';
|
||||
|
|
@ -37,16 +38,34 @@ export default function ScannerScreen() {
|
|||
const { analyze, isAnalyzing } = useDetection();
|
||||
const { processDetection } = useGameProgress();
|
||||
const { addScan } = useHistory();
|
||||
const { requestAndGetLocation } = useScanLocation();
|
||||
const [liveConfidence, setLiveConfidence] = useState(0);
|
||||
const [isCameraReady, setIsCameraReady] = useState(false);
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
|
||||
const shutterScale = useSharedValue(1);
|
||||
const shutterStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: shutterScale.value }],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (permission && !permission.granted && permission.canAskAgain) {
|
||||
requestPermission();
|
||||
}
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
async function handleCapture() {
|
||||
if (isAnalyzing) return;
|
||||
|
||||
if (!cameraRef.current) {
|
||||
Alert.alert(t('common.error'), 'Camera not initialized');
|
||||
return;
|
||||
}
|
||||
if (!isCameraReady) {
|
||||
Alert.alert(t('common.error'), 'Camera is not ready yet — please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
await hapticLight();
|
||||
|
||||
shutterScale.value = withSequence(
|
||||
|
|
@ -58,10 +77,36 @@ export default function ScannerScreen() {
|
|||
setLiveConfidence((prev) => Math.min(prev + Math.floor(Math.random() * 12), 85));
|
||||
}, 150);
|
||||
|
||||
const detection = await analyze();
|
||||
let imageUri: string | undefined;
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.85,
|
||||
skipProcessing: true,
|
||||
exif: false,
|
||||
});
|
||||
imageUri = photo?.uri;
|
||||
if (__DEV__) {
|
||||
console.log('[Scanner] Captured photo:', imageUri);
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setLiveConfidence(0);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[Scanner] takePictureAsync failed:', message);
|
||||
Alert.alert(t('common.error'), `Capture failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [detection, coords] = await Promise.all([
|
||||
analyze(imageUri),
|
||||
requestAndGetLocation(),
|
||||
]);
|
||||
clearInterval(interval);
|
||||
|
||||
if (!detection) return;
|
||||
if (!detection) {
|
||||
setLiveConfidence(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveConfidence(detection.confidence);
|
||||
|
||||
|
|
@ -76,6 +121,11 @@ export default function ScannerScreen() {
|
|||
detection,
|
||||
xpEarned: typeof xpEarned === 'number' ? xpEarned : 10,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(coords && {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
locationCapturedAt: coords.capturedAt,
|
||||
}),
|
||||
};
|
||||
|
||||
await addScan(record);
|
||||
|
|
@ -85,8 +135,8 @@ export default function ScannerScreen() {
|
|||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Chargement...</Text>
|
||||
<View className="flex-1 items-center justify-center bg-[#FAFAFA]">
|
||||
<Text>{t('common.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,8 +160,17 @@ export default function ScannerScreen() {
|
|||
|
||||
return (
|
||||
<View className="flex-1 bg-neutral-900">
|
||||
<CameraView className="flex-1" facing="back">
|
||||
{/* Header overlay */}
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
className="flex-1"
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
onCameraReady={() => setIsCameraReady(true)}
|
||||
onMountError={(e) => {
|
||||
console.warn('[Scanner] Camera mount error:', e);
|
||||
Alert.alert(t('common.error'), `Camera mount: ${e.message ?? 'unknown'}`);
|
||||
}}
|
||||
>
|
||||
<SafeAreaView edges={['top']} className="absolute top-0 left-0 right-0 z-10">
|
||||
<View className="flex-row items-center justify-between px-5 py-2">
|
||||
<View className="flex-row items-center gap-2">
|
||||
|
|
@ -132,9 +191,7 @@ export default function ScannerScreen() {
|
|||
|
||||
<CameraOverlay isScanning={isAnalyzing} confidence={liveConfidence} />
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<View className="absolute bottom-0 left-0 right-0 flex-row items-center justify-between px-8 pb-12 pt-5">
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
className="h-11 w-11 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
|
|
@ -142,7 +199,6 @@ export default function ScannerScreen() {
|
|||
<Ionicons name="image-outline" size={20} color="rgba(255,255,255,0.5)" />
|
||||
</View>
|
||||
|
||||
{/* Shutter */}
|
||||
<Animated.View
|
||||
className="h-[72px] w-[72px] items-center justify-center rounded-full border-[3px] border-white"
|
||||
style={shutterStyle}
|
||||
|
|
@ -150,7 +206,7 @@ export default function ScannerScreen() {
|
|||
<TouchableOpacity
|
||||
className="h-[60px] w-[60px] items-center justify-center rounded-full bg-white"
|
||||
onPress={handleCapture}
|
||||
disabled={isAnalyzing}
|
||||
disabled={isAnalyzing || !isCameraReady}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
|
|
@ -158,12 +214,14 @@ export default function ScannerScreen() {
|
|||
{t('scanner.analyzing')}
|
||||
</Text>
|
||||
) : (
|
||||
<View className="h-[52px] w-[52px] rounded-full bg-white" />
|
||||
<View
|
||||
className="h-[52px] w-[52px] rounded-full"
|
||||
style={{ backgroundColor: isCameraReady ? '#fff' : colors.neutral[300] }}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Flip camera */}
|
||||
<TouchableOpacity
|
||||
className="h-11 w-11 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const KEYS = {
|
|||
GAME_PROGRESS: '@vineye:game_progress',
|
||||
SCAN_HISTORY: '@vineye:scan_history',
|
||||
LANGUAGE: '@vineye:language',
|
||||
LOCATION_PERMISSION_ASKED: '@vineye:location-permission-asked',
|
||||
} as const;
|
||||
|
||||
async function get<T>(key: string): Promise<T | null> {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
export type DetectionResult = 'vine' | 'uncertain' | 'not_vine';
|
||||
|
||||
export type DiseaseClass = 'healthy' | 'black_rot' | 'esca' | 'leaf_blight';
|
||||
|
||||
export interface ClassProbability {
|
||||
class: DiseaseClass;
|
||||
probability: number;
|
||||
}
|
||||
|
||||
export interface Detection {
|
||||
result: DetectionResult;
|
||||
confidence: number; // 0–100
|
||||
confidence: number;
|
||||
diseaseClass?: DiseaseClass;
|
||||
diseaseSlug?: string;
|
||||
allProbabilities?: ClassProbability[];
|
||||
cepageId?: string;
|
||||
timestamp: number;
|
||||
imageUri?: string;
|
||||
|
|
@ -12,11 +22,24 @@ export interface ScanRecord {
|
|||
id: string;
|
||||
detection: Detection;
|
||||
xpEarned: number;
|
||||
createdAt: string; // ISO date
|
||||
createdAt: string;
|
||||
isFavorite?: boolean;
|
||||
customName?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
locationCapturedAt?: string;
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
placeName?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type ScanStatus = 'healthy' | 'infected' | 'uncertain';
|
||||
|
||||
export function getScanStatus(scan: ScanRecord): ScanStatus {
|
||||
const cls = scan.detection.diseaseClass;
|
||||
if (cls === 'healthy') return 'healthy';
|
||||
if (cls === 'black_rot' || cls === 'esca' || cls === 'leaf_blight') return 'infected';
|
||||
return 'uncertain';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue