/** * 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 = { 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() // Read live position from store const { currentPosition } = useLocationStore() const [allMissions, setAllMissions] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [category, setCategory] = useState('alla') const [sort, setSort] = useState('distance') const [userLat, setUserLat] = useState(null) const [userLng, setUserLng] = useState(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 ( Hämtar uppdrag nära dig… ) } return ( Uppdrag {/* Live position indicator */} {currentPosition && ( 📍 {currentPosition.coords.latitude.toFixed(4)}, {currentPosition.coords.longitude.toFixed(4)} {currentPosition.coords.accuracy && ` (±${Math.round(currentPosition.coords.accuracy)}m)`} )} {/* Category filter */} {CATEGORIES.map(c => ( setCategory(c.key)} > {c.emoji} {c.label} ))} {/* Sort bar */} Sortera: {(['distance', 'reward', 'deadline'] as SortKey[]).map(s => ( setSort(s)} > {s === 'distance' ? 'Avstånd' : s === 'reward' ? 'Ersättning' : 'Deadline'} ))} {/* Count */} {filtered.length} uppdrag {category !== 'alla' ? `i "${category}"` : 'totalt'} m.id} contentContainerStyle={{ paddingBottom: 32 }} refreshControl={ } renderItem={({ item: m }) => ( navigation.navigate('MissionDetail', { missionId: m.id })} activeOpacity={0.75} > {m.category} {(m.reward_amount / 100).toFixed(0)} SEK {m.title} {m.description} 📍 {m.city || 'Stockholms skärgård'} {m.distance_meters != null ? ` · ${formatDistance(m.distance_meters)}` : ''} {m.deadline_at && ( ⏱ {timeLeft(m.deadline_at)} )} )} ListEmptyComponent={ 🎯 Inga uppdrag just nu Prova en annan kategori eller dra för att uppdatera. } /> ) } 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 }, })