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:
Yanis 2026-04-30 12:31:54 +02:00
parent 08c4eba940
commit 3be2d3b531
4 changed files with 171 additions and 16 deletions

View 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 };
}

View file

@ -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)' }}

View file

@ -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> {

View file

@ -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; // 0100
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';
}