Compare commits
	
		
			3 Commits
		
	
	
		
			82b73ddadf
			...
			d08dcb9720
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d08dcb9720 | |||
| 91963e378d | |||
| 1ce5045871 | 
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|   "expo": { | ||||
|     "name": "Traintrape-moi", | ||||
|     "slug": "traintrape-moi", | ||||
|     "slug": "traintrape-moi-client", | ||||
|     "version": "1.0.0", | ||||
|     "orientation": "portrait", | ||||
|     "icon": "./assets/images/icon.png", | ||||
| @@ -34,9 +34,7 @@ | ||||
|           "backgroundColor": "#ffffff" | ||||
|         } | ||||
|       ], | ||||
|       [ | ||||
|         "@maplibre/maplibre-react-native" | ||||
|       ], | ||||
|       "@maplibre/maplibre-react-native", | ||||
|       [ | ||||
|         "expo-location", | ||||
|         { | ||||
| @@ -44,7 +42,8 @@ | ||||
|           "isIosBackgroundLocationEnabled": true, | ||||
|           "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location." | ||||
|         } | ||||
|       ] | ||||
|       ], | ||||
|       "expo-task-manager" | ||||
|     ], | ||||
|     "experiments": { | ||||
|       "typedRoutes": true | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { useColorScheme } from '@/hooks/useColorScheme' | ||||
| import { FontAwesome6, MaterialIcons } from '@expo/vector-icons' | ||||
|  | ||||
| export default function TabLayout() { | ||||
|   const colorScheme = useColorScheme(); | ||||
|   const colorScheme = useColorScheme() | ||||
|  | ||||
|   return ( | ||||
|     <Tabs | ||||
| @@ -46,5 +46,5 @@ export default function TabLayout() { | ||||
|         }} | ||||
|       /> | ||||
|     </Tabs> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|   | ||||
| @@ -1,33 +1,18 @@ | ||||
| import { StyleSheet } from 'react-native' | ||||
| import { ThemedView } from '@/components/ThemedView' | ||||
| import { useEffect, useState } from 'react' | ||||
| import "maplibre-gl/dist/maplibre-gl.css" | ||||
|  | ||||
| import * as Location from 'expo-location' | ||||
| import Map from '@/components/map' | ||||
| import { useBackgroundPermissions } from 'expo-location' | ||||
| import Map from '@/components/Map' | ||||
| import { ThemedText } from '@/components/ThemedText' | ||||
|  | ||||
| export default function MapScreen() { | ||||
|   const [location, setLocation] = useState<Location.LocationObject | null>(null) | ||||
|   const [locationAccessGranted, setLocationAccessGranted] = useState(false) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     async function watchPosition() {  | ||||
|       let { status } = await Location.requestForegroundPermissionsAsync() | ||||
|       if (status !== 'granted') { | ||||
|         setLocationAccessGranted(false) | ||||
|         alert("Vous devez activer votre géolocalisation pour utiliser l'application.") | ||||
|         return | ||||
|       } | ||||
|       setLocationAccessGranted(true) | ||||
|       await Location.watchPositionAsync({accuracy: Location.Accuracy.BestForNavigation}, location => setLocation(location)) | ||||
|     } | ||||
|     watchPosition() | ||||
|   }, []) | ||||
|   const [backgroundStatus, requestBackgroundPermission] = useBackgroundPermissions() | ||||
|   if (!backgroundStatus?.granted && backgroundStatus?.canAskAgain) | ||||
|     requestBackgroundPermission() | ||||
|  | ||||
|   return ( | ||||
|     <ThemedView style={styles.page}> | ||||
|       {locationAccessGranted ? <Map location={location} /> : <ThemedText>La géolocalisation est requise pour utiliser la carte.</ThemedText>} | ||||
|       {backgroundStatus?.granted ? <Map /> : <ThemedText>La géolocalisation est requise pour utiliser la carte.</ThemedText>} | ||||
|     </ThemedView> | ||||
|   ) | ||||
| } | ||||
|   | ||||
| @@ -2,14 +2,23 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native | ||||
| import { Stack } from "expo-router" | ||||
| import { useColorScheme } from '@/hooks/useColorScheme' | ||||
| import { StatusBar } from 'expo-status-bar' | ||||
| import { Provider } from 'react-redux' | ||||
| import store from '@/utils/store' | ||||
| import { useStartGeolocationServiceEffect } from '@/utils/geolocation' | ||||
|  | ||||
| export default function RootLayout() { | ||||
|   useStartGeolocationServiceEffect() | ||||
|   const colorScheme = useColorScheme() | ||||
|   return <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> | ||||
|     <Stack> | ||||
|       <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> | ||||
|       <Stack.Screen name="+not-found" /> | ||||
|     </Stack> | ||||
|     <StatusBar style="auto" /> | ||||
|   </ThemeProvider> | ||||
|  | ||||
|   return ( | ||||
|     <Provider store={store}> | ||||
|       <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> | ||||
|         <Stack> | ||||
|           <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> | ||||
|           <Stack.Screen name="+not-found" /> | ||||
|         </Stack> | ||||
|         <StatusBar style="auto" /> | ||||
|       </ThemeProvider> | ||||
|     </Provider> | ||||
|   ) | ||||
| } | ||||
|   | ||||
| @@ -1,45 +0,0 @@ | ||||
| import { PropsWithChildren, useState } from 'react'; | ||||
| import { StyleSheet, TouchableOpacity } from 'react-native'; | ||||
|  | ||||
| import { ThemedText } from '@/components/ThemedText'; | ||||
| import { ThemedView } from '@/components/ThemedView'; | ||||
| import { IconSymbol } from '@/components/ui/IconSymbol'; | ||||
| import { Colors } from '@/constants/Colors'; | ||||
| import { useColorScheme } from '@/hooks/useColorScheme'; | ||||
|  | ||||
| export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { | ||||
|   const [isOpen, setIsOpen] = useState(false); | ||||
|   const theme = useColorScheme() ?? 'light'; | ||||
|  | ||||
|   return ( | ||||
|     <ThemedView> | ||||
|       <TouchableOpacity | ||||
|         style={styles.heading} | ||||
|         onPress={() => setIsOpen((value) => !value)} | ||||
|         activeOpacity={0.8}> | ||||
|         <IconSymbol | ||||
|           name="chevron.right" | ||||
|           size={18} | ||||
|           weight="medium" | ||||
|           color={theme === 'light' ? Colors.light.icon : Colors.dark.icon} | ||||
|           style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }} | ||||
|         /> | ||||
|  | ||||
|         <ThemedText type="defaultSemiBold">{title}</ThemedText> | ||||
|       </TouchableOpacity> | ||||
|       {isOpen && <ThemedView style={styles.content}>{children}</ThemedView>} | ||||
|     </ThemedView> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
|   heading: { | ||||
|     flexDirection: 'row', | ||||
|     alignItems: 'center', | ||||
|     gap: 6, | ||||
|   }, | ||||
|   content: { | ||||
|     marginTop: 6, | ||||
|     marginLeft: 24, | ||||
|   }, | ||||
| }); | ||||
| @@ -1,24 +0,0 @@ | ||||
| import { Link } from 'expo-router'; | ||||
| import { openBrowserAsync } from 'expo-web-browser'; | ||||
| import { type ComponentProps } from 'react'; | ||||
| import { Platform } from 'react-native'; | ||||
|  | ||||
| type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: string }; | ||||
|  | ||||
| export function ExternalLink({ href, ...rest }: Props) { | ||||
|   return ( | ||||
|     <Link | ||||
|       target="_blank" | ||||
|       {...rest} | ||||
|       href={href} | ||||
|       onPress={async (event) => { | ||||
|         if (Platform.OS !== 'web') { | ||||
|           // Prevent the default behavior of linking to the default browser on native. | ||||
|           event.preventDefault(); | ||||
|           // Open the link in an in-app browser. | ||||
|           await openBrowserAsync(href); | ||||
|         } | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,18 +0,0 @@ | ||||
| import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; | ||||
| import { PlatformPressable } from '@react-navigation/elements'; | ||||
| import * as Haptics from 'expo-haptics'; | ||||
|  | ||||
| export function HapticTab(props: BottomTabBarButtonProps) { | ||||
|   return ( | ||||
|     <PlatformPressable | ||||
|       {...props} | ||||
|       onPressIn={(ev) => { | ||||
|         if (process.env.EXPO_OS === 'ios') { | ||||
|           // Add a soft haptic feedback when pressing down on the tabs. | ||||
|           Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | ||||
|         } | ||||
|         props.onPressIn?.(ev); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,37 +0,0 @@ | ||||
| import { StyleSheet } from 'react-native'; | ||||
| import Animated, { | ||||
|   useSharedValue, | ||||
|   useAnimatedStyle, | ||||
|   withTiming, | ||||
|   withRepeat, | ||||
|   withSequence, | ||||
| } from 'react-native-reanimated'; | ||||
|  | ||||
| import { ThemedText } from '@/components/ThemedText'; | ||||
|  | ||||
| export function HelloWave() { | ||||
|   const rotationAnimation = useSharedValue(0); | ||||
|  | ||||
|   rotationAnimation.value = withRepeat( | ||||
|     withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })), | ||||
|     4 // Run the animation 4 times | ||||
|   ); | ||||
|  | ||||
|   const animatedStyle = useAnimatedStyle(() => ({ | ||||
|     transform: [{ rotate: `${rotationAnimation.value}deg` }], | ||||
|   })); | ||||
|  | ||||
|   return ( | ||||
|     <Animated.View style={animatedStyle}> | ||||
|       <ThemedText style={styles.text}>👋</ThemedText> | ||||
|     </Animated.View> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
|   text: { | ||||
|     fontSize: 28, | ||||
|     lineHeight: 32, | ||||
|     marginTop: -6, | ||||
|   }, | ||||
| }); | ||||
| @@ -1,20 +1,21 @@ | ||||
| import { StyleSheet, Text } from 'react-native' | ||||
| import { StyleSheet } from 'react-native' | ||||
| import MapLibreGL, { Camera, FillLayer, LineLayer, MapView, PointAnnotation, RasterLayer, RasterSource, ShapeSource, UserLocation } from '@maplibre/maplibre-react-native' | ||||
| import { LocationObject } from 'expo-location' | ||||
| import { FontAwesome5 } from '@expo/vector-icons' | ||||
| import { circle } from '@turf/circle' | ||||
| import { useLocation } from '@/hooks/useLocation' | ||||
| 
 | ||||
| export default function Map({ location }: { location: LocationObject | null }) { | ||||
| export default function Map() { | ||||
|   const userLocation = useLocation() | ||||
|   MapLibreGL.setAccessToken(null) | ||||
|   const accuracyCircle = circle([location?.coords.longitude ?? 0, location?.coords.latitude ?? 0], location?.coords.accuracy ?? 0, {steps: 64, units: 'meters'}) | ||||
|   const accuracyCircle = circle([userLocation?.coords.longitude ?? 0, userLocation?.coords.latitude ?? 0], userLocation?.coords.accuracy ?? 0, {steps: 64, units: 'meters'}) | ||||
|   return ( | ||||
|     <MapView | ||||
|       logoEnabled={true} | ||||
|       style={styles.map} | ||||
|       styleURL="https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json"> | ||||
|       {/* FIXME Il faudra pouvoir avoir un bouton de suivi pour activer le suivi de la caméro */} | ||||
|       {location && <Camera | ||||
|           defaultSettings={{centerCoordinate: [location?.coords.longitude, location?.coords.latitude], zoomLevel: 15}} />} | ||||
|       {userLocation && <Camera | ||||
|           defaultSettings={{centerCoordinate: [userLocation?.coords.longitude, userLocation?.coords.latitude], zoomLevel: 15}} />} | ||||
|       <RasterSource id="railwaymap-source" tileUrlTemplates={["https://a.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png"]}></RasterSource> | ||||
|       <RasterLayer id="railwaymap-layer" sourceID="railwaymap-source" style={{rasterOpacity: 0.7}} /> | ||||
| 
 | ||||
| @@ -22,7 +23,7 @@ export default function Map({ location }: { location: LocationObject | null }) { | ||||
|       <ShapeSource id="accuracy-radius" shape={accuracyCircle} /> | ||||
|       <FillLayer id="accuracy-radius-fill" sourceID="accuracy-radius" style={{fillOpacity: 0.4, fillColor: 'lightblue'}} aboveLayerID="railwaymap-layer" /> | ||||
|       <LineLayer id="accuracy-radius-border" sourceID="accuracy-radius" style={{lineOpacity: 0.4, lineColor: 'blue'}} aboveLayerID="accuracy-radius-fill" /> | ||||
|       <PointAnnotation id="current-location" coordinate={[location?.coords.longitude ?? 0, location?.coords.latitude ?? 0]}> | ||||
|       <PointAnnotation id="current-location" coordinate={[userLocation?.coords.longitude ?? 2.9, userLocation?.coords.latitude ?? 46.5]}> | ||||
|         <FontAwesome5 name="map-marker-alt" size={24} color="blue" /> | ||||
|       </PointAnnotation> | ||||
|       {/* <UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} /> */} | ||||
							
								
								
									
										39
									
								
								client/components/Map.web.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								client/components/Map.web.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { useLocation } from "@/hooks/useLocation" | ||||
| import { circle } from "@turf/circle" | ||||
| import { type Map as MaplibreGLMap } from "maplibre-gl" | ||||
| import { RLayer, RMap, RMarker, RNavigationControl, RSource, useMap } from "maplibre-react-components" | ||||
| import { useState } from "react" | ||||
|  | ||||
| export default function Map() { | ||||
|   return ( | ||||
|     <RMap | ||||
|         initialCenter={[2.9, 46.5]} | ||||
|         initialZoom={6} | ||||
|         mapStyle="https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json"> | ||||
|       <RNavigationControl position="bottom-right" showCompass={true} showZoom={true} visualizePitch={true} /> | ||||
|  | ||||
|       <RSource id="railwaymap-source" type="raster" tiles={["https://a.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png"]} /> | ||||
|       <RLayer id="railwaymap-layer" type="raster" source="railwaymap-source" paint={{"raster-opacity": 0.7}} /> | ||||
|  | ||||
|       <UserLocation /> | ||||
|     </RMap> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function UserLocation() { | ||||
|   const userLocation = useLocation() | ||||
|   const [firstUserPositionFetched, setFirstUserPositionFetched] = useState(false) | ||||
|   const map: MaplibreGLMap = useMap() | ||||
|   if (userLocation != null && !firstUserPositionFetched) { | ||||
|     setFirstUserPositionFetched(true) | ||||
|     map.flyTo({center: [userLocation.coords.longitude, userLocation.coords.latitude], zoom: 15}) | ||||
|   } | ||||
|   const accuracyCircle = circle([userLocation?.coords.longitude ?? 0, userLocation?.coords.latitude ?? 0], userLocation?.coords.accuracy ?? 0, {steps: 64, units: 'meters'}) | ||||
|   const marker = userLocation ? <RMarker longitude={userLocation?.coords.longitude} latitude={userLocation?.coords.latitude} /> : <></> | ||||
|   return <> | ||||
|     <RSource id="accuracy-radius" type="geojson" data={accuracyCircle} /> | ||||
|     <RLayer id="accuracy-radius-fill" type="fill" source="accuracy-radius" paint={{"fill-color": "lightblue", "fill-opacity": 0.4}} /> | ||||
|     <RLayer id="accuracy-radius-border" type="line" source="accuracy-radius" paint={{"line-color": "blue", "line-opacity": 0.4}} /> | ||||
|     {marker} | ||||
|   </> | ||||
| } | ||||
| @@ -1,82 +0,0 @@ | ||||
| import type { PropsWithChildren, ReactElement } from 'react'; | ||||
| import { StyleSheet } from 'react-native'; | ||||
| import Animated, { | ||||
|   interpolate, | ||||
|   useAnimatedRef, | ||||
|   useAnimatedStyle, | ||||
|   useScrollViewOffset, | ||||
| } from 'react-native-reanimated'; | ||||
|  | ||||
| import { ThemedView } from '@/components/ThemedView'; | ||||
| import { useBottomTabOverflow } from '@/components/ui/TabBarBackground'; | ||||
| import { useColorScheme } from '@/hooks/useColorScheme'; | ||||
|  | ||||
| const HEADER_HEIGHT = 250; | ||||
|  | ||||
| type Props = PropsWithChildren<{ | ||||
|   headerImage: ReactElement; | ||||
|   headerBackgroundColor: { dark: string; light: string }; | ||||
| }>; | ||||
|  | ||||
| export default function ParallaxScrollView({ | ||||
|   children, | ||||
|   headerImage, | ||||
|   headerBackgroundColor, | ||||
| }: Props) { | ||||
|   const colorScheme = useColorScheme() ?? 'light'; | ||||
|   const scrollRef = useAnimatedRef<Animated.ScrollView>(); | ||||
|   const scrollOffset = useScrollViewOffset(scrollRef); | ||||
|   const bottom = useBottomTabOverflow(); | ||||
|   const headerAnimatedStyle = useAnimatedStyle(() => { | ||||
|     return { | ||||
|       transform: [ | ||||
|         { | ||||
|           translateY: interpolate( | ||||
|             scrollOffset.value, | ||||
|             [-HEADER_HEIGHT, 0, HEADER_HEIGHT], | ||||
|             [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] | ||||
|           ), | ||||
|         }, | ||||
|         { | ||||
|           scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), | ||||
|         }, | ||||
|       ], | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <ThemedView style={styles.container}> | ||||
|       <Animated.ScrollView | ||||
|         ref={scrollRef} | ||||
|         scrollEventThrottle={16} | ||||
|         scrollIndicatorInsets={{ bottom }} | ||||
|         contentContainerStyle={{ paddingBottom: bottom }}> | ||||
|         <Animated.View | ||||
|           style={[ | ||||
|             styles.header, | ||||
|             { backgroundColor: headerBackgroundColor[colorScheme] }, | ||||
|             headerAnimatedStyle, | ||||
|           ]}> | ||||
|           {headerImage} | ||||
|         </Animated.View> | ||||
|         <ThemedView style={styles.content}>{children}</ThemedView> | ||||
|       </Animated.ScrollView> | ||||
|     </ThemedView> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
|   container: { | ||||
|     flex: 1, | ||||
|   }, | ||||
|   header: { | ||||
|     height: HEADER_HEIGHT, | ||||
|     overflow: 'hidden', | ||||
|   }, | ||||
|   content: { | ||||
|     flex: 1, | ||||
|     padding: 32, | ||||
|     gap: 16, | ||||
|     overflow: 'hidden', | ||||
|   }, | ||||
| }); | ||||
| @@ -1,26 +0,0 @@ | ||||
| import { circle } from "@turf/circle" | ||||
| import { LocationObject } from "expo-location" | ||||
| import { RLayer, RMap, RMarker, RNavigationControl, RSource } from "maplibre-react-components" | ||||
|  | ||||
| export default function Map({ location }: { location: LocationObject }) { | ||||
|   if (!location) | ||||
|     // FIXME On devrait avoir la position qui se centre sur la position une fois qu'elle est établie | ||||
|     return <></> | ||||
|   const accuracyCircle = circle([location?.coords.longitude ?? 0, location?.coords.latitude ?? 0], location?.coords.accuracy ?? 0, {steps: 64, units: 'meters'}) | ||||
|   return ( | ||||
|     <RMap | ||||
|         initialCenter={[location?.coords.longitude, location?.coords.latitude]} | ||||
|         initialZoom={15} | ||||
|         mapStyle="https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json"> | ||||
|       <RNavigationControl position="bottom-right" showCompass={true} showZoom={true} visualizePitch={true} /> | ||||
|  | ||||
|       <RSource id="railwaymap-source" type="raster" tiles={["https://a.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png"]} /> | ||||
|       <RLayer id="railwaymap-layer" type="raster" source="railwaymap-source" paint={{"raster-opacity": 0.7}} /> | ||||
|  | ||||
|       <RSource id="accuracy-radius" type="geojson" data={accuracyCircle} /> | ||||
|       <RLayer id="accuracy-radius-fill" type="fill" source="accuracy-radius" paint={{"fill-color": "lightblue", "fill-opacity": 0.4}} /> | ||||
|       <RLayer id="accuracy-radius-border" type="line" source="accuracy-radius" paint={{"line-color": "blue", "line-opacity": 0.4}} /> | ||||
|       {location && <RMarker longitude={location?.coords.longitude} latitude={location?.coords.latitude} />} | ||||
|     </RMap> | ||||
|   ) | ||||
| } | ||||
| @@ -1,32 +0,0 @@ | ||||
| import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; | ||||
| import { StyleProp, ViewStyle } from 'react-native'; | ||||
|  | ||||
| export function IconSymbol({ | ||||
|   name, | ||||
|   size = 24, | ||||
|   color, | ||||
|   style, | ||||
|   weight = 'regular', | ||||
| }: { | ||||
|   name: SymbolViewProps['name']; | ||||
|   size?: number; | ||||
|   color: string; | ||||
|   style?: StyleProp<ViewStyle>; | ||||
|   weight?: SymbolWeight; | ||||
| }) { | ||||
|   return ( | ||||
|     <SymbolView | ||||
|       weight={weight} | ||||
|       tintColor={color} | ||||
|       resizeMode="scaleAspectFit" | ||||
|       name={name} | ||||
|       style={[ | ||||
|         { | ||||
|           width: size, | ||||
|           height: size, | ||||
|         }, | ||||
|         style, | ||||
|       ]} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,43 +0,0 @@ | ||||
| // This file is a fallback for using MaterialIcons on Android and web. | ||||
|  | ||||
| import MaterialIcons from '@expo/vector-icons/MaterialIcons'; | ||||
| import { SymbolWeight } from 'expo-symbols'; | ||||
| import React from 'react'; | ||||
| import { OpaqueColorValue, StyleProp, ViewStyle } from 'react-native'; | ||||
|  | ||||
| // Add your SFSymbol to MaterialIcons mappings here. | ||||
| const MAPPING = { | ||||
|   // See MaterialIcons here: https://icons.expo.fyi | ||||
|   // See SF Symbols in the SF Symbols app on Mac. | ||||
|   'house.fill': 'home', | ||||
|   'paperplane.fill': 'send', | ||||
|   'chevron.left.forwardslash.chevron.right': 'code', | ||||
|   'chevron.right': 'chevron-right', | ||||
| } as Partial< | ||||
|   Record< | ||||
|     import('expo-symbols').SymbolViewProps['name'], | ||||
|     React.ComponentProps<typeof MaterialIcons>['name'] | ||||
|   > | ||||
| >; | ||||
|  | ||||
| export type IconSymbolName = keyof typeof MAPPING; | ||||
|  | ||||
| /** | ||||
|  * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. | ||||
|  * | ||||
|  * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons. | ||||
|  */ | ||||
| export function IconSymbol({ | ||||
|   name, | ||||
|   size = 24, | ||||
|   color, | ||||
|   style, | ||||
| }: { | ||||
|   name: IconSymbolName; | ||||
|   size?: number; | ||||
|   color: string | OpaqueColorValue; | ||||
|   style?: StyleProp<ViewStyle>; | ||||
|   weight?: SymbolWeight; | ||||
| }) { | ||||
|   return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />; | ||||
| } | ||||
| @@ -1,22 +0,0 @@ | ||||
| import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; | ||||
| import { BlurView } from 'expo-blur'; | ||||
| import { StyleSheet } from 'react-native'; | ||||
| import { useSafeAreaInsets } from 'react-native-safe-area-context'; | ||||
|  | ||||
| export default function BlurTabBarBackground() { | ||||
|   return ( | ||||
|     <BlurView | ||||
|       // System chrome material automatically adapts to the system's theme | ||||
|       // and matches the native tab bar appearance on iOS. | ||||
|       tint="systemChromeMaterial" | ||||
|       intensity={100} | ||||
|       style={StyleSheet.absoluteFill} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function useBottomTabOverflow() { | ||||
|   const tabHeight = useBottomTabBarHeight(); | ||||
|   const { bottom } = useSafeAreaInsets(); | ||||
|   return tabHeight - bottom; | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| // This is a shim for web and Android where the tab bar is generally opaque. | ||||
| export default undefined; | ||||
|  | ||||
| export function useBottomTabOverflow() { | ||||
|   return 0; | ||||
| } | ||||
							
								
								
									
										9
									
								
								client/hooks/useLocation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								client/hooks/useLocation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { LocationObject } from "expo-location" | ||||
| import { useAppDispatch, useAppSelector } from "./useStore" | ||||
| import { setLocation } from "@/utils/features/location/locationSlice" | ||||
|  | ||||
| export const useLocation = () => useAppSelector((state) => state.location.location) | ||||
| export const useSetLocation = () => (location: LocationObject) => { | ||||
|     const dispatch = useAppDispatch() | ||||
|     dispatch(setLocation(location)) | ||||
| } | ||||
							
								
								
									
										5
									
								
								client/hooks/useStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								client/hooks/useStore.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { AppDispatch, RootState } from '@/utils/store' | ||||
| import { useDispatch, useSelector } from 'react-redux' | ||||
|  | ||||
| export const useAppDispatch = useDispatch.withTypes<AppDispatch>() | ||||
| export const useAppSelector = useSelector.withTypes<RootState>() | ||||
							
								
								
									
										108
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										108
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -12,6 +12,7 @@ | ||||
|         "@maplibre/maplibre-react-native": "^10.0.0-alpha.28", | ||||
|         "@react-navigation/bottom-tabs": "^7.0.0", | ||||
|         "@react-navigation/native": "^7.0.0", | ||||
|         "@reduxjs/toolkit": "^2.4.0", | ||||
|         "@turf/circle": "^7.1.0", | ||||
|         "expo": "~52.0.11", | ||||
|         "expo-blur": "~14.0.1", | ||||
| @@ -26,6 +27,7 @@ | ||||
|         "expo-status-bar": "~2.0.0", | ||||
|         "expo-symbols": "~0.2.0", | ||||
|         "expo-system-ui": "~4.0.4", | ||||
|         "expo-task-manager": "^12.0.3", | ||||
|         "expo-web-browser": "~14.0.1", | ||||
|         "maplibre-gl": "^4.7.1", | ||||
|         "maplibre-react-components": "^0.1.9", | ||||
| @@ -38,7 +40,8 @@ | ||||
|         "react-native-safe-area-context": "4.12.0", | ||||
|         "react-native-screens": "~4.1.0", | ||||
|         "react-native-web": "~0.19.13", | ||||
|         "react-native-webview": "13.12.2" | ||||
|         "react-native-webview": "13.12.2", | ||||
|         "react-redux": "^9.1.2" | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@babel/core": "^7.25.2", | ||||
| @@ -4214,6 +4217,30 @@ | ||||
|         "nanoid": "3.3.7" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@reduxjs/toolkit": { | ||||
|       "version": "2.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.4.0.tgz", | ||||
|       "integrity": "sha512-wJZEuSKj14tvNfxiIiJws0tQN77/rDqucBq528ApebMIRHyWpCanJVQRxQ8WWZC19iCDKxDsGlbAir3F1layxA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "immer": "^10.0.3", | ||||
|         "redux": "^5.0.1", | ||||
|         "redux-thunk": "^3.1.0", | ||||
|         "reselect": "^5.1.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": "^16.9.0 || ^17.0.0 || ^18", | ||||
|         "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "react": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "react-redux": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@remix-run/node": { | ||||
|       "version": "2.15.0", | ||||
|       "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.15.0.tgz", | ||||
| @@ -4844,6 +4871,12 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/use-sync-external-store": { | ||||
|       "version": "0.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", | ||||
|       "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/yargs": { | ||||
|       "version": "17.0.33", | ||||
|       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", | ||||
| @@ -7838,6 +7871,19 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/expo-task-manager": { | ||||
|       "version": "12.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-12.0.3.tgz", | ||||
|       "integrity": "sha512-XNbDWPqBJw9kuWrYFhpcjRBbuxMUlgiFdEUHpm7VmMqGmm86UAZTO20zSGkM0U25yIcmQgsHiEbfV9B2S84dqA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "unimodules-app-loader": "~5.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "expo": "*", | ||||
|         "react-native": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/expo-updates-interface": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.0.0.tgz", | ||||
| @@ -8816,6 +8862,16 @@ | ||||
|         "node": ">=16.x" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/immer": { | ||||
|       "version": "10.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", | ||||
|       "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", | ||||
|       "license": "MIT", | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/immer" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/import-fresh": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", | ||||
| @@ -13232,6 +13288,29 @@ | ||||
|         "async-limiter": "~1.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-redux": { | ||||
|       "version": "9.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", | ||||
|       "integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@types/use-sync-external-store": "^0.0.3", | ||||
|         "use-sync-external-store": "^1.0.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@types/react": "^18.2.25", | ||||
|         "react": "^18.0", | ||||
|         "redux": "^5.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@types/react": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "redux": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-refresh": { | ||||
|       "version": "0.14.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", | ||||
| @@ -13321,6 +13400,21 @@ | ||||
|         "node": ">=0.10.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/redux": { | ||||
|       "version": "5.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", | ||||
|       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/redux-thunk": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", | ||||
|       "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
|         "redux": "^5.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/regenerate": { | ||||
|       "version": "1.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", | ||||
| @@ -13442,6 +13536,12 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/reselect": { | ||||
|       "version": "5.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", | ||||
|       "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/resolve": { | ||||
|       "version": "1.22.8", | ||||
|       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", | ||||
| @@ -15348,6 +15448,12 @@ | ||||
|         "node": ">=4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/unimodules-app-loader": { | ||||
|       "version": "5.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-5.0.0.tgz", | ||||
|       "integrity": "sha512-0Zc3u344NmlvyQBmcgnxHcQhrLeFV4hn80U6S4YwAfaexXCWmiHOzMe4+P+YhgHiRWb5lJgadr08hLbee3XTHg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/union-value": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", | ||||
|   | ||||
| @@ -18,6 +18,7 @@ | ||||
|     "@maplibre/maplibre-react-native": "^10.0.0-alpha.28", | ||||
|     "@react-navigation/bottom-tabs": "^7.0.0", | ||||
|     "@react-navigation/native": "^7.0.0", | ||||
|     "@reduxjs/toolkit": "^2.4.0", | ||||
|     "@turf/circle": "^7.1.0", | ||||
|     "expo": "~52.0.11", | ||||
|     "expo-blur": "~14.0.1", | ||||
| @@ -32,6 +33,7 @@ | ||||
|     "expo-status-bar": "~2.0.0", | ||||
|     "expo-symbols": "~0.2.0", | ||||
|     "expo-system-ui": "~4.0.4", | ||||
|     "expo-task-manager": "^12.0.3", | ||||
|     "expo-web-browser": "~14.0.1", | ||||
|     "maplibre-gl": "^4.7.1", | ||||
|     "maplibre-react-components": "^0.1.9", | ||||
| @@ -44,7 +46,8 @@ | ||||
|     "react-native-safe-area-context": "4.12.0", | ||||
|     "react-native-screens": "~4.1.0", | ||||
|     "react-native-web": "~0.19.13", | ||||
|     "react-native-webview": "13.12.2" | ||||
|     "react-native-webview": "13.12.2", | ||||
|     "react-redux": "^9.1.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.25.2", | ||||
|   | ||||
							
								
								
									
										24
									
								
								client/utils/features/location/locationSlice.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								client/utils/features/location/locationSlice.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { createSlice, PayloadAction } from '@reduxjs/toolkit' | ||||
| import { LocationObject } from 'expo-location' | ||||
|  | ||||
| interface LocationState { | ||||
|   location: LocationObject | null | ||||
| } | ||||
|  | ||||
| const initialState: LocationState = { | ||||
|   location: null | ||||
| } | ||||
|  | ||||
| export const locationSlice = createSlice({ | ||||
|   name: 'location', | ||||
|   initialState: initialState, | ||||
|   reducers: { | ||||
|     setLocation: (state, action: PayloadAction<LocationObject>) => { | ||||
|       state.location = action.payload | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| export const { setLocation } = locationSlice.actions | ||||
|  | ||||
| export default locationSlice.reducer | ||||
							
								
								
									
										60
									
								
								client/utils/geolocation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								client/utils/geolocation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import * as Location from 'expo-location' | ||||
| import * as TaskManager from 'expo-task-manager' | ||||
| import { Platform } from 'react-native' | ||||
| import { setLocation } from './features/location/locationSlice' | ||||
| import store from './store' | ||||
| import { useEffect } from 'react' | ||||
|  | ||||
| const LOCATION_TASK = "fetch-geolocation" | ||||
|  | ||||
| TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => { | ||||
|   if (error) { | ||||
|     console.error(error) | ||||
|     return | ||||
|   } | ||||
|   const { locations } = data | ||||
|   store.dispatch(setLocation(locations.at(-1))) | ||||
|   for (let location of locations) { | ||||
|     // TODO Envoyer les positions au serveur | ||||
|   } | ||||
| }) | ||||
|    | ||||
| export async function startGeolocationService(): Promise<void | (() => void)> { | ||||
|   if (Platform.OS !== "web" && await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK)) | ||||
|     return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK) | ||||
|  | ||||
|   await Location.enableNetworkProviderAsync().catch(error => alert(error)) | ||||
|  | ||||
|   const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync() | ||||
|   if (foregroundStatus !== 'granted') | ||||
|     alert("Vous devez activer votre géolocalisation pour utiliser l'application.") | ||||
|  | ||||
|   const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync() | ||||
|   if (backgroundStatus !== 'granted') | ||||
|     alert("Vous devez activer votre géolocalisation en arrière-plan pour utiliser l'application.") | ||||
|  | ||||
|   if (Platform.OS !== "web") { | ||||
|     await Location.startLocationUpdatesAsync(LOCATION_TASK, { | ||||
|       accuracy: Location.Accuracy.BestForNavigation, | ||||
|       activityType: Location.ActivityType.OtherNavigation, | ||||
|       deferredUpdatesInterval: 100, | ||||
|       foregroundService: { | ||||
|         killServiceOnDestroy: false, | ||||
|         notificationBody: "Géolocalisation activée pour « Traintrape-moi »", | ||||
|         notificationTitle: "Traintrape-moi", | ||||
|         notificationColor: "#FFFF00", | ||||
|       } | ||||
|     }) | ||||
|     return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK) | ||||
|   } | ||||
|   else { | ||||
|     const locationSubscription = await Location.watchPositionAsync({accuracy: Location.Accuracy.BestForNavigation}, location_nouveau => store.dispatch(setLocation(location_nouveau))) | ||||
|     return locationSubscription.remove | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const useStartGeolocationServiceEffect = () => useEffect(() => { | ||||
|   let cleanup: void | (() => void) = () => {} | ||||
|   startGeolocationService().then(result => cleanup = result) | ||||
|   return cleanup | ||||
| }, []) | ||||
							
								
								
									
										13
									
								
								client/utils/store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								client/utils/store.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { configureStore } from '@reduxjs/toolkit' | ||||
| import locationReducer from './features/location/locationSlice' | ||||
|  | ||||
| const store = configureStore({ | ||||
|   reducer: { | ||||
|     location: locationReducer, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| export default store | ||||
|  | ||||
| export type RootState = ReturnType<typeof store.getState> | ||||
| export type AppDispatch = typeof store.dispatch | ||||
		Reference in New Issue
	
	Block a user