feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
@@ -0,0 +1,181 @@
/**
* API Client
* HTTP client for quiXzoom API with offline support
*/
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
// API Configuration
const API_BASE_URL = 'https://api.quixzoom.com/v1';
const API_TIMEOUT = 30000;
// Create axios instance
const client = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Request interceptor
client.interceptors.request.use(
async (config) => {
// Add auth token
const token = await AsyncStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Check network status
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
// Queue request for later
await queueRequest(config);
throw new Error('OFFLINE');
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.message === 'OFFLINE') {
return { data: { queued: true } };
}
// Handle 401 - Unauthorized
if (error.response?.status === 401) {
await AsyncStorage.removeItem('authToken');
// Trigger re-auth
}
return Promise.reject(error);
}
);
// Queue request for offline sync
async function queueRequest(config) {
const queue = await AsyncStorage.getItem('requestQueue');
const requests = queue ? JSON.parse(queue) : [];
requests.push({
id: Date.now().toString(),
url: config.url,
method: config.method,
data: config.data,
headers: config.headers,
timestamp: new Date().toISOString(),
});
await AsyncStorage.setItem('requestQueue', JSON.stringify(requests));
}
// Sync queued requests
export async function syncQueue() {
const queue = await AsyncStorage.getItem('requestQueue');
if (!queue) return { synced: 0, failed: 0 };
const requests = JSON.parse(queue);
let synced = 0;
let failed = 0;
for (const request of requests) {
try {
await client.request({
url: request.url,
method: request.method,
data: request.data,
headers: request.headers,
});
synced++;
} catch (error) {
failed++;
}
}
// Clear synced requests
await AsyncStorage.setItem('requestQueue', JSON.stringify([]));
return { synced, failed };
}
// Auth API
export const auth = {
register: (username, password, role = 'zoomer') =>
client.post('/auth/register', { username, password, role }),
login: (username, password) =>
client.post('/auth/login', { username, password }),
logout: () =>
client.post('/auth/logout'),
};
// Mission API
export const missions = {
list: (params = {}) =>
client.get('/missions', { params }),
get: (id) =>
client.get(`/missions/${id}`),
accept: (id) =>
client.post(`/missions/${id}/accept`),
complete: (id, data) =>
client.post(`/missions/${id}/complete`, data),
};
// Observation API
export const observations = {
list: (params = {}) =>
client.get('/observations', { params }),
create: (data) =>
client.post('/observations', data),
upload: (formData) =>
client.post('/observations/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
analyze: (imagePath, imageId) =>
client.post('/visual-geolocation/analyze', { image_path: imagePath, image_id: imageId }),
};
// Stats API
export const stats = {
get: () =>
client.get('/stats'),
earnings: () =>
client.get('/stats/earnings'),
leaderboard: () =>
client.get('/stats/leaderboard'),
};
// User API
export const user = {
profile: () =>
client.get('/user/profile'),
update: (data) =>
client.put('/user/profile', data),
settings: () =>
client.get('/user/settings'),
updateSettings: (data) =>
client.put('/user/settings', data),
};
export default client;
@@ -0,0 +1,308 @@
/**
* CameraView Component
* Advanced camera with real-time evidence overlay
*/
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
Dimensions,
} from 'react-native';
import { Camera, useCameraDevices } from 'react-native-vision-camera';
import Icon from 'react-native-vector-icons/MaterialIcons';
const { width, height } = Dimensions.get('window');
export default function CameraView({ onCapture, mission, evidence }) {
const [hasPermission, setHasPermission] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [flashMode, setFlashMode] = useState('auto');
const [zoom, setZoom] = useState(0);
const camera = useRef(null);
const devices = useCameraDevices();
const device = devices.back;
const pulseAnim = useRef(new Animated.Value(1)).current;
// Pulse animation for capture button
useEffect(() => {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}, []);
// Check permissions
useEffect(() => {
checkPermission();
}, []);
const checkPermission = async () => {
const status = await Camera.requestCameraPermission();
setHasPermission(status === 'authorized');
};
// Capture photo
const capturePhoto = async () => {
if (!camera.current || isCapturing) return;
setIsCapturing(true);
try {
const photo = await camera.current.takePhoto({
qualityPrioritization: 'quality',
flash: flashMode,
enableShutterSound: true,
});
onCapture(photo);
} catch (error) {
console.error('Capture failed:', error);
} finally {
setIsCapturing(false);
}
};
// Toggle flash
const toggleFlash = () => {
const modes = ['off', 'auto', 'on'];
const currentIndex = modes.indexOf(flashMode);
setFlashMode(modes[(currentIndex + 1) % modes.length]);
};
// Get flash icon
const getFlashIcon = () => {
switch (flashMode) {
case 'on': return 'flash-on';
case 'off': return 'flash-off';
default: return 'flash-auto';
}
};
if (!device) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading camera...</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Camera */}
<Camera
ref={camera}
style={styles.camera}
device={device}
isActive={true}
photo={true}
zoom={zoom}
/>
{/* Evidence Overlay */}
{evidence && (
<View style={styles.evidenceOverlay}>
<View style={styles.evidenceBadge}>
<Icon name="search" size={16} color="#fff" />
<Text style={styles.evidenceText}>
{evidence.visual_objects || 0} objects detected
</Text>
</View>
</View>
)}
{/* Mission Overlay */}
{mission && (
<View style={styles.missionOverlay}>
<View style={styles.missionCard}>
<Text style={styles.missionTitle}>{mission.title}</Text>
<Text style={styles.missionReward}>${mission.reward_usd}</Text>
</View>
</View>
)}
{/* Top Controls */}
<View style={styles.topControls}>
<TouchableOpacity onPress={toggleFlash} style={styles.controlButton}>
<Icon name={getFlashIcon()} size={24} color="#fff" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => setZoom(zoom === 0 ? 0.5 : 0)}
style={styles.controlButton}
>
<Icon name={zoom > 0 ? 'zoom-out' : 'zoom-in'} size={24} color="#fff" />
</TouchableOpacity>
</View>
{/* Bottom Controls */}
<View style={styles.bottomControls}>
{/* Capture Button */}
<Animated.View style={[styles.captureButton, { transform: [{ scale: pulseAnim }] }]}>
<TouchableOpacity
onPress={capturePhoto}
disabled={isCapturing}
style={styles.captureInner}
>
{isCapturing ? (
<Icon name="hourglass-empty" size={32} color="#fff" />
) : (
<View style={styles.captureCircle} />
)}
</TouchableOpacity>
</Animated.View>
</View>
{/* Grid Overlay */}
<View style={styles.gridOverlay} pointerEvents="none">
<View style={styles.gridLine} />
<View style={[styles.gridLine, styles.gridLineVertical]} />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
loadingText: {
color: '#fff',
fontSize: 16,
},
camera: {
flex: 1,
},
evidenceOverlay: {
position: 'absolute',
top: 100,
left: 16,
},
evidenceBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,122,255,0.8)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
evidenceText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
missionOverlay: {
position: 'absolute',
top: 100,
right: 16,
},
missionCard: {
backgroundColor: 'rgba(52,199,89,0.9)',
padding: 12,
borderRadius: 12,
minWidth: 120,
},
missionTitle: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
missionReward: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
marginTop: 4,
},
topControls: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
controlButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
bottomControls: {
position: 'absolute',
bottom: 100,
left: 0,
right: 0,
alignItems: 'center',
},
captureButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 70,
height: 70,
borderRadius: 35,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
captureCircle: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#fff',
},
gridOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
gridLine: {
position: 'absolute',
top: '33%',
left: 0,
right: 0,
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
},
gridLineVertical: {
top: 0,
left: '33%',
width: 1,
height: '100%',
},
});
@@ -0,0 +1,155 @@
/**
* EvidencePanel Component
* Displays extracted evidence from image analysis
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Animated,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
export default function EvidencePanel({ evidence, confidence }) {
if (!evidence) return null;
const fadeAnim = new Animated.Value(0);
React.useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}).start();
}, [evidence]);
const renderEvidenceLayer = (icon, title, items, color) => (
<View style={[styles.layerCard, { borderLeftColor: color }]}>
<View style={styles.layerHeader}>
<Icon name={icon} size={20} color={color} />
<Text style={[styles.layerTitle, { color }]}>{title}</Text>
<Text style={styles.layerCount}>{items?.length || 0}</Text>
</View>
{items && items.map((item, index) => (
<Text key={index} style={styles.layerItem}> {item}</Text>
))}
</View>
);
return (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<ScrollView style={styles.scrollView}>
{/* Confidence Header */}
<View style={styles.confidenceHeader}>
<Text style={styles.confidenceTitle}>Evidence Analysis</Text>
<View style={styles.confidenceBadge}>
<Text style={styles.confidenceText}>
{(confidence * 100).toFixed(0)}% confidence
</Text>
</View>
</View>
{/* Evidence Layers */}
{renderEvidenceLayer('camera-alt', 'Metadata',
evidence.metadata ? [
`GPS: ${evidence.metadata.latitude?.toFixed(6)}, ${evidence.metadata.longitude?.toFixed(6)}`,
`Accuracy: ±${evidence.metadata.accuracy?.toFixed(0)}m`,
`Device: ${evidence.metadata.device_model}`,
] : [], '#007AFF')}
{renderEvidenceLayer('visibility', 'Visual Objects',
evidence.visual_objects?.map(obj => `${obj.label} (${(obj.confidence * 100).toFixed(0)}%)`), '#34C759')}
{renderEvidenceLayer('category', 'Semantic Objects',
evidence.semantic_objects?.map(obj => `${obj.label} (${(obj.confidence * 100).toFixed(0)}%)`), '#5856D6')}
{renderEvidenceLayer('text-fields', 'Text Detections',
evidence.text_detections?.map(t => t.text), '#FF9500')}
{renderEvidenceLayer('straighten', 'Geometric Features',
evidence.geometric_features ? [
`Horizon: ${evidence.geometric_features.horizon_detected ? 'Yes' : 'No'}`,
`Vanishing points: ${evidence.geometric_features.vanishing_points || 0}`,
] : [], '#AF52DE')}
{renderEvidenceLayer('wb-sunny', 'Environmental',
evidence.environmental_signals ? [
`Time: ${evidence.environmental_signals.time_of_day}`,
`Lighting: ${evidence.environmental_signals.lighting_quality}`,
`Weather: ${evidence.environmental_signals.weather || 'Unknown'}`,
] : [], '#FF3B30')}
{renderEvidenceLayer('history', 'Temporal',
evidence.temporal_signals ? [
`Season: ${evidence.temporal_signals.season}`,
`Day: ${evidence.temporal_signals.day_of_week}`,
] : [], '#5AC8FA')}
</ScrollView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
scrollView: {
flex: 1,
padding: 16,
},
confidenceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
confidenceTitle: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
},
confidenceBadge: {
backgroundColor: 'rgba(0,122,255,0.3)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
confidenceText: {
color: '#007AFF',
fontSize: 14,
fontWeight: '600',
},
layerCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderLeftWidth: 4,
},
layerHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
layerTitle: {
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
flex: 1,
},
layerCount: {
fontSize: 14,
color: '#8E8E93',
fontWeight: '600',
},
layerItem: {
fontSize: 14,
color: '#8E8E93',
marginLeft: 28,
marginBottom: 4,
},
});
@@ -0,0 +1,260 @@
/**
* MissionCard Component
* Displays available missions with reward and progress
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
export default function MissionCard({ mission, onPress, isActive }) {
const scaleAnim = new Animated.Value(1);
const handlePressIn = () => {
Animated.spring(scaleAnim, {
toValue: 0.97,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
}).start();
};
// Calculate progress
const progress = mission.completed_observations / mission.total_observations;
const progressPercent = Math.round(progress * 100);
// Get category icon
const getCategoryIcon = (category) => {
const icons = {
infrastructure: 'construction',
building: 'business',
road: 'directions-car',
bridge: 'terrain',
environment: 'nature',
safety: 'security',
default: 'assignment',
};
return icons[category] || icons.default;
};
// Get difficulty color
const getDifficultyColor = (difficulty) => {
const colors = {
easy: '#34C759',
medium: '#FF9500',
hard: '#FF3B30',
};
return colors[difficulty] || colors.medium;
};
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<TouchableOpacity
style={[styles.card, isActive && styles.activeCard]}
onPress={() => onPress(mission)}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={0.9}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Icon
name={getCategoryIcon(mission.category)}
size={24}
color="#007AFF"
/>
</View>
<View style={styles.headerText}>
<Text style={styles.title} numberOfLines={1}>
{mission.title}
</Text>
<Text style={styles.category}>{mission.category}</Text>
</View>
<View style={[styles.difficulty, { backgroundColor: getDifficultyColor(mission.difficulty) }]}>
<Text style={styles.difficultyText}>{mission.difficulty}</Text>
</View>
</View>
{/* Description */}
<Text style={styles.description} numberOfLines={2}>
{mission.description}
</Text>
{/* Progress Bar */}
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progressPercent}%` }]} />
</View>
<Text style={styles.progressText}>{progressPercent}%</Text>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.footerItem}>
<Icon name="attach-money" size={16} color="#34C759" />
<Text style={styles.reward}>${mission.reward_usd}</Text>
</View>
<View style={styles.footerItem}>
<Icon name="location-on" size={16} color="#8E8E93" />
<Text style={styles.distance}>
{mission.distance ? `${mission.distance.toFixed(1)} km` : 'Remote'}
</Text>
</View>
<View style={styles.footerItem}>
<Icon name="schedule" size={16} color="#8E8E93" />
<Text style={styles.deadline}>
{new Date(mission.deadline).toLocaleDateString()}
</Text>
</View>
</View>
{/* Active Indicator */}
{isActive && (
<View style={styles.activeIndicator}>
<Icon name="check-circle" size={20} color="#34C759" />
<Text style={styles.activeText}>Active</Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: '#2C2C2E',
},
activeCard: {
borderColor: '#34C759',
borderWidth: 2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(0,122,255,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
headerText: {
flex: 1,
},
title: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
category: {
fontSize: 12,
color: '#8E8E93',
marginTop: 2,
textTransform: 'capitalize',
},
difficulty: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
difficultyText: {
color: '#fff',
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
},
description: {
fontSize: 14,
color: '#8E8E93',
marginBottom: 12,
lineHeight: 20,
},
progressContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
progressBar: {
flex: 1,
height: 6,
backgroundColor: '#2C2C2E',
borderRadius: 3,
marginRight: 8,
},
progressFill: {
height: '100%',
backgroundColor: '#34C759',
borderRadius: 3,
},
progressText: {
fontSize: 12,
color: '#8E8E93',
fontWeight: '600',
minWidth: 35,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: '#2C2C2E',
paddingTop: 12,
},
footerItem: {
flexDirection: 'row',
alignItems: 'center',
},
reward: {
fontSize: 14,
fontWeight: '700',
color: '#34C759',
marginLeft: 4,
},
distance: {
fontSize: 12,
color: '#8E8E93',
marginLeft: 4,
},
deadline: {
fontSize: 12,
color: '#8E8E93',
marginLeft: 4,
},
activeIndicator: {
position: 'absolute',
top: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(52,199,89,0.2)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
activeText: {
color: '#34C759',
fontSize: 10,
fontWeight: '700',
marginLeft: 4,
},
});
@@ -0,0 +1,368 @@
/**
* StatsView Component
* Zoomer statistics and earnings dashboard
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Animated,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
export default function StatsView({ stats }) {
const fadeAnim = new Animated.Value(0);
React.useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}).start();
}, []);
const renderStatCard = (icon, title, value, subtitle, color) => (
<View style={[styles.statCard, { borderLeftColor: color }]}>
<View style={styles.statIconContainer}>
<Icon name={icon} size={24} color={color} />
</View>
<View style={styles.statContent}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statTitle}>{title}</Text>
{subtitle && <Text style={styles.statSubtitle}>{subtitle}</Text>}
</View>
</View>
);
const renderLevelProgress = () => {
const levels = ['Supplementary', 'Active', 'Professional'];
const currentLevelIndex = levels.indexOf(stats.level);
const nextLevel = levels[currentLevelIndex + 1];
const progress = stats.level_progress || 0;
return (
<View style={styles.levelCard}>
<View style={styles.levelHeader}>
<Text style={styles.levelTitle}>Zoomer Level</Text>
<View style={[styles.levelBadge, { backgroundColor: getLevelColor(stats.level) }]}>
<Text style={styles.levelBadgeText}>{stats.level}</Text>
</View>
</View>
<View style={styles.levelProgressContainer}>
<View style={styles.levelProgressBar}>
<View style={[styles.levelProgressFill, { width: `${progress}%` }]} />
</View>
<Text style={styles.levelProgressText}>{progress}% to {nextLevel || 'Max'}</Text>
</View>
<View style={styles.levelRequirements}>
<Text style={styles.levelRequirementsTitle}>Requirements for {nextLevel}:</Text>
{getLevelRequirements(nextLevel).map((req, index) => (
<View key={index} style={styles.requirementRow}>
<Icon
name={req.completed ? 'check-circle' : 'radio-button-unchecked'}
size={16}
color={req.completed ? '#34C759' : '#8E8E93'}
/>
<Text style={[styles.requirementText, req.completed && styles.requirementCompleted]}>
{req.text}
</Text>
</View>
))}
</View>
</View>
);
};
const getLevelColor = (level) => {
const colors = {
'Supplementary': '#8E8E93',
'Active': '#007AFF',
'Professional': '#34C759',
};
return colors[level] || '#8E8E93';
};
const getLevelRequirements = (level) => {
const requirements = {
'Active': [
{ text: 'Complete 10 missions', completed: (stats.completed_missions || 0) >= 10 },
{ text: 'Earn $500 total', completed: (stats.total_earnings || 0) >= 500 },
{ text: '95% approval rate', completed: (stats.approval_rate || 0) >= 95 },
],
'Professional': [
{ text: 'Complete 50 missions', completed: (stats.completed_missions || 0) >= 50 },
{ text: 'Earn $5,000 total', completed: (stats.total_earnings || 0) >= 5000 },
{ text: '98% approval rate', completed: (stats.approval_rate || 0) >= 98 },
{ text: '5+ countries', completed: (stats.countries || 0) >= 5 },
],
};
return requirements[level] || [];
};
return (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<ScrollView style={styles.scrollView}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Your Stats</Text>
<Text style={styles.headerSubtitle}>Last 30 days</Text>
</View>
{/* Earnings Card */}
<View style={styles.earningsCard}>
<Text style={styles.earningsLabel}>Total Earnings</Text>
<Text style={styles.earningsValue}>${(stats.total_earnings || 0).toFixed(2)}</Text>
<View style={styles.earningsTrend}>
<Icon name="trending-up" size={16} color="#34C759" />
<Text style={styles.earningsTrendText}>+{(stats.earnings_growth || 0).toFixed(1)}% this month</Text>
</View>
</View>
{/* Stats Grid */}
<View style={styles.statsGrid}>
{renderStatCard('camera-alt', 'Observations', stats.total_observations || 0, `${stats.approved_observations || 0} approved`, '#007AFF')}
{renderStatCard('check-circle', 'Missions', stats.completed_missions || 0, `${stats.active_missions || 0} active`, '#34C759')}
{renderStatCard('star', 'Rating', `${stats.rating || 0}/5.0`, `${stats.reviews || 0} reviews`, '#FF9500')}
{renderStatCard('schedule', 'Response Time', `${stats.avg_response_time || 0}h`, 'Average', '#5856D6')}
</View>
{/* Level Progress */}
{renderLevelProgress()}
{/* Recent Activity */}
<View style={styles.activityCard}>
<Text style={styles.activityTitle}>Recent Activity</Text>
{(stats.recent_activity || []).map((activity, index) => (
<View key={index} style={styles.activityRow}>
<View style={[styles.activityIcon, { backgroundColor: activity.color || '#007AFF20' }]}>
<Icon name={activity.icon || 'circle'} size={16} color={activity.color || '#007AFF'} />
</View>
<View style={styles.activityContent}>
<Text style={styles.activityText}>{activity.text}</Text>
<Text style={styles.activityTime}>{activity.time}</Text>
</View>
{activity.amount && (
<Text style={[styles.activityAmount, { color: activity.amount > 0 ? '#34C759' : '#FF3B30' }]}>
{activity.amount > 0 ? '+' : ''}${Math.abs(activity.amount).toFixed(2)}
</Text>
)}
</View>
))}
</View>
</ScrollView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
scrollView: {
flex: 1,
padding: 16,
},
header: {
marginBottom: 20,
},
headerTitle: {
fontSize: 28,
fontWeight: '700',
color: '#fff',
},
headerSubtitle: {
fontSize: 14,
color: '#8E8E93',
marginTop: 4,
},
earningsCard: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 20,
marginBottom: 16,
alignItems: 'center',
},
earningsLabel: {
fontSize: 14,
color: '#8E8E93',
marginBottom: 8,
},
earningsValue: {
fontSize: 48,
fontWeight: '700',
color: '#34C759',
},
earningsTrend: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
earningsTrendText: {
fontSize: 14,
color: '#34C759',
marginLeft: 4,
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginBottom: 16,
},
statCard: {
width: '48%',
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderLeftWidth: 3,
flexDirection: 'row',
alignItems: 'center',
},
statIconContainer: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.05)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
statContent: {
flex: 1,
},
statValue: {
fontSize: 24,
fontWeight: '700',
color: '#fff',
},
statTitle: {
fontSize: 12,
color: '#8E8E93',
marginTop: 2,
},
statSubtitle: {
fontSize: 11,
color: '#34C759',
marginTop: 2,
},
levelCard: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 20,
marginBottom: 16,
},
levelHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
levelTitle: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
levelBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
},
levelBadgeText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
},
levelProgressContainer: {
marginBottom: 16,
},
levelProgressBar: {
height: 8,
backgroundColor: '#2C2C2E',
borderRadius: 4,
marginBottom: 8,
},
levelProgressFill: {
height: '100%',
backgroundColor: '#34C759',
borderRadius: 4,
},
levelProgressText: {
fontSize: 12,
color: '#8E8E93',
textAlign: 'right',
},
levelRequirements: {
borderTopWidth: 1,
borderTopColor: '#2C2C2E',
paddingTop: 12,
},
levelRequirementsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
marginBottom: 8,
},
requirementRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
requirementText: {
fontSize: 13,
color: '#8E8E93',
marginLeft: 8,
},
requirementCompleted: {
color: '#34C759',
textDecorationLine: 'line-through',
},
activityCard: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 20,
marginBottom: 16,
},
activityTitle: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
marginBottom: 12,
},
activityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#2C2C2E',
},
activityIcon: {
width: 32,
height: 32,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
activityContent: {
flex: 1,
},
activityText: {
fontSize: 14,
color: '#fff',
},
activityTime: {
fontSize: 12,
color: '#8E8E93',
marginTop: 2,
},
activityAmount: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,107 @@
/**
* useCamera Hook
* React hook for camera operations
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Camera, useCameraDevices } from 'react-native-vision-camera';
export function useCamera() {
const [hasPermission, setHasPermission] = useState(false);
const [isActive, setIsActive] = useState(true);
const [flashMode, setFlashMode] = useState('auto');
const [zoom, setZoom] = useState(0);
const [isCapturing, setIsCapturing] = useState(false);
const cameraRef = useRef(null);
const devices = useCameraDevices();
const device = devices.back;
// Check permission on mount
useEffect(() => {
checkPermission();
}, []);
const checkPermission = async () => {
const status = await Camera.requestCameraPermission();
setHasPermission(status === 'authorized');
};
/**
* Capture photo
*/
const capturePhoto = useCallback(async (options = {}) => {
if (!cameraRef.current || isCapturing) {
throw new Error('Camera not ready');
}
setIsCapturing(true);
try {
const photo = await cameraRef.current.takePhoto({
qualityPrioritization: options.quality || 'quality',
flash: options.flash || flashMode,
enableShutterSound: options.shutterSound !== false,
});
return {
path: photo.path,
width: photo.width,
height: photo.height,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('Capture failed:', error);
throw error;
} finally {
setIsCapturing(false);
}
}, [flashMode, isCapturing]);
/**
* Toggle flash mode
*/
const toggleFlash = useCallback(() => {
setFlashMode(prev => {
const modes = ['off', 'auto', 'on'];
const currentIndex = modes.indexOf(prev);
return modes[(currentIndex + 1) % modes.length];
});
}, []);
/**
* Set zoom level
*/
const setZoomLevel = useCallback((level) => {
setZoom(Math.max(0, Math.min(1, level)));
}, []);
/**
* Focus on point
*/
const focus = useCallback(async (point) => {
if (cameraRef.current) {
await cameraRef.current.focus(point);
}
}, []);
return {
// Refs
cameraRef,
// State
hasPermission,
isActive,
flashMode,
zoom,
isCapturing,
device,
// Actions
capturePhoto,
toggleFlash,
setZoomLevel,
focus,
setIsActive,
};
}
@@ -0,0 +1,147 @@
/**
* useLocation Hook
* React hook for GPS location tracking
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import Geolocation from '@react-native-community/geolocation';
export function useLocation(options = {}) {
const [location, setLocation] = useState(null);
const [hasPermission, setHasPermission] = useState(false);
const [isTracking, setIsTracking] = useState(false);
const [error, setError] = useState(null);
const watchIdRef = useRef(null);
const defaultOptions = {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 10000,
distanceFilter: 10, // meters
...options,
};
/**
* Request location permission
*/
const requestPermission = useCallback(async () => {
try {
const result = await Geolocation.requestAuthorization();
setHasPermission(result === 'granted');
return result === 'granted';
} catch (err) {
setError(err.message);
return false;
}
}, []);
/**
* Get current location (one-time)
*/
const getCurrentLocation = useCallback(() => {
return new Promise((resolve, reject) => {
Geolocation.getCurrentPosition(
(position) => {
const loc = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
accuracy: position.coords.accuracy,
heading: position.coords.heading,
speed: position.coords.speed,
timestamp: new Date(position.timestamp).toISOString(),
};
setLocation(loc);
setError(null);
resolve(loc);
},
(err) => {
setError(err.message);
reject(err);
},
defaultOptions
);
});
}, [defaultOptions]);
/**
* Start continuous location tracking
*/
const startTracking = useCallback(() => {
if (watchIdRef.current) {
return; // Already tracking
}
setIsTracking(true);
watchIdRef.current = Geolocation.watchPosition(
(position) => {
const loc = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
accuracy: position.coords.accuracy,
heading: position.coords.heading,
speed: position.coords.speed,
timestamp: new Date(position.timestamp).toISOString(),
};
setLocation(loc);
setError(null);
},
(err) => {
setError(err.message);
},
{
...defaultOptions,
distanceFilter: defaultOptions.distanceFilter,
}
);
}, [defaultOptions]);
/**
* Stop location tracking
*/
const stopTracking = useCallback(() => {
if (watchIdRef.current) {
Geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
setIsTracking(false);
}, []);
/**
* Toggle tracking
*/
const toggleTracking = useCallback(() => {
if (isTracking) {
stopTracking();
} else {
startTracking();
}
}, [isTracking, startTracking, stopTracking]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (watchIdRef.current) {
Geolocation.clearWatch(watchIdRef.current);
}
};
}, []);
return {
// State
location,
hasPermission,
isTracking,
error,
// Actions
requestPermission,
getCurrentLocation,
startTracking,
stopTracking,
toggleTracking,
};
}
@@ -0,0 +1,175 @@
/"/**
* useMissions Hook
* React hook for mission management
*/
import { useState, useEffect, useCallback } from 'react';
// Mock data for development
const MOCK_MISSIONS = [
{
id: 'M001',
title: 'Street Light Inspection',
description: 'Document condition of street lights in downtown area',
category: 'infrastructure',
difficulty: 'easy',
reward_usd: 25,
total_observations: 10,
completed_observations: 3,
distance: 1.2,
deadline: '2026-07-01',
status: 'active',
coordinates: { lat: 59.3293, lng: 18.0686 },
},
{
id: 'M002',
title: 'Bridge Safety Check',
description: 'Visual inspection of pedestrian bridge for defects',
category: 'bridge',
difficulty: 'medium',
reward_usd: 75,
total_observations: 5,
completed_observations: 0,
distance: 3.5,
deadline: '2026-07-05',
status: 'active',
coordinates: { lat: 59.3301, lng: 18.0692 },
},
{
id: 'M003',
title: 'Building Facade Survey',
description: 'Photograph and assess building facade condition',
category: 'building',
difficulty: 'hard',
reward_usd: 150,
total_observations: 20,
completed_observations: 8,
distance: 0.8,
deadline: '2026-07-10',
status: 'active',
coordinates: { lat: 59.3289, lng: 18.0678 },
},
];
export function useMissions(apiClient) {
const [missions, setMissions] = useState([]);
const [activeMission, setActiveMission] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
/**
* Load missions from API
*/
const loadMissions = useCallback(async () => {
setLoading(true);
setError(null);
try {
// In production: const data = await apiClient.missions.list();
// For now, use mock data
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate API call
setMissions(MOCK_MISSIONS);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiClient]);
/**
* Accept a mission
*/
const acceptMission = useCallback(async (missionId) => {
try {
// In production: await apiClient.missions.accept(missionId);
setMissions(prev =>
prev.map(m =>
m.id === missionId ? { ...m, status: 'accepted' } : m
)
);
return true;
} catch (err) {
setError(err.message);
return false;
}
}, [apiClient]);
/**
* Complete a mission
*/
const completeMission = useCallback(async (missionId, observations) => {
try {
// In production: await apiClient.missions.complete(missionId, { observations });
setMissions(prev =>
prev.map(m =>
m.id === missionId
? { ...m, status: 'completed', completed_observations: m.total_observations }
: m
)
);
return true;
} catch (err) {
setError(err.message);
return false;
}
}, [apiClient]);
/**
* Select active mission
*/
const selectMission = useCallback((mission) => {
setActiveMission(prev => (prev?.id === mission?.id ? null : mission));
}, []);
/**
* Get missions by status
*/
const getMissionsByStatus = useCallback((status) => {
return missions.filter(m => m.status === status);
}, [missions]);
/**
* Get nearby missions
*/
const getNearbyMissions = useCallback((lat, lng, radius = 10) => {
// Simple distance calculation (km)
const distance = (lat1, lng1, lat2, lng2) => {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
return missions
.map(m => ({
...m,
calculatedDistance: distance(lat, lng, m.coordinates.lat, m.coordinates.lng),
}))
.filter(m => m.calculatedDistance <= radius)
.sort((a, b) => a.calculatedDistance - b.calculatedDistance);
}, [missions]);
// Load missions on mount
useEffect(() => {
loadMissions();
}, [loadMissions]);
return {
// State
missions,
activeMission,
loading,
error,
// Actions
loadMissions,
acceptMission,
completeMission,
selectMission,
getMissionsByStatus,
getNearbyMissions,
};
}
@@ -0,0 +1,90 @@
/**
* AppNavigator
* Root navigation with tab and stack navigators
*/
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createStackNavigator } from '@react-navigation/stack';
import Icon from 'react-native-vector-icons/MaterialIcons';
// Screens
import HomeScreen from '../screens/HomeScreen';
import StatsScreen from '../screens/StatsScreen';
import ProfileScreen from '../screens/ProfileScreen';
const Tab = createBottomTabNavigator();
const Stack = createStackNavigator();
// Home Stack
function HomeStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="HomeMain" component={HomeScreen} />
</Stack.Navigator>
);
}
// Main Tab Navigator
function MainTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarStyle: {
backgroundColor: '#1C1C1E',
borderTopColor: '#2C2C2E',
paddingBottom: 8,
paddingTop: 8,
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarIcon: ({ focused, color, size }) => {
let iconName;
switch (route.name) {
case 'Home':
iconName = 'camera-alt';
break;
case 'Stats':
iconName = 'bar-chart';
break;
case 'Profile':
iconName = 'person';
break;
default:
iconName = 'circle';
}
return <Icon name={iconName} size={size} color={color} />;
},
})}
>
<Tab.Screen
name="Home"
component={HomeStack}
options={{ tabBarLabel: 'Capture' }}
/>
<Tab.Screen
name="Stats"
component={StatsScreen}
options={{ tabBarLabel: 'Stats' }}
/>
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{ tabBarLabel: 'Profile' }}
/>
</Tab.Navigator>
);
}
// Root Navigator
export default function AppNavigator() {
return (
<NavigationContainer>
<MainTabs />
</NavigationContainer>
);
}
@@ -0,0 +1,287 @@
/**
* HomeScreen
* Main screen with camera, missions, and quick actions
*/
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
RefreshControl,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import CameraView from '../components/CameraView';
import MissionCard from '../components/MissionCard';
import EvidencePanel from '../components/EvidencePanel';
export default function HomeScreen({ navigation }) {
const [activeTab, setActiveTab] = useState('camera'); // camera | missions | evidence
const [missions, setMissions] = useState([]);
const [activeMission, setActiveMission] = useState(null);
const [capturedImage, setCapturedImage] = useState(null);
const [evidence, setEvidence] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Load missions
useEffect(() => {
loadMissions();
}, []);
const loadMissions = async () => {
// Mock data - replace with API call
setMissions([
{
id: 'M001',
title: 'Street Light Inspection',
description: 'Document condition of street lights in downtown area',
category: 'infrastructure',
difficulty: 'easy',
reward_usd: 25,
total_observations: 10,
completed_observations: 3,
distance: 1.2,
deadline: '2026-07-01',
},
{
id: 'M002',
title: 'Bridge Safety Check',
description: 'Visual inspection of pedestrian bridge for defects',
category: 'bridge',
difficulty: 'medium',
reward_usd: 75,
total_observations: 5,
completed_observations: 0,
distance: 3.5,
deadline: '2026-07-05',
},
{
id: 'M003',
title: 'Building Facade Survey',
description: 'Photograph and assess building facade condition',
category: 'building',
difficulty: 'hard',
reward_usd: 150,
total_observations: 20,
completed_observations: 8,
distance: 0.8,
deadline: '2026-07-10',
},
]);
};
// Handle photo capture
const handleCapture = (photo) => {
setCapturedImage(photo);
setActiveTab('evidence');
// Mock evidence extraction
setEvidence({
metadata: {
latitude: 59.3293,
longitude: 18.0686,
accuracy: 5,
device_model: 'iPhone14,2',
},
visual_objects: [
{ label: 'street_light', confidence: 0.95 },
{ label: 'road', confidence: 0.88 },
],
semantic_objects: [
{ label: 'urban_street', confidence: 0.92 },
],
text_detections: [],
geometric_features: {
horizon_detected: true,
vanishing_points: 2,
},
environmental_signals: {
time_of_day: 'day',
lighting_quality: 'good',
weather: 'clear',
},
temporal_signals: {
season: 'summer',
day_of_week: 'Friday',
},
});
};
// Handle mission selection
const handleMissionPress = (mission) => {
setActiveMission(activeMission?.id === mission.id ? null : mission);
};
// Handle refresh
const handleRefresh = async () => {
setIsRefreshing(true);
await loadMissions();
setIsRefreshing(false);
};
// Render camera tab
const renderCamera = () => (
<CameraView
onCapture={handleCapture}
mission={activeMission}
evidence={evidence}
/>
);
// Render missions tab
const renderMissions = () => (
<ScrollView
style={styles.missionsContainer}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
}
>
<Text style={styles.sectionTitle}>Available Missions</Text>
{missions.map((mission) => (
<MissionCard
key={mission.id}
mission={mission}
onPress={handleMissionPress}
isActive={activeMission?.id === mission.id}
/>
))}
</ScrollView>
);
// Render evidence tab
const renderEvidence = () => (
<EvidencePanel
evidence={evidence}
confidence={0.85}
/>
);
return (
<View style={styles.container}>
{/* Content */}
<View style={styles.content}>
{activeTab === 'camera' && renderCamera()}
{activeTab === 'missions' && renderMissions()}
{activeTab === 'evidence' && renderEvidence()}
</View>
{/* Bottom Navigation */}
<View style={styles.bottomNav}>
<TouchableOpacity
style={[styles.navButton, activeTab === 'camera' && styles.navButtonActive]}
onPress={() => setActiveTab('camera')}
>
<Icon name="camera-alt" size={24} color={activeTab === 'camera' ? '#007AFF' : '#8E8E93'} />
<Text style={[styles.navText, activeTab === 'camera' && styles.navTextActive]}>Capture</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.navButton, activeTab === 'missions' && styles.navButtonActive]}
onPress={() => setActiveTab('missions')}
>
<Icon name="assignment" size={24} color={activeTab === 'missions' ? '#007AFF' : '#8E8E93'} />
<Text style={[styles.navText, activeTab === 'missions' && styles.navTextActive]}>Missions</Text>
{missions.length > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{missions.length}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.navButton, activeTab === 'evidence' && styles.navButtonActive]}
onPress={() => setActiveTab('evidence')}
>
<Icon name="search" size={24} color={activeTab === 'evidence' ? '#007AFF' : '#8E8E93'} />
<Text style={[styles.navText, activeTab === 'evidence' && styles.navTextActive]}>Evidence</Text>
{evidence && (
<View style={[styles.badge, styles.badgeActive]}>
<Text style={styles.badgeText}>1</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.navButton}
onPress={() => navigation.navigate('Stats')}
>
<Icon name="bar-chart" size={24} color="#8E8E93" />
<Text style={styles.navText}>Stats</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.navButton}
onPress={() => navigation.navigate('Profile')}
>
<Icon name="person" size={24} color="#8E8E93" />
<Text style={styles.navText}>Profile</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
content: {
flex: 1,
},
missionsContainer: {
flex: 1,
padding: 16,
},
sectionTitle: {
fontSize: 24,
fontWeight: '700',
color: '#fff',
marginBottom: 16,
},
bottomNav: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: 12,
backgroundColor: '#1C1C1E',
borderTopWidth: 1,
borderTopColor: '#2C2C2E',
},
navButton: {
alignItems: 'center',
position: 'relative',
},
navButtonActive: {
// Active state styling
},
navText: {
fontSize: 10,
color: '#8E8E93',
marginTop: 4,
},
navTextActive: {
color: '#007AFF',
},
badge: {
position: 'absolute',
top: -4,
right: -8,
backgroundColor: '#FF3B30',
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: 'center',
alignItems: 'center',
},
badgeActive: {
backgroundColor: '#34C759',
},
badgeText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
paddingHorizontal: 4,
},
});
@@ -0,0 +1,250 @@
/**
* ProfileScreen
* User profile, settings, and account management
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Switch,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
export default function ProfileScreen({ navigation }) {
const [notifications, setNotifications] = React.useState(true);
const [offlineMode, setOfflineMode] = React.useState(false);
const [autoSync, setAutoSync] = React.useState(true);
const renderSettingItem = (icon, title, subtitle, value, onValueChange) => (
<View style={styles.settingItem}>
<View style={styles.settingIcon}>
<Icon name={icon} size={20} color="#007AFF" />
</View>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>{title}</Text>
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
</View>
{value !== undefined && (
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: '#2C2C2E', true: '#34C759' }}
thumbColor="#fff"
/>
)}
</View>
);
const renderActionItem = (icon, title, color = '#007AFF', onPress) => (
<TouchableOpacity style={styles.actionItem} onPress={onPress}>
<View style={[styles.actionIcon, { backgroundColor: `${color}20` }]}>
<Icon name={icon} size={20} color={color} />
</View>
<Text style={[styles.actionText, { color }]}>{title}</Text>
<Icon name="chevron-right" size={20} color="#8E8E93" />
</TouchableOpacity>
);
return (
<ScrollView style={styles.container}>
{/* Profile Header */}
<View style={styles.header}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>ES</Text>
</View>
<Text style={styles.name}>Erik Svensson</Text>
<Text style={styles.email}>erik@landvex.com</Text>
<View style={styles.levelBadge}>
<Text style={styles.levelText}>Active Zoomer</Text>
</View>
</View>
{/* Stats Summary */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>156</Text>
<Text style={styles.statLabel}>Observations</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<Text style={styles.statValue}>$1,250</Text>
<Text style={styles.statLabel}>Earned</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<Text style={styles.statValue}>4.8</Text>
<Text style={styles.statLabel}>Rating</Text>
</View>
</View>
{/* Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Settings</Text>
{renderSettingItem('notifications', 'Push Notifications', 'Mission alerts and updates', notifications, setNotifications)}
{renderSettingItem('cloud-off', 'Offline Mode', 'Work without internet', offlineMode, setOfflineMode)}
{renderSettingItem('sync', 'Auto Sync', 'Upload when connected', autoSync, setAutoSync)}
</View>
{/* Account */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
{renderActionItem('payment', 'Payment Methods', '#007AFF')}
{renderActionItem('history', 'Transaction History', '#007AFF')}
{renderActionItem('verified-user', 'Verification Status', '#34C759')}
{renderActionItem('help', 'Help & Support', '#007AFF')}
</View>
{/* Danger Zone */}
<View style={styles.section}>
{renderActionItem('logout', 'Sign Out', '#FF3B30')}
</View>
{/* Version */}
<Text style={styles.version}>quiXzoom v1.0.0</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
alignItems: 'center',
padding: 24,
backgroundColor: '#1C1C1E',
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
avatarText: {
fontSize: 28,
fontWeight: '700',
color: '#fff',
},
name: {
fontSize: 20,
fontWeight: '600',
color: '#fff',
},
email: {
fontSize: 14,
color: '#8E8E93',
marginTop: 4,
},
levelBadge: {
backgroundColor: 'rgba(0,122,255,0.2)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
marginTop: 8,
},
levelText: {
color: '#007AFF',
fontSize: 12,
fontWeight: '600',
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: 20,
backgroundColor: '#1C1C1E',
marginTop: 1,
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
},
statLabel: {
fontSize: 12,
color: '#8E8E93',
marginTop: 4,
},
statDivider: {
width: 1,
backgroundColor: '#2C2C2E',
},
section: {
marginTop: 16,
backgroundColor: '#1C1C1E',
borderRadius: 12,
marginHorizontal: 16,
overflow: 'hidden',
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
color: '#8E8E93',
textTransform: 'uppercase',
padding: 16,
paddingBottom: 8,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#2C2C2E',
},
settingIcon: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: 'rgba(0,122,255,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
settingContent: {
flex: 1,
},
settingTitle: {
fontSize: 16,
color: '#fff',
},
settingSubtitle: {
fontSize: 12,
color: '#8E8E93',
marginTop: 2,
},
actionItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#2C2C2E',
},
actionIcon: {
width: 32,
height: 32,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
actionText: {
flex: 1,
fontSize: 16,
},
version: {
textAlign: 'center',
color: '#8E8E93',
fontSize: 12,
padding: 24,
},
});
@@ -0,0 +1,70 @@
/**
* StatsScreen
* Zoomer statistics and earnings
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import StatsView from '../components/StatsView';
export default function StatsScreen() {
// Mock stats - replace with API call
const stats = {
total_earnings: 1250.00,
earnings_growth: 23.5,
total_observations: 156,
approved_observations: 148,
completed_missions: 12,
active_missions: 3,
rating: 4.8,
reviews: 24,
avg_response_time: 4.2,
level: 'Active',
level_progress: 65,
countries: 2,
approval_rate: 94.9,
recent_activity: [
{
text: 'Completed Street Light Inspection',
time: '2 hours ago',
amount: 25.00,
icon: 'check-circle',
color: '#34C759',
},
{
text: 'New mission available: Bridge Safety',
time: '5 hours ago',
amount: null,
icon: 'new-releases',
color: '#007AFF',
},
{
text: 'Observation approved',
time: '1 day ago',
amount: 15.00,
icon: 'thumb-up',
color: '#34C759',
},
{
text: 'Bonus: Early completion',
time: '2 days ago',
amount: 10.00,
icon: 'star',
color: '#FF9500',
},
],
};
return (
<View style={styles.container}>
<StatsView stats={stats} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
});
@@ -0,0 +1,177 @@
//**
* Redux Store
* Global state management for quiXzoom app
*/
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Auth slice
const authSlice = createSlice({
name: 'auth',
initialState: {
token: null,
user: null,
isAuthenticated: false,
},
reducers: {
setToken: (state, action) => {
state.token = action.payload;
state.isAuthenticated = !!action.payload;
},
setUser: (state, action) => {
state.user = action.payload;
},
logout: (state) => {
state.token = null;
state.user = null;
state.isAuthenticated = false;
},
},
});
// Mission slice
const missionSlice = createSlice({
name: 'missions',
initialState: {
list: [],
active: null,
loading: false,
error: null,
},
reducers: {
setMissions: (state, action) => {
state.list = action.payload;
},
setActiveMission: (state, action) => {
state.active = action.payload;
},
addMission: (state, action) => {
state.list.push(action.payload);
},
updateMission: (state, action) => {
const index = state.list.findIndex(m => m.id === action.payload.id);
if (index !== -1) {
state.list[index] = action.payload;
}
},
setLoading: (state, action) => {
state.loading = action.payload;
},
setError: (state, action) => {
state.error = action.payload;
},
},
});
// Observation slice
const observationSlice = createSlice({
name: 'observations',
initialState: {
queue: [],
uploaded: [],
current: null,
evidence: null,
},
reducers: {
addToQueue: (state, action) => {
state.queue.push(action.payload);
},
removeFromQueue: (state, action) => {
state.queue = state.queue.filter(o => o.id !== action.payload);
},
setCurrentObservation: (state, action) => {
state.current = action.payload;
},
setEvidence: (state, action) => {
state.evidence = action.payload;
},
markUploaded: (state, action) => {
state.uploaded.push(action.payload);
state.queue = state.queue.filter(o => o.id !== action.payload.id);
},
clearQueue: (state) => {
state.queue = [];
},
},
});
// Stats slice
const statsSlice = createSlice({
name: 'stats',
initialState: {
earnings: 0,
observations: 0,
missions: 0,
rating: 0,
level: 'Supplementary',
loading: false,
},
reducers: {
setStats: (state, action) => {
return { ...state, ...action.payload };
},
addEarnings: (state, action) => {
state.earnings += action.payload;
},
incrementObservations: (state) => {
state.observations += 1;
},
setLoading: (state, action) => {
state.loading = action.payload;
},
},
});
// Network slice
const networkSlice = createSlice({
name: 'network',
initialState: {
isConnected: true,
isInternetReachable: true,
},
reducers: {
setConnectionStatus: (state, action) => {
state.isConnected = action.payload.isConnected;
state.isInternetReachable = action.payload.isInternetReachable;
},
},
});
// Export actions
export const { setToken, setUser, logout } = authSlice.actions;
export const { setMissions, setActiveMission, addMission, updateMission } = missionSlice.actions;
export const { addToQueue, removeFromQueue, setCurrentObservation, setEvidence, markUploaded, clearQueue } = observationSlice.actions;
export const { setStats, addEarnings, incrementObservations } = statsSlice.actions;
export const { setConnectionStatus } = networkSlice.actions;
// Persist config
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['auth', 'missions', 'stats'],
};
// Root reducer
const rootReducer = {
auth: authSlice.reducer,
missions: missionSlice.reducer,
observations: observationSlice.reducer,
stats: statsSlice.reducer,
network: networkSlice.reducer,
};
// Store
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
});
// Persistor
export const persistor = persistStore(store);
@@ -0,0 +1,200 @@
/**
* Offline Manager
* Handle offline queue, sync, and storage
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
const QUEUE_KEY = '@quixzoom:observationQueue';
const SYNC_INTERVAL = 30000; // 30 seconds
/**
* Add observation to offline queue
*/
export async function queueObservation(observation) {
try {
const queue = await getQueue();
const queuedObservation = {
...observation,
id: `queued_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
queuedAt: new Date().toISOString(),
retryCount: 0,
};
queue.push(queuedObservation);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
return queuedObservation.id;
} catch (error) {
console.error('Failed to queue observation:', error);
throw error;
}
}
/**
* Get offline queue
*/
export async function getQueue() {
try {
const queueJson = await AsyncStorage.getItem(QUEUE_KEY);
return queueJson ? JSON.parse(queueJson) : [];
} catch (error) {
console.error('Failed to get queue:', error);
return [];
}
}
/**
* Remove observation from queue
*/
export async function removeFromQueue(observationId) {
try {
const queue = await getQueue();
const filtered = queue.filter(obs => obs.id !== observationId);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(filtered));
return filtered.length;
} catch (error) {
console.error('Failed to remove from queue:', error);
throw error;
}
}
/**
* Update observation in queue
*/
export async function updateInQueue(observationId, updates) {
try {
const queue = await getQueue();
const index = queue.findIndex(obs => obs.id === observationId);
if (index !== -1) {
queue[index] = { ...queue[index], ...updates };
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
} catch (error) {
console.error('Failed to update queue:', error);
}
}
/**
* Clear queue
*/
export async function clearQueue() {
try {
await AsyncStorage.removeItem(QUEUE_KEY);
} catch (error) {
console.error('Failed to clear queue:', error);
}
}
/**
* Get queue count
*/
export async function getQueueCount() {
const queue = await getQueue();
return queue.length;
}
/**
* Check if device is online
*/
export async function isOnline() {
const netInfo = await NetInfo.fetch();
return netInfo.isConnected && netInfo.isInternetReachable;
}
/**
* Sync queue with server
*/
export async function syncQueue(apiClient) {
try {
const online = await isOnline();
if (!online) {
console.log('Device is offline, skipping sync');
return { synced: 0, failed: 0 };
}
const queue = await getQueue();
if (queue.length === 0) {
return { synced: 0, failed: 0 };
}
console.log(`Syncing ${queue.length} observations...`);
let synced = 0;
let failed = 0;
for (const observation of queue) {
try {
// Attempt to upload
await apiClient.observations.create(observation);
await removeFromQueue(observation.id);
synced++;
} catch (error) {
console.error(`Failed to sync observation ${observation.id}:`, error);
// Increment retry count
const newRetryCount = (observation.retryCount || 0) + 1;
if (newRetryCount >= 5) {
// Max retries reached, remove from queue
await removeFromQueue(observation.id);
console.log(`Observation ${observation.id} removed after max retries`);
} else {
await updateInQueue(observation.id, { retryCount: newRetryCount });
}
failed++;
}
}
console.log(`Sync complete: ${synced} synced, ${failed} failed`);
return { synced, failed };
} catch (error) {
console.error('Sync failed:', error);
return { synced: 0, failed: 0, error: error.message };
}
}
/**
* Start automatic sync
*/
export function startAutoSync(apiClient, interval = SYNC_INTERVAL) {
console.log('Starting auto sync...');
// Initial sync
syncQueue(apiClient);
// Periodic sync
const syncInterval = setInterval(() => {
syncQueue(apiClient);
}, interval);
// Listen for network changes
const unsubscribe = NetInfo.addEventListener(state => {
if (state.isConnected && state.isInternetReachable) {
console.log('Network restored, triggering sync...');
syncQueue(apiClient);
}
});
// Return cleanup function
return () => {
clearInterval(syncInterval);
unsubscribe();
};
}
/**
* Get queue status
*/
export async function getQueueStatus() {
const queue = await getQueue();
const total = queue.length;
const pending = queue.filter(obs => (obs.retryCount || 0) === 0).length;
const retrying = queue.filter(obs => (obs.retryCount || 0) > 0).length;
return {
total,
pending,
retrying,
oldestObservation: total > 0 ? queue[0].queuedAt : null,
};
}
@@ -0,0 +1,128 @@
/**
* Permissions Utility
* Handle camera, location, and storage permissions
*/
import { Platform, PermissionsAndroid } from 'react-native';
import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions';
/**
* Request camera permission
*/
export async function requestCameraPermission() {
if (Platform.OS === 'ios') {
const result = await check(PERMISSIONS.IOS.CAMERA);
if (result === RESULTS.DENIED) {
return await request(PERMISSIONS.IOS.CAMERA);
}
return result;
} else {
const result = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.CAMERA,
{
title: 'Camera Permission',
message: 'quiXzoom needs camera access to capture observations.',
buttonPositive: 'Allow',
buttonNegative: 'Deny',
}
);
return result === PermissionsAndroid.RESULTS.GRANTED ? RESULTS.GRANTED : RESULTS.DENIED;
}
}
/**
* Request location permission
*/
export async function requestLocationPermission() {
if (Platform.OS === 'ios') {
const result = await check(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
if (result === RESULTS.DENIED) {
return await request(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
}
return result;
} else {
const result = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'Location Permission',
message: 'quiXzoom needs location access to tag observations with GPS.',
buttonPositive: 'Allow',
buttonNegative: 'Deny',
}
);
return result === PermissionsAndroid.RESULTS.GRANTED ? RESULTS.GRANTED : RESULTS.DENIED;
}
}
/**
* Request storage permission
*/
export async function requestStoragePermission() {
if (Platform.OS === 'ios') {
const result = await check(PERMISSIONS.IOS.PHOTO_LIBRARY);
if (result === RESULTS.DENIED) {
return await request(PERMISSIONS.IOS.PHOTO_LIBRARY);
}
return result;
} else {
const result = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
{
title: 'Storage Permission',
message: 'quiXzoom needs storage access to save observations.',
buttonPositive: 'Allow',
buttonNegative: 'Deny',
}
);
return result === PermissionsAndroid.RESULTS.GRANTED ? RESULTS.GRANTED : RESULTS.DENIED;
}
}
/**
* Request all required permissions
*/
export async function requestAllPermissions() {
const results = await Promise.all([
requestCameraPermission(),
requestLocationPermission(),
requestStoragePermission(),
]);
return {
camera: results[0],
location: results[1],
storage: results[2],
allGranted: results.every(r => r === RESULTS.GRANTED),
};
}
/**
* Check if all permissions are granted
*/
export async function checkPermissions() {
if (Platform.OS === 'ios') {
const [camera, location, storage] = await Promise.all([
check(PERMISSIONS.IOS.CAMERA),
check(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE),
check(PERMISSIONS.IOS.PHOTO_LIBRARY),
]);
return {
camera,
location,
storage,
allGranted: camera === RESULTS.GRANTED && location === RESULTS.GRANTED && storage === RESULTS.GRANTED,
};
} else {
const [camera, location, storage] = await Promise.all([
PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA),
PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION),
PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE),
]);
return {
camera: camera ? RESULTS.GRANTED : RESULTS.DENIED,
location: location ? RESULTS.GRANTED : RESULTS.DENIED,
storage: storage ? RESULTS.GRANTED : RESULTS.DENIED,
allGranted: camera && location && storage,
};
}
}