bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
692 lines
17 KiB
JavaScript
692 lines
17 KiB
JavaScript
/**
|
||
* quiXzoom Native App
|
||
* iOS/Android field data collection
|
||
* Mobile-first design
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
Image,
|
||
ScrollView,
|
||
Alert,
|
||
ActivityIndicator,
|
||
SafeAreaView,
|
||
StatusBar,
|
||
} from 'react-native';
|
||
import { Camera, useCameraDevices } from 'react-native-vision-camera';
|
||
import Geolocation from '@react-native-community/geolocation';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
|
||
// API Configuration
|
||
const API_URL = 'https://api.quixzoom.com';
|
||
const API_KEY = 'YOUR_API_KEY';
|
||
|
||
/**
|
||
* quiXzoom App Component
|
||
*/
|
||
export default function quiXzoomApp() {
|
||
// State
|
||
const [hasPermission, setHasPermission] = useState(false);
|
||
const [isCapturing, setIsCapturing] = useState(false);
|
||
const [capturedImage, setCapturedImage] = useState(null);
|
||
const [location, setLocation] = useState(null);
|
||
const [evidence, setEvidence] = useState(null);
|
||
const [missions, setMissions] = useState([]);
|
||
const [activeMission, setActiveMission] = useState(null);
|
||
const [isOffline, setIsOffline] = useState(false);
|
||
const [queueCount, setQueueCount] = useState(0);
|
||
|
||
// Camera ref
|
||
const camera = useRef(null);
|
||
const devices = useCameraDevices();
|
||
const device = devices.back;
|
||
|
||
// Initialize
|
||
useEffect(() => {
|
||
checkPermissions();
|
||
loadMissions();
|
||
loadQueue();
|
||
}, []);
|
||
|
||
// Check camera and location permissions
|
||
const checkPermissions = async () => {
|
||
const cameraStatus = await Camera.requestCameraPermission();
|
||
const locationStatus = await Geolocation.requestAuthorization();
|
||
setHasPermission(cameraStatus === 'authorized');
|
||
};
|
||
|
||
// Load missions from API
|
||
const loadMissions = async () => {
|
||
try {
|
||
const response = await fetch(`${API_URL}/missions`, {
|
||
headers: { 'Authorization': `Bearer ${API_KEY}` }
|
||
});
|
||
const data = await response.json();
|
||
setMissions(data.missions || []);
|
||
} catch (error) {
|
||
console.log('Offline mode - using cached missions');
|
||
setIsOffline(true);
|
||
}
|
||
};
|
||
|
||
// Load offline queue
|
||
const loadQueue = async () => {
|
||
const queue = await AsyncStorage.getItem('observationQueue');
|
||
if (queue) {
|
||
setQueueCount(JSON.parse(queue).length);
|
||
}
|
||
};
|
||
|
||
// Capture photo
|
||
const capturePhoto = async () => {
|
||
if (!camera.current) return;
|
||
|
||
setIsCapturing(true);
|
||
|
||
try {
|
||
// Take photo
|
||
const photo = await camera.current.takePhoto({
|
||
qualityPrioritization: 'quality',
|
||
flash: 'auto',
|
||
});
|
||
|
||
// Get GPS location
|
||
Geolocation.getCurrentPosition(
|
||
async (position) => {
|
||
const loc = {
|
||
latitude: position.coords.latitude,
|
||
longitude: position.coords.longitude,
|
||
altitude: position.coords.altitude,
|
||
accuracy: position.coords.accuracy,
|
||
heading: position.coords.heading,
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
|
||
setLocation(loc);
|
||
setCapturedImage(photo.path);
|
||
|
||
// Extract evidence
|
||
await extractEvidence(photo.path, loc);
|
||
},
|
||
(error) => {
|
||
Alert.alert('GPS Error', 'Could not get location');
|
||
setIsCapturing(false);
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 }
|
||
);
|
||
} catch (error) {
|
||
Alert.alert('Error', 'Failed to capture photo');
|
||
setIsCapturing(false);
|
||
}
|
||
};
|
||
|
||
// Extract evidence from image
|
||
const extractEvidence = async (imagePath, loc) => {
|
||
try {
|
||
// Create form data
|
||
const formData = new FormData();
|
||
formData.append('image', {
|
||
uri: imagePath,
|
||
type: 'image/jpeg',
|
||
name: 'observation.jpg',
|
||
});
|
||
formData.append('latitude', loc.latitude.toString());
|
||
formData.append('longitude', loc.longitude.toString());
|
||
formData.append('altitude', loc.altitude?.toString() || '0');
|
||
|
||
// Send to API
|
||
const response = await fetch(`${API_URL}/visual-geolocation/analyze`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${API_KEY}`,
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
body: formData,
|
||
});
|
||
|
||
const data = await response.json();
|
||
setEvidence(data);
|
||
setIsCapturing(false);
|
||
|
||
// Save to queue if offline
|
||
if (isOffline) {
|
||
await saveToQueue({
|
||
imagePath,
|
||
location: loc,
|
||
evidence: data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.log('Evidence extraction failed, saving to queue');
|
||
await saveToQueue({
|
||
imagePath,
|
||
location: loc,
|
||
evidence: null,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
setIsCapturing(false);
|
||
}
|
||
};
|
||
|
||
// Save to offline queue
|
||
const saveToQueue = async (observation) => {
|
||
const queue = await AsyncStorage.getItem('observationQueue');
|
||
const observations = queue ? JSON.parse(queue) : [];
|
||
observations.push(observation);
|
||
await AsyncStorage.setItem('observationQueue', JSON.stringify(observations));
|
||
setQueueCount(observations.length);
|
||
};
|
||
|
||
// Sync offline queue
|
||
const syncQueue = async () => {
|
||
const queue = await AsyncStorage.getItem('observationQueue');
|
||
if (!queue) return;
|
||
|
||
const observations = JSON.parse(queue);
|
||
let synced = 0;
|
||
|
||
for (const obs of observations) {
|
||
try {
|
||
// Re-send to API
|
||
const formData = new FormData();
|
||
formData.append('image', {
|
||
uri: obs.imagePath,
|
||
type: 'image/jpeg',
|
||
name: 'observation.jpg',
|
||
});
|
||
formData.append('latitude', obs.location.latitude.toString());
|
||
formData.append('longitude', obs.location.longitude.toString());
|
||
|
||
await fetch(`${API_URL}/visual-geolocation/analyze`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${API_KEY}`,
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
body: formData,
|
||
});
|
||
|
||
synced++;
|
||
} catch (error) {
|
||
console.log('Sync failed for observation');
|
||
}
|
||
}
|
||
|
||
// Clear synced observations
|
||
const remaining = observations.slice(synced);
|
||
await AsyncStorage.setItem('observationQueue', JSON.stringify(remaining));
|
||
setQueueCount(remaining.length);
|
||
|
||
Alert.alert('Sync Complete', `${synced} observations synced`);
|
||
};
|
||
|
||
// Submit observation
|
||
const submitObservation = async () => {
|
||
if (!capturedImage || !location) return;
|
||
|
||
try {
|
||
const response = await fetch(`${API_URL}/observations`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${API_KEY}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
image_url: capturedImage,
|
||
latitude: location.latitude,
|
||
longitude: location.longitude,
|
||
altitude: location.altitude,
|
||
evidence: evidence,
|
||
mission_id: activeMission?.id,
|
||
timestamp: new Date().toISOString(),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
Alert.alert('Success', 'Observation submitted!');
|
||
setCapturedImage(null);
|
||
setEvidence(null);
|
||
setLocation(null);
|
||
} else {
|
||
throw new Error('Submit failed');
|
||
}
|
||
} catch (error) {
|
||
Alert.alert('Error', 'Saved to queue for later sync');
|
||
await saveToQueue({
|
||
imagePath: capturedImage,
|
||
location,
|
||
evidence,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
};
|
||
|
||
// Render camera view
|
||
const renderCamera = () => {
|
||
if (device == null) return <ActivityIndicator size="large" />;
|
||
|
||
return (
|
||
<View style={styles.cameraContainer}>
|
||
<Camera
|
||
ref={camera}
|
||
style={styles.camera}
|
||
device={device}
|
||
isActive={true}
|
||
photo={true}
|
||
/>
|
||
|
||
{/* Overlay */}
|
||
<View style={styles.overlay}>
|
||
{/* GPS Status */}
|
||
<View style={styles.gpsBadge}>
|
||
<Text style={styles.gpsText}>
|
||
{location ? '📍 GPS Ready' : '📍 Getting GPS...'}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Mission Info */}
|
||
{activeMission && (
|
||
<View style={styles.missionBadge}>
|
||
<Text style={styles.missionText}>
|
||
🎯 {activeMission.title}
|
||
</Text>
|
||
<Text style={styles.missionReward}>
|
||
${activeMission.reward_usd}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Queue Status */}
|
||
{queueCount > 0 && (
|
||
<View style={styles.queueBadge}>
|
||
<Text style={styles.queueText}>
|
||
📤 {queueCount} in queue
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Capture Button */}
|
||
<TouchableOpacity
|
||
style={styles.captureButton}
|
||
onPress={capturePhoto}
|
||
disabled={isCapturing}
|
||
>
|
||
{isCapturing ? (
|
||
<ActivityIndicator size="large" color="#fff" />
|
||
) : (
|
||
<View style={styles.captureInner} />
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
// Render preview
|
||
const renderPreview = () => {
|
||
return (
|
||
<ScrollView style={styles.previewContainer}>
|
||
<Image source={{ uri: capturedImage }} style={styles.previewImage} />
|
||
|
||
{/* Location Info */}
|
||
{location && (
|
||
<View style={styles.infoCard}>
|
||
<Text style={styles.infoTitle}>📍 Location</Text>
|
||
<Text style={styles.infoText}>
|
||
Lat: {location.latitude.toFixed(6)}
|
||
</Text>
|
||
<Text style={styles.infoText}>
|
||
Lng: {location.longitude.toFixed(6)}
|
||
</Text>
|
||
<Text style={styles.infoText}>
|
||
Accuracy: ±{location.accuracy?.toFixed(0)}m
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Evidence */}
|
||
{evidence && (
|
||
<View style={styles.infoCard}>
|
||
<Text style={styles.infoTitle}>🔍 Evidence</Text>
|
||
<Text style={styles.infoText}>
|
||
Visual Objects: {evidence.evidence_summary?.visual_objects || 0}
|
||
</Text>
|
||
<Text style={styles.infoText}>
|
||
Text Detections: {evidence.evidence_summary?.text_detections || 0}
|
||
</Text>
|
||
<Text style={styles.infoText}>
|
||
Confidence: {(evidence.confidence_report?.overall * 100).toFixed(0)}%
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<View style={styles.actionButtons}>
|
||
<TouchableOpacity
|
||
style={styles.submitButton}
|
||
onPress={submitObservation}
|
||
>
|
||
<Text style={styles.submitText}>✓ Submit Observation</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={styles.retakeButton}
|
||
onPress={() => {
|
||
setCapturedImage(null);
|
||
setEvidence(null);
|
||
}}
|
||
>
|
||
<Text style={styles.retakeText}>↻ Retake</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</ScrollView>
|
||
);
|
||
};
|
||
|
||
// Render missions list
|
||
const renderMissions = () => {
|
||
return (
|
||
<ScrollView style={styles.missionsContainer}>
|
||
<Text style={styles.sectionTitle}>🎯 Available Missions</Text>
|
||
|
||
{missions.map((mission) => (
|
||
<TouchableOpacity
|
||
key={mission.id}
|
||
style={[
|
||
styles.missionCard,
|
||
activeMission?.id === mission.id && styles.activeMission,
|
||
]}
|
||
onPress={() => setActiveMission(mission)}
|
||
>
|
||
<Text style={styles.missionTitle}>{mission.title}</Text>
|
||
<Text style={styles.missionDescription}>
|
||
{mission.description}
|
||
</Text>
|
||
<View style={styles.missionFooter}>
|
||
<Text style={styles.missionReward}>
|
||
${mission.reward_usd}
|
||
</Text>
|
||
<Text style={styles.missionDeadline}>
|
||
{new Date(mission.deadline).toLocaleDateString()}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
))}
|
||
|
||
{missions.length === 0 && (
|
||
<Text style={styles.emptyText}>No missions available</Text>
|
||
)}
|
||
</ScrollView>
|
||
);
|
||
};
|
||
|
||
// Main render
|
||
return (
|
||
<SafeAreaView style={styles.container}>
|
||
<StatusBar barStyle="light-content" backgroundColor="#000" />
|
||
|
||
{/* Header */}
|
||
<View style={styles.header}>
|
||
<Text style={styles.headerTitle}>quiXzoom</Text>
|
||
<View style={styles.headerRight}>
|
||
{isOffline && (
|
||
<Text style={styles.offlineBadge}>OFFLINE</Text>
|
||
)}
|
||
{queueCount > 0 && (
|
||
<TouchableOpacity onPress={syncQueue}>
|
||
<Text style={styles.queueBadge}>
|
||
📤 {queueCount}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Content */}
|
||
{capturedImage ? renderPreview() : renderCamera()}
|
||
|
||
{/* Bottom Navigation */}
|
||
<View style={styles.bottomNav}>
|
||
<TouchableOpacity style={styles.navButton}>
|
||
<Text style={styles.navText}>📷 Capture</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={styles.navButton}>
|
||
<Text style={styles.navText}>🎯 Missions</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={styles.navButton}>
|
||
<Text style={styles.navText}>📊 Stats</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={styles.navButton}>
|
||
<Text style={styles.navText}>⚙️ Settings</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
// Styles
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: '#000',
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: 16,
|
||
backgroundColor: '#1a1a1a',
|
||
},
|
||
headerTitle: {
|
||
fontSize: 20,
|
||
fontWeight: 'bold',
|
||
color: '#fff',
|
||
},
|
||
headerRight: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
offlineBadge: {
|
||
color: '#ff9500',
|
||
fontSize: 12,
|
||
fontWeight: 'bold',
|
||
marginRight: 8,
|
||
},
|
||
cameraContainer: {
|
||
flex: 1,
|
||
position: 'relative',
|
||
},
|
||
camera: {
|
||
flex: 1,
|
||
},
|
||
overlay: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
padding: 16,
|
||
},
|
||
gpsBadge: {
|
||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||
padding: 8,
|
||
borderRadius: 8,
|
||
alignSelf: 'flex-start',
|
||
marginBottom: 8,
|
||
},
|
||
gpsText: {
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
},
|
||
missionBadge: {
|
||
backgroundColor: 'rgba(0,122,255,0.8)',
|
||
padding: 8,
|
||
borderRadius: 8,
|
||
alignSelf: 'flex-start',
|
||
marginBottom: 8,
|
||
},
|
||
missionText: {
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
fontWeight: 'bold',
|
||
},
|
||
missionReward: {
|
||
color: '#fff',
|
||
fontSize: 12,
|
||
},
|
||
queueBadge: {
|
||
backgroundColor: 'rgba(255,149,0,0.8)',
|
||
padding: 8,
|
||
borderRadius: 8,
|
||
alignSelf: 'flex-start',
|
||
},
|
||
queueText: {
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
},
|
||
captureButton: {
|
||
position: 'absolute',
|
||
bottom: 100,
|
||
alignSelf: 'center',
|
||
width: 80,
|
||
height: 80,
|
||
borderRadius: 40,
|
||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
captureInner: {
|
||
width: 60,
|
||
height: 60,
|
||
borderRadius: 30,
|
||
backgroundColor: '#fff',
|
||
},
|
||
previewContainer: {
|
||
flex: 1,
|
||
backgroundColor: '#000',
|
||
},
|
||
previewImage: {
|
||
width: '100%',
|
||
height: 300,
|
||
resizeMode: 'cover',
|
||
},
|
||
infoCard: {
|
||
backgroundColor: '#1a1a1a',
|
||
margin: 16,
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
},
|
||
infoTitle: {
|
||
color: '#fff',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
marginBottom: 8,
|
||
},
|
||
infoText: {
|
||
color: '#ccc',
|
||
fontSize: 14,
|
||
marginBottom: 4,
|
||
},
|
||
actionButtons: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
margin: 16,
|
||
},
|
||
submitButton: {
|
||
backgroundColor: '#34c759',
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
flex: 1,
|
||
marginRight: 8,
|
||
},
|
||
submitText: {
|
||
color: '#fff',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
textAlign: 'center',
|
||
},
|
||
retakeButton: {
|
||
backgroundColor: '#ff3b30',
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
flex: 1,
|
||
marginLeft: 8,
|
||
},
|
||
retakeText: {
|
||
color: '#fff',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
textAlign: 'center',
|
||
},
|
||
missionsContainer: {
|
||
flex: 1,
|
||
backgroundColor: '#000',
|
||
padding: 16,
|
||
},
|
||
sectionTitle: {
|
||
color: '#fff',
|
||
fontSize: 20,
|
||
fontWeight: 'bold',
|
||
marginBottom: 16,
|
||
},
|
||
missionCard: {
|
||
backgroundColor: '#1a1a1a',
|
||
padding: 16,
|
||
borderRadius: 12,
|
||
marginBottom: 12,
|
||
},
|
||
activeMission: {
|
||
borderColor: '#007aff',
|
||
borderWidth: 2,
|
||
},
|
||
missionTitle: {
|
||
color: '#fff',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
marginBottom: 4,
|
||
},
|
||
missionDescription: {
|
||
color: '#ccc',
|
||
fontSize: 14,
|
||
marginBottom: 8,
|
||
},
|
||
missionFooter: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
},
|
||
missionReward: {
|
||
color: '#34c759',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
},
|
||
missionDeadline: {
|
||
color: '#999',
|
||
fontSize: 14,
|
||
},
|
||
emptyText: {
|
||
color: '#666',
|
||
fontSize: 16,
|
||
textAlign: 'center',
|
||
marginTop: 32,
|
||
},
|
||
bottomNav: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
padding: 16,
|
||
backgroundColor: '#1a1a1a',
|
||
borderTopWidth: 1,
|
||
borderTopColor: '#333',
|
||
},
|
||
navButton: {
|
||
alignItems: 'center',
|
||
},
|
||
navText: {
|
||
color: '#fff',
|
||
fontSize: 12,
|
||
},
|
||
});
|