Modify folder structure

This commit is contained in:
Sebastien Riviere
2026-02-18 00:45:38 +01:00
parent 2dfddd86e6
commit 4d8dcd241c
56 changed files with 27 additions and 27 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,39 @@
// React
import { forwardRef } from 'react';
import { TouchableHighlight, View, Text, StyleSheet } from "react-native";
export const CustomButton = forwardRef(function CustomButton({ label, onPress }, ref) {
return (
<View style={styles.buttonContainer}>
<TouchableHighlight style={styles.button} onPress={onPress} ref={ref}>
<Text style={styles.buttonLabel}>{label}</Text>
</TouchableHighlight>
</View>
);
});
const styles = StyleSheet.create({
buttonContainer: {
width: "100%",
maxWidth: 240,
height: 80,
alignItems: 'center',
justifyContent: 'center',
padding: 3,
borderWidth: 4,
borderColor: '#888',
borderRadius: 18
},
button: {
borderRadius: 10,
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#555'
},
buttonLabel: {
color: '#fff',
fontSize: 16,
},
});

View File

@@ -0,0 +1,178 @@
// React
import { useState, useEffect, useMemo, Fragment } from 'react';
import { ScrollView, View, Text, Image, StyleSheet, TouchableOpacity, TouchableHighlight, Alert } from 'react-native';
import Collapsible from 'react-native-collapsible';
import LinearGradient from 'react-native-linear-gradient';
// Components
import { CustomImage } from './image';
import { CustomTextInput } from './input';
import { Stat } from './stat';
// Contexts
import { useTeamConnexion } from '../context/teamConnexionContext';
import { useTeamContext } from '../context/teamContext';
// Hooks
import { useTimeDifference } from '../hook/useTimeDifference';
import { useGame } from '../hook/useGame';
// Services
import { enemyImage } from '../services/imageService';
// Util
import { secondsToHHMMSS } from '../util/functions';
// Constants
import { GAME_STATE, COLORS } from '../constants';
export const Drawer = ({ height }) => {
const { teamId } = useTeamConnexion();
const [collapsibleState, setCollapsibleState] = useState(true);
const [enemyCaptureCode, setEnemyCaptureCode] = useState("");
const {teamInfos, gameState, startDate} = useTeamContext();
const {enemyName, captureCode, name, distance, finishDate, nCaptures, nSentLocation, hasHandicap} = teamInfos;
const {capture} = useGame();
const [timeSinceStart] = useTimeDifference(startDate, 1000);
const [captureStatus, setCaptureStatus] = useState(0); // 0 : no capture | 1 : waiting for response from server | 2 : capture failed | 3 : capture succesful
const captureStatusColor = {0: "#777", 1: "#FFA500", 2: "#FF6B6B", 3: "#81C784"};
const avgSpeed = useMemo(() => {
const hours = (finishDate ? (finishDate - startDate) : timeSinceStart*1000) / 1000 / 3600;
if (hours <= 0 || distance <= 0) return 0;
const km = distance / 1000;
const speed = km / hours;
return parseFloat(speed.toFixed(1));
}, [finishDate, startDate, timeSinceStart, distance]);
// Capture state update
useEffect(() => {
if (captureStatus == 2 || captureStatus == 3) {
const timeout = setTimeout(() => {
setCaptureStatus(0);
}, 3000);
return () => clearTimeout(timeout);
}
}, [captureStatus]);
const handleCapture = () => {
if (captureStatus != 1) {
setCaptureStatus(1);
capture(enemyCaptureCode)
.then((response) => {
if (response.hasCaptured) {
setCaptureStatus(3);
} else {
setCaptureStatus(2);
}
})
.catch(() => {
Alert.alert("Échec", "La connexion au serveur a échoué.");
setCaptureStatus(2);
});
setEnemyCaptureCode("");
}
};
return (
<View style={styles.outerDrawerContainer}>
<LinearGradient colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.5)']} style={{height: 70, width: "100%", position: "absolute", top: -30}}/>
<View style={styles.innerDrawerContainer}>
<TouchableHighlight onPress={() => setCollapsibleState(!collapsibleState)} style={styles.collapsibleButton} underlayColor="#d9d9d9">
<Image source={require('../assets/images/arrow.png')} style={{width: 20, height: 20, transform: [{ scaleY: collapsibleState ? 1 : -1 }] }} resizeMode="contain"></Image>
</TouchableHighlight>
<Collapsible style={[styles.collapsibleWindow, {height: height - 44}]} title="Collapse" collapsed={collapsibleState}>
<ScrollView contentContainerStyle={styles.collapsibleContent}>
{ gameState == GAME_STATE.PLAYING &&
<Text style={{fontSize: 22, fontWeight: "bold", textAlign: "center"}}>Code de {(name ?? "Indisponible")} : {String(captureCode).padStart(4,"0")}</Text>
}
{ gameState == GAME_STATE.PLAYING && !hasHandicap && <Fragment>
<View style={styles.imageContainer}>
<Text style={{fontSize: 15, margin: 5}}>{"Cible (" + (enemyName ?? "Indisponible") + ")"}</Text>
<CustomImage source={enemyImage(teamId)} canZoom/>
</View>
<View style={styles.actionsContainer}>
<View style={styles.actionsLeftContainer}>
<CustomTextInput style={{borderColor: captureStatusColor[captureStatus]}} value={enemyCaptureCode} inputMode="numeric" placeholder="Code cible" onChangeText={setEnemyCaptureCode}/>
</View>
<View style={styles.actionsRightContainer}>
<TouchableOpacity style={styles.button} onPress={handleCapture}>
<Image source={require("../assets/images/target/white.png")} style={{width: 40, height: 40}} resizeMode="contain"/>
</TouchableOpacity>
</View>
</View>
</Fragment>}
<View style={{gap: 15, width: "100%", marginVertical: 15}}>
<View style={{flexDirection: "row", justifyContent: "space-around"}}>
<Stat source={require('../assets/images/distance.png')} description={"Distance parcourue"}>{Math.floor(distance / 100) / 10}km</Stat>
<Stat source={require('../assets/images/time.png')} description={"Temps écoulé au format HH:MM:SS"}>{secondsToHHMMSS((finishDate ? Math.floor((finishDate - startDate) / 1000) : timeSinceStart))}</Stat>
<Stat source={require('../assets/images/running.png')} description={"Vitesse moyenne"}>{avgSpeed}km/h</Stat>
</View>
<View style={{flexDirection: "row", justifyContent: "space-around"}}>
<Stat source={require('../assets/images/target/black.png')} description={"Nombre total de captures par votre équipe"}>{nCaptures}</Stat>
<Stat source={require('../assets/images/update_position.png')} description={"Nombre total d'envois de votre position"}>{nSentLocation}</Stat>
</View>
</View>
</ScrollView>
</Collapsible>
</View>
</View>
);
};
const styles = StyleSheet.create({
outerDrawerContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
innerDrawerContainer: {
width: "100%",
backgroundColor: COLORS.background,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
},
collapsibleButton: {
justifyContent: 'center',
alignItems: 'center',
width: "100%",
height: 45
},
collapsibleWindow: {
width: "100%",
justifyContent: 'center',
backgroundColor: COLORS.background,
},
collapsibleContent: {
paddingHorizontal: 15,
},
imageContainer: {
width: "100%",
alignItems: "center",
justifyContent: "center",
marginTop: 15
},
actionsContainer: {
flexDirection: "row",
width: "100%",
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 15
},
actionsLeftContainer: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
marginRight: 15
},
actionsRightContainer: {
width: 100,
alignItems: 'center',
justifyContent: 'center'
},
button: {
borderRadius: 12,
width: '100%',
height: 75,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#444'
},
});

