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
+263
View File
@@ -0,0 +1,263 @@
/**
* EarningsScreen — saldo + utbetalningshistorik
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, ActivityIndicator, RefreshControl, Alert,
} from 'react-native'
import { wallet, payouts, type WalletBalance, type Payout } from '../lib/api'
type TabKey = 'oversikt' | 'utbetalningar'
const PAYOUT_STATUS: Record<string, { label: string; color: string }> = {
pending: { label: 'Väntar', color: '#f59e0b' },
approved: { label: 'Godkänd', color: '#10b981' },
paid: { label: 'Utbetald', color: '#6366f1' },
rejected: { label: 'Nekad', color: '#ef4444' },
}
export function EarningsScreen() {
const [tab, setTab] = useState<TabKey>('oversikt')
const [balance, setBalance] = useState<WalletBalance | null>(null)
const [payoutList, setPayoutList] = useState<Payout[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [requesting, setRequesting] = useState(false)
const load = useCallback(async () => {
try {
const [b, p] = await Promise.all([
wallet.balance(),
payouts.history(),
])
setBalance(b)
setPayoutList(p)
} catch {
// silent
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { load() }, [])
const onRefresh = useCallback(() => { setRefreshing(true); load() }, [load])
const handleRequestPayout = () => {
if (!balance) return
const available = balance.balance - balance.pending_payouts
if (available < 10000) { // 100 SEK minimum
Alert.alert('Minsta utbetalning 100 kr', `Du har ${(available / 100).toFixed(0)} kr tillgängligt.`)
return
}
Alert.alert(
'Begär utbetalning',
`Vill du ta ut ${(available / 100).toFixed(0)} SEK till ditt bankkonto?`,
[
{ text: 'Avbryt', style: 'cancel' },
{
text: 'Ta ut',
onPress: async () => {
setRequesting(true)
try {
await payouts.request(available)
Alert.alert('✅ Utbetalning begärd!', 'Pengarna är hos dig inom 13 bankdagar.')
load()
} catch (e: any) {
Alert.alert('Fel', e?.message ?? 'Kunde inte begära utbetalning.')
} finally {
setRequesting(false)
}
},
},
],
)
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#6366f1" size="large" />
</View>
)
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366f1" />}
>
{/* Big balance card */}
<View style={styles.balanceCard}>
<Text style={styles.balanceLabel}>Tillgängligt</Text>
<Text style={styles.balanceAmount}>
{balance ? (balance.balance / 100).toFixed(0) : '0'} SEK
</Text>
{balance && balance.pending_payouts > 0 && (
<Text style={styles.pendingNote}>
+ {(balance.pending_payouts / 100).toFixed(0)} SEK under behandling
</Text>
)}
<TouchableOpacity
style={[styles.payoutBtn, requesting && styles.payoutBtnDisabled]}
onPress={handleRequestPayout}
disabled={requesting}
>
{requesting
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.payoutBtnText}>💳 Begär utbetalning</Text>
}
</TouchableOpacity>
</View>
{/* Tab bar */}
<View style={styles.tabBar}>
{([['oversikt', 'Översikt'], ['utbetalningar', 'Utbetalningar']] as [TabKey, string][]).map(([key, label]) => (
<TouchableOpacity
key={key}
style={[styles.tab, tab === key && styles.tabActive]}
onPress={() => setTab(key)}
>
<Text style={[styles.tabText, tab === key && styles.tabTextActive]}>{label}</Text>
</TouchableOpacity>
))}
</View>
{/* Översikt */}
{tab === 'oversikt' && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Din ekonomi</Text>
<View style={styles.infoCard}>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Valuta</Text>
<Text style={styles.infoValue}>{balance?.currency ?? 'SEK'}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Tillgängligt</Text>
<Text style={styles.infoValue}>{balance ? (balance.balance / 100).toFixed(0) : '0'} kr</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Väntande utbetalningar</Text>
<Text style={styles.infoValue}>{balance ? (balance.pending_payouts / 100).toFixed(0) : '0'} kr</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Totalt utbetalt</Text>
<Text style={styles.infoValue}>
{payoutList
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount, 0) / 100} kr
</Text>
</View>
</View>
</View>
)}
{/* Utbetalningar */}
{tab === 'utbetalningar' && (
<View style={styles.section}>
{payoutList.length === 0
? (
<View style={styles.emptyWrap}>
<Text style={styles.emptyEmoji}>💳</Text>
<Text style={styles.emptyTitle}>Inga utbetalningar ännu</Text>
<Text style={styles.empty}>Begär din första utbetalning ovan.</Text>
</View>
)
: payoutList.map(p => {
const s = PAYOUT_STATUS[p.status] ?? { label: p.status, color: '#fff' }
return (
<View key={p.id} style={styles.payoutRow}>
<View>
<Text style={styles.payoutDate}>
{new Date(p.created_at).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric', year: 'numeric' })}
</Text>
{p.paid_at && (
<Text style={styles.payoutPaidAt}>
Betald {new Date(p.paid_at).toLocaleDateString('sv-SE')}
</Text>
)}
</View>
<Text style={styles.payoutAmount}>
{(p.amount / 100).toFixed(0)} {p.currency}
</Text>
<View style={[styles.payoutStatusBadge, { backgroundColor: s.color + '22' }]}>
<Text style={[styles.payoutStatusText, { color: s.color }]}>{s.label}</Text>
</View>
</View>
)
})
}
</View>
)}
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center' },
balanceCard: {
margin: 16,
marginTop: 56,
backgroundColor: '#6366f1',
borderRadius: 24, padding: 28,
alignItems: 'center',
},
balanceLabel: { color: 'rgba(255,255,255,0.7)', fontSize: 14, marginBottom: 6 },
balanceAmount: { color: '#fff', fontSize: 48, fontWeight: '900', letterSpacing: -2 },
pendingNote: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginTop: 4 },
payoutBtn: {
marginTop: 20,
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: 14, paddingHorizontal: 28, paddingVertical: 12,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.25)',
},
payoutBtnDisabled: { opacity: 0.6 },
payoutBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
tabBar: {
flexDirection: 'row', marginHorizontal: 16, marginTop: 16, marginBottom: 4,
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 14, padding: 4,
},
tab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderRadius: 10 },
tabActive: { backgroundColor: '#6366f1' },
tabText: { color: 'rgba(255,255,255,0.4)', fontSize: 12, fontWeight: '600' },
tabTextActive: { color: '#fff' },
section: { paddingHorizontal: 16, paddingTop: 16 },
sectionTitle: { color: 'rgba(255,255,255,0.35)', fontSize: 12, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 16 },
infoCard: {
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 16, padding: 20,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
},
infoRow: {
flexDirection: 'row', justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.05)',
},
infoLabel: { color: 'rgba(255,255,255,0.5)', fontSize: 14 },
infoValue: { color: '#fff', fontSize: 14, fontWeight: '700' },
emptyWrap: { alignItems: 'center', paddingTop: 40 },
emptyEmoji: { fontSize: 40, marginBottom: 12 },
emptyTitle: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 6 },
empty: { color: 'rgba(255,255,255,0.3)', textAlign: 'center', fontSize: 14 },
payoutRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: 14,
borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.05)',
},
payoutDate: { color: '#fff', fontSize: 13, fontWeight: '500' },
payoutPaidAt: { color: 'rgba(255,255,255,0.3)', fontSize: 11 },
payoutAmount: { color: '#fff', fontWeight: '700', fontSize: 14 },
payoutStatusBadge: { borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4 },
payoutStatusText: { fontSize: 12, fontWeight: '600' },
})
+332
View File
@@ -0,0 +1,332 @@
/**
* HomeScreen — karta med live-uppdrag + snabbstatistik (intjänat idag)
* Integrerad med useLiveLocation() för kontinuerlig GPS-tracking
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import {
View, Text, StyleSheet, TouchableOpacity,
ActivityIndicator, Platform,
} from 'react-native'
import MapView, { Marker, Region, PROVIDER_DEFAULT } from 'react-native-maps'
import { useNavigation } from '@react-navigation/native'
import { missions, wallet, type Mission, type WalletBalance } from '../lib/api'
import { useLiveLocation } from '../src/hooks/useLiveLocation'
import { useHeading } from '../src/hooks/useHeading'
import { useLocationStore } from '../src/stores/locationStore'
import { UserLocationMarker } from '../src/components/gps/UserLocationMarker'
import { FollowUserButton } from '../src/components/gps/FollowUserButton'
import { GpsDiagnostics } from '../src/components/gps/GpsDiagnostics'
const STOCKHOLM_ARCHIPELAGO: Region = {
latitude: 59.45,
longitude: 18.5,
latitudeDelta: 0.8,
longitudeDelta: 0.8,
}
const CATEGORY_EMOJI: Record<string, string> = {
infrastruktur: '🏗️',
vägar: '🛤️',
hamnar: '⚓',
bryggor: '🚢',
fritidshus: '🏠',
miljö: '🌿',
övrigt: '📍',
}
export function HomeScreen() {
const navigation = useNavigation<any>()
const mapRef = useRef<MapView>(null)
// Initialize live location tracking
useLiveLocation()
useHeading()
const {
currentPosition,
followUser,
setUserPannedMap,
locationAuthStatus,
} = useLocationStore()
const [region, setRegion] = useState<Region>(STOCKHOLM_ARCHIPELAGO)
const [nearbyMissions, setNearbyMissions] = useState<Mission[]>([])
const [balance, setBalance] = useState<WalletBalance | null>(null)
const [loadingMissions, setLoadingMissions] = useState(false)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [initialLocationSet, setInitialLocationSet] = useState(false)
// Center map on first position fix
useEffect(() => {
if (currentPosition && !initialLocationSet) {
const newRegion: Region = {
latitude: currentPosition.coords.latitude,
longitude: currentPosition.coords.longitude,
latitudeDelta: 0.5,
longitudeDelta: 0.5,
}
setRegion(newRegion)
mapRef.current?.animateToRegion(newRegion, 800)
fetchNearby(currentPosition.coords.latitude, currentPosition.coords.longitude)
setInitialLocationSet(true)
}
}, [currentPosition, initialLocationSet])
// Auto-follow user when followUser is true
useEffect(() => {
if (followUser && currentPosition && mapRef.current) {
const newRegion: Region = {
latitude: currentPosition.coords.latitude,
longitude: currentPosition.coords.longitude,
latitudeDelta: region.latitudeDelta,
longitudeDelta: region.longitudeDelta,
}
mapRef.current.animateToRegion(newRegion, 300)
}
}, [currentPosition, followUser, region.latitudeDelta, region.longitudeDelta])
// Wallet balance
useEffect(() => {
wallet.balance().then(setBalance).catch(() => {})
}, [])
const fetchNearby = useCallback(async (lat: number, lng: number) => {
setLoadingMissions(true)
try {
const data = await missions.nearby(lat, lng, 60)
setNearbyMissions(data)
} catch {
// silent
} finally {
setLoadingMissions(false)
}
}, [])
const onRegionChangeComplete = useCallback((r: Region) => {
setRegion(r)
fetchNearby(r.latitude, r.longitude)
}, [fetchNearby])
const onPanDrag = useCallback(() => {
setUserPannedMap(true)
}, [setUserPannedMap])
const availableSEK = balance ? ((balance.balance - balance.pending_payouts) / 100).toFixed(0) : '—'
const pendingSEK = balance ? (balance.pending_payouts / 100).toFixed(0) : '—'
return (
<View style={styles.container}>
{/* Map */}
<MapView
ref={mapRef}
style={styles.map}
provider={PROVIDER_DEFAULT}
initialRegion={STOCKHOLM_ARCHIPELAGO}
region={region}
onRegionChangeComplete={onRegionChangeComplete}
onPanDrag={onPanDrag}
showsUserLocation={false} // We use custom UserLocationMarker
showsMyLocationButton={false}
rotateEnabled={false} // Map stays north-up, marker rotates
>
{/* Custom user location marker with heading */}
{currentPosition && (
<Marker
coordinate={{
latitude: currentPosition.coords.latitude,
longitude: currentPosition.coords.longitude,
}}
anchor={{ x: 0.5, y: 0.5 }}
flat={true}
>
<UserLocationMarker />
</Marker>
)}
{nearbyMissions.map(m => (
<Marker
key={m.id}
coordinate={{ latitude: m.latitude, longitude: m.longitude }}
title={m.title}
description={`${(m.reward_amount / 100).toFixed(0)} SEK`}
onCalloutPress={() => navigation.navigate('MissionDetail', { missionId: m.id })}
>
<View style={styles.markerBubble}>
<Text style={styles.markerEmoji}>
{CATEGORY_EMOJI[m.category] ?? '📍'}
</Text>
<Text style={styles.markerReward}>
{(m.reward_amount / 100).toFixed(0)} kr
</Text>
</View>
</Marker>
))}
</MapView>
{/* Loading indicator */}
{loadingMissions && (
<View style={styles.loadingOverlay}>
<ActivityIndicator color="#6366f1" size="small" />
</View>
)}
{/* GPS Diagnostics toggle */}
<TouchableOpacity
style={styles.diagnosticsToggle}
onPress={() => setShowDiagnostics(prev => !prev)}
>
<Text style={styles.diagnosticsToggleText}>
{showDiagnostics ? '🔧' : '🛠️'}
</Text>
</TouchableOpacity>
{/* GPS Diagnostics overlay */}
{showDiagnostics && <GpsDiagnostics />}
{/* Stats card */}
<View style={styles.statsCard}>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Tillgängligt</Text>
<Text style={styles.statValue}>{availableSEK} kr</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<Text style={styles.statLabel}>Under behandling</Text>
<Text style={[styles.statValue, { color: '#f59e0b' }]}>{pendingSEK} kr</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<Text style={styles.statLabel}>Uppdrag nära</Text>
<Text style={[styles.statValue, { color: '#10b981' }]}>{nearbyMissions.length}</Text>
</View>
</View>
{/* Follow user button */}
<View style={styles.followBtnContainer}>
<FollowUserButton />
</View>
{/* Location status indicator */}
{locationAuthStatus !== 'granted' && locationAuthStatus !== null && (
<View style={styles.locationWarning}>
<Text style={styles.locationWarningText}>
GPS-behörighet: {locationAuthStatus}
</Text>
</View>
)}
{/* Missions list button */}
<TouchableOpacity
style={styles.missionsBtn}
onPress={() => navigation.navigate('Uppdrag')}
>
<Text style={styles.missionsBtnText}>
🎯 {nearbyMissions.length} uppdrag nära dig
</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
map: { flex: 1 },
markerBubble: {
backgroundColor: '#6366f1',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 6,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
elevation: 4,
},
markerEmoji: { fontSize: 14 },
markerReward: { color: '#fff', fontSize: 11, fontWeight: '700' },
loadingOverlay: {
position: 'absolute',
top: Platform.OS === 'ios' ? 60 : 16,
right: 16,
backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 20,
padding: 8,
},
diagnosticsToggle: {
position: 'absolute',
top: Platform.OS === 'ios' ? 60 : 16,
right: 60,
width: 36,
height: 36,
backgroundColor: 'rgba(10,10,27,0.9)',
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
alignItems: 'center',
justifyContent: 'center',
},
diagnosticsToggleText: { fontSize: 16 },
statsCard: {
position: 'absolute',
top: Platform.OS === 'ios' ? 60 : 16,
left: 16,
right: 104,
flexDirection: 'row',
backgroundColor: 'rgba(10,10,27,0.92)',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
padding: 12,
alignItems: 'center',
},
statItem: { flex: 1, alignItems: 'center' },
statLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, marginBottom: 2 },
statValue: { color: '#fff', fontSize: 15, fontWeight: '800' },
statDivider: { width: 1, height: 28, backgroundColor: 'rgba(255,255,255,0.1)' },
followBtnContainer: {
position: 'absolute',
bottom: 100,
right: 16,
},
locationWarning: {
position: 'absolute',
top: Platform.OS === 'ios' ? 120 : 76,
left: 16,
right: 16,
backgroundColor: 'rgba(245,158,11,0.2)',
borderRadius: 12,
padding: 10,
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.4)',
alignItems: 'center',
},
locationWarningText: {
color: '#f59e0b',
fontSize: 12,
fontWeight: '600',
},
missionsBtn: {
position: 'absolute',
bottom: 32,
left: 16,
right: 16,
backgroundColor: '#6366f1',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
shadowColor: '#6366f1',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
missionsBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
})
@@ -0,0 +1,593 @@
/**
* MissionDetailScreen — uppdragsinfo, karta, acceptera-knapp, kamera-upload vid leverans
* Live markör, distance overlay, heading indicator
*/
import { useEffect, useState, useRef, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, ActivityIndicator, Alert,
Image, Platform,
} from 'react-native'
import MapView, { Marker, Region, PROVIDER_DEFAULT } from 'react-native-maps'
import * as ImagePicker from 'expo-image-picker'
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'
import { missions, type MissionDetail } from '../lib/api'
import { useLocationStore } from '../src/stores/locationStore'
import { UserLocationMarker } from '../src/components/gps/UserLocationMarker'
import { FollowUserButton } from '../src/components/gps/FollowUserButton'
type RouteParams = { MissionDetail: { missionId: string } }
const CATEGORY_EMOJI: Record<string, string> = {
infrastruktur: '🏗️',
vägar: '🛤️',
hamnar: '⚓',
bryggor: '🚢',
fritidshus: '🏠',
miljö: '🌿',
övrigt: '📍',
}
/** Calculate distance between two coordinates in meters */
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371e3 // Earth radius in meters
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lng2 - lng1) * 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))
return R * c
}
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
/** Calculate bearing from point 1 to point 2 in degrees */
function calculateBearing(lat1: number, lng1: number, lat2: number, lng2: number): number {
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δλ = ((lng2 - lng1) * 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)
return ((θ * 180) / Math.PI + 360) % 360
}
function getDirectionArrow(degrees: number): string {
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 '↖'
}
export function MissionDetailScreen() {
const navigation = useNavigation<any>()
const route = useRoute<RouteProp<RouteParams, 'MissionDetail'>>()
const { missionId } = route.params
const mapRef = useRef<MapView>(null)
// Live location from store
const {
currentPosition,
heading,
followUser,
setUserPannedMap,
} = useLocationStore()
const [mission, setMission] = useState<MissionDetail | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [mediaUris, setMediaUris] = useState<string[]>([])
const [distance, setDistance] = useState<number | null>(null)
const [bearing, setBearing] = useState<number | null>(null)
useEffect(() => {
missions.get(missionId)
.then(m => { setMission(m); setLoading(false) })
.catch(() => { Alert.alert('Fel', 'Kunde inte hämta uppdraget.'); navigation.goBack() })
}, [missionId])
// Update distance and bearing when position or mission changes
useEffect(() => {
if (currentPosition && mission) {
const dist = haversineDistance(
currentPosition.coords.latitude,
currentPosition.coords.longitude,
mission.latitude,
mission.longitude
)
setDistance(dist)
const bear = calculateBearing(
currentPosition.coords.latitude,
currentPosition.coords.longitude,
mission.latitude,
mission.longitude
)
setBearing(bear)
}
}, [currentPosition, mission])
// 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.05,
longitudeDelta: 0.05,
}
mapRef.current.animateToRegion(region, 300)
}
}, [currentPosition, followUser])
const onPanDrag = useCallback(() => {
setUserPannedMap(true)
}, [setUserPannedMap])
const handleAccept = async () => {
if (!mission) return
setAccepting(true)
try {
await missions.accept(mission.id)
setAccepted(true)
Alert.alert('✅ Uppdraget accepterat!', 'Ta dig till platsen och leverera när du är framme.')
} catch (e: any) {
Alert.alert('Fel', e?.message ?? 'Kunde inte acceptera uppdraget.')
} finally {
setAccepting(false)
}
}
const handlePickMedia = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Kamera-åtkomst krävs', 'Tillåt kameraåtkomst i telefonens inställningar.')
return
}
Alert.alert('Välj källa', '', [
{
text: 'Ta foto',
onPress: async () => {
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.85,
allowsEditing: false,
})
if (!result.canceled && result.assets[0]) {
setMediaUris(prev => [...prev, result.assets[0].uri])
}
},
},
{
text: 'Välj från bibliotek',
onPress: async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.85,
allowsMultipleSelection: true,
selectionLimit: 10,
})
if (!result.canceled) {
setMediaUris(prev => [...prev, ...result.assets.map(a => a.uri)])
}
},
},
{ text: 'Avbryt', style: 'cancel' },
])
}
const handleSubmit = async () => {
if (!mission) return
if (mediaUris.length === 0) {
Alert.alert('Inga bilder', 'Lägg till minst en bild innan du skickar in.')
return
}
Alert.alert(
'Skicka in uppdraget?',
`${mediaUris.length} bild${mediaUris.length > 1 ? 'er' : ''} kommer att skickas.`,
[
{ text: 'Avbryt', style: 'cancel' },
{
text: 'Skicka in',
onPress: async () => {
setSubmitting(true)
try {
await missions.submit(mission.id, mediaUris)
Alert.alert(
'🎉 Inlämnat!',
`Dina bilder granskas. Om allt godkänns får du ${(mission.reward_amount / 100).toFixed(0)} kr.`,
[{ text: 'Tillbaka', onPress: () => navigation.goBack() }],
)
} catch (e: any) {
Alert.alert('Fel', e?.message ?? 'Kunde inte skicka in uppdraget.')
} finally {
setSubmitting(false)
}
},
},
],
)
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#6366f1" size="large" />
</View>
)
}
if (!mission) return null
const isExpired = mission.deadline_at ? new Date(mission.deadline_at) < new Date() : false
const spotsLeft = mission.max_submissions - mission.submission_count
// Calculate relative bearing for navigation arrow
const relativeBearing = bearing != null && heading != null
? ((bearing - heading + 360) % 360)
: null
return (
<View style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 120 }}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
<Text style={styles.backBtnText}> Tillbaka</Text>
</TouchableOpacity>
</View>
{/* Map with live tracking */}
<View style={styles.mapContainer}>
<MapView
ref={mapRef}
style={styles.map}
provider={PROVIDER_DEFAULT}
region={{
latitude: mission.latitude,
longitude: mission.longitude,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
onPanDrag={onPanDrag}
showsUserLocation={false} // Custom marker
rotateEnabled={false}
>
{/* Mission marker */}
<Marker
coordinate={{ latitude: mission.latitude, longitude: mission.longitude }}
pinColor="#10b981"
title={mission.title}
/>
{/* Live 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>
)}
</MapView>
{/* Follow user button */}
<View style={styles.followBtnContainer}>
<FollowUserButton />
</View>
{/* Distance overlay */}
{distance != null && (
<View style={styles.distanceOverlay}>
<View style={styles.distanceRow}>
<Text style={styles.distanceLabel}>Avstånd till uppdrag</Text>
<Text style={styles.distanceValue}>{formatDistance(distance)}</Text>
</View>
{relativeBearing != null && (
<View style={styles.distanceRow}>
<Text style={styles.distanceLabel}>Riktning</Text>
<Text style={styles.distanceValue}>
{getDirectionArrow(relativeBearing)} {relativeBearing.toFixed(0)}°
</Text>
</View>
)}
{heading != null && (
<View style={styles.distanceRow}>
<Text style={styles.distanceLabel}>Din kurs</Text>
<Text style={styles.distanceValue}>{heading.toFixed(0)}°</Text>
</View>
)}
{currentPosition?.coords.speed != null && (
<View style={styles.distanceRow}>
<Text style={styles.distanceLabel}>Hastighet</Text>
<Text style={styles.distanceValue}>
{(currentPosition.coords.speed * 3.6).toFixed(1)} km/h
</Text>
</View>
)}
</View>
)}
</View>
{/* Content */}
<View style={styles.content}>
{/* Category + status row */}
<View style={styles.metaRow}>
<View style={styles.categoryBadge}>
<Text style={styles.categoryEmoji}>{CATEGORY_EMOJI[mission.category] ?? '📍'}</Text>
<Text style={styles.categoryText}>{mission.category}</Text>
</View>
{isExpired && <View style={styles.expiredBadge}><Text style={styles.expiredText}>Utgången</Text></View>}
{spotsLeft <= 3 && !isExpired && (
<View style={styles.urgentBadge}>
<Text style={styles.urgentText}>Bara {spotsLeft} platser kvar</Text>
</View>
)}
</View>
{/* Title */}
<Text style={styles.title}>{mission.title}</Text>
{/* Reward + deadline */}
<View style={styles.rewardRow}>
<View style={styles.rewardBox}>
<Text style={styles.rewardLabel}>Ersättning</Text>
<Text style={styles.rewardValue}>{(mission.reward_amount / 100).toFixed(0)} SEK</Text>
</View>
{mission.deadline_at && (
<View style={styles.rewardBox}>
<Text style={styles.rewardLabel}>Deadline</Text>
<Text style={styles.rewardValue}>
{new Date(mission.deadline_at).toLocaleDateString('sv-SE', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</Text>
</View>
)}
<View style={styles.rewardBox}>
<Text style={styles.rewardLabel}>Plats</Text>
<Text style={styles.rewardValue}>{mission.city || 'Skärgården'}</Text>
</View>
</View>
{/* Description */}
<Text style={styles.sectionTitle}>Beskrivning</Text>
<Text style={styles.desc}>{mission.description}</Text>
{/* Instructions */}
{mission.instructions && (
<>
<Text style={styles.sectionTitle}>Instruktioner</Text>
<Text style={styles.desc}>{mission.instructions}</Text>
</>
)}
{/* Requirements */}
{mission.requirements?.length > 0 && (
<>
<Text style={styles.sectionTitle}>Krav</Text>
{mission.requirements.map((r, i) => (
<View key={i} style={styles.reqRow}>
<Text style={styles.reqBullet}></Text>
<Text style={styles.reqText}>{r}</Text>
</View>
))}
</>
)}
{/* Media picker (only when accepted) */}
{accepted && (
<>
<Text style={styles.sectionTitle}>Dina bilder</Text>
<View style={styles.mediaGrid}>
{mediaUris.map((uri, i) => (
<View key={i} style={styles.mediaThumbWrap}>
<Image source={{ uri }} style={styles.mediaThumb} />
<TouchableOpacity
style={styles.removeBtn}
onPress={() => setMediaUris(prev => prev.filter((_, j) => j !== i))}
>
<Text style={styles.removeBtnText}></Text>
</TouchableOpacity>
</View>
))}
<TouchableOpacity style={styles.addMediaBtn} onPress={handlePickMedia}>
<Text style={styles.addMediaIcon}>📸</Text>
<Text style={styles.addMediaText}>Lägg till bild</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</ScrollView>
{/* Bottom action bar */}
{!isExpired && (
<View style={styles.bottomBar}>
{!accepted ? (
<TouchableOpacity
style={[styles.actionBtn, accepting && styles.actionBtnDisabled]}
onPress={handleAccept}
disabled={accepting}
>
{accepting
? <ActivityIndicator color="#fff" />
: <Text style={styles.actionBtnText}>🎯 Acceptera uppdraget</Text>
}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.actionBtn, styles.submitBtn, (submitting || mediaUris.length === 0) && styles.actionBtnDisabled]}
onPress={handleSubmit}
disabled={submitting || mediaUris.length === 0}
>
{submitting
? <ActivityIndicator color="#fff" />
: <Text style={styles.actionBtnText}>
📤 Skicka in ({mediaUris.length} bild{mediaUris.length !== 1 ? 'er' : ''})
</Text>
}
</TouchableOpacity>
)}
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center' },
header: {
paddingTop: Platform.OS === 'ios' ? 56 : 16,
paddingHorizontal: 16,
paddingBottom: 8,
},
backBtn: { paddingVertical: 4 },
backBtnText: { color: '#6366f1', fontSize: 15, fontWeight: '600' },
mapContainer: {
height: 280,
position: 'relative',
},
map: {
flex: 1,
},
followBtnContainer: {
position: 'absolute',
bottom: 16,
right: 16,
},
distanceOverlay: {
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: 160,
},
distanceRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
distanceLabel: {
color: 'rgba(255,255,255,0.45)',
fontSize: 11,
marginRight: 12,
},
distanceValue: {
color: '#fff',
fontSize: 13,
fontWeight: '700',
},
content: { padding: 20 },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
categoryBadge: {
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: 'rgba(99,102,241,0.15)',
borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6,
},
categoryEmoji: { fontSize: 14 },
categoryText: { color: '#a5b4fc', fontSize: 12, fontWeight: '600', textTransform: 'capitalize' },
expiredBadge: {
backgroundColor: 'rgba(239,68,68,0.15)',
borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6,
},
expiredText: { color: '#ef4444', fontSize: 12, fontWeight: '600' },
urgentBadge: {
backgroundColor: 'rgba(245,158,11,0.15)',
borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6,
},
urgentText: { color: '#f59e0b', fontSize: 12, fontWeight: '600' },
title: { color: '#fff', fontSize: 24, fontWeight: '800', lineHeight: 30, marginBottom: 20, letterSpacing: -0.5 },
rewardRow: { flexDirection: 'row', gap: 12, marginBottom: 24 },
rewardBox: {
flex: 1,
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 14, padding: 14,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
},
rewardLabel: { color: 'rgba(255,255,255,0.4)', fontSize: 11, marginBottom: 4 },
rewardValue: { color: '#fff', fontSize: 15, fontWeight: '700' },
sectionTitle: { color: 'rgba(255,255,255,0.5)', fontSize: 12, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8, marginTop: 4 },
desc: { color: 'rgba(255,255,255,0.7)', fontSize: 15, lineHeight: 24, marginBottom: 20 },
reqRow: { flexDirection: 'row', gap: 10, marginBottom: 8 },
reqBullet: { color: '#10b981', fontWeight: '700', fontSize: 14 },
reqText: { color: 'rgba(255,255,255,0.65)', fontSize: 14, lineHeight: 20, flex: 1 },
mediaGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginBottom: 8 },
mediaThumbWrap: { position: 'relative' },
mediaThumb: { width: 90, height: 90, borderRadius: 12 },
removeBtn: {
position: 'absolute', top: -6, right: -6,
width: 22, height: 22, borderRadius: 11,
backgroundColor: '#ef4444', alignItems: 'center', justifyContent: 'center',
},
removeBtnText: { color: '#fff', fontSize: 10, fontWeight: '800' },
addMediaBtn: {
width: 90, height: 90, borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 2, borderColor: 'rgba(255,255,255,0.12)',
borderStyle: 'dashed',
alignItems: 'center', justifyContent: 'center', gap: 4,
},
addMediaIcon: { fontSize: 24 },
addMediaText: { color: 'rgba(255,255,255,0.4)', fontSize: 11 },
bottomBar: {
position: 'absolute', bottom: 0, left: 0, right: 0,
padding: 16,
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
backgroundColor: 'rgba(10,10,27,0.95)',
borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.08)',
},
actionBtn: {
backgroundColor: '#6366f1',
borderRadius: 16, paddingVertical: 16,
alignItems: 'center', justifyContent: 'center',
},
submitBtn: { backgroundColor: '#10b981' },
actionBtnDisabled: { opacity: 0.5 },
actionBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
})
+338
View File
@@ -0,0 +1,338 @@
/**
* MissionsScreen — lista aktiva uppdrag nära mig, filter, sortering
* Läser position från locationStore för live-avståndsuppdateringar
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, TouchableOpacity,
StyleSheet, RefreshControl, ActivityIndicator,
ScrollView,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { missions, type Mission, type MissionCategory } from '../lib/api'
import { useLocationStore } from '../src/stores/locationStore'
type SortKey = 'distance' | 'reward' | 'deadline'
const CATEGORIES: { key: MissionCategory | 'alla'; label: string; emoji: string }[] = [
{ key: 'alla', label: 'Alla', emoji: '🗺️' },
{ key: 'hamnar', label: 'Hamnar', emoji: '⚓' },
{ key: 'bryggor', label: 'Bryggor', emoji: '🚢' },
{ key: 'vägar', label: 'Vägar', emoji: '🛤️' },
{ key: 'infrastruktur', label: 'Infrastruktur', emoji: '🏗️' },
{ key: 'fritidshus', label: 'Fritidshus', emoji: '🏠' },
{ key: 'miljö', label: 'Miljö', emoji: '🌿' },
]
const STATUS_COLOR: Record<string, string> = {
open: '#10b981',
active: '#f59e0b',
completed: '#6366f1',
cancelled: '#ef4444',
expired: '#6b7280',
}
function timeLeft(deadline: string | null): string {
if (!deadline) return ''
const diff = new Date(deadline).getTime() - Date.now()
if (diff < 0) return 'Utgången'
const h = Math.floor(diff / 3600000)
if (h < 24) return `${h}h kvar`
return `${Math.floor(h / 24)}d kvar`
}
/** Calculate distance between two coordinates in meters */
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371e3 // Earth radius in meters
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lng2 - lng1) * 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))
return R * c
}
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
export function MissionsScreen() {
const navigation = useNavigation<any>()
// Read live position from store
const { currentPosition } = useLocationStore()
const [allMissions, setAllMissions] = useState<Mission[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [category, setCategory] = useState<MissionCategory | 'alla'>('alla')
const [sort, setSort] = useState<SortKey>('distance')
const [userLat, setUserLat] = useState<number | null>(null)
const [userLng, setUserLng] = useState<number | null>(null)
// Initialize with store position or fallback
useEffect(() => {
if (currentPosition) {
setUserLat(currentPosition.coords.latitude)
setUserLng(currentPosition.coords.longitude)
loadMissions(currentPosition.coords.latitude, currentPosition.coords.longitude)
} else {
// Fallback to Stockholm archipelago if no GPS yet
setUserLat(59.45)
setUserLng(18.5)
loadMissions(59.45, 18.5)
}
}, []) // Only on mount — subsequent updates come from currentPosition
// Update user position when store updates
useEffect(() => {
if (currentPosition) {
setUserLat(currentPosition.coords.latitude)
setUserLng(currentPosition.coords.longitude)
}
}, [currentPosition])
const loadMissions = async (lat: number, lng: number) => {
try {
const data = await missions.nearby(lat, lng, 80)
setAllMissions(data)
} catch {
// silent — show empty
} finally {
setLoading(false)
setRefreshing(false)
}
}
const onRefresh = useCallback(() => {
setRefreshing(true)
const lat = userLat ?? 59.45
const lng = userLng ?? 18.5
loadMissions(lat, lng)
}, [userLat, userLng])
// Compute live distances using current position
const missionsWithLiveDistance = allMissions.map(m => {
if (userLat != null && userLng != null) {
const liveDistance = haversineDistance(userLat, userLng, m.latitude, m.longitude)
return { ...m, distance_meters: liveDistance }
}
return m
})
const filtered = missionsWithLiveDistance
.filter(m => category === 'alla' || m.category === category)
.sort((a, b) => {
if (sort === 'reward') return b.reward_amount - a.reward_amount
if (sort === 'deadline') {
if (!a.deadline_at) return 1
if (!b.deadline_at) return -1
return new Date(a.deadline_at).getTime() - new Date(b.deadline_at).getTime()
}
// distance — use live-calculated distance
return (a.distance_meters ?? Infinity) - (b.distance_meters ?? Infinity)
})
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#6366f1" size="large" />
<Text style={styles.loadingText}>Hämtar uppdrag nära dig</Text>
</View>
)
}
return (
<View style={styles.container}>
<Text style={styles.heading}>Uppdrag</Text>
{/* Live position indicator */}
{currentPosition && (
<View style={styles.locationBar}>
<Text style={styles.locationText}>
📍 {currentPosition.coords.latitude.toFixed(4)}, {currentPosition.coords.longitude.toFixed(4)}
{currentPosition.coords.accuracy && `${Math.round(currentPosition.coords.accuracy)}m)`}
</Text>
</View>
)}
{/* Category filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
{CATEGORIES.map(c => (
<TouchableOpacity
key={c.key}
style={[styles.chip, category === c.key && styles.chipActive]}
onPress={() => setCategory(c.key)}
>
<Text style={styles.chipEmoji}>{c.emoji}</Text>
<Text style={[styles.chipText, category === c.key && styles.chipTextActive]}>
{c.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Sort bar */}
<View style={styles.sortBar}>
<Text style={styles.sortLabel}>Sortera:</Text>
{(['distance', 'reward', 'deadline'] as SortKey[]).map(s => (
<TouchableOpacity
key={s}
style={[styles.sortBtn, sort === s && styles.sortBtnActive]}
onPress={() => setSort(s)}
>
<Text style={[styles.sortBtnText, sort === s && styles.sortBtnTextActive]}>
{s === 'distance' ? 'Avstånd' : s === 'reward' ? 'Ersättning' : 'Deadline'}
</Text>
</TouchableOpacity>
))}
</View>
{/* Count */}
<Text style={styles.count}>
{filtered.length} uppdrag {category !== 'alla' ? `i "${category}"` : 'totalt'}
</Text>
<FlatList
data={filtered}
keyExtractor={m => m.id}
contentContainerStyle={{ paddingBottom: 32 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366f1" />
}
renderItem={({ item: m }) => (
<TouchableOpacity
style={styles.card}
onPress={() => navigation.navigate('MissionDetail', { missionId: m.id })}
activeOpacity={0.75}
>
<View style={styles.cardTop}>
<View style={styles.cardMeta}>
<View style={[styles.statusDot, { backgroundColor: STATUS_COLOR[m.status] ?? '#6b7280' }]} />
<Text style={styles.category}>{m.category}</Text>
</View>
<Text style={styles.reward}>
{(m.reward_amount / 100).toFixed(0)} SEK
</Text>
</View>
<Text style={styles.title} numberOfLines={2}>{m.title}</Text>
<Text style={styles.desc} numberOfLines={2}>{m.description}</Text>
<View style={styles.cardFooter}>
<Text style={styles.location}>
📍 {m.city || 'Stockholms skärgård'}
{m.distance_meters != null
? ` · ${formatDistance(m.distance_meters)}`
: ''}
</Text>
{m.deadline_at && (
<Text style={[
styles.deadline,
timeLeft(m.deadline_at) === 'Utgången' && { color: '#ef4444' },
]}>
{timeLeft(m.deadline_at)}
</Text>
)}
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
<View style={styles.emptyWrap}>
<Text style={styles.emptyEmoji}>🎯</Text>
<Text style={styles.emptyTitle}>Inga uppdrag just nu</Text>
<Text style={styles.emptyText}>Prova en annan kategori eller dra för att uppdatera.</Text>
</View>
}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B', paddingTop: 56 },
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center', gap: 12 },
loadingText: { color: 'rgba(255,255,255,0.4)', fontSize: 14 },
heading: {
color: '#fff', fontSize: 28, fontWeight: '800',
marginHorizontal: 16, marginBottom: 8, letterSpacing: -0.5,
},
locationBar: {
marginHorizontal: 16,
marginBottom: 8,
backgroundColor: 'rgba(99,102,241,0.1)',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
borderWidth: 1,
borderColor: 'rgba(99,102,241,0.2)',
},
locationText: {
color: '#a5b4fc',
fontSize: 11,
fontWeight: '500',
},
filterRow: { paddingHorizontal: 16, gap: 8, paddingBottom: 4 },
chip: {
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: 'rgba(255,255,255,0.05)',
borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)',
borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8,
},
chipActive: { backgroundColor: 'rgba(99,102,241,0.2)', borderColor: '#6366f1' },
chipEmoji: { fontSize: 14 },
chipText: { color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: '500' },
chipTextActive: { color: '#a5b4fc', fontWeight: '700' },
sortBar: {
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingHorizontal: 16, marginTop: 12, marginBottom: 4,
},
sortLabel: { color: 'rgba(255,255,255,0.3)', fontSize: 12, marginRight: 4 },
sortBtn: {
paddingHorizontal: 10, paddingVertical: 5,
borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.04)',
},
sortBtnActive: { backgroundColor: 'rgba(99,102,241,0.15)' },
sortBtnText: { color: 'rgba(255,255,255,0.4)', fontSize: 12 },
sortBtnTextActive: { color: '#a5b4fc', fontWeight: '600' },
count: { color: 'rgba(255,255,255,0.25)', fontSize: 12, marginHorizontal: 16, marginBottom: 12 },
card: {
marginHorizontal: 16, marginBottom: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 18, padding: 16,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 6 },
statusDot: { width: 7, height: 7, borderRadius: 4 },
category: { color: 'rgba(255,255,255,0.35)', fontSize: 12, textTransform: 'capitalize' },
reward: { color: '#10b981', fontWeight: '800', fontSize: 16 },
title: { color: '#fff', fontWeight: '700', fontSize: 16, marginBottom: 6, lineHeight: 22 },
desc: { color: 'rgba(255,255,255,0.45)', fontSize: 14, lineHeight: 20, marginBottom: 12 },
cardFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
location: { color: 'rgba(255,255,255,0.3)', fontSize: 12 },
deadline: { color: '#f59e0b', fontSize: 12, fontWeight: '600' },
emptyWrap: { alignItems: 'center', marginTop: 60, paddingHorizontal: 32 },
emptyEmoji: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 8 },
emptyText: { color: 'rgba(255,255,255,0.35)', fontSize: 14, textAlign: 'center', lineHeight: 20 },
})
+269
View File
@@ -0,0 +1,269 @@
/**
* ProfileScreen — zoomer-profil, stats, badges
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, ActivityIndicator, Alert, RefreshControl,
} from 'react-native'
import { auth, profile, type Zoomer } from '../lib/api'
const LEVEL_LABELS: Record<number, string> = {
1: 'Rookie Zoomer',
2: 'Junior Zoomer',
3: 'Zoomer',
4: 'Senior Zoomer',
5: 'Elite Zoomer',
6: 'Legend Zoomer',
}
const LEVEL_COLOR: Record<number, string> = {
1: '#6b7280',
2: '#10b981',
3: '#3b82f6',
4: '#8b5cf6',
5: '#f59e0b',
6: '#ef4444',
}
function levelProgress(level: number, totalEarned: number): number {
// Simple XP curve: each level needs level*500 kr more
const levelThreshold = level * 50000 // in öre = level * 500 SEK
const prevThreshold = (level - 1) * 50000
const progress = (totalEarned - prevThreshold) / (levelThreshold - prevThreshold)
return Math.min(Math.max(progress, 0), 1)
}
export function ProfileScreen() {
const [zoomer, setZoomer] = useState<Zoomer | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
try {
const data = await profile.get()
setZoomer(data)
} catch {
// silent
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { load() }, [])
const onRefresh = useCallback(() => { setRefreshing(true); load() }, [load])
const handleSignOut = () => {
Alert.alert(
'Logga ut',
'Är du säker på att du vill logga ut?',
[
{ text: 'Avbryt', style: 'cancel' },
{
text: 'Logga ut',
style: 'destructive',
onPress: async () => {
await auth.logout()
// AppNavigator will detect missing token and show AuthScreen
},
},
],
)
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#6366f1" size="large" />
</View>
)
}
const level = zoomer?.level ?? 1
const levelLabel = LEVEL_LABELS[level] ?? `Nivå ${level}`
const levelColor = LEVEL_COLOR[level] ?? '#6366f1'
const progress = zoomer ? levelProgress(level, zoomer.total_earned) : 0
const initial = (zoomer?.display_name ?? zoomer?.email ?? '?')[0].toUpperCase()
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366f1" />}
>
{/* Avatar + name */}
<View style={styles.headerSection}>
<View style={[styles.avatar, { borderColor: levelColor }]}>
<Text style={styles.avatarText}>{initial}</Text>
</View>
<Text style={styles.displayName}>
{zoomer?.display_name || 'Namnlös zoomer'}
</Text>
<Text style={styles.email}>{zoomer?.email}</Text>
{/* Level badge */}
<View style={[styles.levelBadge, { backgroundColor: levelColor + '22', borderColor: levelColor + '55' }]}>
<Text style={[styles.levelBadgeText, { color: levelColor }]}>
{levelLabel}
</Text>
</View>
{/* XP bar */}
<View style={styles.xpBarWrap}>
<View style={styles.xpBarBg}>
<View style={[styles.xpBarFill, { width: `${(progress * 100).toFixed(0)}%` as any, backgroundColor: levelColor }]} />
</View>
<Text style={styles.xpLabel}>
Nivå {level} {level + 1} ({(progress * 100).toFixed(0)}%)
</Text>
</View>
</View>
{/* Stats */}
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{zoomer ? (zoomer.total_earned / 100).toFixed(0) : '0'} kr
</Text>
<Text style={styles.statLabel}>Totalt intjänat</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: '#10b981' }]}>
{zoomer?.missions_completed ?? 0}
</Text>
<Text style={styles.statLabel}>Uppdrag klara</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: levelColor }]}>
{level}
</Text>
<Text style={styles.statLabel}>Nivå</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: '#f59e0b' }]}>
{zoomer?.missions_completed ?? 0}
</Text>
<Text style={styles.statLabel}>Uppdrag</Text>
</View>
</View>
{/* Badges — kommer när backend har stöd */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Badges</Text>
<View style={styles.emptyBadges}>
<Text style={styles.emptyEmoji}>🏅</Text>
<Text style={styles.emptyText}>
Slutför ditt första uppdrag för att låsa upp din första badge!
</Text>
</View>
</View>
{/* Member since */}
{zoomer?.joined_at && (
<Text style={styles.memberSince}>
Zoomer sedan {new Date(zoomer.joined_at).toLocaleDateString('sv-SE', { month: 'long', year: 'numeric' })}
</Text>
)}
{/* Sign out */}
<View style={styles.section}>
<TouchableOpacity style={styles.signOutBtn} onPress={handleSignOut}>
<Text style={styles.signOutText}>Logga ut</Text>
</TouchableOpacity>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center' },
headerSection: {
alignItems: 'center',
paddingTop: 64,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.06)',
},
avatar: {
width: 88, height: 88, borderRadius: 44,
backgroundColor: 'rgba(99,102,241,0.3)',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
borderWidth: 3,
},
avatarText: { color: '#fff', fontSize: 36, fontWeight: '800' },
displayName: { color: '#fff', fontSize: 22, fontWeight: '800', marginBottom: 4 },
email: { color: 'rgba(255,255,255,0.4)', fontSize: 14, marginBottom: 16 },
levelBadge: {
borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8,
borderWidth: 1, marginBottom: 16,
},
levelBadgeText: { fontWeight: '700', fontSize: 13 },
xpBarWrap: { width: '100%', gap: 6 },
xpBarBg: {
height: 6, backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 3, overflow: 'hidden',
},
xpBarFill: { height: '100%', borderRadius: 3 },
xpLabel: { color: 'rgba(255,255,255,0.3)', fontSize: 11, textAlign: 'center' },
statsGrid: {
flexDirection: 'row', flexWrap: 'wrap',
padding: 16, gap: 10,
},
statCard: {
width: '47%',
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 16, padding: 16,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
alignItems: 'center',
},
statValue: { color: '#fff', fontSize: 24, fontWeight: '800', marginBottom: 4 },
statLabel: { color: 'rgba(255,255,255,0.35)', fontSize: 12 },
section: { paddingHorizontal: 16, marginTop: 8 },
sectionTitle: {
color: 'rgba(255,255,255,0.35)', fontSize: 12, fontWeight: '700',
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 12,
},
badgesGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
badgeCard: {
width: '30%',
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 14, padding: 12,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
alignItems: 'center', gap: 4,
},
badgeEmoji: { fontSize: 28 },
badgeLabel: { color: '#fff', fontSize: 11, fontWeight: '600', textAlign: 'center' },
badgeDate: { color: 'rgba(255,255,255,0.3)', fontSize: 9 },
emptyBadges: {
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16, padding: 24,
alignItems: 'center', gap: 8,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)',
},
emptyEmoji: { fontSize: 36 },
emptyText: { color: 'rgba(255,255,255,0.4)', fontSize: 13, textAlign: 'center', lineHeight: 20 },
memberSince: {
color: 'rgba(255,255,255,0.2)', fontSize: 12, textAlign: 'center',
marginTop: 16, marginBottom: 8,
},
signOutBtn: {
borderWidth: 1, borderColor: 'rgba(239,68,68,0.35)',
borderRadius: 14, paddingVertical: 14,
alignItems: 'center', marginTop: 8,
},
signOutText: { color: '#ef4444', fontWeight: '700', fontSize: 15 },
})