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
-181
View File
@@ -1,181 +0,0 @@
/**
* 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;
@@ -1,308 +0,0 @@
/**
* 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%',
},
});
@@ -1,155 +0,0 @@
/**
* 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,
},
});
@@ -1,260 +0,0 @@
/**
* 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,
},
});
@@ -1,368 +0,0 @@
/**
* 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,210 @@
/**
* ActiveMissionMap.tsx — Mission map with live tracking, auto-follow, distance/heading overlay
* Wraps react-native-maps with live location integration
*/
import { useEffect, useRef, useCallback } from 'react'
import { View, Text, StyleSheet } from 'react-native'
import MapView, { Marker, Region, PROVIDER_DEFAULT } from 'react-native-maps'
import { useLocationStore } from '../../stores/locationStore'
import { UserLocationMarker } from './UserLocationMarker'
import { FollowUserButton } from './FollowUserButton'
import type { Mission } from '../../../lib/api'
interface ActiveMissionMapProps {
mission?: Mission
style?: object
}
export function ActiveMissionMap({ mission, style }: ActiveMissionMapProps) {
const mapRef = useRef<MapView>(null)
const {
currentPosition,
heading,
followUser,
setUserPannedMap,
} = useLocationStore()
// Auto-follow user when followUser is true
useEffect(() => {
if (followUser && currentPosition && mapRef.current) {
const region: Region = {
latitude: currentPosition.coords.latitude,
longitude: currentPosition.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}
mapRef.current.animateToRegion(region, 300)
}
}, [currentPosition, followUser])
const onPanDrag = useCallback(() => {
setUserPannedMap(true)
}, [setUserPannedMap])
// Calculate distance and bearing to mission
const getDistanceToMission = (): string => {
if (!currentPosition || !mission) return '—'
const R = 6371e3 // Earth radius in meters
const φ1 = (currentPosition.coords.latitude * Math.PI) / 180
const φ2 = (mission.latitude * Math.PI) / 180
const Δφ = ((mission.latitude - currentPosition.coords.latitude) * Math.PI) / 180
const Δλ = ((mission.longitude - currentPosition.coords.longitude) * Math.PI) / 180
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const distance = R * c
if (distance < 1000) {
return `${Math.round(distance)} m`
}
return `${(distance / 1000).toFixed(1)} km`
}
const getBearingToMission = (): number | null => {
if (!currentPosition || !mission) return null
const φ1 = (currentPosition.coords.latitude * Math.PI) / 180
const φ2 = (mission.latitude * Math.PI) / 180
const Δλ = ((mission.longitude - currentPosition.coords.longitude) * Math.PI) / 180
const y = Math.sin(Δλ) * Math.cos(φ2)
const x =
Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
const θ = Math.atan2(y, x)
const bearing = ((θ * 180) / Math.PI + 360) % 360
return bearing
}
const bearing = getBearingToMission()
const relativeBearing = bearing != null && heading != null
? ((bearing - heading + 360) % 360)
: null
return (
<View style={[styles.container, style]}>
<MapView
ref={mapRef}
style={styles.map}
provider={PROVIDER_DEFAULT}
showsUserLocation={false} // We use custom marker
showsMyLocationButton={false}
onPanDrag={onPanDrag}
rotateEnabled={false} // Map stays north-up, marker rotates
>
{/* Custom user location marker */}
{currentPosition && (
<Marker
coordinate={{
latitude: currentPosition.coords.latitude,
longitude: currentPosition.coords.longitude,
}}
anchor={{ x: 0.5, y: 0.5 }}
flat={true}
>
<UserLocationMarker />
</Marker>
)}
{/* Mission marker */}
{mission && (
<Marker
coordinate={{
latitude: mission.latitude,
longitude: mission.longitude,
}}
pinColor="#10b981"
title={mission.title}
/>
)}
</MapView>
{/* Distance / heading overlay */}
{mission && (
<View style={styles.overlay}>
<View style={styles.overlayRow}>
<Text style={styles.overlayLabel}>Avstånd</Text>
<Text style={styles.overlayValue}>{getDistanceToMission()}</Text>
</View>
{relativeBearing != null && (
<View style={styles.overlayRow}>
<Text style={styles.overlayLabel}>Riktning</Text>
<Text style={styles.overlayValue}>
{getDirectionArrow(relativeBearing)} {relativeBearing.toFixed(0)}°
</Text>
</View>
)}
{heading != null && (
<View style={styles.overlayRow}>
<Text style={styles.overlayLabel}>Din kurs</Text>
<Text style={styles.overlayValue}>{heading.toFixed(0)}°</Text>
</View>
)}
</View>
)}
{/* Follow user button */}
<View style={styles.followButtonContainer}>
<FollowUserButton />
</View>
</View>
)
}
function getDirectionArrow(degrees: number): string {
// Convert bearing to cardinal direction arrow
if (degrees >= 337.5 || degrees < 22.5) return '↑'
if (degrees >= 22.5 && degrees < 67.5) return '↗'
if (degrees >= 67.5 && degrees < 112.5) return '→'
if (degrees >= 112.5 && degrees < 157.5) return '↘'
if (degrees >= 157.5 && degrees < 202.5) return '↓'
if (degrees >= 202.5 && degrees < 247.5) return '↙'
if (degrees >= 247.5 && degrees < 292.5) return '←'
return '↖'
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
overlay: {
position: 'absolute',
top: 16,
left: 16,
backgroundColor: 'rgba(10,10,27,0.85)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
minWidth: 140,
},
overlayRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
overlayLabel: {
color: 'rgba(255,255,255,0.45)',
fontSize: 11,
marginRight: 12,
},
overlayValue: {
color: '#fff',
fontSize: 13,
fontWeight: '700',
},
followButtonContainer: {
position: 'absolute',
bottom: 100,
right: 16,
},
})
@@ -0,0 +1,61 @@
/**
* FollowUserButton.tsx — Toggle follow/nofollow, return to follow after pan
* State managed in locationStore
*/
import { TouchableOpacity, Text, StyleSheet } from 'react-native'
import { useLocationStore } from '../../stores/locationStore'
interface FollowUserButtonProps {
onPress?: () => void
}
export function FollowUserButton({ onPress }: FollowUserButtonProps) {
const { followUser, setFollowUser } = useLocationStore()
const handlePress = () => {
const newFollow = !followUser
setFollowUser(newFollow)
onPress?.()
}
return (
<TouchableOpacity
style={[styles.button, followUser && styles.buttonActive]}
onPress={handlePress}
activeOpacity={0.8}
>
<Text style={[styles.icon, followUser && styles.iconActive]}>
{followUser ? '📍' : '📌'}
</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
button: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(10,10,27,0.9)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 4,
},
buttonActive: {
backgroundColor: 'rgba(99,102,241,0.3)',
borderColor: '#6366f1',
},
icon: {
fontSize: 20,
opacity: 0.6,
},
iconActive: {
opacity: 1,
},
})
@@ -0,0 +1,145 @@
/**
* GpsDiagnostics.tsx — Developer overlay for GPS metrics
* Semi-transparent overlay, toggle via settings
*/
import { View, Text, StyleSheet } from 'react-native'
import { useLocationStore } from '../../stores/locationStore'
export function GpsDiagnostics() {
const {
currentPosition,
heading,
headingAccuracy,
accuracy,
speed,
isTracking,
isStale,
lastUpdateTimestamp,
updateIntervalMs,
locationAuthStatus,
} = useLocationStore()
const locationAge = lastUpdateTimestamp
? ((Date.now() - lastUpdateTimestamp) / 1000).toFixed(1)
: '—'
const speedKmh = speed != null ? (speed * 3.6).toFixed(1) : '—'
return (
<View style={styles.container}>
<View style={styles.panel}>
<Text style={styles.title}>🛰 GPS Diagnostics</Text>
<View style={styles.row}>
<Text style={styles.label}>Tracking</Text>
<Text style={[styles.value, isTracking ? styles.good : styles.bad]}>
{isTracking ? '● LIVE' : '○ OFF'}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Auth Status</Text>
<Text style={styles.value}>{locationAuthStatus ?? '—'}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>GPS Accuracy</Text>
<Text style={[styles.value, getAccuracyColor(accuracy)]}>
{accuracy != null ? `${accuracy.toFixed(1)} m` : '—'}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Heading</Text>
<Text style={styles.value}>
{heading != null ? `${heading.toFixed(1)}°` : '—'}
{headingAccuracy != null ? `${headingAccuracy.toFixed(0)}°)` : ''}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Speed</Text>
<Text style={styles.value}>
{speed != null ? `${speed.toFixed(1)} m/s (${speedKmh} km/h)` : '—'}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Update Interval</Text>
<Text style={styles.value}>
{updateIntervalMs != null ? `${updateIntervalMs} ms` : '—'}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Location Age</Text>
<Text style={[styles.value, isStale ? styles.bad : styles.good]}>
{locationAge}s {isStale ? '(STALE)' : ''}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Coords</Text>
<Text style={styles.value} numberOfLines={1}>
{currentPosition
? `${currentPosition.coords.latitude.toFixed(6)}, ${currentPosition.coords.longitude.toFixed(6)}`
: '—'}
</Text>
</View>
</View>
</View>
)
}
function getAccuracyColor(accuracy: number | null): object {
if (accuracy == null) return styles.neutral
if (accuracy <= 5) return styles.excellent
if (accuracy <= 10) return styles.good
if (accuracy <= 20) return styles.warning
return styles.bad
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
right: 8,
zIndex: 999,
},
panel: {
backgroundColor: 'rgba(0,0,0,0.75)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
minWidth: 220,
},
title: {
color: '#fff',
fontSize: 12,
fontWeight: '800',
marginBottom: 8,
letterSpacing: 0.5,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
label: {
color: 'rgba(255,255,255,0.45)',
fontSize: 10,
},
value: {
color: '#fff',
fontSize: 10,
fontWeight: '600',
maxWidth: 140,
},
excellent: { color: '#10b981' },
good: { color: '#34d399' },
warning: { color: '#f59e0b' },
bad: { color: '#ef4444' },
neutral: { color: 'rgba(255,255,255,0.5)' },
})
@@ -0,0 +1,154 @@
/**
* UserLocationMarker.tsx — Animated user location marker with heading rotation
* Includes accuracy circle (pulsing) and smooth heading animation
*/
import { useEffect, useRef } from 'react'
import { View, StyleSheet, Animated } from 'react-native'
import { useLocationStore } from '../../stores/locationStore'
export function UserLocationMarker() {
const { heading, accuracy } = useLocationStore()
const rotationAnim = useRef(new Animated.Value(0)).current
const pulseAnim = useRef(new Animated.Value(1)).current
// Smooth heading rotation
useEffect(() => {
if (heading != null) {
Animated.spring(rotationAnim, {
toValue: heading,
useNativeDriver: true,
friction: 8,
tension: 40,
}).start()
}
}, [heading, rotationAnim])
// Pulsing accuracy circle
useEffect(() => {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.3,
duration: 1500,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1500,
useNativeDriver: true,
}),
])
)
pulse.start()
return () => {
pulse.stop()
}
}, [pulseAnim])
const spin = rotationAnim.interpolate({
inputRange: [0, 360],
outputRange: ['0deg', '360deg'],
})
const accuracyRadius = accuracy ?? 10
return (
<View style={styles.container}>
{/* Accuracy circle (pulsing) */}
<Animated.View
style={[
styles.accuracyCircle,
{
width: accuracyRadius * 2,
height: accuracyRadius * 2,
borderRadius: accuracyRadius,
transform: [{ scale: pulseAnim }],
opacity: pulseAnim.interpolate({
inputRange: [1, 1.3],
outputRange: [0.3, 0.1],
}),
},
]}
/>
{/* Static accuracy ring */}
<View
style={[
styles.accuracyRing,
{
width: accuracyRadius * 2,
height: accuracyRadius * 2,
borderRadius: accuracyRadius,
},
]}
/>
{/* User dot */}
<View style={styles.dot}>
<View style={styles.dotInner} />
</View>
{/* Heading arrow */}
<Animated.View style={[styles.arrow, { transform: [{ rotate: spin }] }]}>
<View style={styles.arrowShape} />
</Animated.View>
</View>
)
}
const styles = StyleSheet.create({
container: {
width: 60,
height: 60,
alignItems: 'center',
justifyContent: 'center',
},
accuracyCircle: {
position: 'absolute',
backgroundColor: 'rgba(99,102,241,0.3)',
},
accuracyRing: {
position: 'absolute',
borderWidth: 1,
borderColor: 'rgba(99,102,241,0.4)',
backgroundColor: 'transparent',
},
dot: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#fff',
borderWidth: 3,
borderColor: '#6366f1',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
},
dotInner: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#6366f1',
},
arrow: {
position: 'absolute',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'flex-start',
zIndex: 1,
},
arrowShape: {
width: 0,
height: 0,
backgroundColor: 'transparent',
borderStyle: 'solid',
borderLeftWidth: 6,
borderRightWidth: 6,
borderBottomWidth: 14,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
borderBottomColor: 'rgba(99,102,241,0.85)',
marginTop: -4,
},
})
@@ -0,0 +1,7 @@
/**
* GPS Components barrel export
*/
export { GpsDiagnostics } from './GpsDiagnostics'
export { UserLocationMarker } from './UserLocationMarker'
export { FollowUserButton } from './FollowUserButton'
export { ActiveMissionMap } from './ActiveMissionMap'
@@ -0,0 +1,204 @@
/**
* AuthScreen — JWT-baserad login/signup (identity-core, ej Supabase)
*/
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ScrollView,
} from 'react-native'
import { auth } from '../../../lib/api'
interface Props {
onLoginSuccess: () => void
}
export function AuthScreen({ onLoginSuccess }: Props) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [loading, setLoading] = useState(false)
const [mode, setMode] = useState<'login' | 'signup'>('login')
const submit = async () => {
if (!email.trim() || !password) return
setLoading(true)
try {
if (mode === 'login') {
await auth.login(email.trim(), password)
} else {
if (password.length < 8) {
Alert.alert('Lösenord för kort', 'Minst 8 tecken krävs.')
return
}
await auth.register(email.trim(), password, displayName.trim() || undefined)
}
onLoginSuccess()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Okänt fel — försök igen.'
Alert.alert('Inloggning misslyckades', msg)
} finally {
setLoading(false)
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.inner}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo */}
<Text style={styles.logo}>quiXzoom</Text>
<Text style={styles.tagline}>Bli zoomer. Tjäna pengar. dina villkor.</Text>
{/* Mode toggle */}
<View style={styles.modeToggle}>
<TouchableOpacity
style={[styles.modeBtn, mode === 'login' && styles.modeBtnActive]}
onPress={() => setMode('login')}
>
<Text style={[styles.modeBtnText, mode === 'login' && styles.modeBtnTextActive]}>
Logga in
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeBtn, mode === 'signup' && styles.modeBtnActive]}
onPress={() => setMode('signup')}
>
<Text style={[styles.modeBtnText, mode === 'signup' && styles.modeBtnTextActive]}>
Bli zoomer
</Text>
</TouchableOpacity>
</View>
{/* Form */}
<View style={styles.form}>
{mode === 'signup' && (
<View style={styles.inputWrap}>
<Text style={styles.inputLabel}>Namn (valfritt)</Text>
<TextInput
style={styles.input}
placeholder="Vad ska folk kalla dig?"
placeholderTextColor="rgba(255,255,255,0.25)"
value={displayName}
onChangeText={setDisplayName}
autoCapitalize="words"
returnKeyType="next"
/>
</View>
)}
<View style={styles.inputWrap}>
<Text style={styles.inputLabel}>E-post</Text>
<TextInput
style={styles.input}
placeholder="din@epost.se"
placeholderTextColor="rgba(255,255,255,0.25)"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
returnKeyType="next"
/>
</View>
<View style={styles.inputWrap}>
<Text style={styles.inputLabel}>Lösenord</Text>
<TextInput
style={styles.input}
placeholder={mode === 'signup' ? 'Minst 8 tecken' : '••••••••'}
placeholderTextColor="rgba(255,255,255,0.25)"
value={password}
onChangeText={setPassword}
secureTextEntry
returnKeyType="done"
onSubmitEditing={submit}
/>
</View>
<TouchableOpacity
style={[styles.btn, loading && styles.btnDisabled]}
onPress={submit}
disabled={loading}
>
<Text style={styles.btnText}>
{loading ? '…' : mode === 'login' ? 'Logga in' : 'Skapa zoomer-konto'}
</Text>
</TouchableOpacity>
</View>
{/* Sverige launch note */}
{mode === 'signup' && (
<View style={styles.launchNote}>
<Text style={styles.launchNoteText}>
🇸🇪 quiXzoom lanserar i Stockholms skärgård juni 2026.
Registrera dig nu och var bland de första zoomerarna.
</Text>
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
inner: { flexGrow: 1, justifyContent: 'center', padding: 24, paddingBottom: 48 },
logo: {
fontSize: 40, fontWeight: '900', color: '#fff',
textAlign: 'center', marginBottom: 6, letterSpacing: -2,
},
tagline: {
color: 'rgba(255,255,255,0.35)',
textAlign: 'center', marginBottom: 40, fontSize: 14, lineHeight: 20,
},
modeToggle: {
flexDirection: 'row',
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 14, padding: 4,
marginBottom: 28,
},
modeBtn: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: 10 },
modeBtnActive: { backgroundColor: '#6366f1' },
modeBtnText: { color: 'rgba(255,255,255,0.4)', fontWeight: '600', fontSize: 14 },
modeBtnTextActive: { color: '#fff' },
form: { gap: 14 },
inputWrap: { gap: 6 },
inputLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 12, fontWeight: '600', letterSpacing: 0.5 },
input: {
backgroundColor: 'rgba(255,255,255,0.06)',
borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)',
borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14,
color: '#fff', fontSize: 16,
},
btn: {
backgroundColor: '#6366f1',
borderRadius: 14, paddingVertical: 16,
alignItems: 'center', marginTop: 4,
},
btnDisabled: { opacity: 0.6 },
btnText: { color: '#fff', fontWeight: '800', fontSize: 16 },
launchNote: {
marginTop: 24,
backgroundColor: 'rgba(99,102,241,0.1)',
borderRadius: 12, padding: 14,
borderWidth: 1, borderColor: 'rgba(99,102,241,0.25)',
},
launchNoteText: { color: 'rgba(255,255,255,0.55)', fontSize: 13, lineHeight: 20, textAlign: 'center' },
})
@@ -0,0 +1,259 @@
/**
* PasswordlessApprovalScreen — App visar godkännande för webb-inloggning
* Används när appen öppnas via deep link från webb
*/
import { useState, useEffect } from 'react'
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { auth } from '../../../lib/api'
interface Props {
sessionId: string
requestToken: string
onComplete: () => void
onCancel: () => void
}
export function PasswordlessApprovalScreen({
sessionId,
requestToken,
onComplete,
onCancel,
}: Props) {
const [loading, setLoading] = useState(false)
const [sessionInfo, setSessionInfo] = useState<{
deviceName?: string
location?: string
browser?: string
}>({})
useEffect(() => {
// Hämta session info om möjligt
fetchSessionInfo()
}, [])
const fetchSessionInfo = async () => {
try {
// TODO: Implementera endpoint för att hämta session info
// För nu visar vi generisk info
setSessionInfo({
deviceName: 'Webbbläsare',
location: 'Sverige',
browser: 'Chrome/Safari',
})
} catch (e) {
console.error('Kunde inte hämta session info:', e)
}
}
const handleApprove = async () => {
setLoading(true)
try {
// Skapa signatur (förenklad för MVP)
const timestamp = new Date().toISOString()
const signature = await createSignature(sessionId, requestToken, timestamp)
const res = await fetch('https://api.quixzoom.com/v1/auth/passwordless/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getToken()}`,
},
body: JSON.stringify({
session_id: sessionId,
request_token: requestToken,
signature,
timestamp,
approving_device_id: await getDeviceId(),
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || 'Kunde inte godkänna')
}
Alert.alert(
'✅ Inloggning godkänd',
'Du är nu inloggad på webben.',
[{ text: 'OK', onPress: onComplete }]
)
} catch (e: any) {
Alert.alert('Fel', e.message || 'Något gick fel')
} finally {
setLoading(false)
}
}
const handleReject = async () => {
try {
await fetch('https://api.quixzoom.com/v1/auth/passwordless/reject', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getToken()}`,
},
body: JSON.stringify({
session_id: sessionId,
request_token: requestToken,
device_id: await getDeviceId(),
}),
})
} catch (e) {
// Ignorera fel vid nekad
}
onCancel()
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.icon}>🔐</Text>
<Text style={styles.title}>Inloggningsförfrågan</Text>
<Text style={styles.subtitle}>
Försöker du logga in quixzoom.se?
</Text>
<View style={styles.infoBox}>
<Text style={styles.infoText}>📱 Enhet: {sessionInfo.deviceName || 'Okänd'}</Text>
<Text style={styles.infoText}>🌐 Webbläsare: {sessionInfo.browser || 'Okänd'}</Text>
<Text style={styles.infoText}>📍 Plats: {sessionInfo.location || 'Okänd'}</Text>
</View>
<Text style={styles.warning}>
Godkänn endast om du själv försöker logga in.
</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, styles.rejectButton]}
onPress={handleReject}
disabled={loading}
>
<Text style={styles.rejectButtonText}>Nej, neka</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.approveButton]}
onPress={handleApprove}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.approveButtonText}>Ja, godkänn</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
)
}
// Hjälpfunktioner
async function getToken(): Promise<string | null> {
// Använd samma token-hantering som i api.ts
const { getToken } = await import('../../../lib/api')
return getToken()
}
async function getDeviceId(): Promise<string> {
// TODO: Implementera enhets-ID-hantering
return 'device_unknown'
}
async function createSignature(
sessionId: string,
requestToken: string,
timestamp: string
): Promise<string> {
// TODO: Implementera riktig kryptografisk signatur
// För MVP använder vi en enkel hash
const data = `${sessionId}:${requestToken}:${timestamp}`
// I produktion: HMAC-SHA256 med enhets-privat-nyckel
return btoa(data)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0A0A1B',
justifyContent: 'center',
padding: 24,
},
card: {
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 16,
padding: 32,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
icon: {
fontSize: 48,
textAlign: 'center',
marginBottom: 16,
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#fff',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: 'rgba(255,255,255,0.7)',
textAlign: 'center',
marginBottom: 24,
},
infoBox: {
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
infoText: {
fontSize: 14,
color: 'rgba(255,255,255,0.6)',
marginBottom: 8,
},
warning: {
fontSize: 13,
color: 'rgba(255,200,100,0.8)',
textAlign: 'center',
marginBottom: 24,
fontStyle: 'italic',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
padding: 16,
borderRadius: 12,
alignItems: 'center',
},
rejectButton: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
rejectButtonText: {
color: 'rgba(255,255,255,0.8)',
fontSize: 16,
fontWeight: '600',
},
approveButton: {
backgroundColor: '#0066FF',
},
approveButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
})
@@ -0,0 +1,7 @@
/**
* Auth features — exports all auth-related components and hooks
*/
export { AuthScreen } from './AuthScreen'
export { PasswordlessApprovalScreen } from './PasswordlessApprovalScreen'
export { usePasswordlessAuth } from './usePasswordlessAuth'
export type { AuthRequest } from './usePasswordlessAuth'
@@ -0,0 +1,178 @@
/**
* usePasswordlessAuth — hanterar push-notifieringar och deep links
* för passwordless cross-device authentication
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { Platform } from 'react-native'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import type { Subscription } from 'expo-notifications'
import type { EventType } from 'expo-linking'
import { passwordless } from '../../../lib/api'
export interface AuthRequest {
requestToken: string
deviceInfo?: {
name?: string
browser?: string
type?: string
}
}
export function usePasswordlessAuth(
onRequest: (request: AuthRequest) => void,
onApproved: () => void,
onDenied: () => void,
) {
const [pushToken, setPushToken] = useState<string | null>(null)
const notificationListener = useRef<Subscription | null>(null)
const responseListener = useRef<Subscription | null>(null)
const urlListener = useRef<{ remove: () => void } | null>(null)
// Register for push notifications
useEffect(() => {
registerForPushNotifications()
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current)
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current)
}
if (urlListener.current) {
urlListener.current.remove()
}
}
}, [])
// Listen for incoming notifications
useEffect(() => {
// Foreground notification received
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification: Notifications.Notification) => {
handleNotification(notification.request.content.data as Record<string, unknown>)
},
)
// Notification tapped
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response: Notifications.NotificationResponse) => {
handleNotification(response.notification.request.content.data as Record<string, unknown>)
},
)
// Deep links
urlListener.current = Linking.addEventListener('url', (event: EventType) => {
handleDeepLink(event.url)
})
// Check initial URL (app opened via deep link)
Linking.getInitialURL().then((url: string | null) => {
if (url) handleDeepLink(url)
})
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current)
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current)
}
if (urlListener.current) {
urlListener.current.remove()
}
}
}, [onRequest, onApproved, onDenied])
const handleNotification = useCallback(
(data: Record<string, unknown> | undefined) => {
if (!data) return
if (data.type === 'passwordless_auth') {
const requestToken = data.request_token as string
const deviceInfo = data.device_info as AuthRequest['deviceInfo'] | undefined
if (requestToken) {
onRequest({ requestToken, deviceInfo })
}
}
if (data.type === 'passwordless_approved') {
onApproved()
}
if (data.type === 'passwordless_denied') {
onDenied()
}
},
[onRequest, onApproved, onDenied],
)
const handleDeepLink = useCallback(
(url: string) => {
const parsed = Linking.parse(url)
if (parsed.scheme === 'quixzoom' && parsed.path === 'auth/approve') {
const requestToken = parsed.queryParams?.token as string | undefined
const deviceName = parsed.queryParams?.device as string | undefined
if (requestToken) {
onRequest({
requestToken,
deviceInfo: {
name: deviceName || 'quixzoom.se',
type: 'web',
},
})
}
}
},
[onRequest],
)
const registerForPushNotifications = async () => {
try {
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
console.log('[usePasswordlessAuth] Push permission not granted')
return
}
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'quixzoom',
})
setPushToken(token.data)
// Register push token with backend
await registerPushToken(token.data)
} catch (error) {
console.error('[usePasswordlessAuth] Failed to register push:', error)
}
}
const registerPushToken = async (token: string) => {
try {
await fetch('https://api.quixzoom.com/auth/push-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
platform: Platform.OS,
}),
})
} catch (e) {
console.error('[usePasswordlessAuth] Failed to register push token:', e)
}
}
return { pushToken }
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Hooks barrel export
*/
export { useLiveLocation } from './useLiveLocation'
export { useHeading } from './useHeading'
-107
View File
@@ -1,107 +0,0 @@
/**
* 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,
};
}
+82
View File
@@ -0,0 +1,82 @@
/**
* useHeading.ts — React Hook for compass heading updates
* Uses watchHeadingAsync with headingFilter=1°
* Handles calibration state
*/
import { useEffect, useRef, useCallback } from 'react'
import * as Location from 'expo-location'
import { useLocationStore } from '../stores/locationStore'
const HEADING_FILTER = 1 // Update on every degree change
export function useHeading() {
const watchSubscription = useRef<Location.LocationSubscription | null>(null)
const { setHeading } = useLocationStore()
const startWatching = useCallback(async () => {
if (watchSubscription.current) {
console.log('[useHeading] Already watching, skipping start')
return
}
try {
// Check if compass/heading is available
const hasServices = await Location.hasServicesEnabledAsync()
if (!hasServices) {
console.log('[useHeading] Location services not enabled')
return
}
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
console.log('[useHeading] Location permission denied')
return
}
watchSubscription.current = await Location.watchHeadingAsync(
(headingData) => {
// Use trueHeading if available, fallback to magneticHeading
const degrees = headingData.trueHeading >= 0
? headingData.trueHeading
: headingData.magHeading
const accuracy = headingData.accuracy
setHeading(degrees, accuracy)
// Log calibration state
if (headingData.accuracy < 0) {
console.log('[useHeading] Compass calibration needed')
}
}
)
console.log('[useHeading] watchHeadingAsync started with filter=1°')
} catch (error) {
console.error('[useHeading] Error starting heading watch:', error)
}
}, [setHeading])
const stopWatching = useCallback(() => {
if (watchSubscription.current) {
watchSubscription.current.remove()
watchSubscription.current = null
console.log('[useHeading] watchHeadingAsync stopped')
}
}, [])
// Auto-start on mount, stop on unmount
useEffect(() => {
startWatching()
return () => {
stopWatching()
}
}, [startWatching, stopWatching])
return {
startWatching,
stopWatching,
}
}
@@ -0,0 +1,132 @@
/**
* useLiveLocation.ts — React Hook for continuous GPS tracking
* Mission-grade config: BestForNavigation, distanceFilter=0, no pausing
* Includes stale-data rejection (reject positions older than 5s, accuracy > 20m)
*/
import { useEffect, useRef, useCallback } from 'react'
import * as Location from 'expo-location'
import { useLocationStore } from '../stores/locationStore'
const STALE_AGE_MS = 5000 // Reject positions older than 5 seconds
const MAX_ACCURACY_M = 20 // Reject positions with accuracy > 20m during mission
const MIN_ACCURACY_M = 150 // Reject positions with accuracy > 150m always
export function useLiveLocation() {
const watchSubscription = useRef<Location.LocationSubscription | null>(null)
const staleTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const { isTracking, startTracking, stopTracking, updatePosition, markStale } = useLocationStore()
const validatePosition = useCallback((location: Location.LocationObject): boolean => {
const now = Date.now()
const age = now - location.timestamp
// Reject stale positions
if (age > STALE_AGE_MS) {
console.log(`[useLiveLocation] Rejected stale position: age=${age}ms > ${STALE_AGE_MS}ms`)
return false
}
// Reject low accuracy positions
const accuracy = location.coords.accuracy
if (accuracy == null) {
console.log('[useLiveLocation] Rejected: no accuracy data')
return false
}
if (accuracy > MIN_ACCURACY_M) {
console.log(`[useLiveLocation] Rejected poor accuracy: ${accuracy}m > ${MIN_ACCURACY_M}m`)
return false
}
// Warn if accuracy is mission-suboptimal but still acceptable
if (accuracy > MAX_ACCURACY_M) {
console.log(`[useLiveLocation] Warning: accuracy ${accuracy}m > mission-grade ${MAX_ACCURACY_M}m`)
// Still accept it — don't reject, just warn
}
return true
}, [])
const startWatching = useCallback(async () => {
if (watchSubscription.current) {
console.log('[useLiveLocation] Already watching, skipping start')
return
}
try {
// Request permissions
const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync()
if (foregroundStatus !== 'granted') {
console.log('[useLiveLocation] Foreground location permission denied')
useLocationStore.getState().setLocationAuthStatus('denied')
return
}
// Request background permission for iOS
if (foregroundStatus === 'granted') {
const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync()
useLocationStore.getState().setLocationAuthStatus(backgroundStatus)
}
// Mission-grade location config
const options: Location.LocationOptions = {
accuracy: Location.Accuracy.BestForNavigation,
distanceInterval: 0, // Update on every movement
timeInterval: 1000, // At least every 1 second
mayShowUserSettingsDialog: true,
}
watchSubscription.current = await Location.watchPositionAsync(
options,
(location) => {
if (validatePosition(location)) {
updatePosition(location)
}
}
)
startTracking()
console.log('[useLiveLocation] watchPositionAsync started with BestForNavigation')
// Start stale checker
staleTimer.current = setInterval(() => {
markStale()
}, 2000)
} catch (error) {
console.error('[useLiveLocation] Error starting location watch:', error)
useLocationStore.getState().setLocationAuthStatus('error')
}
}, [validatePosition, startTracking, updatePosition, markStale])
const stopWatching = useCallback(() => {
if (watchSubscription.current) {
watchSubscription.current.remove()
watchSubscription.current = null
console.log('[useLiveLocation] watchPositionAsync stopped')
}
if (staleTimer.current) {
clearInterval(staleTimer.current)
staleTimer.current = null
}
stopTracking()
}, [stopTracking])
// Auto-start on mount, stop on unmount
useEffect(() => {
startWatching()
return () => {
stopWatching()
}
}, [startWatching, stopWatching])
return {
isTracking,
startWatching,
stopWatching,
}
}
-147
View File
@@ -1,147 +0,0 @@
/**
* 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,
};
}
-175
View File
@@ -1,175 +0,0 @@
/"/**
* 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,
};
}
@@ -1,90 +0,0 @@
/**
* 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,244 @@
/**
* AppNavigator — Root navigation with location permission request at startup
* Starts location tracking when user is authenticated
* Includes passwordless cross-device auth support
*/
import { useEffect, useState, useCallback } from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { Text, View, ActivityIndicator, Alert } from 'react-native'
import * as Location from 'expo-location'
import * as Notifications from 'expo-notifications'
import { getToken, auth } from '../../lib/api'
import { HomeScreen } from '../../screens/HomeScreen'
import { MissionsScreen } from '../../screens/MissionsScreen'
import { MissionDetailScreen } from '../../screens/MissionDetailScreen'
import { EarningsScreen } from '../../screens/EarningsScreen'
import { ProfileScreen } from '../../screens/ProfileScreen'
import { AuthScreen } from '../features/auth/AuthScreen'
import { PasswordlessApprovalScreen } from '../features/auth/PasswordlessApprovalScreen'
import { usePasswordlessAuth } from '../features/auth/usePasswordlessAuth'
import { useLocationStore } from '../stores/locationStore'
const Tab = createBottomTabNavigator()
const Stack = createNativeStackNavigator()
function MissionsStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MissionsList" component={MissionsScreen} />
<Stack.Screen name="MissionDetail" component={MissionDetailScreen} />
</Stack.Navigator>
)
}
function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
tabBarStyle: {
backgroundColor: '#0A0A1B',
borderTopColor: 'rgba(255,255,255,0.08)',
},
tabBarActiveTintColor: '#6366f1',
tabBarInactiveTintColor: 'rgba(255,255,255,0.35)',
headerShown: false,
}}
>
<Tab.Screen
name="Karta"
component={HomeScreen}
options={{
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>🗺</Text>,
}}
/>
<Tab.Screen
name="Uppdrag"
component={MissionsStack}
options={{
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>🎯</Text>,
}}
/>
<Tab.Screen
name="Intjänat"
component={EarningsScreen}
options={{
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>💰</Text>,
}}
/>
<Tab.Screen
name="Profil"
component={ProfileScreen}
options={{
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>👤</Text>,
}}
/>
</Tab.Navigator>
)
}
// Configure notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
})
export function AppNavigator() {
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null)
const [authRequest, setAuthRequest] = useState<{
requestToken: string
deviceInfo?: { name?: string; browser?: string; type?: string }
} | null>(null)
const { setLocationAuthStatus } = useLocationStore()
const handleAuthRequest = useCallback(
(request: { requestToken: string; deviceInfo?: { name?: string; browser?: string; type?: string } }) => {
setAuthRequest(request)
},
[],
)
const handleApproved = useCallback(() => {
setAuthRequest(null)
Alert.alert('✅ Inloggning godkänd', 'Användaren kan nu logga in på webben.')
}, [])
const handleDenied = useCallback(() => {
setAuthRequest(null)
Alert.alert('❌ Inloggning nekad', 'Förfrågan avvisad.')
}, [])
const handleClose = useCallback(() => {
setAuthRequest(null)
}, [])
// Initialize passwordless auth hooks
usePasswordlessAuth(handleAuthRequest, handleApproved, handleDenied)
useEffect(() => {
checkAuth()
requestLocationPermissions()
configureNotifications()
}, [])
const checkAuth = async () => {
const token = await getToken()
if (token) {
// Validate token by calling /auth/me
try {
await auth.me()
setIsLoggedIn(true)
} catch {
setIsLoggedIn(false)
}
} else {
setIsLoggedIn(false)
}
}
const requestLocationPermissions = async () => {
try {
// Check if location services are enabled
const servicesEnabled = await Location.hasServicesEnabledAsync()
if (!servicesEnabled) {
console.log('[AppNavigator] Location services not enabled')
setLocationAuthStatus('services_disabled')
return
}
// Request foreground permission
const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync()
console.log(`[AppNavigator] Foreground permission: ${foregroundStatus}`)
setLocationAuthStatus(foregroundStatus)
if (foregroundStatus !== 'granted') {
Alert.alert(
'GPS-behörighet krävs',
'quiXzoom behöver åtkomst till din plats för att visa uppdrag nära dig och navigera till uppdragsplatser.',
[
{ text: 'OK', style: 'default' },
]
)
return
}
// Request background permission (iOS)
const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync()
console.log(`[AppNavigator] Background permission: ${backgroundStatus}`)
if (backgroundStatus === 'granted') {
setLocationAuthStatus('granted')
}
} catch (error) {
console.error('[AppNavigator] Error requesting location permissions:', error)
setLocationAuthStatus('error')
}
}
const configureNotifications = async () => {
try {
// Set notification categories for auth requests
await Notifications.setNotificationCategoryAsync('passwordless_auth', [
{
identifier: 'approve',
buttonTitle: 'Godkänn',
options: { isDestructive: false },
},
{
identifier: 'deny',
buttonTitle: 'Neka',
options: { isDestructive: true },
},
])
} catch (error) {
console.error('[AppNavigator] Error configuring notifications:', error)
}
}
if (isLoggedIn === null) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0A0A1B' }}>
<ActivityIndicator color="#6366f1" size="large" />
</View>
)
}
if (!isLoggedIn) {
return (
<View style={{ flex: 1 }}>
<AuthScreen onLoginSuccess={() => setIsLoggedIn(true)} />
{authRequest && (
<PasswordlessApprovalScreen
requestToken={authRequest.requestToken}
deviceInfo={authRequest.deviceInfo}
onApproved={handleApproved}
onDenied={handleDenied}
onClose={handleClose}
/>
)}
</View>
)
}
return (
<NavigationContainer>
<View style={{ flex: 1 }}>
<MainTabs />
{authRequest && (
<PasswordlessApprovalScreen
requestToken={authRequest.requestToken}
deviceInfo={authRequest.deviceInfo}
onApproved={handleApproved}
onDenied={handleDenied}
onClose={handleClose}
/>
)}
</View>
</NavigationContainer>
)
}
-287
View File
@@ -1,287 +0,0 @@
/**
* 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,
},
});
@@ -1,250 +0,0 @@
/**
* 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,
},
});
@@ -1,70 +0,0 @@
/**
* 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,8 @@
/**
* LEGACY: This file previously contained the Supabase client.
* Auth has been migrated to identity-core JWT via lib/api.ts
*
* Re-exports from lib/api.ts for backwards compatibility.
* Remove this file once all src/features/* screens have been updated.
*/
export { getToken, setToken, clearTokens, auth } from '../../lib/api'
-177
View File
@@ -1,177 +0,0 @@
//**
* 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);
+5
View File
@@ -0,0 +1,5 @@
/**
* Stores barrel export
*/
export { useLocationStore } from './locationStore'
export type { LocationState } from './locationStore'
@@ -0,0 +1,142 @@
/**
* locationStore.ts — Zustand store for live GPS location
* Holds current position, heading, accuracy, speed, tracking state
*/
import { create } from 'zustand'
import type { LocationObject } from 'expo-location'
export interface LocationState {
// Position data
currentPosition: LocationObject | null
heading: number | null
headingAccuracy: number | null
accuracy: number | null
speed: number | null
course: number | null
// Tracking state
isTracking: boolean
isStale: boolean
lastUpdateTimestamp: number | null
updateIntervalMs: number | null
// Camera / UI state
followUser: boolean
userPannedMap: boolean
// Auth status
locationAuthStatus: string | null
// Actions
startTracking: () => void
stopTracking: () => void
updatePosition: (position: LocationObject) => void
setHeading: (heading: number, accuracy?: number) => void
setFollowUser: (follow: boolean) => void
setUserPannedMap: (panned: boolean) => void
setLocationAuthStatus: (status: string) => void
markStale: () => void
}
const STALE_THRESHOLD_MS = 5000 // 5 seconds
export const useLocationStore = create<LocationState>((set, get) => ({
// Initial state
currentPosition: null,
heading: null,
headingAccuracy: null,
accuracy: null,
speed: null,
course: null,
isTracking: false,
isStale: false,
lastUpdateTimestamp: null,
updateIntervalMs: null,
followUser: true,
userPannedMap: false,
locationAuthStatus: null,
startTracking: () => {
set({ isTracking: true, isStale: false })
console.log('[LocationStore] Tracking started')
},
stopTracking: () => {
set({
isTracking: false,
isStale: true,
currentPosition: null,
heading: null,
speed: null,
course: null,
})
console.log('[LocationStore] Tracking stopped')
},
updatePosition: (position: LocationObject) => {
const now = Date.now()
const prevTimestamp = get().lastUpdateTimestamp
const interval = prevTimestamp ? now - prevTimestamp : null
set({
currentPosition: position,
accuracy: position.coords.accuracy ?? null,
speed: position.coords.speed ?? null,
course: position.coords.course ?? null,
lastUpdateTimestamp: now,
updateIntervalMs: interval,
isStale: false,
})
console.log(
`[LocationStore] Position updated: lat=${position.coords.latitude.toFixed(6)}, ` +
`lng=${position.coords.longitude.toFixed(6)}, accuracy=${position.coords.accuracy}m, ` +
`speed=${position.coords.speed ?? 'null'}m/s, interval=${interval ?? 'first'}ms`
)
},
setHeading: (heading: number, accuracy?: number) => {
set({
heading,
headingAccuracy: accuracy ?? null,
})
console.log(`[LocationStore] Heading updated: ${heading.toFixed(1)}° (accuracy: ${accuracy ?? 'unknown'})`)
},
setFollowUser: (follow: boolean) => {
set({ followUser: follow })
if (follow) {
set({ userPannedMap: false })
}
},
setUserPannedMap: (panned: boolean) => {
set({ userPannedMap: panned })
if (panned) {
set({ followUser: false })
}
},
setLocationAuthStatus: (status: string) => {
set({ locationAuthStatus: status })
console.log(`[LocationStore] Auth status: ${status}`)
},
markStale: () => {
const { lastUpdateTimestamp } = get()
if (lastUpdateTimestamp && Date.now() - lastUpdateTimestamp > STALE_THRESHOLD_MS) {
set({ isStale: true })
console.log('[LocationStore] Position marked stale')
}
},
}))
// Selector hooks for performance
export const selectCurrentPosition = (state: LocationState) => state.currentPosition
export const selectIsTracking = (state: LocationState) => state.isTracking
export const selectIsStale = (state: LocationState) => state.isStale
export const selectHeading = (state: LocationState) => state.heading
export const selectAccuracy = (state: LocationState) => state.accuracy
export const selectSpeed = (state: LocationState) => state.speed
export const selectFollowUser = (state: LocationState) => state.followUser
export const selectLocationAuthStatus = (state: LocationState) => state.locationAuthStatus
@@ -1,200 +0,0 @@
/**
* 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,
};
}
-128
View File
@@ -1,128 +0,0 @@
/**
* 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,
};
}
}