View File

@@ -0,0 +1,37 @@
// React
import { useState } from 'react';
import { StyleSheet, View, Image, TouchableOpacity } from "react-native";
import ImageViewing from 'react-native-image-viewing';
export const CustomImage = ({ source, canZoom, onPress }) => {
// canZoom : boolean
const [isModalVisible, setIsModalVisible] = useState(false);
return (
<View style={styles.container}>
<TouchableOpacity onPress={canZoom ? () => setIsModalVisible(true) : onPress}>
<Image style={styles.image} resizeMode="contain" source={source}/>
</TouchableOpacity>
<ImageViewing
images={[source]}
visible={isModalVisible}
onRequestClose={() => setIsModalVisible(false)}
swipeToCloseEnabled={false}
doubleTapToZoomEnabled={false}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
width: "100%",
alignItems: "center",
justifyContent: "center"
},
image: {
width: "100%",
height: undefined,
aspectRatio: 1.5
}
});

View File

@@ -0,0 +1,27 @@
// React
import { TextInput, StyleSheet } from 'react-native';
export const CustomTextInput = ({ style, value, inputMode, placeholder, onChangeText }) => {
return (
<TextInput
value={value}
inputMode={inputMode}
style={[styles.input, style]}
placeholder={placeholder}
multiline={false}
onChangeText={onChangeText}
/>
);
};
const styles = StyleSheet.create({
input: {
width: "100%",
padding: 15,
borderColor: '#777',
borderRadius: 12,
borderWidth: 2,
backgroundColor: '#fff',
fontSize: 20,
},
});

