Restructuration of the project folders
61
mobile/traque-app/app.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "la-traque",
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
],
|
||||
"newArchEnabled": true,
|
||||
"name": "La Traque",
|
||||
"slug": "la-traque",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/logo/logo_traque.png",
|
||||
"splash": {
|
||||
"image": "./assets/images/logo/logo_traque.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"platforms": [
|
||||
"ios",
|
||||
"android"
|
||||
],
|
||||
"android": {
|
||||
"package": "net.rezel.traque",
|
||||
"permissions": [
|
||||
"ACCESS_FINE_LOCATION",
|
||||
"ACCESS_COARSE_LOCATION",
|
||||
"ACCESS_BACKGROUND_LOCATION",
|
||||
"FOREGROUND_SERVICE",
|
||||
"FOREGROUND_SERVICE_LOCATION",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"INTERNET",
|
||||
"POST_NOTIFICATIONS"
|
||||
],
|
||||
"config": {
|
||||
"googleMaps": {
|
||||
"apiKey": "AIzaSyD0yuWIHFbsIDVfGQ9wEM3pOtVC2TgEO1U"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "net.rezel.traque",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"location"
|
||||
],
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription": "Your location is used to track you in the background.",
|
||||
"NSLocationWhenInUseUsageDescription": "Location is used to track your movement."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
mobile/traque-app/app/+not-found.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Unmatched } from 'expo-router';
|
||||
|
||||
export default Unmatched;
|
||||
16
mobile/traque-app/app/_layout.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Slot } from 'expo-router';
|
||||
import SocketProvider from "../context/socketContext";
|
||||
import { TeamConnexionProvider } from "../context/teamConnexionContext";
|
||||
import { TeamProvider } from "../context/teamContext";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<TeamConnexionProvider>
|
||||
<TeamProvider>
|
||||
<Slot/>
|
||||
</TeamProvider>
|
||||
</TeamConnexionProvider>
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
600
mobile/traque-app/app/display.js
Normal file
@@ -0,0 +1,600 @@
|
||||
// React
|
||||
import { useState, useEffect, useRef, Fragment } from 'react';
|
||||
import { ScrollView, View, Text, Image, Alert, StyleSheet, TouchableOpacity, TouchableHighlight } from 'react-native';
|
||||
import MapView, { Marker, Circle, Polygon } from 'react-native-maps';
|
||||
// Expo
|
||||
import { useRouter } from 'expo-router';
|
||||
// Components
|
||||
import CustomImage from '../components/image';
|
||||
import CustomTextInput from '../components/input';
|
||||
import Stat from '../components/stat';
|
||||
import Collapsible from 'react-native-collapsible';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
// Other
|
||||
import { useSocket } from '../context/socketContext';
|
||||
import { useTeamContext } from '../context/teamContext';
|
||||
import { useTeamConnexion } from '../context/teamConnexionContext';
|
||||
import { useTimeDifference } from '../hook/useTimeDifference';
|
||||
import { GameState } from '../util/gameState';
|
||||
import useGame from '../hook/useGame';
|
||||
|
||||
const backgroundColor = '#f5f5f5';
|
||||
const initialRegion = {latitude: 48.864, longitude: 2.342, latitudeDelta: 0, longitudeDelta: 50} // France centrée sur Paris
|
||||
|
||||
const zoneTypes = {
|
||||
circle: "circle",
|
||||
polygon: "polygon"
|
||||
}
|
||||
|
||||
export default function Display() {
|
||||
const arrowUp = require('../assets/images/arrow.png');
|
||||
const [collapsibleState, setCollapsibleState] = useState(true);
|
||||
const [bottomContainerHeight, setBottomContainerHeight] = useState(0);
|
||||
const router = useRouter();
|
||||
const {SERVER_URL} = useSocket();
|
||||
const {messages, zoneType, zoneExtremities, nextZoneDate, isShrinking, location, startLocationTracking, stopLocationTracking, gameState, startDate} = useTeamContext();
|
||||
const {loggedIn, logout, loading} = useTeamConnexion();
|
||||
const {sendCurrentPosition, capture, enemyLocation, enemyName, startingArea, captureCode, name, ready, captured, lastSentLocation, locationSendDeadline, teamId, outOfZone, outOfZoneDeadline, distance, finishDate, nCaptures, nSentLocation, hasHandicap, enemyHasHandicap} = useGame();
|
||||
const [enemyCaptureCode, setEnemyCaptureCode] = useState("");
|
||||
const [timeLeftSendLocation] = useTimeDifference(locationSendDeadline, 1000);
|
||||
const [timeLeftNextZone] = useTimeDifference(nextZoneDate, 1000);
|
||||
const [timeLeftOutOfZone] = useTimeDifference(outOfZoneDeadline, 1000);
|
||||
const [timeSinceStart] = useTimeDifference(startDate, 1000);
|
||||
const [avgSpeed, setAvgSpeed] = useState(0); // Speed in m/s
|
||||
const [enemyImageURI, setEnemyImageURI] = useState("../assets/images/missing_image.jpg");
|
||||
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 mapRef = useRef(null);
|
||||
const [centerMap, setCenterMap] = useState(true);
|
||||
|
||||
// Router
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (!loggedIn) {
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
}, [loggedIn, loading]);
|
||||
|
||||
// Activating geolocation tracking
|
||||
useEffect(() => {
|
||||
if (loggedIn) {
|
||||
startLocationTracking();
|
||||
} else {
|
||||
stopLocationTracking();
|
||||
}
|
||||
}, [loggedIn, gameState, captured]);
|
||||
|
||||
// Refresh the image
|
||||
useEffect(() => {
|
||||
setEnemyImageURI(`${SERVER_URL}/photo/enemy?team=${teamId}&t=${new Date().getTime()}`);
|
||||
}, [enemyName, teamId]);
|
||||
|
||||
// Capture state update
|
||||
useEffect(() => {
|
||||
if (captureStatus == 2 || captureStatus == 3) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCaptureStatus(0);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [captureStatus]);
|
||||
|
||||
// Center the map on user position
|
||||
useEffect(() => {
|
||||
if (centerMap && mapRef.current && location) {
|
||||
mapRef.current.animateToRegion({latitude: location[0], longitude: location[1], latitudeDelta: 0, longitudeDelta: 0.02}, 1000);
|
||||
}
|
||||
}, [centerMap, mapRef, location]);
|
||||
|
||||
// Update the average speed
|
||||
useEffect(() => {
|
||||
const hours = (finishDate ? (finishDate - startDate) : timeSinceStart*1000) / 1000 / 3600;
|
||||
const km = distance / 1000;
|
||||
setAvgSpeed(Math.floor(km / hours * 10) / 10);
|
||||
}, [distance, finishDate, timeSinceStart]);
|
||||
|
||||
function toggleCollapsible() {
|
||||
setCollapsibleState(!collapsibleState);
|
||||
};
|
||||
|
||||
function 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("");
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeMinutes(time) {
|
||||
// time is in seconds
|
||||
if (!Number.isInteger(time)) return "Inconnue";
|
||||
if (time < 0) time = 0;
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0");
|
||||
}
|
||||
|
||||
function formatTimeHours(time) {
|
||||
// time is in seconds
|
||||
if (!Number.isInteger(time)) return "Inconnue";
|
||||
if (time < 0) time = 0;
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor(time / 60 % 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return String(hours).padStart(2,"0") + ":" + String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0");
|
||||
}
|
||||
|
||||
const Logout = () => {
|
||||
return (
|
||||
<TouchableOpacity style={{width: 40, height: 40}} onPress={logout}>
|
||||
<Image source={require('../assets/images/logout.png')} style={{width: 40, height: 40}} resizeMode="contain"></Image>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
return (
|
||||
<TouchableOpacity style={{width: 40, height: 40}} onPress={() => Alert.alert("Settings")}>
|
||||
<Image source={require('../assets/images/cogwheel.png')} style={{width: 40, height: 40}} resizeMode="contain"></Image>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const TeamName = () => {
|
||||
return(
|
||||
<Text style={{fontSize: 36, fontWeight: "bold", textAlign: "center"}}>{(name ?? "Indisponible")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const GameLog = () => {
|
||||
return (
|
||||
<TouchableOpacity style={{width:"100%"}}>
|
||||
{ gameState == GameState.SETUP && <Text style={styles.gameState}>{messages?.waiting || "Préparation de la partie"}</Text>}
|
||||
{ gameState == GameState.PLACEMENT && <Text style={styles.gameState}>Phase de placement</Text>}
|
||||
{ gameState == GameState.PLAYING && !outOfZone && <Text style={styles.gameState}>La partie est en cours</Text>}
|
||||
{ gameState == GameState.PLAYING && outOfZone && !hasHandicap && <Text style={styles.gameStateOutOfZone}>{`Veuillez retourner dans la zone\nHandicap dans ${formatTimeMinutes(-timeLeftOutOfZone)}`}</Text>}
|
||||
{ gameState == GameState.PLAYING && hasHandicap && <Text style={styles.gameStateOutOfZone}>{`Veuillez retourner dans la zone\nVotre position est révélée en continue`}</Text>}
|
||||
{ gameState == GameState.FINISHED && <Text style={styles.gameState}>La partie est terminée</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const TimeBeforeNextZone = () => {
|
||||
return (
|
||||
<View style={{width: "100%", alignItems: "center", justifyContent: "center"}}>
|
||||
<Text style={{fontSize: 15}}>{isShrinking ? "Réduction de la zone" : "Durée de la zone"}</Text>
|
||||
<Text style={{fontSize: 30, fontWeight: "bold"}}>{formatTimeMinutes(-timeLeftNextZone)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const TimeBeforeNextPosition = () => {
|
||||
return (
|
||||
<View style={{width: "100%", alignItems: "center", justifyContent: "center"}}>
|
||||
<Text style={{fontSize: 15}}>Position envoyée dans</Text>
|
||||
<Text style={{fontSize: 30, fontWeight: "bold"}}>{ !hasHandicap ? formatTimeMinutes(-timeLeftSendLocation) : "00:00"}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Timers = () => {
|
||||
return (
|
||||
<View style={styles.timersContainer}>
|
||||
<View style={styles.zoneTimerContainer}>
|
||||
{ TimeBeforeNextZone() }
|
||||
</View>
|
||||
<View style={styles.positionTimerContainer}>
|
||||
{ TimeBeforeNextPosition() }
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Ready = () => {
|
||||
return (
|
||||
<View style={styles.timersContainer}>
|
||||
<View style={[styles.readyIndicator, {backgroundColor: ready ? "#3C3" : "#C33"}]}>
|
||||
<Text style={{color: '#fff', fontSize: 16}}>{ready ? "Placé" : "Non placé"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CapturedMessage = () => {
|
||||
return (
|
||||
<View style={[styles.timersContainer, {height: 61}]}>
|
||||
<Text style={{fontSize: 20}}>{messages?.captured || "Vous avez été éliminé..."}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const EndGameMessage = () => {
|
||||
return (
|
||||
<View style={[styles.timersContainer, {height: 61}]}>
|
||||
{captured && <Text style={{fontSize: 20}}>{messages?.loser || "Vous avez perdu..."}</Text>}
|
||||
{!captured && <Text style={{fontSize: 20}}>{messages?.winner || "Vous avez gagné !"}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Zones = () => {
|
||||
const latToLatitude = (pos) => ({latitude: pos.lat, longitude: pos.lng});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{ zoneType == zoneTypes.circle && zoneExtremities.begin && <Circle center={latToLatitude(zoneExtremities.begin.center)} radius={zoneExtremities.begin.radius} strokeColor="red" fillColor="rgba(255,0,0,0.1)" strokeWidth={2} />}
|
||||
{ zoneType == zoneTypes.circle && zoneExtremities.end && <Circle center={latToLatitude(zoneExtremities.end.center)} radius={zoneExtremities.end.radius} strokeColor="green" fillColor="rgba(0,255,0,0.1)" strokeWidth={2} />}
|
||||
{ zoneType == zoneTypes.polygon && zoneExtremities.begin && <Polygon coordinates={zoneExtremities.begin.polygon.map(pos => latToLatitude(pos))} strokeColor="red" fillColor="rgba(255,0,0,0.1)" strokeWidth={2} /> }
|
||||
{ zoneType == zoneTypes.polygon && zoneExtremities.end && <Polygon coordinates={zoneExtremities.end.polygon.map(pos => latToLatitude(pos))} strokeColor="green" fillColor="rgba(0,255,0,0.1)" strokeWidth={2} /> }
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const Map = () => {
|
||||
return (
|
||||
<MapView ref={mapRef} style={{flex: 1}} initialRegion={initialRegion} mapType="standard" onTouchMove={() => setCenterMap(false)} toolbarEnabled={false}>
|
||||
{ gameState == GameState.PLACEMENT && startingArea && <Circle 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 == GameState.PLAYING && zoneExtremities && <Zones/>}
|
||||
{ location &&
|
||||
<Marker coordinate={{ latitude: location[0], longitude: location[1] }} 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={{width: 24, height: 24}} resizeMode="contain"/>
|
||||
</Marker>
|
||||
}
|
||||
{ gameState == GameState.PLAYING && lastSentLocation && !hasHandicap &&
|
||||
<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={{width: 24, height: 24}} resizeMode="contain"/>
|
||||
</Marker>
|
||||
}
|
||||
{ gameState == GameState.PLAYING && enemyLocation && !hasHandicap &&
|
||||
<Marker coordinate={{ latitude: enemyLocation[0], longitude: enemyLocation[1] }} anchor={{ x: 0.33, y: 0.33 }}>
|
||||
<Image source={require("../assets/images/marker/red.png")} style={{width: 24, height: 24}} resizeMode="contain" onPress={() => Alert.alert("Position ennemie", "Ceci est la dernière position de vos ennemis connue")}/>
|
||||
</Marker>
|
||||
}
|
||||
</MapView>
|
||||
);
|
||||
}
|
||||
|
||||
const UpdatePositionButton = () => {
|
||||
return ( !hasHandicap &&
|
||||
<TouchableOpacity style={styles.updatePositionContainer} onPress={sendCurrentPosition}>
|
||||
<Image source={require("../assets/images/update_position.png")} style={{width: 40, height: 40}} resizeMode="contain"></Image>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const CenterMapButton = () => {
|
||||
return (
|
||||
<TouchableOpacity style={styles.centerMapContainer} onPress={() => setCenterMap(true)}>
|
||||
<Image source={require("../assets/images/centerMap.png")} style={{width: 30, height: 30}} resizeMode="contain"></Image>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const LayerButton = () => {
|
||||
return(
|
||||
<TouchableOpacity style={styles.layerContainer} onPress={() => Alert.alert("Layer")}>
|
||||
<Image source={require('../assets/images/path.png')} style={{width: 40, height: 40}} resizeMode="contain"></Image>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const CollapsibleButton = () => {
|
||||
return (
|
||||
<TouchableHighlight onPress={toggleCollapsible} style={styles.collapsibleButton} underlayColor="#d9d9d9">
|
||||
<Image source={arrowUp} style={{width: 20, height: 20, transform: [{ scaleY: collapsibleState ? 1 : -1 }] }} resizeMode="contain"></Image>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const TeamCaptureCode = () => {
|
||||
return (
|
||||
<Text style={{fontSize: 22, fontWeight: "bold", textAlign: "center"}}>Code de {(name ?? "Indisponible")} : {String(captureCode).padStart(4,"0")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const ChasedTeamImage = () => {
|
||||
return (
|
||||
<View style={styles.imageContainer}>
|
||||
{<Text style={{fontSize: 15, margin: 5}}>{"Cible (" + (enemyName ?? "Indisponible") + ")"}</Text>}
|
||||
{<CustomImage source={{ uri : enemyImageURI }} canZoom/>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CaptureCode = () => {
|
||||
return (
|
||||
<View style={styles.actionsLeftContainer}>
|
||||
<CustomTextInput style={{borderColor: captureStatusColor[captureStatus]}} value={enemyCaptureCode} inputMode="numeric" placeholder="Code cible" onChangeText={setEnemyCaptureCode}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CaptureButton = () => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const Stats = () => {
|
||||
return (
|
||||
<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"}>{formatTimeHours((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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.globalContainer}>
|
||||
<View style={styles.topContainer}>
|
||||
<View style={styles.topheadContainer}>
|
||||
{ Logout() }
|
||||
{ false && Settings() }
|
||||
</View>
|
||||
<View style={styles.teamNameContainer}>
|
||||
{ TeamName() }
|
||||
</View>
|
||||
<View style={styles.logContainer}>
|
||||
{ GameLog() }
|
||||
</View>
|
||||
{ gameState == GameState.PLACEMENT &&
|
||||
Ready()
|
||||
}
|
||||
{ gameState == GameState.PLAYING && !captured && <Fragment>
|
||||
{Timers()}
|
||||
{enemyHasHandicap && <Text style={{fontSize: 18, marginTop: 6, fontWeight: "bold"}}>Position ennemie révélée en continue !</Text>}
|
||||
</Fragment>}
|
||||
{ gameState == GameState.PLAYING && captured &&
|
||||
CapturedMessage()
|
||||
}
|
||||
{ gameState == GameState.FINISHED &&
|
||||
EndGameMessage()
|
||||
}
|
||||
</View>
|
||||
<View style={styles.bottomContainer} onLayout={(event) => {setBottomContainerHeight(event.nativeEvent.layout.height)}}>
|
||||
<View style={styles.mapContainer}>
|
||||
{ Map() }
|
||||
<LinearGradient colors={['rgba(0,0,0,0.3)', 'rgba(0,0,0,0)']} style={{height: 40, width: "100%", position: "absolute"}}/>
|
||||
{ !centerMap && CenterMapButton() }
|
||||
{ false && gameState == GameState.PLAYING && !captured &&
|
||||
<View style={styles.toolBarLeft}>
|
||||
{ LayerButton() }
|
||||
</View>
|
||||
}
|
||||
{ gameState == GameState.PLAYING && !captured &&
|
||||
<View style={styles.toolBarRight}>
|
||||
{ UpdatePositionButton() }
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
{ (gameState == GameState.PLAYING || gameState == GameState.FINISHED) &&
|
||||
<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}>
|
||||
{ CollapsibleButton() }
|
||||
<Collapsible style={[styles.collapsibleWindow, {height: bottomContainerHeight - 44}]} title="Collapse" collapsed={collapsibleState}>
|
||||
<ScrollView contentContainerStyle={styles.collapsibleContent}>
|
||||
{ gameState == GameState.PLAYING && TeamCaptureCode() }
|
||||
{ gameState == GameState.PLAYING && !hasHandicap && <Fragment>
|
||||
{ ChasedTeamImage() }
|
||||
<View style={styles.actionsContainer}>
|
||||
{ CaptureCode() }
|
||||
{ CaptureButton() }
|
||||
</View>
|
||||
</Fragment>}
|
||||
{ Stats() }
|
||||
</ScrollView>
|
||||
</Collapsible>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
globalContainer: {
|
||||
backgroundColor: backgroundColor,
|
||||
flex: 1,
|
||||
},
|
||||
topContainer: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
padding: 15,
|
||||
},
|
||||
topheadContainer: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
teamNameContainer: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
logContainer: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 15
|
||||
},
|
||||
gameState: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 10,
|
||||
width: "100%",
|
||||
backgroundColor: 'white',
|
||||
fontSize: 18,
|
||||
padding: 10,
|
||||
},
|
||||
gameStateOutOfZone: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 10,
|
||||
width: "100%",
|
||||
backgroundColor: 'white',
|
||||
fontSize: 18,
|
||||
padding: 10,
|
||||
borderColor: 'red'
|
||||
},
|
||||
timersContainer: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginTop: 15
|
||||
},
|
||||
zoneTimerContainer: {
|
||||
width: "50%",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
positionTimerContainer: {
|
||||
width: "50%",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
readyIndicator: {
|
||||
width: "100%",
|
||||
maxWidth: 240,
|
||||
height: 61,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 3,
|
||||
borderRadius: 10
|
||||
},
|
||||
bottomContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
mapContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
outerDrawerContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
innerDrawerContainer: {
|
||||
width: "100%",
|
||||
backgroundColor: backgroundColor,
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
collapsibleButton: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: "100%",
|
||||
height: 45
|
||||
},
|
||||
collapsibleWindow: {
|
||||
width: "100%",
|
||||
justifyContent: 'center',
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
collapsibleContent: {
|
||||
paddingHorizontal: 15,
|
||||
},
|
||||
centerMapContainer: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: 20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: 2,
|
||||
borderColor: 'black',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
toolBarLeft: {
|
||||
position: 'absolute',
|
||||
left: 30,
|
||||
bottom: 80
|
||||
},
|
||||
toolBarRight: {
|
||||
position: 'absolute',
|
||||
right: 30,
|
||||
bottom: 80
|
||||
},
|
||||
updatePositionContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: 4,
|
||||
borderColor: 'black',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
layerContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'white',
|
||||
borderWidth: 4,
|
||||
borderColor: 'black',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
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'
|
||||
},
|
||||
});
|
||||
113
mobile/traque-app/app/index.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// React
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ScrollView, View, Text, StyleSheet, Image, Alert } from 'react-native';
|
||||
// Expo
|
||||
import { useRouter } from 'expo-router';
|
||||
// Components
|
||||
import CustomButton from '../components/button';
|
||||
import CustomImage from '../components/image';
|
||||
import CustomTextInput from '../components/input';
|
||||
// Other
|
||||
import { useSocket } from '../context/socketContext';
|
||||
import { useTeamContext } from '../context/teamContext';
|
||||
import { useTeamConnexion } from "../context/teamConnexionContext";
|
||||
import { usePickImage } from '../hook/usePickImage';
|
||||
|
||||
const backgroundColor = '#f5f5f5';
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const {SERVER_URL} = useSocket();
|
||||
const {login, loggedIn, loading} = useTeamConnexion();
|
||||
const {getLocationAuthorization, stopLocationTracking} = useTeamContext();
|
||||
const {image, pickImage, sendImage} = usePickImage();
|
||||
const [teamID, setTeamID] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Disbaling location tracking
|
||||
useEffect(() => {
|
||||
stopLocationTracking();
|
||||
}, []);
|
||||
|
||||
// Routeur
|
||||
useEffect(() => {
|
||||
if (!loading && loggedIn) {
|
||||
router.replace("/display");
|
||||
}
|
||||
}, [loggedIn, loading]);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isSubmitting && !loading) {
|
||||
setIsSubmitting(true);
|
||||
if (getLocationAuthorization()) {
|
||||
login(parseInt(teamID))
|
||||
.then((response) => {
|
||||
if (response.isLoggedIn) {
|
||||
sendImage(`${SERVER_URL}/upload?team=${teamID}`);
|
||||
} else {
|
||||
Alert.alert("Échec", "L'ID d'équipe est inconnu.");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Échec", "La connection au serveur a échoué.");
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}
|
||||
setTeamID("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<View style={styles.transitionContainer}>
|
||||
<View style={styles.subContainer}>
|
||||
<Image style={styles.logoImage} source={require('../assets/images/logo/logo_traque.png')}/>
|
||||
<Text style={styles.logoText}>LA TRAQUE</Text>
|
||||
</View>
|
||||
<View style={styles.subContainer}>
|
||||
<CustomTextInput value={teamID} inputMode="numeric" placeholder="ID de l'équipe" style={styles.input} onChangeText={setTeamID}/>
|
||||
</View>
|
||||
<View style={styles.subContainer}>
|
||||
<Text style={{fontSize: 15}}>Appuyer pour changer la photo d'équipe</Text>
|
||||
<Text style={{fontSize: 13, marginBottom: 3}}>(Le haut du corps doit être visible)</Text>
|
||||
<CustomImage source={image ? {uri: image.uri} : require('../assets/images/missing_image.jpg')} onPress={pickImage}/>
|
||||
</View>
|
||||
<View style={styles.subContainer}>
|
||||
<CustomButton label={(isSubmitting || loading) ? "..." : "Valider"} onPress={handleSubmit}/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
backgroundColor: backgroundColor
|
||||
},
|
||||
transitionContainer: {
|
||||
flexGrow: 1,
|
||||
width: '80%',
|
||||
maxWidth: 600,
|
||||
alignItems: 'center',
|
||||
},
|
||||
subContainer: {
|
||||
flexGrow: 1,
|
||||
width: "100%",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: 10,
|
||||
},
|
||||
logoImage: {
|
||||
width: 130,
|
||||
height: 130,
|
||||
margin: 10,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 50,
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
});
|
||||
BIN
mobile/traque-app/assets/images/arrow.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
mobile/traque-app/assets/images/battery/black.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
mobile/traque-app/assets/images/battery/green.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
mobile/traque-app/assets/images/battery/red.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
mobile/traque-app/assets/images/centerMap.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
mobile/traque-app/assets/images/cogwheel.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
mobile/traque-app/assets/images/distance.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
mobile/traque-app/assets/images/localisation/black.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
mobile/traque-app/assets/images/localisation/green.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mobile/traque-app/assets/images/localisation/red.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mobile/traque-app/assets/images/logo/logo_traque.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
mobile/traque-app/assets/images/logout.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
mobile/traque-app/assets/images/marker/blue.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
mobile/traque-app/assets/images/marker/grey.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
mobile/traque-app/assets/images/marker/red.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
mobile/traque-app/assets/images/missing_image.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/traque-app/assets/images/observed.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
mobile/traque-app/assets/images/path.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
mobile/traque-app/assets/images/placement.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
mobile/traque-app/assets/images/running.jpg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
mobile/traque-app/assets/images/running.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
mobile/traque-app/assets/images/target/black.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
mobile/traque-app/assets/images/target/white.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
mobile/traque-app/assets/images/time.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
mobile/traque-app/assets/images/update_position.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
38
mobile/traque-app/components/button.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { TouchableHighlight, View, Text, StyleSheet } from "react-native";
|
||||
|
||||
export default 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,
|
||||
},
|
||||
});
|
||||
36
mobile/traque-app/components/image.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, View, Image, TouchableOpacity } from "react-native";
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function 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
|
||||
}
|
||||
});
|
||||
26
mobile/traque-app/components/input.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { TextInput, StyleSheet } from 'react-native';
|
||||
|
||||
export default function 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,
|
||||
},
|
||||
});
|
||||
12
mobile/traque-app/components/stat.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TouchableOpacity, View, Image, Text, Alert } from 'react-native';
|
||||
|
||||
export default function 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>
|
||||
);
|
||||
};
|
||||
22
mobile/traque-app/context/socketContext.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
const SOCKET_URL = `ws://0.0.0.0/player`;
|
||||
const SERVER_URL = `http://0.0.0.0/back`;
|
||||
|
||||
export const teamSocket = io(SOCKET_URL, {
|
||||
path: "/back/socket.io",
|
||||
});
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
export default function SocketProvider({ children }) {
|
||||
const value = useMemo(() => ({ teamSocket, SERVER_URL }), [teamSocket]);
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
return useContext(SocketContext);
|
||||
}
|
||||
25
mobile/traque-app/context/teamConnexionContext.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { useSocket } from "./socketContext";
|
||||
import { useSocketAuth } from "../hook/useSocketAuth";
|
||||
|
||||
const teamConnexionContext = createContext();
|
||||
|
||||
const TeamConnexionProvider = ({ children }) => {
|
||||
const { teamSocket } = useSocket();
|
||||
const { login, password: teamId, loggedIn, loading, logout } = useSocketAuth(teamSocket, "team_password");
|
||||
|
||||
const value = useMemo(() => ({ teamId, login, logout, loggedIn, loading}), [teamId, login, loggedIn, loading]);
|
||||
|
||||
return (
|
||||
<teamConnexionContext.Provider value={value}>
|
||||
{children}
|
||||
</teamConnexionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTeamConnexion() {
|
||||
return useContext(teamConnexionContext);
|
||||
}
|
||||
|
||||
export { TeamConnexionProvider, useTeamConnexion };
|
||||
|
||||
70
mobile/traque-app/context/teamContext.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useLocation } from "../hook/useLocation";
|
||||
import { useSocketListener } from "../hook/useSocketListener";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { useSocket } from "./socketContext";
|
||||
import { GameState } from "../util/gameState";
|
||||
import useSendDeviceInfo from "../hook/useSendDeviceInfo";
|
||||
import { useTeamConnexion } from "./teamConnexionContext";
|
||||
|
||||
const teamContext = createContext();
|
||||
|
||||
function TeamProvider({children}) {
|
||||
const {teamSocket} = useSocket();
|
||||
const [location, getLocationAuthorization, startLocationTracking, stopLocationTracking] = useLocation(5000, 10);
|
||||
// update_team
|
||||
const [teamInfos, setTeamInfos] = useState({});
|
||||
// game_state
|
||||
const [gameState, setGameState] = useState(GameState.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(teamSocket, "update_team", (data) => {
|
||||
setTeamInfos(teamInfos => ({...teamInfos, ...data}))
|
||||
});
|
||||
|
||||
useSocketListener(teamSocket, "game_state", (data) => {
|
||||
setGameState(data.state);
|
||||
setStartDate(data.date);
|
||||
});
|
||||
|
||||
useSocketListener(teamSocket, "settings", (data) => {
|
||||
setMessages(data.messages);
|
||||
setZoneType(data.zone.type);
|
||||
//TODO
|
||||
//setSendPositionDelay(data.sendPositionDelay);
|
||||
//setOutOfZoneDelay(data.outOfZoneDelay);
|
||||
});
|
||||
|
||||
useSocketListener(teamSocket, "current_zone", (data) => {
|
||||
setZoneExtremities({begin: data.begin, end: data.end});
|
||||
setNextZoneDate(data.endDate);
|
||||
});
|
||||
|
||||
useSocketListener(teamSocket, "logout", logout);
|
||||
|
||||
|
||||
const value = useMemo(() => (
|
||||
{teamInfos, gameState, startDate, zoneType, zoneExtremities, nextZoneDate, messages, location, getLocationAuthorization, startLocationTracking, stopLocationTracking}
|
||||
), [teamInfos, gameState, startDate, zoneType, zoneExtremities, nextZoneDate, messages, location]);
|
||||
|
||||
return (
|
||||
<teamContext.Provider value={value}>
|
||||
{children}
|
||||
</teamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTeamContext() {
|
||||
return useContext(teamContext);
|
||||
}
|
||||
|
||||
export { TeamProvider, useTeamContext };
|
||||
33
mobile/traque-app/hook/useGame.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSocket } from "../context/socketContext";
|
||||
import { useTeamConnexion } from "../context/teamConnexionContext";
|
||||
import { useTeamContext } from "../context/teamContext";
|
||||
|
||||
export default function useGame() {
|
||||
const { teamSocket } = useSocket();
|
||||
const { teamId } = useTeamConnexion();
|
||||
const { teamInfos } = useTeamContext();
|
||||
|
||||
function sendCurrentPosition() {
|
||||
console.log("Reveal position.")
|
||||
teamSocket.emit("send_position");
|
||||
}
|
||||
|
||||
function capture(captureCode) {
|
||||
console.log("Try to capture :", captureCode);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn("Server did not respond to capture emit.");
|
||||
reject();
|
||||
}, 3000);
|
||||
|
||||
teamSocket.emit("capture", captureCode, (response) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(response.message);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {...teamInfos, sendCurrentPosition, capture, teamId};
|
||||
}
|
||||
34
mobile/traque-app/hook/useLocalStorage.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLocalStorage(key, initialValue) {
|
||||
const [storedValue, setStoredValue] = useState(initialValue);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const item = await AsyncStorage.getItem(key);
|
||||
setStoredValue(item ? JSON.parse(item) : initialValue);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const setValue = async value => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
await AsyncStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue, loading];
|
||||
}
|
||||
80
mobile/traque-app/hook/useLocation.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { defineTask, isTaskRegisteredAsync } from "expo-task-manager";
|
||||
import * as Location from 'expo-location';
|
||||
import { useSocket } from "../context/socketContext";
|
||||
|
||||
export function useLocation(timeInterval, distanceInterval) {
|
||||
const [location, setLocation] = useState(null); // [latitude, longitude]
|
||||
const { teamSocket } = useSocket();
|
||||
const LOCATION_TASK_NAME = "background-location-task";
|
||||
const locationUpdateParameters = {
|
||||
accuracy: Location.Accuracy.High,
|
||||
distanceInterval: distanceInterval, // Update every 10 meters
|
||||
timeInterval: timeInterval, // Minimum interval in 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
|
||||
},
|
||||
};
|
||||
|
||||
defineTask(LOCATION_TASK_NAME, async ({ data, error }) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
const { locations } = data;
|
||||
if (locations.length > 0) {
|
||||
const firstLocation = locations[0];
|
||||
const { latitude, longitude } = firstLocation.coords;
|
||||
const new_location = [latitude, longitude];
|
||||
try {
|
||||
setLocation(new_location);
|
||||
} catch (e) {
|
||||
console.warn("setLocation failed (probably in background):", e);
|
||||
}
|
||||
console.log("Sending position :", new_location);
|
||||
teamSocket.emit("update_position", new_location);
|
||||
} else {
|
||||
console.log("No location measured.")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getLocationAuthorization();
|
||||
}, []);
|
||||
|
||||
async function getLocationAuthorization() {
|
||||
const { status : statusForeground } = await Location.requestForegroundPermissionsAsync();
|
||||
const { status : statusBackground } = await Location.requestBackgroundPermissionsAsync();
|
||||
if (statusForeground !== "granted" || statusBackground !== "granted") {
|
||||
Alert.alert("Échec", "Activez la localisation en arrière plan dans les paramètres.");
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function startLocationTracking() {
|
||||
if (await getLocationAuthorization()) {
|
||||
if (!(await isTaskRegisteredAsync(LOCATION_TASK_NAME))) {
|
||||
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, locationUpdateParameters);
|
||||
console.log("Location tracking started.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopLocationTracking() {
|
||||
if (await isTaskRegisteredAsync(LOCATION_TASK_NAME)) {
|
||||
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
|
||||
console.log("Location tracking stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
return [location, getLocationAuthorization, startLocationTracking, stopLocationTracking];
|
||||
}
|
||||
58
mobile/traque-app/hook/usePickImage.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { launchImageLibraryAsync, requestMediaLibraryPermissionsAsync } from 'expo-image-picker';
|
||||
|
||||
export function usePickImage() {
|
||||
const [image, setImage] = useState(null);
|
||||
|
||||
const pickImage = 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.");
|
||||
}
|
||||
}
|
||||
|
||||
function sendImage(location) {
|
||||
if (image) {
|
||||
let data = new FormData();
|
||||
data.append('file', {
|
||||
uri: image.uri,
|
||||
name: 'photo.jpg',
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
fetch(location , {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {image, pickImage, sendImage};
|
||||
}
|
||||
35
mobile/traque-app/hook/useSendDeviceInfo.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import { useSocket } from "../context/socketContext";
|
||||
import { useTeamConnexion } from "../context/teamConnexionContext";
|
||||
|
||||
export default function useSendDeviceInfo() {
|
||||
const batteryUpdateTimeout = 5*60*1000;
|
||||
const { teamSocket } = useSocket();
|
||||
const {loggedIn} = useTeamConnexion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const sendInfo = async () => {
|
||||
const brand = DeviceInfo.getBrand();
|
||||
const model = DeviceInfo.getModel();
|
||||
const name = await DeviceInfo.getDeviceName();
|
||||
teamSocket.emit('device_info', {model: brand + " " + model, name: name});
|
||||
};
|
||||
|
||||
const sendBattery = async () => {
|
||||
const level = await DeviceInfo.getBatteryLevel();
|
||||
teamSocket.emit('battery_update', Math.round(level * 100));
|
||||
};
|
||||
|
||||
sendInfo();
|
||||
sendBattery();
|
||||
|
||||
const batteryCheckInterval = setInterval(() => sendBattery(), batteryUpdateTimeout);
|
||||
|
||||
return () => {clearInterval(batteryCheckInterval)};
|
||||
}, [loggedIn]);
|
||||
|
||||
return null;
|
||||
}
|
||||
72
mobile/traque-app/hook/useSocketAuth.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
const LOGIN_MESSAGE = "login";
|
||||
const LOGOUT_MESSAGE = "logout";
|
||||
|
||||
export function useSocketAuth(socket, passwordName) {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [waitingForResponse, setWaitingForResponse] = useState(false);
|
||||
const [hasTriedSavedPassword, setHasTriedSavedPassword] = useState(false);
|
||||
const [savedPassword, setSavedPassword, savedPasswordLoading] = useLocalStorage(passwordName, null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !hasTriedSavedPassword) {
|
||||
console.log("Try to log in with saved password :", savedPassword);
|
||||
setWaitingForResponse(true);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn("Server did not respond to login emit.");
|
||||
setWaitingForResponse(false);
|
||||
}, 3000);
|
||||
|
||||
socket.emit(LOGIN_MESSAGE, savedPassword, (response) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(response.message);
|
||||
setLoggedIn(response.isLoggedIn);
|
||||
setWaitingForResponse(false);
|
||||
});
|
||||
setHasTriedSavedPassword(true);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
function login(password) {
|
||||
console.log("Try to log in with :", password);
|
||||
setSavedPassword(password);
|
||||
setWaitingForResponse(true);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn("Server did not respond to login emit.");
|
||||
setWaitingForResponse(false);
|
||||
reject();
|
||||
}, 3000);
|
||||
|
||||
socket.emit(LOGIN_MESSAGE, password, (response) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(response.message);
|
||||
setLoggedIn(response.isLoggedIn);
|
||||
setWaitingForResponse(false);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
console.log("Logout");
|
||||
setSavedPassword(null);
|
||||
setLoggedIn(false);
|
||||
socket.emit(LOGOUT_MESSAGE);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(!waitingForResponse && !savedPasswordLoading) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
}, [waitingForResponse, savedPasswordLoading]);
|
||||
|
||||
return {login, logout, password: savedPassword, loggedIn, loading};
|
||||
}
|
||||
10
mobile/traque-app/hook/useSocketListener.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useSocketListener(socket, event, callback) {
|
||||
useEffect(() => {
|
||||
socket.on(event,callback);
|
||||
return () => {
|
||||
socket.off(event, callback);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
21
mobile/traque-app/hook/useTimeDifference.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function 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]);
|
||||
|
||||
return [time];
|
||||
}
|
||||
39682
mobile/traque-app/package-lock.json
generated
Normal file
50
mobile/traque-app/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "traque-app",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/stack": "^7.1.1",
|
||||
"axxios": "^0.1.0",
|
||||
"expo": "~52.0.46",
|
||||
"expo-build-properties": "~0.13.3",
|
||||
"expo-constants": "~17.0.2",
|
||||
"expo-dev-client": "~5.0.20",
|
||||
"expo-font": "~13.0.4",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-linking": "~7.0.2",
|
||||
"expo-location": "~18.0.10",
|
||||
"expo-router": "~4.0.21",
|
||||
"expo-splash-screen": "~0.29.24",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-task-manager": "~12.0.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-image-picker": "^4.0.6",
|
||||
"react-native-image-viewing": "^0.2.2",
|
||||
"react-native-linear-gradient": "^2.8.3",
|
||||
"react-native-maps": "1.18.0",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-web": "~0.19.6",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@react-native-community/cli": "latest"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
6
mobile/traque-app/util/gameState.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const GameState = {
|
||||
SETUP: "setup",
|
||||
PLACEMENT: "placement",
|
||||
PLAYING: "playing",
|
||||
FINISHED: "finished"
|
||||
}
|
||||