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 { useEffect, useRef, useState } from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
|
@ -19,6 +19,7 @@ import { Button } from '@/components/ui/Button';
|
||||||
import { useDetection } from '@/hooks/useDetection';
|
import { useDetection } from '@/hooks/useDetection';
|
||||||
import { useGameProgress } from '@/hooks/useGameProgress';
|
import { useGameProgress } from '@/hooks/useGameProgress';
|
||||||
import { useHistory } from '@/hooks/useHistory';
|
import { useHistory } from '@/hooks/useHistory';
|
||||||
|
import { useScanLocation } from '@/hooks/useScanLocation';
|
||||||
import { hapticSuccess, hapticLight } from '@/services/haptics';
|
import { hapticSuccess, hapticLight } from '@/services/haptics';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import type { RootStackParamList } from '@/types/navigation';
|
import type { RootStackParamList } from '@/types/navigation';
|
||||||
|
|
@ -37,16 +38,34 @@ export default function ScannerScreen() {
|
||||||
const { analyze, isAnalyzing } = useDetection();
|
const { analyze, isAnalyzing } = useDetection();
|
||||||
const { processDetection } = useGameProgress();
|
const { processDetection } = useGameProgress();
|
||||||
const { addScan } = useHistory();
|
const { addScan } = useHistory();
|
||||||
|
const { requestAndGetLocation } = useScanLocation();
|
||||||
const [liveConfidence, setLiveConfidence] = useState(0);
|
const [liveConfidence, setLiveConfidence] = useState(0);
|
||||||
|
const [isCameraReady, setIsCameraReady] = useState(false);
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
const shutterScale = useSharedValue(1);
|
const shutterScale = useSharedValue(1);
|
||||||
const shutterStyle = useAnimatedStyle(() => ({
|
const shutterStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ scale: shutterScale.value }],
|
transform: [{ scale: shutterScale.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (permission && !permission.granted && permission.canAskAgain) {
|
||||||
|
requestPermission();
|
||||||
|
}
|
||||||
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
async function handleCapture() {
|
async function handleCapture() {
|
||||||
if (isAnalyzing) return;
|
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();
|
await hapticLight();
|
||||||
|
|
||||||
shutterScale.value = withSequence(
|
shutterScale.value = withSequence(
|
||||||
|
|
@ -58,10 +77,36 @@ export default function ScannerScreen() {
|
||||||
setLiveConfidence((prev) => Math.min(prev + Math.floor(Math.random() * 12), 85));
|
setLiveConfidence((prev) => Math.min(prev + Math.floor(Math.random() * 12), 85));
|
||||||
}, 150);
|
}, 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);
|
clearInterval(interval);
|
||||||
|
|
||||||
if (!detection) return;
|
if (!detection) {
|
||||||
|
setLiveConfidence(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLiveConfidence(detection.confidence);
|
setLiveConfidence(detection.confidence);
|
||||||
|
|
||||||
|
|
@ -76,6 +121,11 @@ export default function ScannerScreen() {
|
||||||
detection,
|
detection,
|
||||||
xpEarned: typeof xpEarned === 'number' ? xpEarned : 10,
|
xpEarned: typeof xpEarned === 'number' ? xpEarned : 10,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
...(coords && {
|
||||||
|
latitude: coords.latitude,
|
||||||
|
longitude: coords.longitude,
|
||||||
|
locationCapturedAt: coords.capturedAt,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await addScan(record);
|
await addScan(record);
|
||||||
|
|
@ -85,8 +135,8 @@ export default function ScannerScreen() {
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center bg-[#FAFAFA]">
|
||||||
<Text>Chargement...</Text>
|
<Text>{t('common.loading')}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +160,17 @@ export default function ScannerScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-neutral-900">
|
<View className="flex-1 bg-neutral-900">
|
||||||
<CameraView className="flex-1" facing="back">
|
<CameraView
|
||||||
{/* Header overlay */}
|
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">
|
<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 justify-between px-5 py-2">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
|
|
@ -132,9 +191,7 @@ export default function ScannerScreen() {
|
||||||
|
|
||||||
<CameraOverlay isScanning={isAnalyzing} confidence={liveConfidence} />
|
<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">
|
<View className="absolute bottom-0 left-0 right-0 flex-row items-center justify-between px-8 pb-12 pt-5">
|
||||||
{/* Thumbnail */}
|
|
||||||
<View
|
<View
|
||||||
className="h-11 w-11 items-center justify-center rounded-lg"
|
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)' }}
|
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)" />
|
<Ionicons name="image-outline" size={20} color="rgba(255,255,255,0.5)" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Shutter */}
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
className="h-[72px] w-[72px] items-center justify-center rounded-full border-[3px] border-white"
|
className="h-[72px] w-[72px] items-center justify-center rounded-full border-[3px] border-white"
|
||||||
style={shutterStyle}
|
style={shutterStyle}
|
||||||
|
|
@ -150,7 +206,7 @@ export default function ScannerScreen() {
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="h-[60px] w-[60px] items-center justify-center rounded-full bg-white"
|
className="h-[60px] w-[60px] items-center justify-center rounded-full bg-white"
|
||||||
onPress={handleCapture}
|
onPress={handleCapture}
|
||||||
disabled={isAnalyzing}
|
disabled={isAnalyzing || !isCameraReady}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{isAnalyzing ? (
|
{isAnalyzing ? (
|
||||||
|
|
@ -158,12 +214,14 @@ export default function ScannerScreen() {
|
||||||
{t('scanner.analyzing')}
|
{t('scanner.analyzing')}
|
||||||
</Text>
|
</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>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Flip camera */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="h-11 w-11 items-center justify-center rounded-full"
|
className="h-11 w-11 items-center justify-center rounded-full"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const KEYS = {
|
||||||
GAME_PROGRESS: '@vineye:game_progress',
|
GAME_PROGRESS: '@vineye:game_progress',
|
||||||
SCAN_HISTORY: '@vineye:scan_history',
|
SCAN_HISTORY: '@vineye:scan_history',
|
||||||
LANGUAGE: '@vineye:language',
|
LANGUAGE: '@vineye:language',
|
||||||
|
LOCATION_PERMISSION_ASKED: '@vineye:location-permission-asked',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
async function get<T>(key: string): Promise<T | null> {
|
async function get<T>(key: string): Promise<T | null> {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
export type DetectionResult = 'vine' | 'uncertain' | 'not_vine';
|
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 {
|
export interface Detection {
|
||||||
result: DetectionResult;
|
result: DetectionResult;
|
||||||
confidence: number; // 0–100
|
confidence: number;
|
||||||
|
diseaseClass?: DiseaseClass;
|
||||||
|
diseaseSlug?: string;
|
||||||
|
allProbabilities?: ClassProbability[];
|
||||||
cepageId?: string;
|
cepageId?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
imageUri?: string;
|
imageUri?: string;
|
||||||
|
|
@ -12,11 +22,24 @@ export interface ScanRecord {
|
||||||
id: string;
|
id: string;
|
||||||
detection: Detection;
|
detection: Detection;
|
||||||
xpEarned: number;
|
xpEarned: number;
|
||||||
createdAt: string; // ISO date
|
createdAt: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
customName?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
locationCapturedAt?: string;
|
||||||
location?: {
|
location?: {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
placeName?: string;
|
placeName?: string;
|
||||||
} | null;
|
} | 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