View File

@@ -0,0 +1,71 @@
// React
import { Fragment } from 'react';
import { Polygon } from 'react-native-maps';
import { circleToPolygon } from '../util/functions';
export const InvertedPolygon = ({id, coordinates, fillColor}) => {
// We create 3 rectangles covering earth, with the first rectangle centered on the hole
const shift = Math.floor(coordinates[0].longitude);
const lat = 85;
const lon = 60;
const worldOuterBounds1 = [
{ latitude: -lat, longitude: -lon + shift },
{ latitude: -lat, longitude: lon + shift },
{ latitude: lat, longitude: lon + shift },
{ latitude: lat, longitude: -lon + shift },
];
const worldOuterBounds2 = [
{ latitude: -lat, longitude: -lon + 120 + shift },
{ latitude: -lat, longitude: lon + 120 + shift },
{ latitude: lat, longitude: lon + 120 + shift },
{ latitude: lat, longitude: -lon + 120 + shift },
];
const worldOuterBounds3 = [
{ latitude: -lat, longitude: -lon + 240 + shift },
{ latitude: -lat, longitude: lon + 240 + shift },
{ latitude: lat, longitude: lon + 240 + shift },
{ latitude: lat, longitude: -lon + 240 + shift },
];
return <Fragment>
<Polygon
key={`${id}-mask-1`}
geodesic={true}
holes={[coordinates]}
coordinates={worldOuterBounds1}
fillColor={fillColor}
strokeColor="rgba(0, 0, 0, 0)"
/>
<Polygon
key={`${id}-mask-2`}
geodesic={true}
coordinates={worldOuterBounds2}
fillColor={fillColor}
strokeColor="rgba(0, 0, 0, 0)"
/>
<Polygon
key={`${id}-mask-3`}
geodesic={true}
coordinates={worldOuterBounds3}
fillColor={fillColor}
strokeColor="rgba(0, 0, 0, 0)"
/>
</Fragment>;
};
export const InvertedCircle = ({id, center, radius, fillColor}) => {
return <InvertedPolygon id={id} coordinates={circleToPolygon({center: center, radius: radius})} fillColor={fillColor} />;
};
export const DashedCircle = ({id, center, radius, fillColor, strokeColor, strokeWidth, lineDashPattern}) => {
return (
<Polygon
key={id}
coordinates={circleToPolygon({center: center, radius: radius})}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
lineDashPattern={lineDashPattern}
/>
);
};

View File

