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

333 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
})