Ajout traque-app

This commit is contained in:
Sebastien Riviere
2025-08-24 10:30:32 +02:00
parent 623d1c05bf
commit a7f047388f
72 changed files with 45125 additions and 0 deletions

102
traque-app/apk_android.md Normal file
View File

@@ -0,0 +1,102 @@
# How to create an apk
An apk is an app file that can be installed on an android device without the need of google play store. This tutorial will explain the steps to create the apk of the current project and download it on an android device. Each step has precisions, however some of those precisions may not be suited for your device, don't hesitate to find help on the Internet or ask ChapGPT.
## Set up your environnement
This section will cover the set up of your environnement in order to have the tools to either send the apk of your app on your device or create a virtual android device.
### Tutorial
Follow this tutorial : https://reactnative.dev/docs/set-up-your-environment?os=linux&platform=android
### Precisions
* Android Studio and its dependancies can take up to 15 Go of space.
* The `.bashrc` file is located in your home directory (`cd ~`).
* The Watchman installation isn't necessary.
* If you want to test your app on a physical android device, follow the next section.
## Create the android folder
This section will cover the creation of the android folder if it isn't created yet.
### Tutorial
* Go in your project folder (`traque-app`).
* Run `npx expo install expo-dev-client`. This install the `expo-dev-client` package if it isn't already.
* Run `npx expo prebuild --platform android`. This will add an android folder in your project taking about 1 Go of space.
## Create and add the app key
This section will cover the creation of the app key which is required by google play store. You will need the `.keystore` file associated to the app. If no one in the team have created it yet follow *Tutorial Part A* then *Tutorial Part B*, else get the `.keystore` file, the storePassword, the keyAlias and the keyPassword, and follow *Tutorial Part B*.
### Tutorial Part A
* Go in the folder `traque-app/android/app/`.
* Run `keytool -genkey -v -keystore release.keystore -alias traque_key -keyalg RSA -keysize 2048 -validity 10000`, note that `release` and `traque_key` can be modified if you want.
### Tutorial Part B
* Go in the folder `traque-app/android/app/`.
* Here paste the `.keystore` file associated with your app.
* Modify the android bracket of the `traque-app/android/app/build.gradle` file as follows :
```
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
}
}
...
}
```
## Create the apk
This section will cover the creation of the apk of the app that can be download and installed on any android device.
### Tutorial
* Go in the folder `traque-app/android/`.
* Run `./gradlew assembleRelease`.
* At the end, the created apk will be located in the `traque-app/android/app/build/outputs/apk/release/` folder.
### Precisions
While running `./gradlew assembleRelease`, you can have this error :
```
Task :expo-modules-core:compileDebugKotlin FAILED
e: This version (1.5.15) of the Compose Compiler requires Kotlin version 1.9.25 but you appear to be using Kotlin version 1.9.24 which is not known to be compatible.
```
Follow these steps to fix it :
* In the `android/build.gradle` file, replace the line `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')` by `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25')`.
* In the `traque-app/android/` folder, run `./gradlew assembleRelease` again.
### Other precisions
If the build fail for obscure reasons, it may be usefull to delete entirely the `node_modules/` folder and recreate it by running `npm i`.
If problems persist, you may also delete entirely the `android/` folder and recreate it.
If the problem persist, you may also delete the `~/.gradle/caches/` folder.
If the problem persist, stop working on the project, you can't go against god's will.

61
traque-app/app.json Normal file
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": "com.anonymous.traqueapp",
"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": "com.anonymous.traqueapp",
"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;

16
traque-app/app/_layout.js Normal file
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>
);
}

598
traque-app/app/display.js Normal file
View File