@@ -0,0 +1,171 @@
// React
import { useState, useEffect, useMemo, useRef } from 'react';
import { View, Image, Alert, StyleSheet, TouchableOpacity } from 'react-native';
import MapView, { Marker, Circle, Polygon } from 'react-native-maps';
import LinearGradient from 'react-native-linear-gradient';
// Components
import { DashedCircle, InvertedCircle, InvertedPolygon } from './layer';
// Contexts
import { useTeamContext } from '../context/teamContext';
// Hook
import { useLocation } from '../hook/useLocation';
// Util
import { ZONE_TYPES, INITIAL_REGIONS, GAME_STATE } from '../constants';
export const CustomMap = () => {
const { location } = useLocation();
const {teamInfos, zoneType, zoneExtremities, gameState} = useTeamContext();
const {enemyLocation, startingArea, lastSentLocation, hasHandicap} = teamInfos;
const [centerMap, setCenterMap] = useState(true);
const mapRef = useRef(null);
// Center the map on user position
useEffect(() => {
if (centerMap && location && mapRef.current) {
mapRef.current.animateToRegion({...location, latitudeDelta: 0, longitudeDelta: 0.02}, 1000);
}
}, [centerMap, location]);
// Map layers
const latToLatitude = (pos) => ({latitude: pos.lat, longitude: pos.lng});
const startZone = useMemo(() => {
if (gameState != GAME_STATE.PLACEMENT || !startingArea) return null;
return (
<Circle key="start-zone" center={{ latitude: startingArea.center.lat, longitude: startingArea.center.lng }} radius={startingArea.radius} strokeWidth={2} strokeColor={`rgba(0, 0, 255, 1)`} fillColor={`rgba(0, 0, 255, 0.2)`}/>
);
}, [gameState, startingArea]);
const gameZone = useMemo(() => {
if (gameState !== GAME_STATE.PLAYING || !zoneExtremities) return null;
const items = [];
const nextZoneStrokeColor = "rgb(90, 90, 90)";
const zoneColor = "rgba(25, 83, 169, 0.4)";
const strokeWidth = 3;
const lineDashPattern = [30, 10];
if (zoneType === ZONE_TYPES.CIRCLE) {
if (zoneExtremities.begin) items.push(
<InvertedCircle
key="game-zone-begin-circle"
id="game-zone-begin-circle"
center={latToLatitude(zoneExtremities.begin.center)}
radius={zoneExtremities.begin.radius}
fillColor={zoneColor}
/>
);
if (zoneExtremities.end) items.push(
<DashedCircle
key="game-zone-end-circle"
id="game-zone-end-circle"
center={latToLatitude(zoneExtremities.end.center)}
radius={zoneExtremities.end.radius}
strokeColor={nextZoneStrokeColor}
strokeWidth={strokeWidth}
lineDashPattern={lineDashPattern}
/>
);
} else if (zoneType === ZONE_TYPES.POLYGON) {
if (zoneExtremities.begin) items.push(
<InvertedPolygon
key="game-zone-begin-poly"
id="game-zone-begin-poly"
coordinates={zoneExtremities.begin.polygon.map(pos => latToLatitude(pos))}
fillColor={zoneColor}
/>
);
if (zoneExtremities.end) items.push(
<Polygon
key="game-zone-end-poly"
coordinates={zoneExtremities.end.polygon.map(pos => latToLatitude(pos))}
strokeColor={nextZoneStrokeColor}
strokeWidth={strokeWidth}
lineDashPattern={lineDashPattern}
/>
);
}
return items.length ? items : null;
}, [gameState, zoneType, zoneExtremities]);
const currentPositionMarker = useMemo(() => {
if (!location) return null;
return (
<Marker key={"current-position-marker"} coordinate={location} anchor={{ x: 0.33, y: 0.33 }} onPress={() => Alert.alert("Position actuelle", "Ceci est votre position")}>
<Image source={require("../assets/images/marker/blue.png")} style={styles.markerImage} resizeMode="contain"/>
</Marker>
);
}, [location]);
const lastPositionMarker = useMemo(() => {
if (gameState != GAME_STATE.PLAYING || !lastSentLocation || hasHandicap) return null;
return (
<Marker key={"last-position-marker"} coordinate={{ latitude: lastSentLocation[0], longitude: lastSentLocation[1] }} anchor={{ x: 0.33, y: 0.33 }} onPress={() => Alert.alert("Position envoyée", "Ceci est votre dernière position connue par le serveur")}>
<Image source={require("../assets/images/marker/grey.png")} style={styles.markerImage} resizeMode="contain"/>
</Marker>
);
}, [gameState, hasHandicap, lastSentLocation]);
const enemyPositionMarker = useMemo(() => {
if (gameState != GAME_STATE.PLAYING || !enemyLocation || hasHandicap) return null;
return (
<Marker key={"enemy-position-marker"} coordinate={{ latitude: enemyLocation[0], longitude: enemyLocation[1] }} anchor={{ x: 0.33, y: 0.33 }} onPress={() => Alert.alert("Position ennemie", "Ceci est la dernière position de vos ennemis connue")}>
<Image source={require("../assets/images/marker/red.png")} style={styles.markerImage} resizeMode="contain"/>
</Marker>
);
}, [gameState, hasHandicap, enemyLocation]);
return (
<View style={styles.container}>
<MapView ref={mapRef} style={{flex: 1}} initialRegion={INITIAL_REGIONS.PARIS} mapType="standard" onTouchMove={() => setCenterMap(false)} toolbarEnabled={false}>
{startZone}
{gameZone}
{currentPositionMarker}
{lastPositionMarker}
{enemyPositionMarker}
</MapView>
<LinearGradient colors={['rgba(0,0,0,0.3)', 'rgba(0,0,0,0)']} style={{height: 40, width: "100%", position: "absolute"}}/>
{ !centerMap &&
<TouchableOpacity style={styles.centerMap} onPress={() => setCenterMap(true)}>
<Image source={require("../assets/images/centerMap.png")} style={{width: 30, height: 30}} resizeMode="contain"></Image>
</TouchableOpacity>
}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
},
centerMap: {
position: 'absolute',
right: 20,
top: 20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'white',
borderWidth: 2,
borderColor: 'black',
alignItems: 'center',
justifyContent: 'center',
},
markerImage: {
width: 24,
height: 24
}
});

