6989a98d75
- 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
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
/**
|
|
* 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 },
|
|
})
|