Files
boc/iom/quixzoom-app/screens/MissionsScreen.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

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