View File

@@ -0,0 +1,13 @@
// React
import { TouchableOpacity, View, Image, Text, Alert } from 'react-native';
export const Stat = ({ children, source, description }) => {
return (
<TouchableOpacity onPress={description ? () => Alert.alert("Info", description) : null}>
<View style={{height: 30, flexDirection: "row", justifyContent: 'center', alignItems: 'center'}}>
{source && <Image source={source} style={{width: 30, height: 30, marginRight: 5}} resizeMode="contain"/>}
<Text style={{fontSize: 15}}>{children}</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,20 @@
// React
import { View, Text, StyleSheet } from 'react-native';
// Util
import { secondsToMMSS } from '../util/functions';
export const TimerMMSS = ({ title, seconds, style }) => {
return (
<View style={[styles.container, style]}>
<Text style={{fontSize: 15}}>{title}</Text>
<Text style={{fontSize: 30, fontWeight: "bold"}}>{secondsToMMSS(seconds)}</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
}
});

View File

@@ -0,0 +1,3 @@
export const COLORS = {
background: '#f5f5f5'
};

View File

@@ -0,0 +1,2 @@
export const SERVER_URL = process.env.EXPO_PUBLIC_SERVER_URL;
export const SOCKET_URL = process.env.EXPO_PUBLIC_SOCKET_URL;

View File

@@ -0,0 +1,11 @@
export const GAME_STATE = {
SETUP: "setup",
PLACEMENT: "placement",
PLAYING: "playing",
FINISHED: "finished"
};
export const ZONE_TYPES = {
CIRCLE: "circle",
POLYGON: "polygon"
};

View File

@@ -0,0 +1,4 @@
export * from './config';
export * from './game';
export * from './map';
export * from './colors';

View File

@@ -0,0 +1,32 @@
export const INITIAL_REGIONS = {
PARIS : {
latitude: 48.864,
longitude: 2.342,
latitudeDelta: 0,
longitudeDelta: 50
}
};
export const LOCATION_PARAMETERS = {
LOCAL: {
accuracy: 4, // High
distanceInterval: 3, // meters
timeInterval: 1000, // ms
},
SERVER: {
accuracy: 4, // High
distanceInterval: 5, // meters
timeInterval: 5000, // ms
showsBackgroundLocationIndicator: true, // iOS only
pausesUpdatesAutomatically: false, // (iOS) Prevents auto-pausing of location updates
foregroundService: {
notificationTitle: "Enregistrement de votre position.",
notificationBody: "L'application utilise votre position en arrière plan.",
notificationColor: "#FF0000", // (Android) Notification icon color
},
}
};
export const TASKS = {
BACKGROUND_LOCATION: "background-location-task"
};

View File

@@ -0,0 +1,22 @@
// React
import { createContext, useContext, useMemo } from "react";
// Hook
import { useSocketAuth } from "../hook/useSocketAuth";
const TeamConnexionContext = createContext();
export const TeamConnexionProvider = ({ children }) => {
const { login, password: teamId, loggedIn, logout } = useSocketAuth();
const value = useMemo(() => ({ teamId, login, logout, loggedIn}), [teamId, login, logout, loggedIn]);
return (
<TeamConnexionContext.Provider value={value}>
{children}
</TeamConnexionContext.Provider>
);
};
export const useTeamConnexion = () => {
return useContext(TeamConnexionContext);
};

View File

@@ -0,0 +1,77 @@
// React
import { createContext, useContext, useMemo, useState, useEffect } from "react";
// Context
import { useTeamConnexion } from "./teamConnexionContext";
// Hook
import { useSendDeviceInfo } from "../hook/useSendDeviceInfo";
// Services
import { socket } from "../services/socket";
// Constants
import { GAME_STATE } from "../constants";
const TeamContext = createContext();
const useSocketListener = (event, callback) => {
useEffect(() => {
socket.on(event, callback);
return () => {
socket.off(event, callback);
};
}, [callback, event]);
};
export const TeamProvider = ({children}) => {
// update_team
const [teamInfos, setTeamInfos] = useState({});
// game_state
const [gameState, setGAME_STATE] = useState(GAME_STATE.SETUP);
const [startDate, setStartDate] = useState(null);
// current_zone
const [zoneExtremities, setZoneExtremities] = useState(null);
const [nextZoneDate, setNextZoneDate] = useState(null);
// settings
const [messages, setMessages] = useState(null);
const [zoneType, setZoneType] = useState(null);
// logout
const { logout } = useTeamConnexion();
useSendDeviceInfo();
useSocketListener("update_team", (data) => {
setTeamInfos(teamInfos => ({...teamInfos, ...data}));
});
useSocketListener("game_state", (data) => {
setGAME_STATE(data.state);
setStartDate(data.date);
});
useSocketListener("settings", (data) => {
setMessages(data.messages);
setZoneType(data.zone.type);
//TODO
//setSendPositionDelay(data.sendPositionDelay);
//setOutOfZoneDelay(data.outOfZoneDelay);
});
useSocketListener("current_zone", (data) => {
setZoneExtremities({begin: data.begin, end: data.end});
setNextZoneDate(data.endDate);
});
useSocketListener("logout", logout);
const value = useMemo(() => (
{teamInfos, gameState, startDate, zoneType, zoneExtremities, nextZoneDate, messages}
), [teamInfos, gameState, startDate, zoneType, zoneExtremities, nextZoneDate, messages]);
return (
<TeamContext.Provider value={value}>
{children}
</TeamContext.Provider>
);
};
export const useTeamContext = () => {
return useContext(TeamContext);
};

View File

@@ -0,0 +1,27 @@
// React
import { useCallback } from "react";
// Services
import { emitSendPosition, emitCapture } from "../services/socketEmitter";
export const useGame = () => {
const sendCurrentPosition = useCallback(() => {
emitSendPosition();
}, []);
const capture = useCallback((captureCode) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.warn("Server timeout: capture", captureCode);
reject(new Error("Timeout"));
}, 3000);
emitCapture(captureCode, (response) => {
clearTimeout(timeout);
resolve(response);
});
});
}, []);
return { sendCurrentPosition, capture };
};

