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
333 lines
9.9 KiB
TypeScript
333 lines
9.9 KiB
TypeScript
/**
|
||
* HomeScreen — karta med live-uppdrag + snabbstatistik (intjänat idag)
|
||
* Integrerad med useLiveLocation() för kontinuerlig GPS-tracking
|
||
*/
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import {
|
||
View, Text, StyleSheet, TouchableOpacity,
|
||
ActivityIndicator, Platform,
|
||
} from 'react-native'
|
||
import MapView, { Marker, Region, PROVIDER_DEFAULT } from 'react-native-maps'
|
||
import { useNavigation } from '@react-navigation/native'
|
||
import { missions, wallet, type Mission, type WalletBalance } from '../lib/api'
|
||
import { useLiveLocation } from '../src/hooks/useLiveLocation'
|
||
import { useHeading } from '../src/hooks/useHeading'
|
||
import { useLocationStore } from '../src/stores/locationStore'
|
||
import { UserLocationMarker } from '../src/components/gps/UserLocationMarker'
|
||
import { FollowUserButton } from '../src/components/gps/FollowUserButton'
|
||
import { GpsDiagnostics } from '../src/components/gps/GpsDiagnostics'
|
||
|
||
const STOCKHOLM_ARCHIPELAGO: Region = {
|
||
latitude: 59.45,
|
||
longitude: 18.5,
|
||
latitudeDelta: 0.8,
|
||
longitudeDelta: 0.8,
|
||
}
|
||
|
||
const CATEGORY_EMOJI: Record<string, string> = {
|
||
infrastruktur: '🏗️',
|
||
vägar: '🛤️',
|
||
hamnar: '⚓',
|
||
bryggor: '🚢',
|
||
fritidshus: '🏠',
|
||
miljö: '🌿',
|
||
övrigt: '📍',
|
||
}
|
||
|
||
export function HomeScreen() {
|
||
const navigation = useNavigation<any>()
|
||
const mapRef = useRef<MapView>(null)
|
||
|
||
// Initialize live location tracking
|
||
useLiveLocation()
|
||
useHeading()
|
||
|
||
const {
|
||
currentPosition,
|
||
followUser,
|
||
setUserPannedMap,
|
||
locationAuthStatus,
|
||
} = useLocationStore()
|
||
|
||
const [region, setRegion] = useState<Region>(STOCKHOLM_ARCHIPELAGO)
|
||
const [nearbyMissions, setNearbyMissions] = useState<Mission[]>([])
|
||
const [balance, setBalance] = useState<WalletBalance | null>(null)
|
||
const [loadingMissions, setLoadingMissions] = useState(false)
|
||
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||
const [initialLocationSet, setInitialLocationSet] = useState(false)
|
||
|
||
// Center map on first position fix
|
||
useEffect(() => {
|
||
if (currentPosition && !initialLocationSet) {
|
||
const newRegion: Region = {
|
||
latitude: currentPosition.coords.latitude,
|
||
longitude: currentPosition.coords.longitude,
|
||
latitudeDelta: 0.5,
|
||
longitudeDelta: 0.5,
|
||
}
|
||
setRegion(newRegion)
|
||
mapRef.current?.animateToRegion(newRegion, 800)
|
||
fetchNearby(currentPosition.coords.latitude, currentPosition.coords.longitude)
|
||
setInitialLocationSet(true)
|
||
}
|
||
}, [currentPosition, initialLocationSet])
|
||
|
||
// Auto-follow user when followUser is true
|
||
useEffect(() => {
|
||
if (followUser && currentPosition && mapRef.current) {
|
||
const newRegion: Region = {
|
||
latitude: currentPosition.coords.latitude,
|
||
longitude: currentPosition.coords.longitude,
|
||
latitudeDelta: region.latitudeDelta,
|
||
longitudeDelta: region.longitudeDelta,
|
||
}
|
||
mapRef.current.animateToRegion(newRegion, 300)
|
||
}
|
||
}, [currentPosition, followUser, region.latitudeDelta, region.longitudeDelta])
|
||
|
||
// Wallet balance
|
||
useEffect(() => {
|
||
wallet.balance().then(setBalance).catch(() => {})
|
||
}, [])
|
||
|
||
const fetchNearby = useCallback(async (lat: number, lng: number) => {
|
||
setLoadingMissions(true)
|
||
try {
|
||
const data = await missions.nearby(lat, lng, 60)
|
||
setNearbyMissions(data)
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
setLoadingMissions(false)
|
||
}
|
||
}, [])
|
||
|
||
const onRegionChangeComplete = useCallback((r: Region) => {
|
||
setRegion(r)
|
||
fetchNearby(r.latitude, r.longitude)
|
||
}, [fetchNearby])
|
||
|
||
const onPanDrag = useCallback(() => {
|
||
setUserPannedMap(true)
|
||
}, [setUserPannedMap])
|
||
|
||
const availableSEK = balance ? ((balance.balance - balance.pending_payouts) / 100).toFixed(0) : '—'
|
||
const pendingSEK = balance ? (balance.pending_payouts / 100).toFixed(0) : '—'
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* Map */}
|
||
<MapView
|
||
ref={mapRef}
|
||
style={styles.map}
|
||
provider={PROVIDER_DEFAULT}
|
||
initialRegion={STOCKHOLM_ARCHIPELAGO}
|
||
region={region}
|
||
onRegionChangeComplete={onRegionChangeComplete}
|
||
onPanDrag={onPanDrag}
|
||
showsUserLocation={false} // We use custom UserLocationMarker
|
||
showsMyLocationButton={false}
|
||
rotateEnabled={false} // Map stays north-up, marker rotates
|
||
>
|
||
{/* Custom user location marker with heading */}
|
||
{currentPosition && (
|
||
<Marker
|
||
coordinate={{
|
||
latitude: currentPosition.coords.latitude,
|
||
longitude: currentPosition.coords.longitude,
|
||
}}
|
||
anchor={{ x: 0.5, y: 0.5 }}
|
||
flat={true}
|
||
>
|
||
<UserLocationMarker />
|
||
</Marker>
|
||
)}
|
||
|
||
{nearbyMissions.map(m => (
|
||
<Marker
|
||
key={m.id}
|
||
coordinate={{ latitude: m.latitude, longitude: m.longitude }}
|
||
title={m.title}
|
||
description={`${(m.reward_amount / 100).toFixed(0)} SEK`}
|
||
onCalloutPress={() => navigation.navigate('MissionDetail', { missionId: m.id })}
|
||
>
|
||
<View style={styles.markerBubble}>
|
||
<Text style={styles.markerEmoji}>
|
||
{CATEGORY_EMOJI[m.category] ?? '📍'}
|
||
</Text>
|
||
<Text style={styles.markerReward}>
|
||
{(m.reward_amount / 100).toFixed(0)} kr
|
||
</Text>
|
||
</View>
|
||
</Marker>
|
||
))}
|
||
</MapView>
|
||
|
||
{/* Loading indicator */}
|
||
{loadingMissions && (
|
||
<View style={styles.loadingOverlay}>
|
||
<ActivityIndicator color="#6366f1" size="small" />
|
||
</View>
|
||
)}
|
||
|
||
{/* GPS Diagnostics toggle */}
|
||
<TouchableOpacity
|
||
style={styles.diagnosticsToggle}
|
||
onPress={() => setShowDiagnostics(prev => !prev)}
|
||
>
|
||
<Text style={styles.diagnosticsToggleText}>
|
||
{showDiagnostics ? '🔧' : '🛠️'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
{/* GPS Diagnostics overlay */}
|
||
{showDiagnostics && <GpsDiagnostics />}
|
||
|
||
{/* Stats card */}
|
||
<View style={styles.statsCard}>
|
||
<View style={styles.statItem}>
|
||
<Text style={styles.statLabel}>Tillgängligt</Text>
|
||
<Text style={styles.statValue}>{availableSEK} kr</Text>
|
||
</View>
|
||
<View style={styles.statDivider} />
|
||
<View style={styles.statItem}>
|
||
<Text style={styles.statLabel}>Under behandling</Text>
|
||
<Text style={[styles.statValue, { color: '#f59e0b' }]}>{pendingSEK} kr</Text>
|
||
</View>
|
||
<View style={styles.statDivider} />
|
||
<View style={styles.statItem}>
|
||
<Text style={styles.statLabel}>Uppdrag nära</Text>
|
||
<Text style={[styles.statValue, { color: '#10b981' }]}>{nearbyMissions.length}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Follow user button */}
|
||
<View style={styles.followBtnContainer}>
|
||
<FollowUserButton />
|
||
</View>
|
||
|
||
{/* Location status indicator */}
|
||
{locationAuthStatus !== 'granted' && locationAuthStatus !== null && (
|
||
<View style={styles.locationWarning}>
|
||
<Text style={styles.locationWarningText}>
|
||
⚠️ GPS-behörighet: {locationAuthStatus}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Missions list button */}
|
||
<TouchableOpacity
|
||
style={styles.missionsBtn}
|
||
onPress={() => navigation.navigate('Uppdrag')}
|
||
>
|
||
<Text style={styles.missionsBtnText}>
|
||
🎯 {nearbyMissions.length} uppdrag nära dig
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#0A0A1B' },
|
||
map: { flex: 1 },
|
||
|
||
markerBubble: {
|
||
backgroundColor: '#6366f1',
|
||
borderRadius: 20,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 6,
|
||
alignItems: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.4,
|
||
shadowRadius: 4,
|
||
elevation: 4,
|
||
},
|
||
markerEmoji: { fontSize: 14 },
|
||
markerReward: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||
|
||
loadingOverlay: {
|
||
position: 'absolute',
|
||
top: Platform.OS === 'ios' ? 60 : 16,
|
||
right: 16,
|
||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||
borderRadius: 20,
|
||
padding: 8,
|
||
},
|
||
|
||
diagnosticsToggle: {
|
||
position: 'absolute',
|
||
top: Platform.OS === 'ios' ? 60 : 16,
|
||
right: 60,
|
||
width: 36,
|
||
height: 36,
|
||
backgroundColor: 'rgba(10,10,27,0.9)',
|
||
borderRadius: 18,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.15)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
diagnosticsToggleText: { fontSize: 16 },
|
||
|
||
statsCard: {
|
||
position: 'absolute',
|
||
top: Platform.OS === 'ios' ? 60 : 16,
|
||
left: 16,
|
||
right: 104,
|
||
flexDirection: 'row',
|
||
backgroundColor: 'rgba(10,10,27,0.92)',
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.1)',
|
||
padding: 12,
|
||
alignItems: 'center',
|
||
},
|
||
statItem: { flex: 1, alignItems: 'center' },
|
||
statLabel: { color: 'rgba(255,255,255,0.45)', fontSize: 10, marginBottom: 2 },
|
||
statValue: { color: '#fff', fontSize: 15, fontWeight: '800' },
|
||
statDivider: { width: 1, height: 28, backgroundColor: 'rgba(255,255,255,0.1)' },
|
||
|
||
followBtnContainer: {
|
||
position: 'absolute',
|
||
bottom: 100,
|
||
right: 16,
|
||
},
|
||
|
||
locationWarning: {
|
||
position: 'absolute',
|
||
top: Platform.OS === 'ios' ? 120 : 76,
|
||
left: 16,
|
||
right: 16,
|
||
backgroundColor: 'rgba(245,158,11,0.2)',
|
||
borderRadius: 12,
|
||
padding: 10,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(245,158,11,0.4)',
|
||
alignItems: 'center',
|
||
},
|
||
locationWarningText: {
|
||
color: '#f59e0b',
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
|
||
missionsBtn: {
|
||
position: 'absolute',
|
||
bottom: 32,
|
||
left: 16,
|
||
right: 16,
|
||
backgroundColor: '#6366f1',
|
||
borderRadius: 16,
|
||
paddingVertical: 16,
|
||
alignItems: 'center',
|
||
shadowColor: '#6366f1',
|
||
shadowOffset: { width: 0, height: 4 },
|
||
shadowOpacity: 0.4,
|
||
shadowRadius: 12,
|
||
elevation: 8,
|
||
},
|
||
missionsBtnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||
})
|