@@ -0,0 +1,598 @@
// React
import { useState, useEffect, useRef } 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 { useDeadline, 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
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 {gameSettings, zoneExtremities, nextZoneDate, isShrinking, location, startLocationTracking, stopLocationTracking, gameState, zone} = useTeamContext();
const {loggedIn, logout, loading} = useTeamConnexion();
const {sendCurrentPosition, capture, enemyLocation, enemyName, startingArea, captureCode, name, ready, captured, lastSentLocation, locationSendDeadline, penalties, teamId, outOfZone, outOfZoneDeadline, distance, startDate, finishDate, nCaptures, nSentLocation} = 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 && (gameState == GameState.SETUP || gameState == GameState.PLAYING || gameState == GameState.PLACEMENT) && !captured) {
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 time = finishDate ? (finishDate - startDate) : timeSinceStart;
setAvgSpeed(distance/time);
}, [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);
const seconds = Math.floor(time % 60);
return String(hours).padStart(2,"0") + ":" + String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0");
}
function circle(color, circle) {
return (
<Circle
center={{ latitude: circle.center.lat, longitude: circle.center.lng }}
radius={circle.radius}
strokeWidth={2}
strokeColor={`rgba(${color}, 1)`}
fillColor={`rgba(${color}, 0.2)`}
/>
);
}
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}>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 && <Text style={styles.gameStateOutOfZone}>Hors zone (pénalité dans {formatTimeMinutes(-timeLeftOutOfZone)})</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"}}>{formatTimeMinutes(-timeLeftSendLocation)}</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}}>{gameSettings?.capturedMessage || "Vous avez été éliminé..."}</Text>
</View>
);
}
const EndGameMessage = () => {
return (
<View style={[styles.timersContainer, {height: 61}]}>
{captured && <Text style={{fontSize: 20}}>{gameSettings?.loserEndGameMessage || "Vous avez perdu..."}</Text>}
{!captured && <Text style={{fontSize: 20}}>{gameSettings?.winnerEndGameMessage || "Vous avez gagné !"}</Text>}
</View>
);
}
const Map = () => {
return (
<MapView ref={mapRef} style={{flex: 1}} initialRegion={initialRegion} mapType="standard" onTouchMove={() => setCenterMap(false)}>
{ gameState == GameState.PLACEMENT && startingArea && circle("0, 0, 255", startingArea)}
{ gameState == GameState.PLAYING && zoneExtremities && <Polygon coordinates={zoneExtremities.begin.points} strokeColor="red" fillColor="rgba(255,0,0,0.1)" strokeWidth={2} /> }
{ gameState == GameState.PLAYING && zoneExtremities && <Polygon coordinates={zoneExtremities.end.points} strokeColor="green" fillColor="rgba(0,255,0,0.1)" strokeWidth={2} /> }
{ location &&
<Marker coordinate={{ latitude: location[0], longitude: location[1] }} anchor={{ x: 0.33, y: 0.33 }}>
<Image source={require("../assets/images/marker/blue.png")} style={{width: 24, height: 24}} resizeMode="contain"/>
</Marker>
}
{ gameState == GameState.PLAYING && lastSentLocation &&
<Marker coordinate={{ latitude: lastSentLocation[0], longitude: lastSentLocation[1] }} anchor={{ x: 0.33, y: 0.33 }}>
<Image source={require("../assets/images/marker/grey.png")} style={{width: 24, height: 24}} resizeMode="contain"/>
</Marker>
}
{ gameState == GameState.PLAYING && enemyLocation &&
<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"/>
</Marker>
}
</MapView>
);
}
const UpdatePositionButton = () => {
return (
<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"}>{(distance / 1000).toFixed(1)}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*3.6).toFixed(1)}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() }
{ penalties > 0 && gameState == GameState.PLAYING &&
<Text style={{marginTop: 15, fontSize: 15}}>Pénalités : {penalties}</Text>
}
{ false && Settings() }
</View>
<View style={styles.teamNameContainer}>
{ TeamName() }
</View>
<View style={styles.logContainer}>
{ GameLog() }
</View>
{ gameState == GameState.PLACEMENT &&
Ready()
}
{ gameState == GameState.PLAYING && !captured &&
Timers()
}
{ 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 && !captured &&
<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}>
{ TeamCaptureCode() }
{ ChasedTeamImage() }
<View style={styles.actionsContainer}>
{ CaptureCode() }
{ CaptureButton() }
</View>
{ 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: 20,
padding: 10,
},
gameStateOutOfZone: {
borderWidth: 2,
borderRadius: 10,
width: "100%",
backgroundColor: 'white',
fontSize: 20,
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,
alignItems: 'center'
},
mapContainer: {
flex: 1,
width: '100%',
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
},
outerDrawerContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center'
},
innerDrawerContainer: {
width: "100%",
alignItems: 'center',
backgroundColor: backgroundColor,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
},
collapsibleButton: {
justifyContent: 'center',
alignItems: 'center',
width: "100%",
height: 45
},
collapsibleWindow: {
width: "100%",
alignItems: 'center',
justifyContent: 'center',
backgroundColor: backgroundColor,
},
collapsibleContent: {
paddingHorizontal: 15,
alignItems: 'center'
},
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
traque-app/app/index.js Normal file
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: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 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: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 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

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><title>ionicons-v5-e</title><path d="M112,112l20,320c.95,18.49,14.4,32,32,32H348c17.67,0,30.87-13.51,32-32l20-320" style="fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/><line x1="80" y1="112" x2="432" y2="112" style="stroke:#000000;stroke-linecap:round;stroke-miterlimit:10;stroke-width:32px"/><path d="M192,112V72h0a23.93,23.93,0,0,1,24-24h80a23.93,23.93,0,0,1,24,24h0v40" style="fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/><line x1="256" y1="176" x2="256" y2="400" style="fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/><line x1="184" y1="176" x2="192" y2="400" style="fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/><line x1="328" y1="176" x2="320" y2="400" style="fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 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,24 @@
import { createContext, useContext, useMemo } from "react";
import { io } from "socket.io-client";
const HOST = '192.168.15.129'; // IP of the machine hosting the server
const SOCKET_URL = (HOST == "traque.rezel.net" ? "wss://" : "ws://") + HOST + "/player";
const SERVER_URL = (HOST == "traque.rezel.net" ? "https://" : "http://") + HOST + "/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,24 @@
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,50 @@
import { useLocation } from "../hook/useLocation";
import { useSocketListener } from "../hook/useSocketListener";
import { createContext, useContext, useMemo, useRef, useState } from "react";
import { useSocket } from "./socketContext";
import { GameState } from "../util/gameState";
import useSendDeviceInfo from "../hook/useSendDeviceInfo";
const teamContext = createContext()
function TeamProvider({children}) {
const [teamInfos, setTeamInfos] = useState({});
const [gameState, setGameState] = useState(GameState.SETUP);
const [gameSettings, setGameSettings] = useState(null);
const [zoneExtremities, setZoneExtremities] = useState(null);
const [nextZoneDate, setNextZoneDate] = useState(null);
const [location, getLocationAuthorization, startLocationTracking, stopLocationTracking] = useLocation(5000, 10);
const {teamSocket} = useSocket();
const teamInfosRef = useRef();
useSendDeviceInfo();
teamInfosRef.current = teamInfos;
function setCurrentZone(data) {
const newBegin = {points : data.begin.points.map( p => ({latitude: p.lat,longitude: p.lng}) ), duration: data.begin.duration};
const newEnd = {points : data.end.points.map( p => ({latitude: p.lat,longitude: p.lng}) ), duration: data.end.duration};
setZoneExtremities({begin: newBegin, end: newEnd});
setNextZoneDate(data.endDate);
}
useSocketListener(teamSocket, "update_team", (newTeamInfos) => {setTeamInfos({...teamInfosRef.current, ...newTeamInfos})});
useSocketListener(teamSocket, "game_state", setGameState);
useSocketListener(teamSocket, "current_zone", setCurrentZone);
useSocketListener(teamSocket, "game_settings", setGameSettings);
const value = useMemo(() => (
{teamInfos, gameState, zoneExtremities, nextZoneDate, gameSettings, location, getLocationAuthorization, startLocationTracking, stopLocationTracking}
), [teamInfos, gameState, zoneExtremities, nextZoneDate, gameSettings, location]);
return (
<teamContext.Provider value={value}>
{children}
</teamContext.Provider>
);
}
function useTeamContext() {
return useContext(teamContext);
}
export { TeamProvider, useTeamContext };

View File

@@ -0,0 +1,69 @@
# How to create a local developpement build with Expo
Expo go is great to start a React Native project with Expo but reaches its limits when background localisation or server notifications have to be implemented and tested. This tutorial will explain the steps to create the debug android app of the current project (ie developpement build) and download it on an android device. A similar process can be done to test the IOS app but it requires a Mac so this won't be covered in this tutorial. A virtual android device can be created with Android Studio (explained later) but the process to send the app on the virtual device isn't covered in this tutorial. Here is a link that may help you : https://youtu.be/cs-zgHjt5RQ?si=Fzxik7zreek07uC0. Each step has precisions, however some of those precisions may not be suited for your device, don't hesitate to find help on the Internet or ask ChapGPT.
## Set up your environnement
This section will cover the set up of your environnement in order to have the tools to either send the apk of your app on your device or create a virtual android device.
### Tutorial
Follow this tutorial : https://reactnative.dev/docs/set-up-your-environment?os=linux&platform=android
### Precisions
* Android Studio and its dependancies can take up to 15 Go of space.
* The `.bashrc` file is located in your home directory (`cd ~`).
* The Watchman installation isn't necessary.
* If you want to test your app on a physical android device, follow the next section.
## Set up your physical android device
This section will cover the actions to perform on your device to be able to download the apk of the app.
### Tutorial
Follow the two first sections of this tutorial : https://reactnative.dev/docs/running-on-device?os=linux&platform=android
### Precisions
* In some devices, you may also enable the *install via USB* option.
* There is no need to modify the `/etc/udev/rules.d/51-android-usb.rules`, you can skip this part of the tutorial.
* When your device is connected, keep in your mind that a pop up asking authorizations can appear on it.
## Build the native app
This section will cover the building of the app and the sending on your device.
### Tutorial
* Go in your project folder (`traque-app`).
* Run `npx expo install expo-dev-client`. This install the `expo-dev-client` package if it isn't already.
* Run `npx expo prebuild --platform android`. This will add an android folder in your project taking about 1 Go of space.
* Connect your device to your computer with a USB cable and and run `npx expo run:android`. This will build the app and send it on your device. The operation can last for up to 10 minutes and will take up to 10 Go of space in total (mainly cache in the `~/.gradle` folder).
* The application should appear on your device. At this point there is no more need of the USB cable (you can disable *USB debugging*).
### Precisions
While running `npx expo run:android`, you can have this error :
```
Task :expo-modules-core:compileDebugKotlin FAILED
e: This version (1.5.15) of the Compose Compiler requires Kotlin version 1.9.25 but you appear to be using Kotlin version 1.9.24 which is not known to be compatible.
```
Follow these steps to fix it :
* In the `android/build.gradle` file, replace the line `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')` by `classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25')`.
* In the `traque-app` folder, run `npx expo run:android` again.
## Run the app
This section will explain how to run the app. The process is really similar to the one to run the app on Expo go and can be done with your device on the same WI-FI network as your computer.
### Tutorial
* Start the developpement server by running `npm start`, a blue *development build* should appear in your terminal.
* Enter the created app on your device. The app will update as well as if you were on Expo go if you modify the code on your computer.
* If the app crashes, you may need to restart the developpement server.

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, gameState } = 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, gameState};
}

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('deviceInfo', {model: brand + " " + model, name: name});
};
const sendBattery = async () => {
const level = await DeviceInfo.getBatteryLevel();
teamSocket.emit('batteryUpdate', 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
traque-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
traque-app/package.json Normal file
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"
}