View File

@@ -0,0 +1,43 @@
// React
import { useEffect, useState, useCallback } from "react";
import AsyncStorage from '@react-native-async-storage/async-storage';
export const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(initialValue);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const item = await AsyncStorage.getItem(key);
if (isMounted && item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.error(`Error loading key "${key}":`, error);
}
};
fetchData();
return () => { isMounted = false; };
}, [key]);
const setValue = useCallback(async (value) => {
try {
setStoredValue((prevValue) => {
const valueToStore = value instanceof Function ? value(prevValue) : value;
AsyncStorage.setItem(key, JSON.stringify(valueToStore)).catch(err =>
console.error(`Error saving key "${key}":`, err)
);
return valueToStore;
});
} catch (error) {
console.error(error);
}
}, [key]);
return [storedValue, setValue];
};

View File

@@ -0,0 +1,33 @@
// React
import { useState, useEffect } from 'react';
// Expo
import * as Location from 'expo-location';
// Constants
import { LOCATION_PARAMETERS } from '../constants';
export const useLocation = () => {
const [location, setLocation] = useState(null);
useEffect(() => {
let subscription;
const startWatching = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
subscription = await Location.watchPositionAsync(
LOCATION_PARAMETERS,
(location) => setLocation(location.coords)
);
};
startWatching();
return () => {
if (subscription) subscription.remove();
};
}, []);
return { location };
};

View File

@@ -0,0 +1,39 @@
// React
import { useState, useCallback } from 'react';
import { Alert } from 'react-native';
// Expo
import { launchImageLibraryAsync, requestMediaLibraryPermissionsAsync } from 'expo-image-picker';
export const usePickImage = () => {
const [image, setImage] = useState(null);
const pickImage = useCallback(async () => {
try {
const permissionResult = await requestMediaLibraryPermissionsAsync();
if (permissionResult.granted === false) {
Alert.alert("Permission refusée", "Activez l'accès au stockage ou à la gallerie dans les paramètres.");
return;
}
let result = await launchImageLibraryAsync({
mediaTypes: ['images'],
allowsMultipleSelection: false,
allowsEditing: true,
aspect: [4, 3],
quality: 1,
});
if (!result.canceled && result) {
setImage(result.assets[0]);
}
else {
console.log('Image picker cancelled.');
}
} catch (error) {
console.error('Error picking image;', error);
Alert.alert('Erreur', "Une erreur est survenue lors de la sélection d'une image.");
}
}, []);
return {image, pickImage};
};

