Files
boc/iom/quixzoom-app/screens/MissionDetailScreen.tsx
T
Bernt 6989a98d75 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
2026-07-07 07:11:50 +00:00

594 lines
20 KiB
TypeScript

/**
* 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 },
})