Modify folder structure
BIN
mobile/traque-app/src/assets/images/arrow.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
mobile/traque-app/src/assets/images/battery/black.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
mobile/traque-app/src/assets/images/battery/green.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
mobile/traque-app/src/assets/images/battery/red.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
mobile/traque-app/src/assets/images/centerMap.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
mobile/traque-app/src/assets/images/cogwheel.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
mobile/traque-app/src/assets/images/distance.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
mobile/traque-app/src/assets/images/localisation/black.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
mobile/traque-app/src/assets/images/localisation/green.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mobile/traque-app/src/assets/images/localisation/red.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mobile/traque-app/src/assets/images/logo/logo_traque.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
mobile/traque-app/src/assets/images/logout.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
mobile/traque-app/src/assets/images/marker/blue.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
mobile/traque-app/src/assets/images/marker/grey.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
mobile/traque-app/src/assets/images/marker/red.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
mobile/traque-app/src/assets/images/missing_image.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/traque-app/src/assets/images/observed.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
mobile/traque-app/src/assets/images/path.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
mobile/traque-app/src/assets/images/placement.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
mobile/traque-app/src/assets/images/running.jpg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
mobile/traque-app/src/assets/images/running.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
mobile/traque-app/src/assets/images/target/black.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
mobile/traque-app/src/assets/images/target/white.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
mobile/traque-app/src/assets/images/time.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
mobile/traque-app/src/assets/images/update_position.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
39
mobile/traque-app/src/components/button.jsx
Normal 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,
|
||||
},
|
||||
});
|
||||
178
mobile/traque-app/src/components/drawer.jsx
Normal 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'
|
||||
},
|
||||
});
|
||||
37
mobile/traque-app/src/components/image.jsx
Normal 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
|
||||
}
|
||||
});
|
||||
27
mobile/traque-app/src/components/input.jsx
Normal 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,
|
||||
},
|
||||
});
|
||||
71
mobile/traque-app/src/components/layer.jsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
171
mobile/traque-app/src/components/map.jsx
Normal 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
|
||||
}
|
||||
});
|
||||
13
mobile/traque-app/src/components/stat.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
mobile/traque-app/src/components/timer.jsx
Normal 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',
|
||||
}
|
||||
});
|
||||
3
mobile/traque-app/src/constants/colors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const COLORS = {
|
||||
background: '#f5f5f5'
|
||||
};
|
||||
2
mobile/traque-app/src/constants/config.js
Normal 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;
|
||||
11
mobile/traque-app/src/constants/game.js
Normal 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"
|
||||
};
|
||||
4
mobile/traque-app/src/constants/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './game';
|
||||
export * from './map';
|
||||
export * from './colors';
|
||||
32
mobile/traque-app/src/constants/map.js
Normal 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"
|
||||
};
|
||||
22
mobile/traque-app/src/context/teamConnexionContext.jsx
Normal 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);
|
||||
};
|
||||
77
mobile/traque-app/src/context/teamContext.jsx
Normal 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);
|
||||
};
|
||||
27
mobile/traque-app/src/hook/useGame.jsx
Normal 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 };
|
||||
};
|
||||
43
mobile/traque-app/src/hook/useLocalStorage.jsx
Normal 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];
|
||||
};
|
||||
33
mobile/traque-app/src/hook/useLocation.jsx
Normal 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 };
|
||||
};
|
||||
39
mobile/traque-app/src/hook/usePickImage.jsx
Normal 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};
|
||||
};
|
||||
46
mobile/traque-app/src/hook/useSendDeviceInfo.jsx
Normal 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;
|
||||
};
|
||||
60
mobile/traque-app/src/hook/useSocketAuth.jsx
Normal 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};
|
||||
};
|
||||
22
mobile/traque-app/src/hook/useTimeDifference.jsx
Normal 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];
|
||||
};
|
||||
48
mobile/traque-app/src/services/backgroundLocationTask.js
Normal 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);
|
||||
};
|
||||
32
mobile/traque-app/src/services/imageService.js
Normal 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}`};
|
||||
};
|
||||
8
mobile/traque-app/src/services/socket.js
Normal 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"
|
||||
});
|
||||
44
mobile/traque-app/src/services/socketEmitter.js
Normal 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);
|
||||
};
|
||||
34
mobile/traque-app/src/util/functions.js
Normal 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");
|
||||
};
|
||||