View File

@@ -0,0 +1,46 @@
// React
import { useEffect, useRef } from 'react';
import DeviceInfo from 'react-native-device-info';
// Context
import { useTeamConnexion } from "../context/teamConnexionContext";
// Services
import { emitBattery, emitDeviceInfo } from "../services/socketEmitter";
export const useSendDeviceInfo = () => {
const { loggedIn } = useTeamConnexion();
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
if (!loggedIn) return;
const sendInfo = async () => {
const [brand, model, name] = await Promise.all([
DeviceInfo.getBrand(),
DeviceInfo.getModel(),
DeviceInfo.getDeviceName()
]);
if (!isMounted) return;
emitDeviceInfo({model: brand + " " + model, name: name});
};
const sendBattery = async () => {
const level = await DeviceInfo.getBatteryLevel();
if (!isMounted) return;
emitBattery(Math.round(level * 100));
};
sendInfo();
sendBattery();
const batteryCheckInterval = setInterval(() => sendBattery(), 5*60*1000); // 5 minutes
return () => {
isMounted.current = false;
clearInterval(batteryCheckInterval);
};
}, [loggedIn]);
return null;
};

View File

@@ -0,0 +1,60 @@
// React
import { useState, useEffect, useCallback, useRef } from 'react';
// Hook
import { useLocalStorage } from './useLocalStorage';
// Services
import { emitLogin, emitLogout } from "../services/socketEmitter";
export const useSocketAuth = () => {
const [loggedIn, setLoggedIn] = useState(false);
const [savedPassword, setSavedPassword] = useLocalStorage("team_password", null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
const login = useCallback((password) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (!isMounted.current) return;
console.warn("Server did not respond to login emit.");
reject();
}, 2000);
emitLogin(password, (response) => {
clearTimeout(timeout);
if (!isMounted.current) return;
if (response.isLoggedIn) {
console.log("Log in accepted.");
setLoggedIn(true);
setSavedPassword(password);
resolve(response);
} else {
console.log("Log in refused.");
setLoggedIn(false);
reject();
}
});
});
}, [setSavedPassword]);
useEffect(() => {
if (!loggedIn && savedPassword) {
login(savedPassword);
}
}, [loggedIn, login, savedPassword]);
const logout = useCallback(() => {
setLoggedIn(false);
setSavedPassword(null);
emitLogout();
}, [setSavedPassword]);
return {login, logout, password: savedPassword, loggedIn};
};

View File

@@ -0,0 +1,22 @@
// React
import { useEffect, useState } from "react";
export const useTimeDifference = (refTime, timeout) => {
// If refTime is in the past, time will be positive
// If refTime is in the future, time will be negative
// The time is updated every timeout milliseconds
const [time, setTime] = useState(0);
useEffect(() => {
const updateTime = () => {
setTime(Math.floor((Date.now() - refTime) / 1000));
};
updateTime();
const interval = setInterval(updateTime, timeout);
return () => clearInterval(interval);
}, [refTime, timeout]);
return [time];
};

View File

@@ -0,0 +1,48 @@
// Expo
import { defineTask, isTaskRegisteredAsync } from "expo-task-manager";
import * as Location from 'expo-location';
// Services
import { emitUpdatePosition } from "./socketEmitter";
// Constants
import { TASKS, LOCATION_PARAMETERS } from "../constants";
// Task
defineTask(TASKS.BACKGROUND_LOCATION, async ({ data, error }) => {
if (error) {
console.error(error);
return;
}
if (data) {
const { locations } = data;
if (locations.length == 0) {
console.log("No location measured.");
return;
}
const { latitude, longitude } = locations[0].coords;
emitUpdatePosition([latitude, longitude]);
}
});
// Functions
export const getLocationAuthorization = async () => {
const { status : statusForeground } = await Location.requestForegroundPermissionsAsync();
const { status : statusBackground } = await Location.requestBackgroundPermissionsAsync();
return statusForeground == "granted" && statusBackground == "granted";
};
export const startLocationTracking = async () => {
if (!(await getLocationAuthorization())) return;
if (await isTaskRegisteredAsync(TASKS.BACKGROUND_LOCATION)) return;
console.log("Location tracking started.");
await Location.startLocationUpdatesAsync(TASKS.BACKGROUND_LOCATION, LOCATION_PARAMETERS.SERVER);
};
export const stopLocationTracking = async () => {
if (!await isTaskRegisteredAsync(TASKS.BACKGROUND_LOCATION)) return;
console.log("Location tracking stopped.");
await Location.stopLocationUpdatesAsync(TASKS.BACKGROUND_LOCATION);
};

