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,
|
|||
|
|
},
|
|||
|
|
});
|