/** * 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 = { 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() const route = useRoute>() const { missionId } = route.params const mapRef = useRef(null) // Live location from store const { currentPosition, heading, followUser, setUserPannedMap, } = useLocationStore() const [mission, setMission] = useState(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([]) const [distance, setDistance] = useState(null) const [bearing, setBearing] = useState(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 ( ) } 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 ( {/* Header */} navigation.goBack()}> ← Tillbaka {/* Map with live tracking */} {/* Mission marker */} {/* Live user location marker */} {currentPosition && ( )} {/* Follow user button */} {/* Distance overlay */} {distance != null && ( Avstånd till uppdrag {formatDistance(distance)} {relativeBearing != null && ( Riktning {getDirectionArrow(relativeBearing)} {relativeBearing.toFixed(0)}° )} {heading != null && ( Din kurs {heading.toFixed(0)}° )} {currentPosition?.coords.speed != null && ( Hastighet {(currentPosition.coords.speed * 3.6).toFixed(1)} km/h )} )} {/* Content */} {/* Category + status row */} {CATEGORY_EMOJI[mission.category] ?? '📍'} {mission.category} {isExpired && Utgången} {spotsLeft <= 3 && !isExpired && ( Bara {spotsLeft} platser kvar )} {/* Title */} {mission.title} {/* Reward + deadline */} Ersättning {(mission.reward_amount / 100).toFixed(0)} SEK {mission.deadline_at && ( Deadline {new Date(mission.deadline_at).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', })} )} Plats {mission.city || 'Skärgården'} {/* Description */} Beskrivning {mission.description} {/* Instructions */} {mission.instructions && ( <> Instruktioner {mission.instructions} )} {/* Requirements */} {mission.requirements?.length > 0 && ( <> Krav {mission.requirements.map((r, i) => ( {r} ))} )} {/* Media picker (only when accepted) */} {accepted && ( <> Dina bilder {mediaUris.map((uri, i) => ( setMediaUris(prev => prev.filter((_, j) => j !== i))} > ))} 📸 Lägg till bild )} {/* Bottom action bar */} {!isExpired && ( {!accepted ? ( {accepting ? : 🎯 Acceptera uppdraget } ) : ( {submitting ? : 📤 Skicka in ({mediaUris.length} bild{mediaUris.length !== 1 ? 'er' : ''}) } )} )} ) } 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 }, })