View File

@@ -0,0 +1,32 @@
// Constants
import { SERVER_URL } from "../constants";
export const uploadTeamImage = async (id, imageUri) => {
if (!imageUri || !id) return;
const data = new FormData();
data.append('file', {
uri: imageUri,
name: 'photo.jpg',
type: 'image/jpeg',
});
try {
const response = await fetch(`${SERVER_URL}/upload?team=${id}`, {
method: 'POST',
body: data,
});
if (!response.ok) throw new Error("Échec de l'upload");
return await response.blob();
} catch (error) {
console.error("Erreur uploadImage :", error);
throw error;
}
};
export const enemyImage = (id) => {
if (!id) return require('../assets/images/missing_image.jpg');
return {uri: `${SERVER_URL}/photo/enemy?team=${id}`};
};

View File

@@ -0,0 +1,8 @@
// Socket
import { io } from "socket.io-client";
// Constants
import { SOCKET_URL } from "../constants";
export const socket = io(SOCKET_URL, {
path: "/back/socket.io"
});

View File

@@ -0,0 +1,44 @@
// Services
import { socket } from "./socket";
export const emitLogin = (password, callback) => {
if (!socket?.connected) return;
console.log("Try to log in with :", password);
socket.emit("login", password, callback);
};
export const emitLogout = () => {
if (!socket?.connected) return;
console.log("Logout.");
socket.emit("logout");
};
export const emitSendPosition = () => {
if (!socket?.connected) return;
console.log("Reveal position.");
socket.emit("send_position");
};
export const emitUpdatePosition = (location) => {
if (!socket?.connected) return;
console.log("Update position :", location);
socket.emit("update_position", location);
};
export const emitCapture = (captureCode, callback) => {
if (!socket?.connected) return;
console.log("Try to capture :", captureCode);
socket.emit("capture", captureCode, callback);
};
export const emitBattery = (level) => {
if (!socket?.connected) return;
console.log("Send battery level.");
socket.emit('battery_update', level);
};
export const emitDeviceInfo = (infos) => {
if (!socket?.connected) return;
console.log("Send device infos.");
socket.emit('device_info', infos);
};

View File

@@ -0,0 +1,34 @@
export const circleToPolygon = (circle) => {
// circle : {center: {latitude: ..., longitude: ...}, radius: ...}
// polygon : [{latitude: ..., longitude: ...}, ...]
const polygon = [];
const center = circle.center;
const radiusInDegrees = circle.radius / 111320; // Approximation m -> deg
for (let i = 0; i < 360; i += 5) {
const rad = (i * Math.PI) / 180;
polygon.push({
latitude: center.latitude + radiusInDegrees * Math.sin(rad),
longitude: center.longitude + radiusInDegrees * Math.cos(rad) / Math.cos(center.latitude * Math.PI / 180),
});
}
return polygon;
};
export const secondsToMMSS = (seconds) => {
if (!Number.isInteger(seconds)) return "Inconnue";
if (seconds < 0) seconds = 0;
const strMinutes = String(Math.floor(seconds / 60));
const strSeconds = String(Math.floor(seconds % 60));
return strMinutes.padStart(2,"0") + ":" + strSeconds.padStart(2,"0");
};
export const secondsToHHMMSS = (seconds) => {
if (!Number.isInteger(seconds)) return "Inconnue";
if (seconds < 0) seconds = 0;
const strHours = String(Math.floor(seconds / 3600));
const strMinutes = String(Math.floor(seconds / 60 % 60));
const strSeconds = String(Math.floor(seconds % 60));
return strHours.padStart(2,"0") + ":" + strMinutes.padStart(2,"0") + ":" + strSeconds.padStart(2,"0");
};