Restructuration of the project folders

This commit is contained in:
Sebastien Riviere
2026-02-13 16:06:50 +01:00
parent 5f16500634
commit c1f1688794
188 changed files with 265 additions and 301 deletions

28
mobile/docs/TODO.md Normal file
View File

@@ -0,0 +1,28 @@
# TODO
## Tâches
- [x] Rendre plus claire le code de capture dans l'interface utilisateur (bien préciser que c'est l'id de capture).
- [x] Ajouter timer du rétrécissement des zones.
- [x] Afficher dernière position envoyée par la team.
- [x] Rendre la position de l'ennemi visible dès le départ.
- [x] Préciser que l'équipe doit fournir une photo d'eux où l'on voit leur tête et au moins leur buste.
- [x] Utiliser les messages de victoire/défaite/etc définis par le serveur.
- [x] Centrer la map sur la position à l'ouverture + bouton centrage
- [x] Indiquer que l'équipe est hors zone.
- [x] Mettre les stats dans le tiroir (distance, temps, vitesse moy, nb captures, nb envoi)
- [ ] Implémenter des notifs lors du background (hors zone, position envoyée, update zone)
- [ ] Ajouter les logs de la partie
- [ ] Créer le menu paramètre (idées de section : langue, photo équipe, notifs, mode sombre, unitées)
- [ ] Afficher la trajectoire passée sur la carte (désactivable)
- [ ] Afficher les évènements passés sur la carte (captures, envois, départ) (désactivable)
- [ ] Permettre le changement du style de la carte (schéma, satellite, relief etc)
- [ ] Ajouter imprécision de la position au besoin (comme sur google maps)
- [ ] Synchroniser les horloges sur l'interface
- [ ] Avoir un récap des évènement de la partie
- [ ] Publier sur le playstore
## Autres idées
- Améliorer l'accessibilité (traduction anglaise notamment).
- Améliorer l'UI.

117
mobile/docs/setup.md Normal file
View File

@@ -0,0 +1,117 @@
---
lang: en-GB
---
# Set up mobile
This tutorial will help you to set up your development environment, use a dev build and create an APK.
## Table of Contents
* [Environment](#environment) : Dependencies, packages, app key and device
* [Dev build](#dev-build) : Create, install and use
* [APK](#apk) : Create and install
## Environment
### Installing dependencies and preparing the device
You will need to install Android Studio, some SDKs and prepare your device if you want to use a physical one. Follow this [tutorial](https://reactnative.dev/docs/set-up-your-environment?os=linux&platform=android).
Note : if you want to use a physical android device, you just have to follow the first part of the tutorial. You may also enable install via USB depending on your device.
### Installing packages and create the android folder
Go in the `traque-app` folder and run `npm i` to intall the packages of the project.
Then run `npx expo prebuild --platform android` to create the android folder needed to create a dev build or an apk.
In order to prevent an issue, go to the `android/build.gradle` file and replace the line `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')` by `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25')`.
In order to be able to have a dev build and an apk on the same device, go to the `android/app/build.gradle` file and add :
```txt
android {
...
buildTypes {
debug {
...
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
}
...
}
...
}
```
### App key
An app key is like a signature of the application and is required by google play store. Therefore, you can skip this part if you don't plan to publish your modifications on the Google Playstore.
#### Create the app key
An app cannot have more than one key associated to it. If a key have already been generated for this project, please contact the creator of the key and follow to the next section.
Go in the `traque-app/android/app/` folder and run `keytool -genkey -v -keystore release.keystore -alias traque_key -keyalg RSA -keysize 2048 -validity 10000`.
Note : `release` and `traque_key` can be modified if you want.
#### Add the app key
Put the `.keystore` file associated with your app into `traque-app/android/app/` and then modify the android bracket of the `traque-app/android/app/build.gradle` file as follows :
```txt
android {
...
signingConfigs {
...
release {
storeFile file('TO_BE_FILLED.keystore')
storePassword 'TO_BE_FILLED'
keyAlias 'TO_BE_FILLED'
keyPassword 'TO_BE_FILLED'
}
}
buildTypes {
...
release {
...
signingConfig signingConfigs.release
}
}
...
}
```
## Dev build
A development build (or dev build) is an application that can be installed on you device, that connects to a development server and that allow you to see the modifications instantly.
### Build and install the dev build
Here you need to connect your device to your computer with a USB cable.
Go in the `traque-app` folder and run `npx expo run:android` in order to build the app and send it on your device.
### Use the dev build
Here you can work over USB or over the wifi.
Go in the `traque-app` folder and run `npm start` to start the development server. Verify that a blue *development build* appears in your terminal. Then scan the QR code that appears with your android device.
Note : if the app crashes, you may need to restart the development server.
## APK
An apk is an application that can be installed on an android device without the need of google play store. It is a definitive version of your application that can't be modified after being built.
### Build the APK
Go in the `traque-app/android/` folder and run `./gradlew assembleRelease`. At the end, the created apk will be located in the `app/build/outputs/apk/release/` folder.
Note : if the build fail you may try to delete and recreate `node_modules/` or `android/`, you may also try to delete the `~/.gradle/caches/` folder.
### Install the APK
Simply share the APK with a drive and click on the APK file on your device to install it.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 877 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 517 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 360 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 653 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 245 KiB

View 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."
}
}
}
}

View File

@@ -0,0 +1,3 @@
import { Unmatched } from 'expo-router';
export default Unmatched;

View 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>
);
}

View 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'
},
});

View 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',
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,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,
},
});

View 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
}
});

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

View 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>
);
};

View 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);
}

View 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 };

View 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 };

View 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};
}

View 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];
}

View 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];
}

View 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};
}

View 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;
}

View 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};
}

View 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);
}
}, []);
}

View 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

File diff suppressed because it is too large Load Diff

View 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
}

View File

@@ -0,0 +1,6 @@
export const GameState = {
SETUP: "setup",
PLACEMENT: "placement",
PLAYING: "playing",
FINISHED: "finished"
}