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