Compare commits

...

131 Commits

Author SHA1 Message Date
105d2aad88 Correction du partage de la position pour la joueuse qui n'est pas en course 2024-12-20 08:07:08 +01:00
1f2bee0b08 Dézoom sur le logo 2024-12-19 22:46:51 +01:00
86d07570c6 Passage en version 1.1.0 2024-12-19 21:41:44 +01:00
906dbeca22 Nouvelle icône 2024-12-19 21:41:00 +01:00
60bbe418a6 Nombre de points différents entre la première joueuse et la deuxième 2024-12-19 21:37:00 +01:00
650d77bbfd Retrait du plugin babel react-native-paper (https://github.com/fateh999/react-native-paper-dropdown/issues/115) 2024-12-19 21:23:27 +01:00
7f06ac94e8 Correction banière temps pour poursuite 2024-12-19 19:19:07 +01:00
ced5259272 Retrait de dépendances inutilisées 2024-12-19 17:57:26 +01:00
d1cdf8cf6d Affichage de la dernière localisation reçue uniquement en développement 2024-12-19 17:50:41 +01:00
4246266f9f Réparation du socket en production 2024-12-19 17:49:16 +01:00
956e9b6ffd Stockage du choix de précision de géolocalisation 2024-12-19 17:33:17 +01:00
590539a979 Amélioration Géolocalisation 2024-12-19 16:47:41 +01:00
abb5c3c584 Passage en version 1.0.1 2024-12-17 23:17:51 +01:00
5e61cabcdc Affichage de l'adresse estimée dans les infos de la position d'une joueuse 2024-12-17 23:06:20 +01:00
8ba47fe2f0 Popup pour voir l'heure de dernière position d'une joueuse 2024-12-17 22:59:47 +01:00
834b1643de Corrections et optimisations géolocalisation 2024-12-17 22:03:22 +01:00
55aff5f900 Correction appels multiples à la connexion 2024-12-17 21:14:51 +01:00
afcb5427af Retrait de l'adapter Websocket inutile 2024-12-17 01:35:10 +01:00
0b9df38139 Ajout IoAdapter pour les websockets en production 2024-12-17 01:32:10 +01:00
20f8fd515f La reconnection au serveur est déjà la valeur par défaut 2024-12-17 01:24:05 +01:00
256f0b7684 Gestion nom de domaine websocket 2024-12-17 01:19:00 +01:00
409e13277e Passage du délai de mise à jour des données depuis le serveur à 15 secondes 2024-12-17 01:12:54 +01:00
0433e4695e Transmission plus immédiate via websockets 2024-12-17 01:06:23 +01:00
29c0a234d1 Masquage de la position de la joueuse adverse si on est poursuiveuse 2024-12-16 18:43:10 +01:00
a8182befe5 Retrait de code de debug 2024-12-16 18:26:06 +01:00
ed019a3ed4 Correction suivi position 2024-12-16 18:18:56 +01:00
b726305a44 Importation automatique d'un train depuis Rail Planner par partage de l'URL d'un trajet 2024-12-16 17:52:08 +01:00
71b7df0008 Ajout des sorties Android au gitignore 2024-12-16 14:34:57 +01:00
5143bdc446 Déplacement des providers dans un dossier dédié 2024-12-16 14:34:01 +01:00
0a3fe8c243 Utilisation d'un préfixe global 2024-12-15 20:46:53 +01:00
1fbd8ee5c3 Correction vidange de la file de géolocalisations à transmettre 2024-12-15 20:42:21 +01:00
986649a070 Suivi caméra sur Android 2024-12-15 18:18:19 +01:00
39312adc2a On envoie des localisations quand on est connecté⋅e, pas l'inverse 2024-12-15 18:01:13 +01:00
76643fcc62 Retrait module inutilisé, compilation Android 2024-12-14 20:27:39 +01:00
8084040aef Mise à jour de dépendances 2024-12-14 18:06:39 +01:00
893e6a9e01 Installation expo-share-extension pour supporter le partage direct 2024-12-14 17:52:11 +01:00
3648454da4 Retrait de expo-background-fetch, inutilisé 2024-12-14 17:42:18 +01:00
25ca687448 Transmission des positions seulement après connexion 2024-12-14 17:21:55 +01:00
0f16edd8cc Ajout possibilité modifier son défi en cours 2024-12-14 17:18:41 +01:00
3348979738 Suppression de trains et défis 2024-12-14 15:57:14 +01:00
cb1222d9cf Les pénalités ne se suivent pas d'une tentative à une autre 2024-12-14 13:35:09 +01:00
979362d012 Les échecs de défis donnent lieu à un mouvement de points nul 2024-12-14 13:30:26 +01:00
9dfb2ba15d Gestion erreurs création/modification défi 2024-12-14 13:10:32 +01:00
8878a13f4f Ajout comportements en cas de suppresion d'un élément lié 2024-12-14 13:01:11 +01:00
601b337369 Désactivation de boutons pendant les requêtes 2024-12-14 12:55:55 +01:00
9c6dc97d23 Amélioration page de paramètres 2024-12-14 12:53:56 +01:00
8b8453b276 La réparation du jeu met à jour les montants 2024-12-14 12:42:37 +01:00
fd4b0e8cd1 Ajout, modification et suppression de défi 2024-12-14 12:36:50 +01:00
dba5b511ae Affichage de la liste des défis 2024-12-14 11:55:11 +01:00
50382079c0 Utilisation de snackbars pour afficher le statut d'une requête 2024-12-14 01:44:32 +01:00
57676abb02 Stabilité téléchargement des données 2024-12-14 00:53:22 +01:00
3d50cee9a9 Afifchage de l'historique des mouvements de point 2024-12-14 00:27:30 +01:00
02304527d3 Téléchargmeent des mises à jour de solde 2024-12-13 23:07:37 +01:00
4a33963c12 Boussole affichée en bas à droite sur Android 2024-12-13 01:25:56 +01:00
458183eba8 Affichage des dernières positions sur la carte 2024-12-13 01:18:55 +01:00
9e5cdfb132 Correction masquage bandeau pourchasseuse 2024-12-13 01:00:33 +01:00
30a687018f Correction envoi dernière position 2024-12-13 00:32:52 +01:00
1241669c35 Bandeau indiquant à partir de quand il est possible de commencer à poursuivre 2024-12-13 00:23:39 +01:00
6dbda9d927 Le menu de carte est dans un ficher map.tsx 2024-12-13 00:06:01 +01:00
4cb2677f45 Pénalité lorsqu'on échoue un défi 2024-12-13 00:02:58 +01:00
63ad84eb8c Interaction avec les boutons de fin de défi 2024-12-12 23:41:20 +01:00
47ec3da6df Meilleure fluidité lorsqu'un tire un défi 2024-12-12 23:02:02 +01:00
bd5e39094b Récupération de défis et tirage d'un nouveau défi via des boutons 2024-12-12 22:56:26 +01:00
04f30e3ac2 Récupération de défis et tirage d'un nouveau défi via des boutons 2024-12-12 22:55:59 +01:00
9d0b5cb254 Modification BDD stockage défi actif 2024-12-12 21:03:42 +01:00
db8a8b4b7b Gestion affichage défi selon les cas 2024-12-12 20:15:43 +01:00
ac20baad23 Gestion dynamique de l'affichage du défi 2024-12-12 19:33:44 +01:00
291e7ff8a7 La bannière de pénalité tient compte des vraies données et est animée 2024-12-12 19:10:21 +01:00
9543177db6 Ajout bannière affichant le temps de pénalité restant 2024-12-12 18:43:56 +01:00
9c7a447e4e Structure de défi 2024-12-12 18:01:08 +01:00
e623dad81f On récupère pour l'instant tous les trains sans se préoccuper de la pagination 2024-12-12 15:49:38 +01:00
bfc6069a87 On ne récupère que ses propres trains empruntés 2024-12-12 15:38:47 +01:00
a4a0981b2c Correction filtrage API 2024-12-12 15:37:26 +01:00
246dae446f Correction mise à jour jeton authentification 2024-12-12 15:21:58 +01:00
38117ade07 Récupération de la liste des trains empruntés 2024-12-12 14:56:17 +01:00
33ee18d7e2 Enregistrement de trajet en train 2024-12-11 23:42:22 +01:00
54a7806316 Correction booléen asynchrone 2024-12-11 23:39:47 +01:00
af14cfb11d Correction détection si on est sur la première course 2024-12-11 22:34:15 +01:00
49c320b2db Récupération du solde de points et affichage sur la carte 2024-12-11 22:28:32 +01:00
db560c401a Récupération de l'identifiant de læ joueur⋅se 2024-12-11 21:43:44 +01:00
61b0cd51ae Boutons de démarrage du jeu fonctionnels 2024-12-11 21:33:51 +01:00
c28097d443 Boutons pour démarrer le jeu 2024-12-11 19:35:36 +01:00
7aa9dde5a9 Structure de données jeu client 2024-12-11 17:26:36 +01:00
d1adba04da Correction déconnexion web 2024-12-11 16:23:17 +01:00
9176eb014f Prototype de récupération des dernières positions 2024-12-11 01:53:28 +01:00
bdd53eb8bb Prototype envoi géolocalisations 2024-12-11 01:30:21 +01:00
db7a0b970d Stockage persistent des requêtes 2024-12-11 00:30:20 +01:00
1c52ff5a10 Utilisation de mutations plutôt que d'appels fetch directs 2024-12-10 21:50:22 +01:00
b0c17db233 Renouvellement automatique du jeton d'authentification 2024-12-10 19:26:10 +01:00
363dfa5c74 Stockage du jeton d'authentification dans le store local, permettant l'utilisation de hooks 2024-12-10 18:56:50 +01:00
72862da3a6 Variable d'environnement pour l'URL du serveur API 2024-12-10 08:43:25 +01:00
4da75e310e Désactivation du bouton de connexion pendant une connexion 2024-12-10 08:19:54 +01:00
48845c70c2 Ajout de expo-notifications 2024-12-10 01:01:10 +01:00
8f29169381 Ajout de expo-updates pour des mises à jour automatiques 2024-12-10 00:57:25 +01:00
a98b2b56ec Ajout tâche de mise à jour en arrière-plan 2024-12-10 00:57:09 +01:00
7becd396d3 Déconnexion permise 2024-12-10 00:04:35 +01:00
ead2a91410 Accès à rien tant qu'on est pas connecté⋅e 2024-12-09 23:30:34 +01:00
ff85c8bd51 Ajout squelette HTML 2024-12-09 23:24:53 +01:00
1f4cfe0b77 Correction secure store 2024-12-09 22:57:59 +01:00
4be37ac303 Ajout d'en-tête CORS navigateur 2024-12-09 22:49:31 +01:00
62559810b0 Utilisation du stockage navigateur local 2024-12-09 22:47:43 +01:00
32460062b8 Ajout connexion au serveur 2024-12-09 22:29:48 +01:00
a9cb1ec425 Installation de React Native Paper 2024-12-09 21:00:15 +01:00
c08fbb762a Installation de expo-secure-store pour stocker les données de connexion à l'API 2024-12-09 18:29:48 +01:00
4a4233925d Stockage des constants dans un fichier à part pour tout centraliser 2024-12-08 23:32:57 +01:00
a0fd6ca6ab Correction de l'endpoint qui récupère les dernières géolocalisations 2024-12-08 23:19:56 +01:00
20b4f2e7e8 Ajout endpoints tentatives de course 2024-12-08 23:03:30 +01:00
33689d9c76 Gestion des heures de fin des tentatives 2024-12-08 22:49:47 +01:00
50a9f3369c Si un défi était en cours lors d'une capture, on le clôt 2024-12-08 22:40:51 +01:00
31c44eab6e Ajout d'une structure de tentatives de courses (Run) 2024-12-08 22:37:57 +01:00
23081e0220 Seul⋅e læ joueur⋅se actif⋅ve peut tirer un défi 2024-12-08 20:04:07 +01:00
3eea3a7409 Ajout endpoint pour changer de joueur⋅se actif⋅ve 2024-12-08 20:01:43 +01:00
4c157ff67f Ajout d'un endpoint pour essayer de réparer un état éventuellement cassé 2024-12-08 19:55:07 +01:00
a1b5fccc98 Crédit automatique de points lorsqu'un défi a été réussi 2024-12-08 19:31:54 +01:00
8681752888 Paiement automatique d'une course 2024-12-08 19:27:40 +01:00
481400d404 Ajout démarrage et fin de partie 2024-12-08 18:50:00 +01:00
77b33144f6 Utilisation du plugin swagger pour de la meilleure documentation, et meilleure prise en charge d'erreurs 2024-12-08 18:18:11 +01:00
83d3a573ca Remplacement des codes d'erreurs NotAcceptable par des codes plus adaptés 2024-12-08 17:02:22 +01:00
3af1e498ac Retrait des @ApiForbiddenResponse puisque nous ne renvoyons aucune erreur 403 2024-12-08 16:58:03 +01:00
b93b8b4c04 Ajout structure de jeu 2024-12-08 16:34:06 +01:00
0d96b78c33 Retrait de logs superflus 2024-12-08 16:04:00 +01:00
65576fc5b1 User => Player 2024-12-08 13:41:37 +01:00
c6da328023 Mise à jour automatique du solde d'un⋅e utilisateur⋅rice après création ou modification d'un objet MoneyUpdate 2024-12-08 13:05:07 +01:00
b44ffcd380 Stockage du montant de la modification plutôt que le avant/après 2024-12-08 11:39:57 +01:00
6a0b4049b6 Ajout endpoint mise à jour de solde 2024-12-08 02:23:37 +01:00
7fd2c4d7fe Filtrage historique trajets par utilisateur⋅rice 2024-12-08 01:52:16 +01:00
11ab6f66f7 Estimation de la distance plutôt que de compter sur l'instabilité de signal.eu.org/osm 2024-12-08 01:47:02 +01:00
99bd7a88a5 Importation des trajets depuis Interrail et signal.eu.org 2024-12-07 23:57:14 +01:00
e052b06c83 Ajout endpoint trajets en train 2024-12-07 22:07:46 +01:00
4349a1b61a Ajout endpoint pour terminer ses challenges et gérer les pénalités 2024-12-07 21:36:09 +01:00
ab2d07cd18 Stockage du fuseau horaire dans la base de données 2024-12-07 21:17:53 +01:00
150 changed files with 7024 additions and 1538 deletions

View File

@ -1 +1,6 @@
ANDROID_HOME=/opt/android-sdk
# Public variables
EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER=https://traintrapemoi.luemy.eu/api
EXPO_PUBLIC_TRAINTRAPE_MOI_SOCKET=https://traintrapemoi.luemy.eu/
# Build variables
ANDROID_HOME=/opt/android-sdk

2
client/.env.development Normal file
View File

@ -0,0 +1,2 @@
EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER=http://localhost:3000/
EXPO_PUBLIC_TRAINTRAPE_MOI_SOCKET=http://localhost:3000/

5
client/.gitignore vendored
View File

@ -9,6 +9,11 @@ dist/
web-build/
expo-env.d.ts
# Android output
*.aab
*.apk
*.apks
# Native
*.orig.*
*.jks

View File

@ -2,21 +2,31 @@
"expo": {
"name": "Traintrape-moi",
"slug": "traintrape-moi-client",
"version": "1.0.0",
"version": "1.1.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"scheme": "traintrapemoi",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"bundleIdentifier": "traintrapemoi",
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
"backgroundColor": "#0033fe"
},
"package": "eu.luemy.traintrapemoi"
"package": "eu.luemy.traintrapemoi",
"permissions": [
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_BACKGROUND_LOCATION",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_LOCATION",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.WAKE_LOCK"
]
},
"web": {
"bundler": "metro",
@ -24,17 +34,6 @@
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"@maplibre/maplibre-react-native",
[
"expo-location",
{
@ -43,7 +42,21 @@
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
],
"expo-task-manager"
"expo-router",
"expo-secure-store",
"expo-share-intent",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 128,
"resizeMode": "contain",
"backgroundColor": "#0033fe"
}
],
"expo-task-manager",
"expo-updates",
"@maplibre/maplibre-react-native"
],
"experiments": {
"typedRoutes": true
@ -55,6 +68,12 @@
"eas": {
"projectId": "1898a5de-1db1-41f7-b883-1b02885f750a"
}
},
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/1898a5de-1db1-41f7-b883-1b02885f750a"
}
}
}

View File

@ -1,18 +1,19 @@
import { Tabs } from 'expo-router'
import React from 'react'
import { Colors } from '@/constants/Colors'
import { useColorScheme } from '@/hooks/useColorScheme'
import { FontAwesome6, MaterialIcons } from '@expo/vector-icons'
import TabBar from '@/components/ui/TabBar'
import TabsHeader from '@/components/ui/TabsHeader'
export default function TabLayout() {
const colorScheme = useColorScheme()
return (
<>
<Tabs
tabBar={(props) => <TabBar {...props} />}
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
}}>
tabBarHideOnKeyboard: true,
header: (props) => <TabsHeader navProps={props} children={undefined} />,
}}
>
<Tabs.Screen
name="index"
options={{
@ -24,15 +25,15 @@ export default function TabLayout() {
<Tabs.Screen
name="challenges"
options={{
title: 'Challenges',
headerTitleStyle: {fontSize: 32},
title: 'Défi en cours',
headerShown: false,
tabBarIcon: ({ color }) => <FontAwesome6 name="coins" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="train"
options={{
title: 'Ajouter un trajet',
title: 'Trains',
headerTitleStyle: {fontSize: 32},
tabBarIcon: ({ color }) => <FontAwesome6 name="train" size={24} color={color} />,
}}
@ -45,6 +46,15 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => <MaterialIcons name="history" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Paramètres',
headerTitleStyle: {fontSize: 32},
tabBarIcon: ({ color }) => <MaterialIcons name="settings" size={24} color={color} />,
}}
/>
</Tabs>
</>
)
}

View File

@ -1,14 +1,145 @@
import { ScrollView } from 'react-native'
import ChallengeCard from '@/components/ChallengeCard'
import PenaltyBanner from '@/components/PenalyBanner'
import { useDrawRandomChallengeMutation, useEndChallenge } from '@/hooks/mutations/useChallengeMutation'
import { useAuth } from '@/hooks/useAuth'
import { useChallengeActions } from '@/hooks/useChallengeActions'
import { useChallenges } from '@/hooks/useChallenges'
import { useGame } from '@/hooks/useGame'
import { FontAwesome6, MaterialCommunityIcons } from '@expo/vector-icons'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'expo-router'
import { useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
import { ActivityIndicator, Appbar, Banner, FAB, MD3Colors, Snackbar, Surface, Text, TouchableRipple } from 'react-native-paper'
import { ThemedText } from '@/components/ThemedText'
import { ThemedView } from '@/components/ThemedView'
function ChallengeScreenHeader() {
const router = useRouter()
return <>
<Appbar.Header>
<Appbar.Content title={"Défi en cours"} />
<Appbar.Action icon='format-list-bulleted' onPress={() => router.navigate('/challenges-list')} />
</Appbar.Header>
<PenaltyBanner />
</>
}
function ChallengeScreenBody() {
const queryClient = useQueryClient()
const auth = useAuth()
const game = useGame()
const challengeActions = useChallengeActions()
const challenges = useChallenges()
const currentChallengeAction = useMemo(() => {
if (!game.activeChallengeId)
return null
return challengeActions.find((action) => action.id === game.activeChallengeId)
}, [game, challengeActions])
const currentChallenge = useMemo(() => {
if (!currentChallengeAction)
return null
return challenges.find((challenge) => challenge.id === currentChallengeAction.challengeId)
}, [currentChallengeAction, challenges])
const [loading, setLoading] = useState(false)
const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false)
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const drawRandomChallengeMutation = useDrawRandomChallengeMutation({
auth,
onPostSuccess: () => {
setLoading(true)
setSuccessSnackbarVisible(true)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' || query.queryKey[0] === 'get-player' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const endChallenge = useEndChallenge({
auth,
onPostSuccess: () => {
setLoading(true)
setSuccessSnackbarVisible(true)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' || query.queryKey[0] === 'get-player' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
useEffect(() => {
if (challengeActions)
setLoading(false)
}, [challengeActions])
return <>
{loading &&
<View style={{ flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size={'large'} />
</View>}
{!loading && currentChallenge &&
<ChallengeCard
challenge={currentChallenge}
onSuccess={() => { setLoading(true); endChallenge.mutate({ success: true }) }}
onFail={() => endChallenge.mutate({ success: false })}
style={{ flex: 1, margin: 20 }} />}
{!loading && !game.penaltyEnd && !currentChallenge && game.currentRunner && <>
<Banner
elevation={4}
visible={!currentChallenge && game.currentRunner && !loading}
icon='vanish'>
Aucun défi n'est en cours. Veuillez tirer un défi en cliquant sur le bouton central.
Pour rappel, il faut être hors d'un train pour tirer un défi.
</Banner>
<View style={{ flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<FAB
label='Tirer un défi'
icon='cards'
disabled={drawRandomChallengeMutation.isPending}
visible={!currentChallenge && game.currentRunner && !loading}
onPress={() => drawRandomChallengeMutation.mutate()}
variant='tertiary'
customSize={64} />
</View>
</>}
<Banner
visible={!loading && game.gameStarted && !game.currentRunner}
icon={({ size }) => <FontAwesome6 name='cat' size={size} color={'pink'} />}
style={{ backgroundColor: MD3Colors.secondary30 }}>
Vous êtes poursuiveuse, et n'avez donc pas de défi à accomplir.
</Banner>
<Snackbar
key='success-snackbar'
visible={successSnackbarVisible}
icon={'close'}
onDismiss={() => setSuccessSnackbarVisible(false)}
onIconPress={() => setSuccessSnackbarVisible(false)}>
Jeu actualisé
</Snackbar>
<Snackbar
key='error-snackbar'
visible={errorVisible}
icon={'close'}
onDismiss={() => setErrorVisible(false)}
onIconPress={() => setErrorVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
Erreur {error[0]} : {error[1]}
</Text>
</Snackbar>
</>
}
export default function ChallengesScreen() {
return (
<ScrollView>
<ThemedView>
<ThemedText>Ici on aura la gestion des challenges</ThemedText>
</ThemedView>
</ScrollView>
<Surface style={{ flex: 1 }}>
<ChallengeScreenHeader />
<ChallengeScreenBody />
</Surface>
)
}

View File

@ -1,13 +1,190 @@
import { ScrollView } from 'react-native'
import { ThemedText } from '@/components/ThemedText'
import { ThemedView } from '@/components/ThemedView'
import { useDeleteChallengeActionMutation } from '@/hooks/mutations/useChallengeMutation'
import { useGameRepairMutation } from '@/hooks/mutations/useGameMutation'
import { useDeleteTrainMutation } from '@/hooks/mutations/useTrainMutation'
import { useAuth } from '@/hooks/useAuth'
import { useChallengeActions } from '@/hooks/useChallengeActions'
import { useChallenges } from '@/hooks/useChallenges'
import { useMoneyUpdates } from '@/hooks/useMoneyUpdates'
import { useTrain } from '@/hooks/useTrain'
import { MoneyUpdate } from '@/utils/features/moneyUpdates/moneyUpdatesSlice'
import { FontAwesome6 } from '@expo/vector-icons'
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { FlatList } from 'react-native'
import { Button, Dialog, Divider, List, MD3Colors, Portal, Snackbar, Surface, Text } from 'react-native-paper'
export default function HistoryScreen() {
const auth = useAuth()
const queryClient = useQueryClient()
const moneyUpdates = useMoneyUpdates()
const [deletingMoneyUpdate, setDeletingMoneyUpdate] = useState<MoneyUpdate | null>(null)
const [successVisible, setSuccessVisible] = useState(false)
const [successMessage, setSuccessMessage] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const deleteTrainMutation = useDeleteTrainMutation({
auth,
onPostSuccess: () => {
setDeletingMoneyUpdate(null)
setSuccessVisible(true)
setSuccessMessage("Train supprimé")
gameRepairMutation.mutate()
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-money-updates' || query.queryKey[0] === 'get-trains' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const deleteChallengeActionMutation = useDeleteChallengeActionMutation({
auth,
onPostSuccess: () => {
setDeletingMoneyUpdate(null)
setSuccessVisible(true)
setSuccessMessage("Réalisation du défi supprimée")
gameRepairMutation.mutate()
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-money-updates' || query.queryKey[0] === 'get-challenges' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const gameRepairMutation = useGameRepairMutation({
auth,
onPostSuccess: () => {
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-player' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
function deleteMoneyUpdate(): void {
if (!deletingMoneyUpdate)
return
switch (deletingMoneyUpdate.reason) {
case 'BUY_TRAIN':
if (!deletingMoneyUpdate.tripId) return
deleteTrainMutation.mutate(deletingMoneyUpdate.tripId)
break
case 'CHALLENGE':
if (!deletingMoneyUpdate.actionId) return
deleteChallengeActionMutation.mutate(deletingMoneyUpdate.actionId)
break
}
setDeletingMoneyUpdate(null)
}
return (
<ScrollView>
<ThemedView>
<ThemedText>Ici on aura la gestion de l'historique des trains empruntés et des challenges effectués</ThemedText>
</ThemedView>
</ScrollView>
<Surface style={{ flex :1 }}>
<FlatList
data={moneyUpdates}
keyExtractor={(moneyUpdate) => `money-update-list-item-${moneyUpdate.id}`}
ItemSeparatorComponent={() => <Divider />}
renderItem={(item) =>
<MoneyUpdateListItem moneyUpdate={item.item}
onDelete={['START', 'NEW_RUN'].includes(item.item.reason) ? undefined : () => setDeletingMoneyUpdate(item.item)} />} />
<Snackbar
key='success-snackbar'
visible={successVisible}
icon={'close'}
onDismiss={() => setSuccessVisible(false)}
onIconPress={() => setSuccessVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
{successMessage}
</Text>
</Snackbar>
<Snackbar
key='error-snackbar'
visible={errorVisible}
icon={'close'}
onDismiss={() => setErrorVisible(false)}
onIconPress={() => setErrorVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
Erreur {error[0]} : {error[1]}
</Text>
</Snackbar>
<Portal>
<Dialog visible={deletingMoneyUpdate !== null} onDismiss={() => setDeletingMoneyUpdate(null)}>
<Dialog.Title>Êtes-vous sûre ?</Dialog.Title>
<Dialog.Content>
<Text>Voulez-vous vraiment supprimer ce mouvement de fonds ?</Text>
{deletingMoneyUpdate && <MoneyUpdateListItem moneyUpdate={deletingMoneyUpdate} />}
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setDeletingMoneyUpdate(null)}>Annuler</Button>
<Button onPress={() => deleteMoneyUpdate()}>Confirmer</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</Surface>
)
}
function MoneyUpdateListItem({ moneyUpdate, onDelete }: { moneyUpdate: MoneyUpdate, onDelete?: () => void }) {
const trains = useTrain()
const challengeActions = useChallengeActions()
const challenges = useChallenges()
const icon = useMemo(() => {
switch (moneyUpdate.reason) {
case 'START': return 'star'
case 'NEW_RUN': return 'run'
case 'BUY_TRAIN': return 'train'
case 'CHALLENGE': return 'cards'
}
}, [moneyUpdate.reason])
const title = useMemo(() => {
switch (moneyUpdate.reason) {
case 'START':
return "Début de la partie"
case 'NEW_RUN':
return "Nouvelle tentative"
case 'BUY_TRAIN':
const train = trains.find((train) => train.id === moneyUpdate.tripId)
if (!train) return "Train"
const depDateTime = new Date(train.departureTime)
const depTime = depDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const arrDateTime = new Date(train.arrivalTime)
const arrTime = arrDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
return `${train.from} ${depTime} => ${train.to} ${arrTime}`
case 'CHALLENGE':
const challengeAction = challengeActions.find((challengeAction) => challengeAction.id === moneyUpdate.actionId)
if (!challengeAction) return "Défi"
const challenge = challenges.find((challenge) => challenge.id === challengeAction.challengeId)
if (!challenge) return "Défi"
return challenge.title
}
}, [moneyUpdate.reason, moneyUpdate.tripId, moneyUpdate.actionId])
const description = useMemo(() => {
const earnDate = new Date(moneyUpdate.timestamp).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
const earnTime = new Date(moneyUpdate.timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const verb = moneyUpdate.amount >= 0 ? "Gagné" : "Dépensé"
return <Text>
{verb} {Math.abs(moneyUpdate.amount)} <FontAwesome6 name='coins' /> le {earnDate} à {earnTime}
</Text>
}, [moneyUpdate.amount])
return (
<List.Item
left={(props) => <List.Icon {...props} icon={icon} />}
title={title}
description={description}
onLongPress={onDelete} />
)
}

View File

@ -1,30 +1,68 @@
import { StyleSheet } from 'react-native'
import { ThemedView } from '@/components/ThemedView'
import "maplibre-gl/dist/maplibre-gl.css"
import { useBackgroundPermissions } from 'expo-location'
import Map from '@/components/Map'
import { ThemedText } from '@/components/ThemedText'
import { FAB, Surface, Text } from 'react-native-paper'
import { useGame } from '@/hooks/useGame'
import { FontAwesome6 } from '@expo/vector-icons'
import FreeChaseBanner from '@/components/FreeChaseBanner'
import { View } from 'react-native'
export default function MapScreen() {
const [backgroundStatus, requestBackgroundPermission] = useBackgroundPermissions()
if (!backgroundStatus?.granted && backgroundStatus?.canAskAgain)
requestBackgroundPermission()
const game = useGame()
return (
<ThemedView style={styles.page}>
{backgroundStatus?.granted ? <Map /> : <ThemedText>La géolocalisation est requise pour utiliser la carte.</ThemedText>}
</ThemedView>
<Surface style={styles.page}>
<View style={styles.container}>
{backgroundStatus?.granted ? <Map /> : <Text>La géolocalisation est requise pour utiliser la carte.</Text>}
<FAB
style={styles.moneyBadge}
visible={game.gameStarted || game.money > 0}
icon={(props) => <FontAwesome6 {...props} name='coins' size={20} />}
color='black'
label={`${game.money}`}
onPress={() => {}} />
<FAB
style={styles.statusBadge}
visible={game.gameStarted || game.money > 0}
size='small'
color='black'
icon={game.currentRunner ? 'run-fast' : () => <FontAwesome6 name='cat' size={20} />}
label={game.currentRunner ? "Coureuse" : "Poursuiveuse"}
onPress={() => {}} />
</View>
<FreeChaseBanner />
</Surface>
)
}
const styles = StyleSheet.create({
page: {
flex: 1,
},
container: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
alignItems: 'center'
},
map: {
flex: 1,
alignSelf: 'stretch',
},
moneyBadge: {
position: 'absolute',
top: 40,
right: 20,
backgroundColor: 'orange',
},
statusBadge: {
position: 'absolute',
top: 40,
left: 20,
backgroundColor: 'pink',
},
})

View File

@ -0,0 +1,206 @@
import { useGameRepairMutation, useGameResetMutation, useGameStartMutation, useGameStopMutation, useGameSwitchPlayerMutation } from '@/hooks/mutations/useGameMutation'
import { useAuth } from '@/hooks/useAuth'
import { useGame, useSetLocationAccuracy, useUpdateGameState } from '@/hooks/useGame'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { Button, Dialog, List, MD3Colors, Portal, Snackbar, Surface, Text } from 'react-native-paper'
import { Dropdown } from 'react-native-paper-dropdown'
import { Accuracy } from 'expo-location'
export default function HistoryScreen() {
const [successVisible, setSuccessVisible] = useState(false)
const [successMessage, setSuccessMessage] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const router = useRouter()
const queryClient = useQueryClient()
const auth = useAuth()
const game = useGame()
const updateGameState = useUpdateGameState()
const setLocationAccuracy = useSetLocationAccuracy()
const accuracyArrayList = [
{ value: Accuracy.BestForNavigation.toString(), label: "Navigation" },
{ value: Accuracy.Highest.toString(), label: "Plus haute" },
{ value: Accuracy.High.toString(), label: "Haute" },
{ value: Accuracy.Balanced.toString(), label: "Équilibrée" },
{ value: Accuracy.Low.toString(), label: "Basse" },
{ value: Accuracy.Lowest.toString(), label: "Plus basse" },
{ value: 'null', label: "Désactivée" },
]
const gameStartMutation = useGameStartMutation({
auth,
updateGameState,
onPostSuccess: () => {
setSuccessVisible(true)
setSuccessMessage("Jeu démarré avec succès")
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-game' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const gameStopMutation = useGameStopMutation({
auth,
updateGameState,
onPostSuccess: () => {
setSuccessVisible(true)
setSuccessMessage("Jeu arrêté avec succès")
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-game' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const gameSwitchMutation = useGameSwitchPlayerMutation({
auth,
updateGameState,
onPostSuccess: () => {
setSuccessVisible(true)
setSuccessMessage("Échange de joueuse en course réalisé avec succès")
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-game' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const gameRepairMutation = useGameRepairMutation({
auth,
onPostSuccess: () => {
setSuccessVisible(true)
setSuccessMessage("Réparation du jeu effectuée avec succès")
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-game' })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-player' })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'money-updates' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const gameResetMutation = useGameResetMutation({
auth,
updateGameState,
onPostSuccess: () => {
setSuccessVisible(true)
setSuccessMessage("Jeu réinitialisé avec succès")
queryClient.invalidateQueries()
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const [resetConfirmVisible, setResetConfirmVisible] = useState(false)
return (
<Surface
style={{ flex: 1 }}>
<List.Section title={"Paramètres"}>
<List.Item
key={"login"}
title="Connexion au serveur"
description={auth.loggedIn ? "Vous êtes déjà connecté⋅e" : "Vous n'êtes pas connecté⋅e"}
right={() => <List.Icon icon="login" />}
onPress={() => router.navigate('/login')} />
<List.Item
key={"location-accuracy"}
title="Précision de la géolocalisation"
description="Réglez le niveau de précision de la géolocalisation. Une valeur élevée indique une consommation de batterie accrue."
right={() => <Dropdown label={"Géolocalisation"} hideMenuHeader={true} options={accuracyArrayList} value={game.settings.locationAccuracy?.toString() ?? 'null'} onSelect={(value) => setLocationAccuracy(!value || value == 'null' ? null : +value)} />} />
</List.Section>
<List.Section title={"Gestion du jeu"}>
<List.Item
key={"start"}
title="Démarrer le jeu"
disabled={game.gameStarted}
right={() => <List.Icon icon="play" color={!game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameStartMutation.mutate()} />
<List.Item
key={"stop"}
title="Arrêter le jeu"
disabled={!game.gameStarted}
right={() => <List.Icon icon="stop" color={game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameStopMutation.mutate()} />
<List.Item
key={"switch"}
title="Changer de joueur⋅se en course"
description="À utiliser après une capture"
disabled={!game.gameStarted}
right={() => <List.Icon icon="exit-run" color={game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameSwitchMutation.mutate()} />
</List.Section>
<List.Section title={"Avancé"}>
<List.Item
key={"repair"}
title="Réparer"
description="Permet de réparer les soldes des joueur⋅ses à partir des défis réalisés et des trains emprunter. À manipuler avec précaution."
right={() => <List.Icon icon='tools' color={MD3Colors.error60} />}
onPress={() => gameRepairMutation.mutate()} />
<List.Item
key={"reset"}
title="Réinitialiser les données de jeu"
description="Permet de détruire toutes les données. À manipuler avec précaution."
right={() => <List.Icon icon="reload-alert" color={MD3Colors.error60} />}
onPress={() => setResetConfirmVisible(true)} />
</List.Section>
<Snackbar
key='success-snackbar'
visible={successVisible}
icon={'close'}
onDismiss={() => setSuccessVisible(false)}
onIconPress={() => setSuccessVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
{successMessage}
</Text>
</Snackbar>
<Snackbar
key='error-snackbar'
visible={errorVisible}
icon={'close'}
onDismiss={() => setErrorVisible(false)}
onIconPress={() => setErrorVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
Erreur {error[0]} : {error[1]}
</Text>
</Snackbar>
<Portal>
<Dialog key="confirmReset" visible={resetConfirmVisible} onDismiss={() => setResetConfirmVisible(false)}>
<Dialog.Title>Confirmer</Dialog.Title>
<Dialog.Content>
<Text variant="bodyMedium">
Cette action va réinitialiser TOUTES les données de jeu : l'historique des positions, les défis réalisés et les trains empruntés.
Êtes-vous réellement sûre de vouloir tout supprimer ?
</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setResetConfirmVisible(false)}>Annuler</Button>
<Button onPress={() => { setResetConfirmVisible(false); gameResetMutation.mutate() }}>Confirmer</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</Surface>
)
}

View File

@ -1,14 +1,148 @@
import { ScrollView } from 'react-native'
import { ThemedText } from '@/components/ThemedText'
import { ThemedView } from '@/components/ThemedView'
import PenaltyBanner from '@/components/PenalyBanner'
import { useAddTrainMutation } from '@/hooks/mutations/useTrainMutation'
import { useAuth } from '@/hooks/useAuth'
import { useTrain } from '@/hooks/useTrain'
import { TrainTrip } from '@/utils/features/train/trainSlice'
import { FontAwesome6 } from '@expo/vector-icons'
import { useQueryClient } from '@tanstack/react-query'
import { useShareIntentContext } from 'expo-share-intent'
import { useEffect, useMemo, useState } from 'react'
import { FlatList, StyleSheet } from 'react-native'
import { Button, Dialog, Divider, FAB, HelperText, List, MD3Colors, Portal, Snackbar, Surface, Text, TextInput } from 'react-native-paper'
export default function TrainScreen() {
const [addTrainVisible, setAddTrainVisible] = useState(false)
const [addTrainUrl, setAddTrainUrl] = useState("")
const trainId = useMemo(() => /[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/.exec(addTrainUrl)?.[0], [addTrainUrl])
const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false)
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const auth = useAuth()
const queryClient = useQueryClient()
const addTrainMutation = useAddTrainMutation({
auth,
onPostSuccess: () => {
setAddTrainVisible(false)
setSuccessSnackbarVisible(true)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-trains' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const trains = useTrain()
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext()
useEffect(() => {
if (hasShareIntent) {
resetShareIntent()
if (!shareIntent.text || !shareIntent.text.includes("eurailapp.com/share"))
return
const parsedTrainId = /[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/.exec(shareIntent.text)?.[0]
if (parsedTrainId)
addTrainMutation.mutate(parsedTrainId)
else {
setErrorVisible(true)
setError([400, "Impossible de récupérer l'identifiant du train à ajouter"])
}
}
}, [hasShareIntent])
return (
<ScrollView>
<ThemedView>
<ThemedText>Ici on aura la page pour ajouter un trajet en train depuis Rail Planner</ThemedText>
</ThemedView>
</ScrollView>
<Surface style={{ flex: 1 }}>
<PenaltyBanner />
<FlatList
data={trains}
keyExtractor={(train) => train.id}
ItemSeparatorComponent={() => <Divider />}
renderItem={(item) => <TrainListItem train={item.item} />} />
<FAB
icon='plus'
style={styles.addTrainButton}
onPress={() => setAddTrainVisible(true)} />
<Snackbar
key='success-snackbar'
visible={successSnackbarVisible}
icon={'close'}
onDismiss={() => setSuccessSnackbarVisible(false)}
onIconPress={() => setSuccessSnackbarVisible(false)}>
Train ajouté avec succès
</Snackbar>
<Snackbar
key='error-snackbar'
visible={errorVisible}
icon={'close'}
onDismiss={() => setErrorVisible(false)}
onIconPress={() => setErrorVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
Erreur {error[0]} : {error[1]}
</Text>
</Snackbar>
<Portal>
<Dialog visible={addTrainVisible} onDismiss={() => setAddTrainVisible(false)}>
<Dialog.Title>Ajout d'un train</Dialog.Title>
<Dialog.Content>
<TextInput
label="URL de partage RailPlanner"
autoComplete='url'
inputMode='url'
defaultValue={addTrainUrl}
multiline={true}
onChangeText={setAddTrainUrl}
error={!trainId}
onEndEditing={() => {
if (trainId !== undefined)
addTrainMutation.mutate(trainId)
}}
placeholder="https://eurailapp.com/share/journey?id=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX&type=list&brand=interrail" />
<HelperText type='error' visible={!trainId && addTrainVisible}>
Le champ doit contenir l'identifiant d'un voyage au format UUID. {trainId}
</HelperText>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setAddTrainVisible(false)}>Annuler</Button>
<Button onPress={() => {
if (trainId !== undefined)
addTrainMutation.mutate(trainId)
}} disabled={trainId === undefined || addTrainMutation.isPending}>Ajouter</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</Surface>
)
}
const styles = StyleSheet.create({
addTrainButton: {
position: 'absolute',
right: 25,
bottom: 25,
}
})
function TrainListItem({ train }: { train: TrainTrip }) {
const depDateTime = new Date(train.departureTime)
const depDate = depDateTime.toLocaleDateString()
const depTime = depDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const arrDateTime = new Date(train.arrivalTime)
const arrTime = arrDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const durationInMinutes = (arrDateTime.getTime() - depDateTime.getTime()) / 1000 / 60
const duration = `${Math.floor(durationInMinutes / 60).toString().padStart(2, '0')}:${Math.floor(durationInMinutes % 60).toString().padStart(2, '0')}`
const title = `${train.from} ${depTime} => ${train.to} ${arrTime} (${depDate})`
const distanceKm = Math.ceil(train.distance / 1000)
const cost = 10 * distanceKm
return <>
<List.Item
title={title}
description={<><Text>Durée : {duration}, distance : {distanceKm} km, coût : {cost}</Text> <FontAwesome6 name='coins' /></>}
/>
</>
}

36
client/app/+html.tsx Normal file
View File

@ -0,0 +1,36 @@
import Head from 'expo-router/head'
import { ScrollViewStyleReset } from 'expo-router/html'
import React from 'react'
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<head>
<Head>
<title>Traintrape-moi</title>
</Head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<title>Traintrape-moi</title>
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
</head>
<body>{children}</body>
</html>
)
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`

View File

@ -1,24 +1,69 @@
import { useReactNavigationDevTools } from '@dev-plugins/react-navigation'
import { useReactQueryDevTools } from '@dev-plugins/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack } from "expo-router"
import { useColorScheme } from '@/hooks/useColorScheme'
import { QueryClient } from '@tanstack/react-query'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { Stack, useNavigationContainerRef } from 'expo-router'
import { ShareIntentProvider } from 'expo-share-intent'
import { StatusBar } from 'expo-status-bar'
import { Provider } from 'react-redux'
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper'
import { Provider as StoreProvider } from 'react-redux'
import GameProvider from '@/components/providers/GameProvider'
import GeolocationProvider from '@/components/providers/GeolocationProvider'
import LoginProvider from '@/components/providers/LoginProvider'
import { useColorScheme } from '@/hooks/useColorScheme'
import store from '@/utils/store'
import { useStartGeolocationServiceEffect } from '@/utils/geolocation'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 24 * 60 * 60 * 1000, // 24h
staleTime: 2000,
retry: 5,
}
}
})
export default function RootLayout() {
useStartGeolocationServiceEffect()
const colorScheme = useColorScheme()
const navigationRef = useNavigationContainerRef()
useReactNavigationDevTools(navigationRef)
useReactQueryDevTools(queryClient)
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
})
return (
<Provider store={store}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</Provider>
<StoreProvider store={store}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
onSuccess={() => queryClient.resumePausedMutations().then(() => queryClient.invalidateQueries())}>
<ShareIntentProvider>
<LoginProvider loginRedirect={'/login'}>
<GeolocationProvider>
<GameProvider>
<PaperProvider
theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme} >
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="challenges-list" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</PaperProvider>
</GameProvider>
</GeolocationProvider>
</LoginProvider>
</ShareIntentProvider>
</PersistQueryClientProvider>
</StoreProvider>
)
}

View File

@ -0,0 +1,302 @@
import ChallengeCard from "@/components/ChallengeCard"
import { useAddChallengeMutation, useAttachNewChallenge, useDeleteChallengeMutation, useEditChallengeMutation } from "@/hooks/mutations/useChallengeMutation"
import { useAuth } from "@/hooks/useAuth"
import { useChallengeActions } from "@/hooks/useChallengeActions"
import { useChallenges } from "@/hooks/useChallenges"
import { useGame } from "@/hooks/useGame"
import { Challenge } from "@/utils/features/challenges/challengesSlice"
import { FontAwesome6 } from "@expo/vector-icons"
import { useQueryClient } from "@tanstack/react-query"
import { useRouter } from "expo-router"
import React, { ReactNode, useMemo, useState } from "react"
import { FlatList, StyleSheet } from "react-native"
import { ActivityIndicator, Appbar, Button, Dialog, Divider, FAB, List, MD3Colors, Modal, Portal, Snackbar, Surface, Text, TextInput, Tooltip } from "react-native-paper"
export default function ChallengesList() {
const router = useRouter()
const queryClient = useQueryClient()
const auth = useAuth()
const game = useGame()
const challenges = useChallenges()
const challengeActions = useChallengeActions()
const currentChallengeAction = useMemo(() => {
if (!game.activeChallengeId)
return null
return challengeActions.find((action) => action.id === game.activeChallengeId)
}, [game, challengeActions])
const [editChallengeVisible, setEditChallengeVisible] = useState(false)
const [editChallengeTitle, setEditChallengeTitle] = useState("")
const [editChallengeDescription, setEditChallengeDescription] = useState("")
const [editChallengeReward, setEditChallengeReward] = useState(0)
const [editChallengeId, setEditChallengeId] = useState<number |null>(null)
const [displayedChallenge, setDisplayedChallenge] = useState<Challenge | null>(null)
const [confirmDeletedVisible, setConfirmDeleteVisible] = useState(false)
const [challengeToAttach, setChallengeToAttach] = useState<Challenge | null>(null)
const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false)
const [successMessage, setSuccessMessage] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const addChallengeMutation = useAddChallengeMutation({
auth,
onPostSuccess: () => {
setSuccessMessage("Le défi a bien été ajouté !")
setSuccessSnackbarVisible(true)
setEditChallengeVisible(false)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
setEditChallengeVisible(false)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const editChallengeMutation = useEditChallengeMutation({
auth,
onPostSuccess: () => {
setSuccessMessage("Le défi a bien été modifié !")
setSuccessSnackbarVisible(true)
setEditChallengeVisible(false)
setDisplayedChallenge(null)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
setEditChallengeVisible(false)
setDisplayedChallenge(null)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const deleteChallengeMutation = useDeleteChallengeMutation({
auth,
onPostSuccess: () => {
setSuccessMessage("Le défi a bien été supprimé !")
setDisplayedChallenge(null)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' })
},
onError: ({ response, error }) => {
setErrorVisible(true)
setDisplayedChallenge(null)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
const attachNewChallengeMutation = useAttachNewChallenge({
auth,
onPostSuccess: () => {
setChallengeToAttach(null)
setSuccessMessage("Le défi en cours a bien été modifié !")
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' })
},
onError: ({ response, error }) => {
setChallengeToAttach(null)
setErrorVisible(true)
if (response)
setError([response.statusCode, response.message])
else if (error)
setError([400, error.message])
},
})
function sendEditChallenge() {
if (editChallengeId) {
editChallengeMutation.mutate({
id: editChallengeId,
title: editChallengeTitle,
description: editChallengeDescription,
reward: editChallengeReward,
})
}
else {
addChallengeMutation.mutate({
title: editChallengeTitle,
description: editChallengeDescription,
reward: editChallengeReward,
})
}
}
function sendDeleteChallenge() {
displayedChallenge && deleteChallengeMutation.mutate(displayedChallenge)
}
function sendAttachNewChallenge() {
if (!challengeToAttach || !currentChallengeAction) return
attachNewChallengeMutation.mutate({ challengeActionId: currentChallengeAction.id, newChallengeId: challengeToAttach.id })
}
return (
<Surface style={{ flex: 1 }}>
<Appbar.Header>
<Appbar.BackAction onPress={() => router.canGoBack() ? router.back() : router.navigate('/(tabs)/challenges')} />
<Appbar.Content title={"Liste des défis"} />
</Appbar.Header>
<FlatList
data={challenges}
keyExtractor={(challenge) => `challenge-list-item-${challenge.id}`}
ItemSeparatorComponent={() => <Divider />}
renderItem={(item) =>
<ChallengeListItem challenge={item.item}
activeRunner={game.currentRunner}
onPress={() => setDisplayedChallenge(item.item)}
onLongPress={!currentChallengeAction ? undefined : () => setChallengeToAttach(item.item)} />} />
<Snackbar
key='success-snackbar'
visible={successSnackbarVisible}
icon={'close'}
onDismiss={() => setSuccessSnackbarVisible(false)}
onIconPress={() => setSuccessSnackbarVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
{successMessage}
</Text>
</Snackbar>
<Snackbar
key='error-snackbar'
visible={errorVisible}
icon={'close'}
onDismiss={() => setErrorVisible(false)}
onIconPress={() => setErrorVisible(false)}>
<Text variant='bodyMedium' style={{ color: MD3Colors.secondary0 }}>
Erreur {error[0]} : {error[1]}
</Text>
</Snackbar>
<FAB
icon='plus'
style={{ ...styles.addButton, bottom: errorVisible || successSnackbarVisible ? 80 : styles.addButton.bottom }}
onPress={() => {
if (editChallengeId) {
setEditChallengeTitle("")
setEditChallengeDescription("")
setEditChallengeReward(0)
setEditChallengeId(null)
}
setEditChallengeVisible(true)
}} />
<Portal>
<Modal
visible={displayedChallenge !== null}
onDismiss={() => setDisplayedChallenge(null)}
contentContainerStyle={{ flex: 1, marginHorizontal: 20, marginVertical: 100 }}>
<ChallengeCard
challenge={displayedChallenge}
onEdit={() => {
setEditChallengeTitle(displayedChallenge?.title ?? "")
setEditChallengeDescription(displayedChallenge?.description ?? "")
setEditChallengeReward(displayedChallenge?.reward ?? 0)
setEditChallengeId(displayedChallenge?.id ?? null)
setEditChallengeVisible(true)
}}
onDelete={() => setConfirmDeleteVisible(true)}
style={{ flexGrow: 1 }} />
</Modal>
<Dialog visible={editChallengeVisible} onDismiss={() => setEditChallengeVisible(false)}>
<Dialog.Title>{editChallengeId ? "Modification d'un défi" : "Ajout d'un défi"}</Dialog.Title>
<Dialog.Content>
<TextInput
label="Titre"
defaultValue={editChallengeTitle}
onChangeText={setEditChallengeTitle}
error={!editChallengeTitle} />
<TextInput
label="Description"
defaultValue={editChallengeDescription}
multiline={true}
onChangeText={setEditChallengeDescription}
error={!editChallengeDescription} />
<TextInput
label="Récompense"
defaultValue={editChallengeReward ? editChallengeReward.toString() : ""}
inputMode='numeric'
onChangeText={(text) => setEditChallengeReward(+text)}
error={!editChallengeReward}
onEndEditing={() => !addChallengeMutation.isPending && editChallengeMutation.isPending && sendEditChallenge()} />
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setEditChallengeVisible(false)}>Annuler</Button>
<Button
onPress={sendEditChallenge}
disabled={!editChallengeTitle || !editChallengeDescription || !editChallengeReward || addChallengeMutation.isPending || editChallengeMutation.isPending}>
{editChallengeId ? "Modifier" : "Ajouter"}
</Button>
</Dialog.Actions>
</Dialog>
<Dialog visible={confirmDeletedVisible} onDismiss={() => setConfirmDeleteVisible(false)}>
<Dialog.Title>Êtes-vous sûre ?</Dialog.Title>
<Dialog.Content>
<Text variant='bodyMedium'>
Voulez-vous vraiment supprimer le défi « {displayedChallenge?.title} » ? Cette opération est irréversible !
</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setConfirmDeleteVisible(false)}>Annuler</Button>
<Button onPress={sendDeleteChallenge} disabled={deleteChallengeMutation.isPending}>Confirmer</Button>
</Dialog.Actions>
</Dialog>
<Dialog visible={challengeToAttach !== null} onDismiss={() => setChallengeToAttach(null)}>
<Dialog.Title>Traiter ce défi</Dialog.Title>
<Dialog.Content>
<Text variant='bodyMedium'>
Voulez-vous vraiment remplacer votre défi actuel par le défi « {challengeToAttach?.title} » ?
</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setChallengeToAttach(null)}>Annuler</Button>
<Button onPress={sendAttachNewChallenge} disabled={attachNewChallengeMutation.isPending}>Confirmer</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</Surface>
)
}
type ChallengeListItemProps = {
challenge: Challenge,
activeRunner: boolean,
onPress?: () => void,
onLongPress?: () => void,
}
function ChallengeListItem({ challenge, activeRunner, onPress, onLongPress }: ChallengeListItemProps) {
const challengeActions = useChallengeActions()
const attachedAction = challengeActions.find(challengeAction => challengeAction.challengeId === challenge.id)
const description = <Text>Récompense : {challenge.reward} <FontAwesome6 name='coins' /></Text>
const icon: ReactNode = useMemo(() => {
if (!activeRunner)
return undefined
else if (!attachedAction)
return <Tooltip title="Disponible"><List.Icon icon='cards' /></Tooltip>
else if (!attachedAction.end)
return <Tooltip title="En cours d'accomplissement"><ActivityIndicator /></Tooltip>
else if (attachedAction.success)
return <Tooltip title="Précédemment réussi"><List.Icon icon='emoticon-happy' /></Tooltip>
else
return <Tooltip title="Raté"><List.Icon icon='emoticon-sad' /></Tooltip>
}, [activeRunner, attachedAction])
return (
<List.Item
title={challenge.title}
description={description}
right={() => icon}
onPress={onPress}
onLongPress={attachedAction ? undefined : onLongPress} />
)
}
const styles = StyleSheet.create({
addButton: {
position: 'absolute',
right: 25,
bottom: 25,
}
})

92
client/app/login.tsx Normal file
View File

@ -0,0 +1,92 @@
import { useLoginMutation } from "@/hooks/mutations/useLoginMutation"
import { useAuth, useAuthLogin, useAuthLogout } from "@/hooks/useAuth"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "expo-router"
import { useRef, useState } from "react"
import { Appbar, Button, Dialog, Portal, Surface, Text, TextInput } from "react-native-paper"
export default function Login() {
const router = useRouter()
const auth = useAuth()
const authLogin = useAuthLogin()
const authLogout = useAuthLogout()
const isLoggedIn = auth.loggedIn
const [name, setName] = useState(auth.name ?? "")
const [password, setPassword] = useState("")
const [errorDialogVisible, setErrorDialogVisible] = useState(false)
const [errorTitle, setErrorTitle] = useState("")
const [errorText, setErrorText] = useState("")
const loginRef = useRef<any | null>()
const passwordRef = useRef<any | null>()
const hideErrorDialog = () => setErrorDialogVisible(false)
const loginMutation = useLoginMutation({
authLogin,
onPostSuccess: () => {
if (router.canGoBack())
router.back()
else
router.navigate('/')
},
onError: ({ response, error }) => {
setErrorDialogVisible(true)
if (response) {
setErrorTitle(response.error)
setErrorText(response.message)
}
else {
setErrorTitle("Erreur")
setErrorText(`Une erreur est survenue lors de la connexion : ${error}`)
}
}
})
return (
<Surface style={{ flex: 1 }}>
<Appbar.Header>
{isLoggedIn && router.canGoBack() ? <Appbar.BackAction onPress={() => router.back()} /> : undefined}
<Appbar.Content title={"Connexion"} />
{isLoggedIn ? <Appbar.Action icon={"logout"} onPress={authLogout} /> : undefined}
</Appbar.Header>
<TextInput
ref={loginRef}
label="Nom"
value={name}
onChangeText={setName}
onSubmitEditing={() => passwordRef?.current.focus()}
style={{ margin: 8 }} />
<TextInput
ref={passwordRef}
label="Mot de passe"
value={password}
onChangeText={setPassword}
onSubmitEditing={() => loginMutation.mutate({ name, password })}
secureTextEntry={true}
style={{ margin: 8 }} />
<Button
key={loginMutation.isPending ? "disabledLoginButton" : "loginButton"}
onPress={() => loginMutation.mutate({ name, password })}
mode={"contained"}
icon="login"
disabled={loginMutation.isPending}
style={{ margin: 8 }}>
Se connecter
</Button>
<Portal>
<Dialog visible={errorDialogVisible} onDismiss={hideErrorDialog}>
<Dialog.Title>{errorTitle}</Dialog.Title>
<Dialog.Content>
<Text variant="bodyMedium">{errorText}</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={hideErrorDialog}>Ok</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</Surface>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

6
client/babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
}
}

View File

@ -0,0 +1,44 @@
import { Challenge } from "@/utils/features/challenges/challengesSlice"
import { FontAwesome6 } from "@expo/vector-icons"
import { View, ViewStyle } from "react-native"
import { Button, Card, IconButton, MD3Colors, Surface, Text } from "react-native-paper"
export type ChallengeCardProps = {
challenge: Challenge | null,
onSuccess?: () => void,
onFail?: () => void,
onDelete?: () => void,
onEdit?: () => void,
style?: ViewStyle,
}
export default function ChallengeCard({ challenge, onSuccess, onFail, onDelete, onEdit, style }: ChallengeCardProps) {
return (
<Surface elevation={2} style={{ ...style, borderRadius: 20 }}>
<Card.Title
title={challenge?.title}
titleStyle={{ textAlign: 'center' }}
titleVariant='headlineMedium'
right={(props) => onEdit ? <IconButton {...props} icon='file-document-edit-outline' onPress={() => onEdit()} /> : <></>} />
<View style={{ flexGrow: 1 }}>
<Surface elevation={5} mode='flat' style={{ flexGrow: 1, paddingHorizontal: 15, paddingVertical: 20 }}>
<Text variant='bodyLarge' style={{ flexGrow: 1 }}>{challenge?.description}</Text>
<Text variant='titleMedium'>
Récompense : {challenge?.reward} <FontAwesome6 name='coins' />
</Text>
</Surface>
</View>
<View style={{ flexWrap: 'wrap', flexDirection: 'row', justifyContent: 'space-around', padding: 15 }}>
{onFail && <Button key='failBtn' mode='outlined' icon='cancel' onPress={() => onFail()}>
Échouer
</Button>}
{onSuccess && <Button key='successBtn' mode='contained' icon='check' onPress={() => onSuccess()}>
Terminer
</Button>}
{onDelete && <Button key='deleteBtn' mode='contained' icon='delete' onPress={() => onDelete()} buttonColor={MD3Colors.error60} textColor={MD3Colors.secondary10}>
Supprimer
</Button>}
</View>
</Surface>
)
}

View File

@ -0,0 +1,50 @@
import { useGame } from "@/hooks/useGame"
import { FontAwesome6 } from "@expo/vector-icons"
import { useEffect, useMemo, useState } from "react"
import { View } from "react-native"
import { Banner, MD3Colors, ProgressBar, Text } from "react-native-paper"
export default function FreeChaseBanner() {
const game = useGame()
const chaseFreeTime = game.chaseFreeTime
const chaser = game.gameStarted && !game.currentRunner && chaseFreeTime !== null
const chaseFreeDate = useMemo(() => new Date(chaseFreeTime || 0), [chaseFreeTime])
const chaseFreePretty = chaseFreeDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const [remainingTime, setRemainingTime] = useState(0)
const prettyRemainingTime = useMemo(() => `${Math.floor(remainingTime / 60).toString().padStart(2, '0')}:${Math.floor(remainingTime % 60).toString().padStart(2, '0')}`, [remainingTime])
const iconName = useMemo(() => {
switch (Math.abs(Math.floor(remainingTime) % 4)) {
case 0: return 'hourglass-empty'
case 1: return 'hourglass-end'
case 2: return 'hourglass-half'
case 3: return 'hourglass-start'
}
}, [remainingTime])
useEffect(() => {
const now = new Date().getTime()
if (!chaser || (chaseFreeTime < now && remainingTime < 0))
return
const interval = setInterval(() => setRemainingTime(Math.floor(chaseFreeTime - new Date().getTime()) / 1000), 1000)
return () => clearInterval(interval)
}, [game.gameStarted, game.currentRunner, chaseFreeDate])
return (
<View>
<ProgressBar
visible={chaser && remainingTime > 0}
animatedValue={1 - remainingTime / (45 * 60)}
color={'green'}
style={{ height: 6 }} />
<Banner
visible={chaser && remainingTime > 0}
icon={({ size }) => <FontAwesome6 name={iconName} size={size} color={MD3Colors.secondary90} />}
style={{ backgroundColor: MD3Colors.secondary40 }}>
<View>
<Text variant='titleMedium'>Vous pourrez commencer la poursuite à {chaseFreePretty}.</Text>
<Text variant='titleSmall'>Temps restant : {prettyRemainingTime}</Text>
</View>
</Banner>
</View>
)
}

View File

View File

@ -1,39 +1,182 @@
import { StyleSheet } from 'react-native'
import MapLibreGL, { Camera, FillLayer, LineLayer, MapView, PointAnnotation, RasterLayer, RasterSource, ShapeSource, UserLocation } from '@maplibre/maplibre-react-native'
import { FontAwesome5 } from '@expo/vector-icons'
import { FontAwesome5, MaterialIcons } from '@expo/vector-icons'
import MapLibreGL, { Camera, FillLayer, LineLayer, MapView, PointAnnotation, RasterLayer, RasterSource, ShapeSource, UserLocation, UserTrackingMode } from '@maplibre/maplibre-react-native'
import { useQuery } from '@tanstack/react-query'
import { circle } from '@turf/circle'
import { useLocation } from '@/hooks/useLocation'
import { reverseGeocodeAsync } from 'expo-location'
import React, { useEffect, useMemo, useState } from 'react'
import { StyleSheet } from 'react-native'
import { Button, Dialog, FAB, Portal, Text } from 'react-native-paper'
import { useAuth } from '@/hooks/useAuth'
import { useGame } from '@/hooks/useGame'
import { useLastOwnLocation, useLastPlayerLocations } from '@/hooks/useLocation'
import { isAuthValid } from '@/utils/features/auth/authSlice'
import { Player } from '@/utils/features/game/gameSlice'
import { PlayerLocation } from '@/utils/features/location/locationSlice'
export default function Map() {
const userLocation = useLocation()
const [followUser, setFollowUser] = useState(true)
return (
<>
<MapWrapper followUser={followUser} setFollowUser={setFollowUser} />
<FollowUserButton key={'follow-userr-btn-component'} followUser={followUser} setFollowUser={setFollowUser} />
</>
)
}
type FollowUserProps = {
followUser: boolean,
setFollowUser: React.Dispatch<React.SetStateAction<boolean>>,
}
function MapWrapper({ followUser, setFollowUser }: FollowUserProps) {
const [displayedPlayerId, setDisplayedPlayerId] = useState<number | null>(null)
return (
<>
<MapComponent followUser={followUser} setFollowUser={setFollowUser} setDisplayedPlayerId={setDisplayedPlayerId} />
<Portal>
<PlayerLocationDialog displayedPlayerId={displayedPlayerId} onDismiss={() => setDisplayedPlayerId(null)} />
</Portal>
</>
)
}
type MapComponentProps = {
followUser?: boolean,
setFollowUser: React.Dispatch<React.SetStateAction<boolean>>,
setDisplayedPlayerId: React.Dispatch<React.SetStateAction<number | null>>
}
function MapComponent({ followUser, setFollowUser, setDisplayedPlayerId }: MapComponentProps) {
MapLibreGL.setAccessToken(null)
const accuracyCircle = circle([userLocation?.coords.longitude ?? 0, userLocation?.coords.latitude ?? 0], userLocation?.coords.accuracy ?? 0, {steps: 64, units: 'meters'})
const userLocation = useLastOwnLocation()
return (
<MapView
logoEnabled={true}
logoEnabled={false}
compassViewPosition={2}
style={styles.map}
styleURL="https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json">
{/* FIXME Il faudra pouvoir avoir un bouton de suivi pour activer le suivi de la caméro */}
{userLocation && <Camera
defaultSettings={{centerCoordinate: [userLocation?.coords.longitude, userLocation?.coords.latitude], zoomLevel: 15}} />}
defaultSettings={{centerCoordinate: [userLocation?.coords.longitude, userLocation?.coords.latitude], zoomLevel: 15}}
followUserLocation={followUser}
followUserMode={UserTrackingMode.Follow}
followZoomLevel={16}
onUserTrackingModeChange={(event) => {
if (followUser && !event.nativeEvent.payload.followUserLocation)
setFollowUser(false)
}} />}
<RasterSource id="railwaymap-source" tileUrlTemplates={["https://a.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png"]}></RasterSource>
<RasterLayer id="railwaymap-layer" sourceID="railwaymap-source" style={{rasterOpacity: 0.7}} />
{/* FIXME Il faudra avoir uniquement les positions des autres personnes, puisque sa propre position peut être obtenue nativement */}
<ShapeSource id="accuracy-radius" shape={accuracyCircle} />
<FillLayer id="accuracy-radius-fill" sourceID="accuracy-radius" style={{fillOpacity: 0.4, fillColor: 'lightblue'}} aboveLayerID="railwaymap-layer" />
<LineLayer id="accuracy-radius-border" sourceID="accuracy-radius" style={{lineOpacity: 0.4, lineColor: 'blue'}} aboveLayerID="accuracy-radius-fill" />
<PointAnnotation id="current-location" coordinate={[userLocation?.coords.longitude ?? 2.9, userLocation?.coords.latitude ?? 46.5]}>
<FontAwesome5 name="map-marker-alt" size={24} color="blue" />
</PointAnnotation>
{/* <UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} /> */}
<UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} />
<PlayerLocationsMarkers setDisplayedPlayerId={setDisplayedPlayerId} />
</MapView>
)
}
function PlayerLocationsMarkers({ setDisplayedPlayerId }: { setDisplayedPlayerId: React.Dispatch<React.SetStateAction<number | null>> }) {
const game = useGame()
const lastPlayerLocations = useLastPlayerLocations()
return lastPlayerLocations ? lastPlayerLocations
.filter(() => !game.currentRunner || !game.gameStarted)
.filter(playerLoc => playerLoc.playerId !== game.playerId)
.map(playerLoc => <PlayerLocationMarker key={`player-${playerLoc.playerId}-loc`} playerLocation={playerLoc} setDisplayedPlayerId={setDisplayedPlayerId} />) : <></>
}
function PlayerLocationMarker({ playerLocation, setDisplayedPlayerId }: { playerLocation: PlayerLocation, setDisplayedPlayerId: React.Dispatch<React.SetStateAction<number | null>> }) {
const accuracyCircle = useMemo(() => circle([playerLocation.longitude, playerLocation.latitude], playerLocation.accuracy, {steps: 64, units: 'meters'}), [playerLocation])
return <>
<ShapeSource
id={`accuracy-radius-${playerLocation.playerId}`}
shape={accuracyCircle} />
<FillLayer
id={`accuracy-radius-fill-${playerLocation.playerId}`}
sourceID={`accuracy-radius-${playerLocation.playerId}`}
style={{fillOpacity: 0.4, fillColor: 'pink'}}
aboveLayerID="railwaymap-layer" />
<LineLayer
id={`accuracy-radius-border-${playerLocation.playerId}`}
sourceID={`accuracy-radius-${playerLocation.playerId}`}
style={{lineOpacity: 0.4, lineColor: 'red'}}
aboveLayerID={`accuracy-radius-fill-${playerLocation.playerId}`} />
<PointAnnotation id={`player-location-marker-${playerLocation.playerId}`}
coordinate={[playerLocation.longitude, playerLocation.latitude]}
onSelected={() => { setDisplayedPlayerId(playerLocation.playerId) }}>
<FontAwesome5 name="map-marker-alt" size={24} color="red" />
</PointAnnotation>
</>
}
const styles = StyleSheet.create({
map: {
flex: 1,
alignSelf: 'stretch',
}
})
function FollowUserButton({ followUser, setFollowUser }: FollowUserProps) {
return (
<FAB
key={'follow-user-btn'}
style={{ position: 'absolute', right: 25, bottom: 25 }}
icon={(props) => <MaterialIcons name={followUser ? 'my-location' : 'location-searching'} {...props} />}
onPress={() => setFollowUser(followUser => !followUser)} />
)
}
function PlayerLocationDialog({ displayedPlayerId, onDismiss }: { displayedPlayerId: number | null, onDismiss: () => void }) {
const auth = useAuth()
const lastPlayerLocations = useLastPlayerLocations()
const playersQuery = useQuery({
queryKey: ['get-players', auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/players/`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth),
initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
})
const displayedPlayerLoc = useMemo(() => {
return lastPlayerLocations.find(loc => loc.playerId === displayedPlayerId)
}, [displayedPlayerId, lastPlayerLocations])
const displayedPlayerName = useMemo(() => {
if (!playersQuery.isSuccess || !displayedPlayerId)
return "Chargement…"
const player: Player | undefined = playersQuery.data.data.find((player: Player) => player.id === displayedPlayerId)
if (!player)
return "Chargement…"
return player.name
}, [displayedPlayerId, playersQuery])
const [address, setAddress] = useState("Adresse inconnue")
useEffect(() => {
if (!displayedPlayerLoc)
return setAddress("Adresse inconnue")
reverseGeocodeAsync(displayedPlayerLoc).then(addresses => setAddress(addresses[0].formattedAddress ?? "Adresse inconnue"))
}, [displayedPlayerLoc])
return (
<Dialog visible={displayedPlayerId !== null} onDismiss={onDismiss}>
<Dialog.Title>{displayedPlayerName}</Dialog.Title>
<Dialog.Content>
<Text>
Dernière position : {new Date(displayedPlayerLoc?.timestamp ?? 0).toLocaleString()}
</Text>
<Text>
Précision : {displayedPlayerLoc?.accuracy.toPrecision(3)} m
</Text>
<Text>
Adresse estimée : {address}
</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={onDismiss}>
Fermer
</Button>
</Dialog.Actions>
</Dialog>
)
}

View File

@ -1,8 +1,10 @@
import { useLocation } from "@/hooks/useLocation"
import { useGame } from "@/hooks/useGame"
import { useLastOwnLocation, useLastPlayerLocations } from "@/hooks/useLocation"
import { PlayerLocation } from "@/utils/features/location/locationSlice"
import { circle } from "@turf/circle"
import { type Map as MaplibreGLMap } from "maplibre-gl"
import { RLayer, RMap, RMarker, RNavigationControl, RSource, useMap } from "maplibre-react-components"
import { useState } from "react"
import { useMemo, useState } from "react"
export default function Map() {
return (
@ -16,12 +18,13 @@ export default function Map() {
<RLayer id="railwaymap-layer" type="raster" source="railwaymap-source" paint={{"raster-opacity": 0.7}} />
<UserLocation />
<PlayerLocationsMarkers />
</RMap>
)
}
function UserLocation() {
const userLocation = useLocation()
const userLocation = useLastOwnLocation()
const [firstUserPositionFetched, setFirstUserPositionFetched] = useState(false)
const map: MaplibreGLMap = useMap()
if (userLocation != null && !firstUserPositionFetched) {
@ -31,9 +34,44 @@ function UserLocation() {
const accuracyCircle = circle([userLocation?.coords.longitude ?? 0, userLocation?.coords.latitude ?? 0], userLocation?.coords.accuracy ?? 0, {steps: 64, units: 'meters'})
const marker = userLocation ? <RMarker longitude={userLocation?.coords.longitude} latitude={userLocation?.coords.latitude} /> : <></>
return <>
<RSource id="accuracy-radius" type="geojson" data={accuracyCircle} />
<RLayer id="accuracy-radius-fill" type="fill" source="accuracy-radius" paint={{"fill-color": "lightblue", "fill-opacity": 0.4}} />
<RLayer id="accuracy-radius-border" type="line" source="accuracy-radius" paint={{"line-color": "blue", "line-opacity": 0.4}} />
<RSource id={'accuracy-radius-own'} type="geojson" data={accuracyCircle} />
<RLayer id={'accuracy-radius-fill-own'} type="fill" source='accuracy-radius-own' paint={{"fill-color": "lightblue", "fill-opacity": 0.4}} />
<RLayer id={'accuracy-radius-border'} type="line" source='accuracy-radius-own' paint={{"line-color": "blue", "line-opacity": 0.4}} />
{marker}
</>
}
function PlayerLocationsMarkers() {
const game = useGame()
const lastPlayerLocations = useLastPlayerLocations()
return lastPlayerLocations
// .filter(playerLoc => playerLoc.playerId !== game.playerId)
.map(playerLoc => <PlayerLocationMarker key={`player-${playerLoc.playerId}-loc`} playerLocation={playerLoc} />)
}
function PlayerLocationMarker({ playerLocation }: { playerLocation: PlayerLocation }) {
const map: MaplibreGLMap = useMap()
const accuracyCircle = useMemo(() => circle(
[playerLocation.longitude, playerLocation.latitude],
playerLocation.accuracy,
{steps: 64, units: 'meters'}), [playerLocation])
return <>
<RSource
id={`accuracy-radius-${playerLocation.playerId}`}
type="geojson"
data={accuracyCircle} />
<RLayer
id={`accuracy-radius-fill-${playerLocation.playerId}`}
type="fill" source={`accuracy-radius-${playerLocation.playerId}`}
paint={{"fill-color": "pink", "fill-opacity": 0.4}} />
<RLayer
id={`accuracy-radius-border-${playerLocation.playerId}`}
type="line"
source={`accuracy-radius-${playerLocation.playerId}`}
paint={{"line-color": "red", "line-opacity": 0.4}} />
<RMarker
key={`marker-player-${playerLocation.playerId}`}
longitude={playerLocation.longitude}
latitude={playerLocation.latitude} />
</>
}

View File

@ -0,0 +1,49 @@
import { useGame } from "@/hooks/useGame"
import { FontAwesome6 } from "@expo/vector-icons"
import { useEffect, useMemo, useState } from "react"
import { View } from "react-native"
import { Banner, MD3Colors, ProgressBar, Text } from "react-native-paper"
export default function PenaltyBanner() {
const game = useGame()
const penaltyEnd = game.penaltyEnd
const hasPenalty = penaltyEnd !== null
const penaltyEndDate = useMemo(() => new Date(penaltyEnd || 0), [penaltyEnd])
const penaltyEndPretty = penaltyEndDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const [remainingTime, setRemainingTime] = useState(0)
const prettyRemainingTime = useMemo(() => `${Math.floor(remainingTime / 60).toString().padStart(2, '0')}:${Math.floor(remainingTime % 60).toString().padStart(2, '0')}`, [remainingTime])
const iconName = useMemo(() => {
switch (Math.abs(remainingTime % 4)) {
case 0: return 'hourglass-empty'
case 1: return 'hourglass-end'
case 2: return 'hourglass-half'
case 3: return 'hourglass-start'
}
}, [remainingTime])
useEffect(() => {
if (!hasPenalty)
return
const interval = setInterval(() => setRemainingTime(Math.floor((penaltyEndDate.getTime() - new Date().getTime()) / 1000)), 1000)
return () => clearInterval(interval)
}, [hasPenalty, penaltyEndDate])
return (
<View>
<Banner
visible={hasPenalty}
icon={({ size }) => <FontAwesome6 name={iconName} size={size} color={MD3Colors.tertiary80} />}
style={{ backgroundColor: MD3Colors.primary30 }}>
<View>
<Text variant='titleMedium'>Vous avez actuellement une pénalité jusqu'à {penaltyEndPretty}.</Text>
<Text variant='titleSmall'>Temps restant : {prettyRemainingTime}</Text>
</View>
</Banner>
<ProgressBar
visible={hasPenalty}
animatedValue={1 - remainingTime / (30 * 60)}
color={MD3Colors.error40}
style={{ height: 6 }} />
</View>
)
}

View File

@ -1,59 +0,0 @@
import { Text, type TextProps, StyleSheet } from 'react-native'
import { useThemeColor } from '@/hooks/useThemeColor'
export type ThemedTextProps = TextProps & {
lightColor?: string
darkColor?: string
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'
}
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text')
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
)
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
})

View File

@ -1,13 +0,0 @@
import { View, type ViewProps } from 'react-native'
import { useThemeColor } from '@/hooks/useThemeColor'
export type ThemedViewProps = ViewProps & {
lightColor?: string
darkColor?: string
}
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background')
return <View style={[{ backgroundColor }, style]} {...otherProps} />
}

View File

@ -0,0 +1,126 @@
import { Constants } from '@/constants/Constants'
import { useAuth } from '@/hooks/useAuth'
import { useChallengeActions, useDownloadChallengeActions } from '@/hooks/useChallengeActions'
import { useDownloadChallenges } from '@/hooks/useChallenges'
import { useGame, useUpdateActiveChallengeId, useUpdateGameState, useUpdateMoney, useUpdatePenalty } from '@/hooks/useGame'
import { useDownloadMoneyUpdates } from '@/hooks/useMoneyUpdates'
import { useDownloadTrains } from '@/hooks/useTrain'
import { isAuthValid } from '@/utils/features/auth/authSlice'
import { ChallengeAction, ChallengeActionPayload } from '@/utils/features/challengeActions/challengeActionsSlice'
import { Challenge } from '@/utils/features/challenges/challengesSlice'
import { useQuery } from '@tanstack/react-query'
import { router } from 'expo-router'
import { useShareIntentContext } from 'expo-share-intent'
import React, { ReactNode, useEffect } from 'react'
export default function GameProvider({ children }: { children: ReactNode }) {
const auth = useAuth()
const game = useGame()
const challengeActions = useChallengeActions()
const updateGameState = useUpdateGameState()
const updatePenalty = useUpdatePenalty()
const updateMoney = useUpdateMoney()
const updateActiveChallengeId = useUpdateActiveChallengeId()
const downloadTrains = useDownloadTrains()
const downloadChallenges = useDownloadChallenges()
const downloadChallengeActions = useDownloadChallengeActions()
const downloadMoneyUpdates = useDownloadMoneyUpdates()
const gameQuery = useQuery({
queryKey: ['get-game', auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth),
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (gameQuery.isSuccess && gameQuery.data)
updateGameState(gameQuery.data)
}, [gameQuery.status, gameQuery.dataUpdatedAt])
const playerQuery = useQuery({
queryKey: ['get-player', game.playerId, auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/players/${game.playerId}/`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId,
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (playerQuery.isSuccess && playerQuery.data) {
updateMoney(playerQuery.data.money)
updateActiveChallengeId(playerQuery.data.activeChallengeId)
}
}, [playerQuery.status, playerQuery.dataUpdatedAt])
const trainsQuery = useQuery({
queryKey: ['get-trains', game.playerId, auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/trains/?playerId=${game.playerId}&size=10000`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId,
initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (trainsQuery.isSuccess && trainsQuery.data)
downloadTrains(trainsQuery.data)
}, [trainsQuery.status, trainsQuery.dataUpdatedAt])
const challengesQuery = useQuery({
queryKey: ['get-challenges', auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/?size=10000`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth),
initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (challengesQuery.isSuccess && challengesQuery.data) {
downloadChallenges(challengesQuery.data)
const dataWithPlayerActions = challengesQuery.data.data.filter(
(challenge: (Challenge & {action: ChallengeActionPayload | null})) => challenge.action !== null && challenge.action.playerId === game.playerId)
downloadChallengeActions({ data: dataWithPlayerActions })
}
}, [challengesQuery.status, challengesQuery.dataUpdatedAt])
const moneyUpdatesQuery = useQuery({
queryKey: ['get-money-updates', game.playerId, auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/money-updates/?playerId=${game.playerId}&size=10000`, {
headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId,
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (moneyUpdatesQuery.isSuccess && moneyUpdatesQuery.data)
downloadMoneyUpdates(moneyUpdatesQuery.data)
}, [moneyUpdatesQuery.status, moneyUpdatesQuery.dataUpdatedAt])
useEffect(() => {
const now = new Date().getTime()
const activeChallenge: ChallengeAction | undefined = challengeActions
.find(challengeAction => challengeAction.penaltyStart && challengeAction.penaltyEnd
&& challengeAction.penaltyStart <= now && now <= challengeAction.penaltyEnd)
if (!activeChallenge || !game.currentRunner || game.runId !== activeChallenge.runId)
updatePenalty({ penaltyStart: null, penaltyEnd: null })
else if (activeChallenge && (activeChallenge.penaltyStart !== game.penaltyStart || activeChallenge.penaltyEnd)) {
updatePenalty({ penaltyStart: activeChallenge.penaltyStart, penaltyEnd: activeChallenge.penaltyEnd })
}
}, [game.currentRunner, challengeActions])
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext()
useEffect(() => {
if (hasShareIntent) {
if (!shareIntent.text || !shareIntent.text.includes("eurailapp.com/share"))
return resetShareIntent()
router.replace('/train')
}
}, [hasShareIntent])
return <>
{children}
</>
}

View File

@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import React, { ReactNode, useEffect } from 'react'
import { Platform } from 'react-native'
import { Constants } from '@/constants/Constants'
import { useAuth } from '@/hooks/useAuth'
import { useSetLocationAccuracy } from '@/hooks/useGame'
import { useGeolocationMutation } from '@/hooks/mutations/useGeolocationMutation'
import { useQueuedLocations, useSetLastPlayerLocation, useSetLastPlayerLocations, useUnqueueLocation } from '@/hooks/useLocation'
import { isAuthValid } from '@/utils/features/auth/authSlice'
import { PlayerLocation } from '@/utils/features/location/locationSlice'
import { useStartGeolocationServiceEffect } from '@/utils/geolocation'
import * as SecureStore from '@/utils/SecureStore'
import { socket } from '@/utils/socket'
import { Accuracy } from 'expo-location'
export default function GeolocationProvider({ children }: { children: ReactNode }) {
useStartGeolocationServiceEffect()
const auth = useAuth()
const setLocationAccuracy = useSetLocationAccuracy()
const geolocationsQueue = useQueuedLocations()
const unqueueLocation = useUnqueueLocation()
const setLastPlayerLocations = useSetLastPlayerLocations()
const setLastPlayerLocation = useSetLastPlayerLocation()
const geolocationMutation = useGeolocationMutation({
auth,
onPostSuccess: (data, variables) => unqueueLocation(variables),
onError: ({ response, error }) => console.error(response, error),
})
useEffect(() => {
SecureStore.getItemAsync('locationAccuracy').then(locationAccuracyString => {
if (!locationAccuracyString)
setLocationAccuracy(Accuracy.Balanced)
else if (locationAccuracyString === 'null')
setLocationAccuracy(null)
else
setLocationAccuracy(+locationAccuracyString)
})
}, [])
if (Platform.OS !== "web") {
useEffect(() => {
if (geolocationsQueue.length === 0 || geolocationMutation.isPending || !isAuthValid(auth))
return
const locToSend = geolocationsQueue[0]
geolocationMutation.mutate(locToSend)
}, [auth, geolocationMutation.status, geolocationsQueue])
}
const lastLocationsQuery = useQuery({
queryKey: ['get-last-locations', auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/geolocations/last-locations/`, {
method: "GET",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json()),
initialData: [],
enabled: isAuthValid(auth),
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
})
useEffect(() => {
if (lastLocationsQuery.isSuccess && lastLocationsQuery.data)
setLastPlayerLocations(lastLocationsQuery.data)
}, [lastLocationsQuery.status, lastLocationsQuery.dataUpdatedAt])
useEffect(() => {
const locationListener = async (data: PlayerLocation) => {
if (data.playerId)
setLastPlayerLocation(data)
}
socket.on('last-location', locationListener)
return () => { socket.off('last-location', locationListener) }
}, [])
return <>
{children}
</>
}

View File

@ -0,0 +1,77 @@
import { Href, useRouter } from 'expo-router'
import { useRouteInfo } from 'expo-router/build/hooks'
import React, { ReactNode, useEffect } from 'react'
import { useAuth, useAuthLogin } from '@/hooks/useAuth'
import * as SecureStore from '@/utils/SecureStore'
import { useLoginMutation } from '@/hooks/mutations/useLoginMutation'
import { useGame, useSetPlayerId } from '@/hooks/useGame'
type Props = {
loginRedirect: Href
children: ReactNode
}
export default function LoginProvider({ loginRedirect, children }: Props) {
const router = useRouter()
const route = useRouteInfo()
const auth = useAuth()
const authLogin = useAuthLogin()
const loginMutation = useLoginMutation({
authLogin,
onError: ({ response }) => {
if (response)
authLogin({ name: auth.name ?? "", password: null, token: null })
else
authLogin({ name: auth.name ?? "", token: null })
}
})
const game = useGame()
const setPlayerId = useSetPlayerId()
useEffect(() => {
(async () => {
const storedName = await SecureStore.getItemAsync('apiName')
const storedToken = await SecureStore.getItemAsync('apiToken')
if (!auth.loggedIn && storedName !== null && storedName !== auth.name && storedToken !== auth.token) {
authLogin({ name: storedName, token: storedToken })
return
}
// Si on est pas connecté⋅e, on reste sur la fenêtre de connexion
if ((!auth.loggedIn || !auth.token) && route.pathname !== loginRedirect)
router.navigate(loginRedirect)
})()
}, [auth, authLogin, router, route])
useEffect(() => {
// Renouvellement auto du jeton d'authentification
const { name, token } = auth
const password = SecureStore.getItem('apiPassword')
if (name === null || (password === null && token === null) || loginMutation.isPending)
return
let waitTime = 0
if (token !== null && token !== undefined) {
const arrayToken = token.split('.')
const tokenPayload = JSON.parse(atob(arrayToken[1]))
const expTime: number = tokenPayload.exp * 1000
const now: number = Math.floor(new Date().getTime())
waitTime = expTime - now
const playerId = tokenPayload.playerId
if (playerId !== game.playerId)
setPlayerId(playerId)
}
const timeout = setTimeout(async () => {
const password = SecureStore.getItem('apiPassword')
if (password)
loginMutation.mutate({ name, password })
else
authLogin({ name: name, token: null })
}, waitTime)
return () => clearTimeout(timeout)
}, [auth, authLogin, loginMutation.status, game, setPlayerId])
return <>
{children}
</>
}

View File

@ -0,0 +1,49 @@
import { BottomTabBarProps } from '@react-navigation/bottom-tabs'
import { CommonActions } from '@react-navigation/native'
import React from 'react'
import { BottomNavigation } from 'react-native-paper'
const TabBar = (props: BottomTabBarProps) => (
<BottomNavigation.Bar
shifting
navigationState={props.state}
safeAreaInsets={props.insets}
onTabPress={({ route, preventDefault }) => {
const event = props.navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
})
if (event.defaultPrevented) {
preventDefault()
} else {
props.navigation.dispatch({
...CommonActions.navigate(route.name, route.params),
target: props.state.key,
})
}
}}
renderIcon={({ route, focused, color }) => {
const { options } = props.descriptors[route.key]
if (options.tabBarIcon) {
return options.tabBarIcon({ focused, color, size: 24 })
}
return null
}}
getLabelText={({ route }) => {
const { options } = props.descriptors[route.key]
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel.toString()
: options.title !== undefined
? options.title
: route.name
return label
}}
/>
)
export default TabBar

View File

@ -0,0 +1,66 @@
import { BottomTabHeaderProps } from '@react-navigation/bottom-tabs'
import { getHeaderTitle } from '@react-navigation/elements'
import React from 'react'
import {
Appbar,
AppbarProps,
IconButton,
Searchbar,
SearchbarProps,
Tooltip,
} from 'react-native-paper'
interface TabsHeaderProps extends AppbarProps {
navProps: BottomTabHeaderProps
withSearchBar?: boolean
searchBarProps?: SearchbarProps
}
const TabsHeader = (props: TabsHeaderProps) => {
const [query, setQuery] = React.useState('')
return props.withSearchBar ? (
<Appbar.Header {...props}>
<Searchbar
{...props.searchBarProps}
value={query}
onChangeText={setQuery}
style={{ margin: 8, marginBottom: 16 }}
right={(p) => (
<Tooltip title="Rechercher">
<IconButton
{...p}
icon="check"
onPress={() =>
props.searchBarProps?.onChangeText
? props.searchBarProps.onChangeText(query)
: undefined
}
/>
</Tooltip>
)}
/>
</Appbar.Header>
) : (
<Appbar.Header {...props}>
{props.navProps.options.headerLeft
? props.navProps.options.headerLeft({})
: undefined}
<Appbar.Content
title={getHeaderTitle(
props.navProps.options,
props.navProps.route.name,
)}
/>
{props.navProps.options.headerRight
? props.navProps.options.headerRight({
canGoBack: props.navProps.navigation.canGoBack(),
})
: undefined}
</Appbar.Header>
)
}
export default TabsHeader

View File

@ -1,26 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4'
const tintColorDark = '#fff'
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
}

View File

@ -0,0 +1,6 @@
import { Accuracy } from 'expo-location'
export const Constants = {
MIN_DELAY_LOCATION_SENT: 20,
QUERY_REFETCH_INTERVAL: 15,
}

View File

@ -6,13 +6,16 @@
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal"
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true
"autoIncrement": true,
"channel": "production"
}
},
"submit": {

View File

@ -0,0 +1,230 @@
import { AuthState } from "@/utils/features/auth/authSlice"
import { Challenge } from "@/utils/features/challenges/challengesSlice"
import { useMutation } from "@tanstack/react-query"
type ErrorResponse = {
error: string
message: string
statusCode: number
}
type onPostSuccessFunc = () => void
type ErrorFuncProps = { response?: ErrorResponse, error?: Error }
type onErrorFunc = (props: ErrorFuncProps) => void
type ChallengeActionProps = {
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
type ChallengeProps = {
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useDrawRandomChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/draw-random/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useEndChallenge = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => {
return useMutation({
mutationFn: async ({ success }: { success: boolean }) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenge-actions/end-current/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
success: success,
})
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useAttachNewChallenge = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => {
return useMutation({
mutationFn: async ({ challengeActionId, newChallengeId }: { challengeActionId: number, newChallengeId: number }) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenge-actions/${challengeActionId}/`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
challengeId: newChallengeId,
})
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useDeleteChallengeActionMutation = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => {
return useMutation({
mutationFn: async (challengeActionId: number) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenge-actions/${challengeActionId}/`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
})
},
onSuccess: async (resp) => {
if (resp.status >= 400) {
if (onError)
onError({ response: await resp.json() })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useAddChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeProps) => {
return useMutation({
mutationFn: async (challenge: Omit<Challenge, 'id'>) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: challenge.title,
description: challenge.description,
reward: challenge.reward,
})
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useEditChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeProps) => {
return useMutation({
mutationFn: async (challenge: Challenge) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/${challenge.id}/`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: challenge.title,
description: challenge.description,
reward: challenge.reward,
})
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useDeleteChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeProps) => {
return useMutation({
mutationFn: async (challenge: Challenge) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/${challenge.id}/`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
})
},
onSuccess: async (resp) => {
if (resp.status >= 400) {
if (onError)
onError({ response: await resp.json() })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}

View File

@ -0,0 +1,159 @@
import { AuthState } from "@/utils/features/auth/authSlice"
import { GamePayload, GameState } from "@/utils/features/game/gameSlice"
import { useMutation } from "@tanstack/react-query"
type ErrorResponse = {
error: string
message: string
statusCode: number
}
type onPostSuccessFunc = () => void
type ErrorFuncProps = { response?: ErrorResponse, error?: Error }
type onErrorFunc = (props: ErrorFuncProps) => void
type GameProps = {
updateGameState: (payload: GamePayload) => { payload: GamePayload, type: "game/updateGameState" }
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useGameStartMutation = ({ auth, updateGameState, onPostSuccess, onError }: GameProps) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/start/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
updateGameState(data)
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useGameStopMutation = ({ auth, updateGameState, onPostSuccess, onError }: GameProps) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/stop/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
updateGameState(data)
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useGameSwitchPlayerMutation = ({ auth, updateGameState, onPostSuccess, onError }: GameProps) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/switch-running-player/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
updateGameState(data)
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useGameRepairMutation = ({ auth, onPostSuccess, onError }: Omit<GameProps, 'updateGameState'>) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/repair/`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useGameResetMutation = ({ auth, updateGameState, onPostSuccess, onError }: GameProps) => {
return useMutation({
mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/reset/`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
updateGameState(data)
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}

View File

@ -0,0 +1,56 @@
import { AuthState } from "@/utils/features/auth/authSlice"
import { useMutation } from "@tanstack/react-query"
import { LocationObject } from "expo-location"
type ErrorResponse = {
error: string
message: string
statusCode: number
}
type onPostSuccessFunc = (data: any, variables: LocationObject, context: unknown) => void
type ErrorFuncProps = { response?: ErrorResponse, error?: Error }
type onErrorFunc = (props: ErrorFuncProps) => void
type PostProps = {
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useGeolocationMutation = ({ auth, onPostSuccess, onError }: PostProps) => {
return useMutation({
mutationFn: async (location: LocationObject) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/geolocations/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
longitude: location.coords.longitude,
latitude: location.coords.latitude,
speed: location.coords.speed,
accuracy: location.coords.accuracy,
altitude: location.coords.altitude,
altitudeAccuracy: location.coords.altitudeAccuracy,
timestamp: location.timestamp,
})
}).then(resp => resp.json())
},
networkMode: 'offlineFirst',
onSuccess: async (data, location: LocationObject, context: unknown) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess(data, location, context)
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}

View File

@ -0,0 +1,50 @@
import { AuthPayload } from "@/utils/features/auth/authSlice"
import { useMutation } from "@tanstack/react-query"
type ErrorResponse = {
error: string
message: string
statusCode: number
}
type LoginForm = {
name: string
password: string
}
type onPostSuccessFunc = () => void
type ErrorFuncProps = { response?: ErrorResponse, error?: Error }
type onErrorFunc = (props: ErrorFuncProps) => void
type LoginProps = {
authLogin: (payload: AuthPayload) => { payload: AuthPayload, type: "auth/login" }
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useLoginMutation = ({ authLogin, onPostSuccess, onError }: LoginProps) => {
return useMutation({
mutationFn: async ({ name, password }: LoginForm) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name, password: password })
}).then(resp => resp.json())
},
networkMode: 'always',
onSuccess: async (data, { name, password }: LoginForm) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
authLogin({ name: name, password: password, token: data.accessToken })
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}

View File

@ -0,0 +1,76 @@
import { AuthState } from "@/utils/features/auth/authSlice"
import { useMutation } from "@tanstack/react-query"
type ErrorResponse = {
error: string
message: string
statusCode: number
}
type onPostSuccessFunc = () => void
type ErrorFuncProps = { response?: ErrorResponse, error?: Error }
type onErrorFunc = (props: ErrorFuncProps) => void
type TrainProps = {
// addTrain: (payload: TrainPayload) => { payload: GamePayload, type: "train/addTrain" }
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useAddTrainMutation = ({ auth, onPostSuccess, onError }: TrainProps) => {
return useMutation({
mutationFn: async (trainId: string) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/trains/import/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: trainId,
}),
}).then(resp => resp.json())
},
onSuccess: async (data) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}
export const useDeleteTrainMutation = ({ auth, onPostSuccess, onError }: TrainProps) => {
return useMutation({
mutationFn: async (trainId: string) => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/trains/${trainId}/`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
})
},
onSuccess: async (resp) => {
if (resp.status >= 400) {
if (onError)
onError({ response: await resp.json() })
return
}
if (onPostSuccess)
onPostSuccess()
},
onError: async (error: Error) => {
if (onError)
onError({ error: error })
}
})
}

12
client/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,12 @@
import { useAppDispatch, useAppSelector } from "./useStore"
import { AuthPayload, login, logout } from "@/utils/features/auth/authSlice"
export const useAuth = () => useAppSelector((state) => state.auth)
export const useAuthLogin = () => {
const dispath = useAppDispatch()
return (payload: AuthPayload) => dispath(login(payload))
}
export const useAuthLogout = () => {
const dispatch = useAppDispatch()
return () => dispatch(logout())
}

View File

@ -0,0 +1,8 @@
import { ChallengeActionsPayload, downloadChallengeActions } from "@/utils/features/challengeActions/challengeActionsSlice"
import { useAppDispatch, useAppSelector } from "./useStore"
export const useChallengeActions = () => useAppSelector((state) => state.challengeActions.challengeActions)
export const useDownloadChallengeActions = () => {
const dispath = useAppDispatch()
return (challengesData: ChallengeActionsPayload) => dispath(downloadChallengeActions(challengesData))
}

View File

@ -0,0 +1,8 @@
import { ChallengesPayload, downloadChallenges } from "@/utils/features/challenges/challengesSlice"
import { useAppDispatch, useAppSelector } from "./useStore"
export const useChallenges = () => useAppSelector((state) => state.challenges.challenges)
export const useDownloadChallenges = () => {
const dispath = useAppDispatch()
return (challengesData: ChallengesPayload) => dispath(downloadChallenges(challengesData))
}

30
client/hooks/useGame.ts Normal file
View File

@ -0,0 +1,30 @@
import { Accuracy } from "expo-location"
import { useAppDispatch, useAppSelector } from "./useStore"
import { GamePayload, PenaltyPayload, setLocationAccuracy, setPlayerId, updateActiveChallengeId, updateGameState, updateMoney, updatePenalty } from "@/utils/features/game/gameSlice"
export const useGame = () => useAppSelector((state) => state.game)
export const useSettings = () => useAppSelector((state) => state.game.settings)
export const useSetPlayerId = () => {
const dispath = useAppDispatch()
return (playerId: number) => dispath(setPlayerId(playerId))
}
export const useUpdateMoney = () => {
const dispatch = useAppDispatch()
return (money: number) => dispatch(updateMoney(money))
}
export const useUpdateActiveChallengeId = () => {
const dispatch = useAppDispatch()
return (challengeActionId: number) => dispatch(updateActiveChallengeId(challengeActionId))
}
export const useUpdateGameState = () => {
const dispatch = useAppDispatch()
return (game: GamePayload) => dispatch(updateGameState(game))
}
export const useUpdatePenalty = () => {
const dispatch = useAppDispatch()
return (penalty: PenaltyPayload) => dispatch(updatePenalty(penalty))
}
export const useSetLocationAccuracy = () => {
const dispatch = useAppDispatch()
return (accuracy: Accuracy | null) => dispatch(setLocationAccuracy(accuracy))
}

View File

@ -1,9 +1,24 @@
import { LocationObject } from "expo-location"
import { useAppDispatch, useAppSelector } from "./useStore"
import { setLocation } from "@/utils/features/location/locationSlice"
import { PlayerLocation, setLastLocation, setLastPlayerLocation, setLastPlayerLocations, unqueueLocation } from "@/utils/features/location/locationSlice"
export const useLocation = () => useAppSelector((state) => state.location.location)
export const useSetLocation = () => (location: LocationObject) => {
export const useLastOwnLocation = () => useAppSelector((state) => state.location.lastOwnLocation)
export const useQueuedLocations = () => useAppSelector((state) => state.location.queuedLocations)
export const useLastPlayerLocations = () => useAppSelector((state) => state.location.lastPlayerLocations)
export const useSetLastLocation = () => {
const dispatch = useAppDispatch()
dispatch(setLocation(location))
return (location: LocationObject) => dispatch(setLastLocation(location))
}
export const useUnqueueLocation = () => {
const dispatch = useAppDispatch()
return (location: LocationObject) => dispatch(unqueueLocation(location))
}
export const useSetLastPlayerLocations = () => {
const dispatch = useAppDispatch()
return (playerLocations: PlayerLocation[]) => dispatch(setLastPlayerLocations(playerLocations))
}
export const useSetLastPlayerLocation = () => {
const dispatch = useAppDispatch()
return (playerLocation: PlayerLocation) => dispatch(setLastPlayerLocation(playerLocation))
}

View File

@ -0,0 +1,8 @@
import { useAppDispatch, useAppSelector } from "./useStore"
import { downloadMoneyUpdates, MoneyUpdatesPayload } from "@/utils/features/moneyUpdates/moneyUpdatesSlice"
export const useMoneyUpdates = () => useAppSelector((state) => state.moneyUpdates.moneyUpdates)
export const useDownloadMoneyUpdates = () => {
const dispath = useAppDispatch()
return (moneyUpdatesData: MoneyUpdatesPayload) => dispath(downloadMoneyUpdates(moneyUpdatesData))
}

View File

@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors'
import { useColorScheme } from '@/hooks/useColorScheme'
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light'
const colorFromProps = props[theme]
if (colorFromProps) {
return colorFromProps
} else {
return Colors[theme][colorName]
}
}

8
client/hooks/useTrain.ts Normal file
View File

@ -0,0 +1,8 @@
import { downloadTrains, TrainsPayload } from "@/utils/features/train/trainSlice"
import { useAppDispatch, useAppSelector } from "./useStore"
export const useTrain = () => useAppSelector((state) => state.train.trains)
export const useDownloadTrains = () => {
const dispath = useAppDispatch()
return (trainsData: TrainsPayload) => dispath(downloadTrains(trainsData))
}

2104
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
{
"name": "traintrape-moi-client",
"main": "expo-router/entry",
"version": "1.0.0",
"version": "1.1.1",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
@ -14,27 +14,34 @@
"preset": "jest-expo"
},
"dependencies": {
"@dev-plugins/react-navigation": "^0.1.0",
"@dev-plugins/react-query": "^0.1.0",
"@expo/vector-icons": "^14.0.2",
"@maplibre/maplibre-react-native": "^10.0.0-alpha.28",
"@maplibre/maplibre-react-native": "^10.0.0-beta.8",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@reduxjs/toolkit": "^2.4.0",
"@tanstack/query-async-storage-persister": "^5.62.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-persist-client": "^5.62.7",
"@turf/circle": "^7.1.0",
"expo": "~52.0.11",
"expo-blur": "~14.0.1",
"expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.4",
"expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.3",
"expo-location": "^18.0.2",
"expo-router": "~4.0.9",
"expo-secure-store": "~14.0.0",
"expo-share-intent": "^3.1.1",
"expo-splash-screen": "~0.29.13",
"expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.4",
"expo-task-manager": "^12.0.3",
"expo-web-browser": "~14.0.1",
"expo-updates": "~0.26.10",
"maplibre-gl": "^4.7.1",
"maplibre-react-components": "^0.1.9",
"react": "18.3.1",
@ -42,12 +49,13 @@
"react-map-gl": "^7.1.7",
"react-native": "0.76.3",
"react-native-gesture-handler": "~2.20.2",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-paper": "^5.12.5",
"react-native-paper-dropdown": "^2.3.1",
"react-native-safe-area-context": "~4.12.0",
"react-native-screens": "~4.1.0",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.2",
"react-redux": "^9.1.2"
"react-redux": "^9.1.2",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

@ -14,5 +14,5 @@
".expo/types/**/*.ts",
"expo-env.d.ts",
"app/(tabs)/index.tsx"
]
, "babel.config.js" ]
}

View File

@ -0,0 +1,3 @@
import { deleteItemAsync, getItem, getItemAsync, setItem, setItemAsync } from 'expo-secure-store'
export { deleteItemAsync, getItem, getItemAsync, setItem, setItemAsync }

View File

@ -0,0 +1,19 @@
export async function deleteItemAsync(key: string): Promise<void> {
return localStorage.removeItem(key)
}
export function getItem(key: string): string | null {
return localStorage.getItem(key)
}
export async function getItemAsync(key: string): Promise<string | null> {
return localStorage.getItem(key)
}
export function setItem(key: string, value: string): void {
localStorage.setItem(key, value)
}
export async function setItemAsync(key: string, value: string): Promise<void> {
localStorage.setItem(key, value)
}

View File

@ -0,0 +1,69 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as SecureStore from '@/utils/SecureStore'
import { Platform } from 'react-native'
export interface AuthState {
loggedIn: boolean,
name: string | null,
token: string | null,
}
export interface AuthPayload {
name: string,
password?: string | null,
token: string | null,
}
const initialState: AuthState = {
loggedIn: false,
name: null,
token: null,
}
export function isAuthValid({ loggedIn, token }: AuthState): boolean {
if (!loggedIn || token === null)
return false
const arrayToken = token.split('.')
const tokenPayload = JSON.parse(atob(arrayToken[1]))
const expTime: number = tokenPayload.exp * 1000
const now: number = Math.floor(new Date().getTime())
return expTime >= now
}
export const authSlice = createSlice({
name: 'auth',
initialState: initialState,
reducers: {
login: (state, action: PayloadAction<AuthPayload>) => {
state.loggedIn = action.payload.token !== null
state.name = action.payload.name
state.token = action.payload.token
SecureStore.setItem('apiName', action.payload.name)
if (action.payload.password !== undefined && Platform.OS !== "web") {
// Le stockage navigateur n'est pas sûr, on évite de stocker un mot de passe à l'intérieur
if (action.payload.password)
SecureStore.setItem('apiPassword', action.payload.password)
else
SecureStore.deleteItemAsync('apiPassword')
}
if (action.payload.token)
SecureStore.setItem('apiToken', action.payload.token)
else
SecureStore.deleteItemAsync('apiToken')
},
logout: (state) => {
state.loggedIn = false
state.name = null
state.token = null
SecureStore.deleteItemAsync('apiName')
SecureStore.deleteItemAsync('apiPassword')
SecureStore.deleteItemAsync('apiToken')
}
},
})
export const { login, logout } = authSlice.actions
export default authSlice.reducer

View File

@ -0,0 +1,66 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { Challenge } from '../challenges/challengesSlice'
export interface ChallengeAction {
id: number
challengeId: number
success: boolean,
start: number, // date
end: number | null, // date
penaltyStart: number | null, // date
penaltyEnd: number | null, // date
runId: number,
}
export interface ActionsState {
challengeActions: ChallengeAction[]
}
const initialState: ActionsState = {
challengeActions: []
}
export interface ChallengeActionPayload {
id: number
playerId: number
challengeId: number
success: boolean,
start: string,
end: string | null,
penaltyStart: string | null,
penaltyEnd: string | null,
runId: number,
}
export interface ChallengeActionsPayload {
data: (Challenge & { action: ChallengeActionPayload })[]
}
export const challengeActionsSlice = createSlice({
name: 'challengeActions',
initialState: initialState,
reducers: {
downloadChallengeActions(state, action: PayloadAction<ChallengeActionsPayload>) {
if (state.challengeActions)
state.challengeActions = state.challengeActions.filter(challengeAction => action.payload.data.filter(dlChallenge => dlChallenge.action.id === challengeAction.id) === null)
for (const dlChallenge of action.payload.data) {
state.challengeActions.push({
id: dlChallenge.action.id,
challengeId: dlChallenge.id,
success: dlChallenge.action.success,
start: new Date(dlChallenge.action.start).getTime(),
end: dlChallenge.action.end ? new Date(dlChallenge.action.end).getTime() : null,
penaltyStart: dlChallenge.action.penaltyStart ? new Date(dlChallenge.action.penaltyStart).getTime() : null,
penaltyEnd: dlChallenge.action.penaltyEnd ? new Date(dlChallenge.action.penaltyEnd).getTime() : null,
runId: dlChallenge.action.runId,
})
}
state.challengeActions.sort((c1, c2) => c2.id - c1.id)
},
},
})
export const { downloadChallengeActions } = challengeActionsSlice.actions
export default challengeActionsSlice.reducer

View File

@ -0,0 +1,47 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { PaginationMeta } from '../common'
import { ChallengeAction } from '../challengeActions/challengeActionsSlice'
export interface Challenge {
id: number
title: string,
description: string,
reward: number,
}
export interface ChallengesState {
challenges: Challenge[]
}
const initialState: ChallengesState = {
challenges: []
}
export interface ChallengesPayload {
data: (Challenge & { action: ChallengeAction | null })[]
meta: PaginationMeta
}
export const challengesSlice = createSlice({
name: 'challenges',
initialState: initialState,
reducers: {
downloadChallenges(state, action: PayloadAction<ChallengesPayload>) {
if (state.challenges)
state.challenges = state.challenges.filter(challenge => action.payload.data.filter(dlChallenge => dlChallenge.id === challenge.id) === null)
for (const dlChallenge of action.payload.data) {
state.challenges.push({
id: dlChallenge.id,
title: dlChallenge.title,
description: dlChallenge.description,
reward: dlChallenge.reward,
})
}
state.challenges.sort((c1, c2) => c1.title.localeCompare(c2.title))
},
},
})
export const { downloadChallenges } = challengesSlice.actions
export default challengesSlice.reducer

View File

@ -0,0 +1,8 @@
export interface PaginationMeta {
currentPage: number
lastPage: number
nextPage: number
prevPage: number
total: number
totalPerPage: number
}

View File

@ -0,0 +1,100 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { Accuracy } from 'expo-location'
import * as SecureStore from '@/utils/SecureStore'
export interface RunPayload {
id: number
gameId: number
runnerId: number
start: string
end: string | null
}
export interface GamePayload {
id: number
started: boolean
currentRunId: number | null
currentRun: RunPayload | null
}
export interface PenaltyPayload {
penaltyStart: number | null
penaltyEnd: number | null
}
export interface Player {
id: number
name: string
money: number
activeChallengeId: number | null
}
export interface GameState {
playerId: number | null
runId: number | null
gameStarted: boolean
money: number
currentRunner: boolean
activeChallengeId: number | null
chaseFreeTime: number | null // date
penaltyStart: number | null // date
penaltyEnd: number | null //date
settings: Settings
}
export interface Settings {
locationAccuracy: Accuracy | null
}
const initialState: GameState = {
playerId: null,
runId: null,
gameStarted: false,
money: 0,
currentRunner: false,
activeChallengeId: null,
chaseFreeTime: null,
penaltyStart: null,
penaltyEnd: null,
settings: {
locationAccuracy: Accuracy.Highest,
}
}
export const gameSlice = createSlice({
name: 'game',
initialState: initialState,
reducers: {
setPlayerId: (state, action: PayloadAction<number>) => {
state.playerId = action.payload
},
updateMoney: (state, action: PayloadAction<number>) => {
state.money = action.payload
},
updateActiveChallengeId: (state, action: PayloadAction<number | null>) => {
state.activeChallengeId = action.payload
},
updateGameState: (state, action: PayloadAction<GamePayload>) => {
const game: GamePayload = action.payload
state.gameStarted = game.started
state.runId = game.currentRunId
state.currentRunner = state.playerId === game.currentRun?.runnerId
if (state.currentRunner)
state.chaseFreeTime = null
else if (game.currentRun)
state.chaseFreeTime = new Date(game.currentRun?.start).getTime() + 45 * 60 * 1000
},
updatePenalty: (state, action: PayloadAction<PenaltyPayload>) => {
state.penaltyStart = action.payload.penaltyStart
state.penaltyEnd = action.payload.penaltyEnd
},
setLocationAccuracy: (state, action: PayloadAction<Accuracy | null>) => {
state.settings.locationAccuracy = action.payload
SecureStore.setItem('locationAccuracy', action.payload?.toString() ?? 'null')
}
},
})
export const { setLocationAccuracy, setPlayerId, updateMoney, updateActiveChallengeId, updateGameState, updatePenalty } = gameSlice.actions
export default gameSlice.reducer

View File

@ -1,24 +1,63 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { LocationObject } from 'expo-location'
import { Constants } from '@/constants/Constants'
export type PlayerLocation = {
id?: number
playerId: number
longitude: number
latitude: number
speed: number
accuracy: number
altitude: number
altitudeAccuracy: number
timestamp: string
}
interface LocationState {
location: LocationObject | null
lastOwnLocation: LocationObject | null
lastSentLocation: LocationObject | null
queuedLocations: LocationObject[]
lastPlayerLocations: PlayerLocation[]
}
const initialState: LocationState = {
location: null
lastOwnLocation: null,
lastSentLocation: null,
queuedLocations: [],
lastPlayerLocations: []
}
export const locationSlice = createSlice({
name: 'location',
initialState: initialState,
reducers: {
setLocation: (state, action: PayloadAction<LocationObject>) => {
state.location = action.payload
setLastLocation: (state, action: PayloadAction<LocationObject>) => {
const location: LocationObject = action.payload
state.lastOwnLocation = location
if (state.lastSentLocation === null || (location.timestamp - state.lastSentLocation.timestamp) >= Constants.MIN_DELAY_LOCATION_SENT * 1000) {
state.lastSentLocation = location
state.queuedLocations.push(location)
}
},
unqueueLocation: (state, action: PayloadAction<LocationObject>) => {
const sentLoc = action.payload
state.queuedLocations = state.queuedLocations
.filter(loc => new Date(loc.timestamp).getTime() !== sentLoc.timestamp
&& loc.coords.latitude !== sentLoc.coords.latitude
&& loc.coords.longitude !== sentLoc.coords.longitude)
},
setLastPlayerLocations: (state, action: PayloadAction<PlayerLocation[]>) => {
state.lastPlayerLocations = action.payload
},
setLastPlayerLocation: (state, action: PayloadAction<PlayerLocation>) => {
state.lastPlayerLocations = state.lastPlayerLocations.filter(playerLoc => playerLoc.playerId !== action.payload.playerId)
state.lastPlayerLocations.push(action.payload)
}
},
})
export const { setLocation } = locationSlice.actions
export const { setLastLocation, unqueueLocation, setLastPlayerLocation, setLastPlayerLocations } = locationSlice.actions
export default locationSlice.reducer

View File

@ -0,0 +1,64 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { PaginationMeta } from '../common'
export interface MoneyUpdate {
id: number
playerId: number
amount: number
reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'CHALLENGE'
timestamp: number
runId: number | null
actionId: number | null
tripId: string | null
}
export interface MoneyUpdatesState {
moneyUpdates: MoneyUpdate[]
}
const initialState: MoneyUpdatesState = {
moneyUpdates: []
}
export interface MoneyUpdatePayload {
id: number
playerId: number
amount: number
reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'CHALLENGE'
timestamp: string
runId: number | null
actionId: number | null
tripId: string | null
}
export interface MoneyUpdatesPayload {
data: MoneyUpdatePayload[]
meta: PaginationMeta
}
export const moneyUpdatesSlice = createSlice({
name: 'moneyUpdates',
initialState: initialState,
reducers: {
downloadMoneyUpdates(state, action: PayloadAction<MoneyUpdatesPayload>) {
state.moneyUpdates = state.moneyUpdates.filter(moneyUpdate => action.payload.data.filter(dlMU => dlMU.id === moneyUpdate.id) === null)
for (const dlMU of action.payload.data) {
state.moneyUpdates.push({
id: dlMU.id,
playerId: dlMU.playerId,
amount: dlMU.amount,
reason: dlMU.reason,
timestamp: new Date(dlMU.timestamp).getTime(),
runId: dlMU.runId,
actionId: dlMU.actionId,
tripId: dlMU.tripId,
})
}
state.moneyUpdates.sort((mu1, mu2) => mu2.id - mu1.id)
},
},
})
export const { downloadMoneyUpdates } = moneyUpdatesSlice.actions
export default moneyUpdatesSlice.reducer

View File

@ -0,0 +1,108 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { PaginationMeta } from '../common'
export interface InterrailTime {
hours: number
minutes: number
offset: number
}
export interface InterrailDate {
day: number
month: number
year: number
}
export interface InterrailTravelInfo {
arrivalTime: InterrailTime
date: InterrailDate
departureTime: InterrailTime
haconVersion: number
dataSource: number
}
export interface InterrailStopExtraInfo {
departureTime: InterrailTime
index: number
}
export interface InterrailStopCoordinates {
latitude: number
longitude: number
}
export interface InterrailStopStation {
coordinates: InterrailStopCoordinates
country: string
name: string
stationId: number
}
export interface InterrailLegInfo {
attributeCodes: string[]
attributes: object
duration: InterrailTime
directionStation: string
endTime: InterrailTime
isSeparateTicket: boolean
operationDays: string
operator: object
dataSource: number
startTime: InterrailTime
stopExtraInfo: InterrailStopExtraInfo[]
trainName: string
trainStopStations: InterrailStopStation[]
trainType: number
}
export interface TrainTrip {
id: string
distance: number,
from: string,
to: string,
departureTime: number,
arrivalTime: number,
infoJson?: string,
info?: InterrailLegInfo,
}
export interface TrainsState {
trains: TrainTrip[]
}
const initialState: TrainsState = {
trains: []
}
export interface TrainsPayload {
data: TrainTrip[]
meta: PaginationMeta
}
export const trainSlice = createSlice({
name: 'train',
initialState: initialState,
reducers: {
downloadTrains(state, action: PayloadAction<TrainsPayload>) {
if (state.trains)
state.trains = state.trains.filter(train => action.payload.data.filter(dlTrain => dlTrain.id === train.id) === null)
for (const dlTrain of action.payload.data) {
const info = dlTrain.infoJson ? JSON.parse(dlTrain.infoJson) : undefined
state.trains.push({
id: dlTrain.id,
distance: dlTrain.distance,
from: dlTrain.from,
to: dlTrain.to,
departureTime: dlTrain.departureTime,
arrivalTime: dlTrain.arrivalTime,
info: info,
})
}
state.trains.sort((t1, t2) => t1.departureTime > t2.departureTime ? -1 : t1.departureTime == t2.arrivalTime ? 0 : 1)
}
},
})
export const {downloadTrains } = trainSlice.actions
export default trainSlice.reducer

View File

@ -1,11 +1,15 @@
import * as Location from 'expo-location'
import * as TaskManager from 'expo-task-manager'
import { Platform } from 'react-native'
import { setLocation } from './features/location/locationSlice'
import { PlayerLocation, setLastLocation } from './features/location/locationSlice'
import store from './store'
import { useEffect } from 'react'
import { socket } from './socket'
import { useSettings } from '@/hooks/useGame'
import { useAuth } from '@/hooks/useAuth'
import { isAuthValid } from './features/auth/authSlice'
const LOCATION_TASK = "fetch-geolocation"
const LOCATION_TASK = "TRAINTRAPE_MOI_GEOLOCATION"
TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => {
if (error) {
@ -13,15 +17,32 @@ TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => {
return
}
const { locations } = data
store.dispatch(setLocation(locations.at(-1)))
for (let location of locations) {
// TODO Envoyer les positions au serveur
const lastLoc: Location.LocationObject = locations.at(-1)
if (__DEV__)
console.log("Localisation reçue :", lastLoc)
store.dispatch(setLastLocation(lastLoc))
const playerId = store.getState().game.playerId
if (socket.active && playerId) {
const lastLocToSend: PlayerLocation = {
playerId: playerId,
longitude: lastLoc.coords.longitude,
latitude: lastLoc.coords.latitude,
speed: lastLoc.coords.speed ?? 0,
accuracy: lastLoc.coords.accuracy ?? 0,
altitude: lastLoc.coords.altitude ?? 0,
altitudeAccuracy: lastLoc.coords.altitudeAccuracy ?? 0,
timestamp: new Date(lastLoc.timestamp).toISOString(),
}
socket.emit('last-location', lastLocToSend)
}
})
export async function startGeolocationService(): Promise<void | (() => void)> {
export async function startGeolocationService(locationAccuracy: Location.Accuracy | null): Promise<void | (() => void)> {
if (Platform.OS !== "web" && await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK))
return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK)
await Location.stopLocationUpdatesAsync(LOCATION_TASK)
if (locationAccuracy === null)
return
await Location.enableNetworkProviderAsync().catch(error => alert(error))
@ -35,9 +56,10 @@ export async function startGeolocationService(): Promise<void | (() => void)> {
if (Platform.OS !== "web") {
await Location.startLocationUpdatesAsync(LOCATION_TASK, {
accuracy: Location.Accuracy.BestForNavigation,
accuracy: locationAccuracy,
activityType: Location.ActivityType.OtherNavigation,
deferredUpdatesInterval: 100,
distanceInterval: 10,
timeInterval: 1000,
foregroundService: {
killServiceOnDestroy: false,
notificationBody: "Géolocalisation activée pour « Traintrape-moi »",
@ -48,13 +70,19 @@ export async function startGeolocationService(): Promise<void | (() => void)> {
return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK)
}
else {
const locationSubscription = await Location.watchPositionAsync({accuracy: Location.Accuracy.BestForNavigation}, location_nouveau => store.dispatch(setLocation(location_nouveau)))
const locationSubscription = await Location.watchPositionAsync({ accuracy: locationAccuracy }, location_nouveau => store.dispatch(setLastLocation(location_nouveau)))
return locationSubscription.remove
}
}
export const useStartGeolocationServiceEffect = () => useEffect(() => {
let cleanup: void | (() => void) = () => {}
startGeolocationService().then(result => cleanup = result)
return cleanup
}, [])
export const useStartGeolocationServiceEffect = () => {
const auth = useAuth()
const settings = useSettings()
return useEffect(() => {
if (!isAuthValid(auth))
return
let cleanup: void | (() => void) = () => {}
startGeolocationService(settings.locationAccuracy).then(result => cleanup = result)
return cleanup
}, [auth, settings.locationAccuracy])
}

3
client/utils/socket.ts Normal file
View File

@ -0,0 +1,3 @@
import { io } from 'socket.io-client'
export const socket = io(process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SOCKET)

View File

@ -1,9 +1,21 @@
import { configureStore } from '@reduxjs/toolkit'
import authReducer from './features/auth/authSlice'
import challengesReducer from './features/challenges/challengesSlice'
import challengeActionsReducer from './features/challengeActions/challengeActionsSlice'
import gameReducer from './features/game/gameSlice'
import locationReducer from './features/location/locationSlice'
import moneyUpdatesReducer from './features/moneyUpdates/moneyUpdatesSlice'
import trainReducer from './features/train/trainSlice'
const store = configureStore({
reducer: {
auth: authReducer,
challenges: challengesReducer,
challengeActions: challengeActionsReducer,
game: gameReducer,
location: locationReducer,
moneyUpdates: moneyUpdatesReducer,
train: trainReducer,
},
})

View File

@ -1,2 +1,4 @@
DATABASE_URL="postgres://username:password@localhost:5432/traintrape-moi"
JWT_SECRET="CHANGE_ME"
API_GLOBAL_PREFIX="api"
API_HOSTNAME="traintrape-moi.luemy.eu"

View File

@ -3,6 +3,14 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true
}
}
]
}
}

225
server/package-lock.json generated
View File

@ -14,7 +14,10 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15",
"@nestjs/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.15",
"@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
@ -1815,6 +1818,65 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.15.tgz",
"integrity": "sha512-KZAxNEADPwoORixh3NJgGYWMVGORVPKeTqjD7hbF8TPDLKWWxru9yasBQwEz2/wXH/WgpkQbbaYwx4nUjCIVpw==",
"license": "MIT",
"dependencies": {
"socket.io": "4.8.1",
"tslib": "2.8.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/websockets": "^10.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/platform-ws": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.15.tgz",
"integrity": "sha512-EvioQ4zq5LcaL+wdCfcxWgX/R65f4/VN/qFN18cfoVAxWRRa/JfHtWDT+b1lacAU8jPnYjLNAtWPKXc/mcZ1eQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1",
"ws": "8.18.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/websockets": "^10.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/platform-ws/node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@ -1900,6 +1962,29 @@
}
}
},
"node_modules/@nestjs/websockets": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz",
"integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==",
"license": "MIT",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
"tslib": "2.8.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/platform-socket.io": {
"optional": true
}
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2077,6 +2162,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@ -2181,6 +2272,12 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
"license": "MIT"
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@ -2188,6 +2285,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -3285,6 +3391,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -4304,6 +4419,45 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz",
"integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
@ -7408,6 +7562,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
@ -8590,6 +8753,47 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@ -9802,6 +10006,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "traintrape-moi-server",
"version": "0.0.1",
"version": "1.1.1",
"description": "",
"author": "",
"private": true,
@ -25,7 +25,10 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15",
"@nestjs/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.15",
"@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",

View File

@ -1,109 +0,0 @@
-- CreateEnum
CREATE TYPE "MoneyUpdateType" AS ENUM ('START', 'WIN_CHALLENGE', 'BUY_TRAIN');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"money" INTEGER NOT NULL DEFAULT 0,
"currentRunner" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Geolocation" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Geolocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Challenge" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"reward" INTEGER NOT NULL,
CONSTRAINT "Challenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChallengeAction" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"challengeId" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT false,
"success" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ChallengeAction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrainTrip" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"distance" DOUBLE PRECISION NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"infoJson" JSONB NOT NULL,
"geometry" TEXT NOT NULL,
CONSTRAINT "TrainTrip_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MoneyUpdate" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"before" INTEGER NOT NULL,
"after" INTEGER NOT NULL,
"reason" "MoneyUpdateType" NOT NULL,
"actionId" INTEGER,
"tripId" TEXT,
CONSTRAINT "MoneyUpdate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_name_key" ON "User"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Challenge_title_key" ON "Challenge"("title");
-- CreateIndex
CREATE UNIQUE INDEX "ChallengeAction_challengeId_key" ON "ChallengeAction"("challengeId");
-- CreateIndex
CREATE UNIQUE INDEX "MoneyUpdate_actionId_key" ON "MoneyUpdate"("actionId");
-- CreateIndex
CREATE UNIQUE INDEX "MoneyUpdate_tripId_key" ON "MoneyUpdate"("tripId");
-- AddForeignKey
ALTER TABLE "Geolocation" ADD CONSTRAINT "Geolocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainTrip" ADD CONSTRAINT "TrainTrip_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "ChallengeAction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "TrainTrip"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,14 +0,0 @@
/*
Warnings:
- Added the required column `accuracy` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `altitude` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `altitudeAccuracy` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `speed` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Geolocation" ADD COLUMN "accuracy" DOUBLE PRECISION NOT NULL,
ADD COLUMN "altitude" DOUBLE PRECISION NOT NULL,
ADD COLUMN "altitudeAccuracy" DOUBLE PRECISION NOT NULL,
ADD COLUMN "speed" DOUBLE PRECISION NOT NULL;

View File

@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "ChallengeAction" ADD COLUMN "end" TIMESTAMP(3),
ADD COLUMN "penaltyEnd" TIMESTAMP(3),
ADD COLUMN "penaltyStart" TIMESTAMP(3),
ADD COLUMN "start" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,162 @@
-- CreateEnum
CREATE TYPE "MoneyUpdateType" AS ENUM ('START', 'NEW_RUN', 'WIN_CHALLENGE', 'BUY_TRAIN');
-- CreateTable
CREATE TABLE "Player" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"money" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Game" (
"id" SERIAL NOT NULL,
"started" BOOLEAN NOT NULL DEFAULT false,
"currentRunId" INTEGER,
CONSTRAINT "Game_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlayerRun" (
"id" SERIAL NOT NULL,
"gameId" INTEGER NOT NULL,
"runnerId" INTEGER NOT NULL,
"start" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"end" TIMESTAMPTZ(3),
CONSTRAINT "PlayerRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Geolocation" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"speed" DOUBLE PRECISION NOT NULL,
"accuracy" DOUBLE PRECISION NOT NULL,
"altitude" DOUBLE PRECISION NOT NULL,
"altitudeAccuracy" DOUBLE PRECISION NOT NULL,
"timestamp" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Geolocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Challenge" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"reward" INTEGER NOT NULL,
CONSTRAINT "Challenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChallengeAction" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"challengeId" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT false,
"success" BOOLEAN NOT NULL DEFAULT false,
"start" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"end" TIMESTAMPTZ(3),
"penaltyStart" TIMESTAMPTZ(3),
"penaltyEnd" TIMESTAMPTZ(3),
"runId" INTEGER NOT NULL,
CONSTRAINT "ChallengeAction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrainTrip" (
"id" TEXT NOT NULL,
"playerId" INTEGER NOT NULL,
"distance" DOUBLE PRECISION NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
"departureTime" TIMESTAMPTZ(3) NOT NULL,
"arrivalTime" TIMESTAMPTZ(3) NOT NULL,
"infoJson" JSONB NOT NULL,
"runId" INTEGER NOT NULL,
CONSTRAINT "TrainTrip_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MoneyUpdate" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"amount" INTEGER NOT NULL,
"reason" "MoneyUpdateType" NOT NULL,
"actionId" INTEGER,
"tripId" TEXT,
"runId" INTEGER,
"timestamp" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MoneyUpdate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Player_name_key" ON "Player"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Game_currentRunId_key" ON "Game"("currentRunId");
-- CreateIndex
CREATE UNIQUE INDEX "Challenge_title_key" ON "Challenge"("title");
-- CreateIndex
CREATE UNIQUE INDEX "ChallengeAction_challengeId_key" ON "ChallengeAction"("challengeId");
-- CreateIndex
CREATE UNIQUE INDEX "MoneyUpdate_actionId_key" ON "MoneyUpdate"("actionId");
-- CreateIndex
CREATE UNIQUE INDEX "MoneyUpdate_tripId_key" ON "MoneyUpdate"("tripId");
-- CreateIndex
CREATE UNIQUE INDEX "MoneyUpdate_runId_key" ON "MoneyUpdate"("runId");
-- AddForeignKey
ALTER TABLE "Game" ADD CONSTRAINT "Game_currentRunId_fkey" FOREIGN KEY ("currentRunId") REFERENCES "PlayerRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerRun" ADD CONSTRAINT "PlayerRun_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerRun" ADD CONSTRAINT "PlayerRun_runnerId_fkey" FOREIGN KEY ("runnerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Geolocation" ADD CONSTRAINT "Geolocation_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PlayerRun"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainTrip" ADD CONSTRAINT "TrainTrip_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainTrip" ADD CONSTRAINT "TrainTrip_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PlayerRun"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "ChallengeAction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "TrainTrip"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PlayerRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `active` on the `ChallengeAction` table. All the data in the column will be lost.
- A unique constraint covering the columns `[activeChallengeId]` on the table `Player` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "ChallengeAction" DROP COLUMN "active";
-- AlterTable
ALTER TABLE "Player" ADD COLUMN "activeChallengeId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Player_activeChallengeId_key" ON "Player"("activeChallengeId");
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_activeChallengeId_fkey" FOREIGN KEY ("activeChallengeId") REFERENCES "ChallengeAction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,41 @@
-- DropForeignKey
ALTER TABLE "ChallengeAction" DROP CONSTRAINT "ChallengeAction_challengeId_fkey";
-- DropForeignKey
ALTER TABLE "Geolocation" DROP CONSTRAINT "Geolocation_playerId_fkey";
-- DropForeignKey
ALTER TABLE "MoneyUpdate" DROP CONSTRAINT "MoneyUpdate_actionId_fkey";
-- DropForeignKey
ALTER TABLE "MoneyUpdate" DROP CONSTRAINT "MoneyUpdate_runId_fkey";
-- DropForeignKey
ALTER TABLE "MoneyUpdate" DROP CONSTRAINT "MoneyUpdate_tripId_fkey";
-- DropForeignKey
ALTER TABLE "PlayerRun" DROP CONSTRAINT "PlayerRun_gameId_fkey";
-- DropForeignKey
ALTER TABLE "PlayerRun" DROP CONSTRAINT "PlayerRun_runnerId_fkey";
-- AddForeignKey
ALTER TABLE "PlayerRun" ADD CONSTRAINT "PlayerRun_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerRun" ADD CONSTRAINT "PlayerRun_runnerId_fkey" FOREIGN KEY ("runnerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Geolocation" ADD CONSTRAINT "Geolocation_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChallengeAction" ADD CONSTRAINT "ChallengeAction_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "ChallengeAction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "TrainTrip"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MoneyUpdate" ADD CONSTRAINT "MoneyUpdate_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PlayerRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
/*
Warnings:
- The values [WIN_CHALLENGE] on the enum `MoneyUpdateType` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "MoneyUpdateType_new" AS ENUM ('START', 'NEW_RUN', 'CHALLENGE', 'BUY_TRAIN');
ALTER TABLE "MoneyUpdate" ALTER COLUMN "reason" TYPE "MoneyUpdateType_new" USING ("reason"::text::"MoneyUpdateType_new");
ALTER TYPE "MoneyUpdateType" RENAME TO "MoneyUpdateType_old";
ALTER TYPE "MoneyUpdateType_new" RENAME TO "MoneyUpdateType";
DROP TYPE "MoneyUpdateType_old";
COMMIT;

View File

@ -7,22 +7,46 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String @unique
password String
money Int @default(0)
currentRunner Boolean @default(false)
actions ChallengeAction[]
geolocations Geolocation[]
moneyUpdates MoneyUpdate[]
trips TrainTrip[]
model Player {
id Int @id @default(autoincrement())
name String @unique
password String
money Int @default(0)
activeChallenge ChallengeAction? @relation("ActiveChallenge", fields: [activeChallengeId], references: [id], onDelete: SetNull)
activeChallengeId Int? @unique
actions ChallengeAction[]
geolocations Geolocation[]
moneyUpdates MoneyUpdate[]
trips TrainTrip[]
runs PlayerRun[]
}
model Game {
id Int @id @default(autoincrement())
started Boolean @default(false)
currentRun PlayerRun? @relation("CurrentRun", fields: [currentRunId], references: [id], onDelete: SetNull)
currentRunId Int? @unique
runs PlayerRun[] @relation("GameRuns")
}
model PlayerRun {
id Int @id @default(autoincrement())
game Game @relation("GameRuns", fields: [gameId], references: [id], onDelete: Cascade)
runningGame Game? @relation("CurrentRun")
gameId Int
runner Player @relation(fields: [runnerId], references: [id], onDelete: Cascade)
runnerId Int
start DateTime @default(now()) @db.Timestamptz(3)
end DateTime? @db.Timestamptz(3)
moneyUpdate MoneyUpdate?
challengeActions ChallengeAction[]
trains TrainTrip[]
}
model Geolocation {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
playerId Int
longitude Float
latitude Float
speed Float
@ -42,48 +66,54 @@ model Challenge {
model ChallengeAction {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
challenge Challenge @relation(fields: [challengeId], references: [id])
player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int
challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
challengeId Int @unique
active Boolean @default(false)
success Boolean @default(false)
start DateTime @default(now()) @db.Timestamptz(3)
end DateTime? @db.Timestamptz(3)
penaltyStart DateTime? @db.Timestamptz(3)
penaltyEnd DateTime? @db.Timestamptz(3)
moneyUpdate MoneyUpdate?
run PlayerRun @relation(fields: [runId], references: [id], onDelete: Restrict)
runId Int
activePlayer Player? @relation("ActiveChallenge")
moneyUpdate MoneyUpdate?
}
model TrainTrip {
id String @id
user User @relation(fields: [userId], references: [id])
userId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int
distance Float
from String
to String
departureTime DateTime @db.Timestamptz(3)
arrivalTime DateTime @db.Timestamptz(3)
infoJson Json
geometry String
run PlayerRun @relation(fields: [runId], references: [id], onDelete: Restrict)
runId Int
moneyUpdate MoneyUpdate?
}
model MoneyUpdate {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
before Int
after Int
reason MoneyUpdateType
action ChallengeAction? @relation(fields: [actionId], references: [id])
actionId Int? @unique
trip TrainTrip? @relation(fields: [tripId], references: [id])
tripId String? @unique
id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int
amount Int
reason MoneyUpdateType
action ChallengeAction? @relation(fields: [actionId], references: [id], onDelete: Cascade)
actionId Int? @unique
trip TrainTrip? @relation(fields: [tripId], references: [id], onDelete: Cascade)
tripId String? @unique
run PlayerRun? @relation(fields: [runId], references: [id], onDelete: Cascade)
runId Int? @unique
timestamp DateTime @default(now()) @db.Timestamptz(3)
}
enum MoneyUpdateType {
START
WIN_CHALLENGE
NEW_RUN
CHALLENGE
BUY_TRAIN
}

View File

@ -4,20 +4,21 @@ import * as bcrypt from 'bcrypt'
const prisma = new PrismaClient()
async function main() {
const game = await prisma.game.create({ data: {} })
const emmyPassword = await bcrypt.hash("Emmy", 10)
const emmy = await prisma.user.upsert({
const emmy = await prisma.player.upsert({
where: { id: 1 },
update: { name: 'Emmy' },
create: { name: 'Emmy', password: emmyPassword },
})
const taminaPassword = await bcrypt.hash("Tamina", 10)
const tamina = await prisma.user.upsert({
const tamina = await prisma.player.upsert({
where: { id: 2 },
update: { name: 'Tamina' },
create: { name: 'Tamina', password: taminaPassword },
})
console.log({ emmy, tamina })
console.log({ game, emmy, tamina })
}
main()

View File

@ -1,14 +1,18 @@
import { Module } from '@nestjs/common'
import { PrismaService } from './prisma/prisma.service'
import { PrismaModule } from './prisma/prisma.module'
import { UsersModule } from './users/users.module'
import { PlayersModule } from './players/players.module'
import { AuthModule } from './auth/auth.module'
import { GeolocationsModule } from './geolocations/geolocations.module'
import { ChallengesModule } from './challenges/challenges.module'
import { ChallengeActionsModule } from './challenge-actions/challenge-actions.module'
import { TrainsModule } from './trains/trains.module'
import { MoneyUpdatesModule } from './money-updates/money-updates.module'
import { GameModule } from './game/game.module'
import { RunsModule } from './runs/runs.module'
@Module({
imports: [PrismaModule, UsersModule, AuthModule, GeolocationsModule, ChallengesModule, ChallengeActionsModule],
imports: [PrismaModule, AuthModule, PlayersModule, GameModule, RunsModule, GeolocationsModule, ChallengesModule, ChallengeActionsModule, TrainsModule, MoneyUpdatesModule],
providers: [PrismaService],
})
export class AppModule {}

View File

@ -1,17 +1,21 @@
import { Body, Controller, Post } from '@nestjs/common'
import { AuthService } from './auth.service'
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'
import { ApiTags } from '@nestjs/swagger'
import { AuthEntity } from './entity/auth.entity'
import { LoginDto } from './dto/login.dto'
@Controller('auth')
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* Se connecter par nom et mot de passe pour récupérer un jeton de connexion.
*
* @throws {401} Mot de passe incorrect.
*/
@Post('login')
@ApiOkResponse({ type: AuthEntity })
login(@Body() { name, password }: LoginDto) {
return this.authService.login(name, password)
async login(@Body() { name, password }: LoginDto): Promise<AuthEntity> {
return await this.authService.login(name, password)
}
}

View File

@ -5,7 +5,7 @@ import { PrismaModule } from 'src/prisma/prisma.module'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { env } from 'process'
import { UsersModule } from 'src/users/users.module'
import { PlayersModule } from 'src/players/players.module'
import { JwtStrategy } from './jwt.strategy'
export const JWT_SECRET = env.JWT_SECRET
@ -18,7 +18,7 @@ export const JWT_SECRET = env.JWT_SECRET
secret: JWT_SECRET,
signOptions: { expiresIn: '12h' },
}),
UsersModule,
PlayersModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],

View File

@ -9,17 +9,17 @@ export class AuthService {
constructor(private prisma: PrismaService, private jwtService: JwtService) {}
async login(name: string, password: string): Promise<AuthEntity> {
const user = await this.prisma.user.findUnique({ where: { name: name } })
if (!user) {
throw new NotFoundException(`Aucun⋅e utilisateur⋅rice avec pour nom ${name}`)
const player = await this.prisma.player.findUnique({ where: { name: name } })
if (!player) {
throw new NotFoundException(`Aucun⋅e joueur⋅se avec pour nom ${name}`)
}
const isPasswordValid = await bcrypt.compare(password, user.password)
const isPasswordValid = await bcrypt.compare(password, player.password)
if (!isPasswordValid) {
throw new UnauthorizedException('Mot de passe incorrect')
}
return {
accessToken: this.jwtService.sign({ userId: user.id }),
accessToken: this.jwtService.sign({ playerId: player.id }),
}
}
}

View File

@ -1,14 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsNotEmpty, IsString } from 'class-validator'
import { IsNotEmpty } from 'class-validator'
export class LoginDto {
@IsString()
@IsNotEmpty()
@ApiProperty()
name: string
@IsString()
@IsNotEmpty()
@ApiProperty()
password: string
}

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'
export class AuthEntity {
@ApiProperty()
/**
* Jeton d'accès à l'API, valable 12h.
*/
accessToken: string
}

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
import { User } from '@prisma/client'
import { Player } from '@prisma/client'
import { Request } from 'express'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export type AuthenticatedRequest = Request & { user: User }
export type AuthenticatedRequest = Request & { user: Player }

View File

@ -2,23 +2,23 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { JWT_SECRET } from './auth.module'
import { UsersService } from 'src/users/users.service'
import { User } from '@prisma/client'
import { PlayersService } from 'src/players/players.service'
import { Player } from '@prisma/client'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private usersService: UsersService) {
constructor(private playersService: PlayersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET,
})
}
async validate(payload: { userId: number }): Promise<User> {
const user = await this.usersService.findOne(payload.userId)
if (!user) {
async validate(payload: { playerId: number }): Promise<Player> {
const player = await this.playersService.findOne(payload.playerId)
if (!player) {
throw new UnauthorizedException()
}
return user
return player
}
}

View File

@ -1,7 +1,7 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, HttpCode, UseGuards, Req, Query, NotFoundException } from '@nestjs/common'
import { ChallengeActionsService } from './challenge-actions.service'
import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { ApiBearerAuth, ApiNoContentResponse } from '@nestjs/swagger'
import { ChallengeActionEntity } from './entities/challenge-action.entity'
import { CreateChallengeActionDto } from './dto/create-challenge-action.dto'
import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils'
@ -9,42 +9,50 @@ import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto'
import { UpdateChallengeActionDto } from './dto/update-challenge-action.dto'
import { FilterChallengeActionsDto } from './dto/filter-challenge-action.dto'
import { EndChallengeActionDto } from './dto/end-challenge-action.dto'
@Controller('challenge-actions')
export class ChallengeActionsController {
constructor(private readonly challengeActionsService: ChallengeActionsService) {}
/**
* Création d'une action de défi
*
* @throws {400} Erreurs dans le formulaire de création
* @throws {401} Non authentifié⋅e
*/
@Post()
@HttpCode(201)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: ChallengeActionEntity, description: "Objet créé avec succès" })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
async create(@Req() request: AuthenticatedRequest, @Body() createChallengeActionDto: CreateChallengeActionDto): Promise<ChallengeActionEntity> {
const user = request.user
const challenge = await this.challengeActionsService.create(user, createChallengeActionDto)
const challenge = await this.challengeActionsService.create(request.user, createChallengeActionDto)
return new ChallengeActionEntity(challenge)
}
/**
* Recherche d'actions de défi
*
* @throws {401} Non authentifié⋅e
*/
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponsePaginated(ChallengeActionEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
async findAll(@Query() queryPagination: QueryPaginationDto, @Query() filterChallengeActions: FilterChallengeActionsDto): Promise<PaginateOutputDto<ChallengeActionEntity>> {
const [challengeActions, total] = await this.challengeActionsService.findAll(queryPagination, filterChallengeActions)
return paginateOutput<ChallengeActionEntity>(challengeActions.map(challengeAction => new ChallengeActionEntity(challengeAction)), total, queryPagination)
}
/**
* Recherche d'une action de défi par identifiant
*
* @throws {401} Non authentifié⋅e
* @throws {404} Action de défi non trouvée
*/
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeActionEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ChallengeActionEntity> {
const challenge = await this.challengeActionsService.findOne(id)
if (!challenge)
@ -52,26 +60,46 @@ export class ChallengeActionsController {
return new ChallengeActionEntity(challenge)
}
/**
* Modification d'une action de défi par identifiant
*
* @throws {400} Erreurs dans le formulaire de modification
* @throws {401} Non authentifié⋅e
* @throws {404} Action de défi non trouvée
*/
@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeActionEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async update(@Param('id', ParseIntPipe) id: number, @Body() updateChallengeActionDto: UpdateChallengeActionDto) {
async update(@Param('id', ParseIntPipe) id: number, @Body() updateChallengeActionDto: UpdateChallengeActionDto): Promise<ChallengeActionEntity> {
return await this.challengeActionsService.update(id, updateChallengeActionDto)
}
/**
* Suppression d'une action de défi par identifiant
*
* @throws {401} Non authentifié⋅e
* @throws {404} Action de défi non trouvée
*/
@Delete(':id')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeActionEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async remove(@Param('id', ParseIntPipe) id: number) {
@ApiNoContentResponse({ description: "Action de défi supprimée avec succès" })
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.challengeActionsService.remove(id)
}
/**
* Terminer l'action de défi en cours
*
* @throws {401} Non authentifié⋅e
* @throws {409} Aucun défi à terminer n'est en cours
*/
@Post('/end-current')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async endCurrent(@Req() request: AuthenticatedRequest, @Body() { success }: EndChallengeActionDto): Promise<ChallengeActionEntity> {
const challengeAction = await this.challengeActionsService.endCurrentChallenge(request.user, success)
return new ChallengeActionEntity(challengeAction)
}
}

View File

@ -1,29 +1,34 @@
import { Injectable } from '@nestjs/common'
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { CreateChallengeActionDto } from './dto/create-challenge-action.dto'
import { UpdateChallengeActionDto } from './dto/update-challenge-action.dto'
import { ChallengeAction, User } from '@prisma/client'
import { ChallengeAction, MoneyUpdateType, Player } from '@prisma/client'
import { PrismaService } from 'src/prisma/prisma.service'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { paginate } from 'src/common/utils/pagination.utils'
import { FilterChallengeActionsDto } from './dto/filter-challenge-action.dto'
import { Constants } from 'src/common/utils/constants.utils'
@Injectable()
export class ChallengeActionsService {
constructor(private prisma: PrismaService) { }
async create(authenticatedUser: User, createChallengeActionDto: CreateChallengeActionDto): Promise<ChallengeAction> {
const data = { ...createChallengeActionDto, userId: authenticatedUser.id }
async create(authenticatedPlayer: Player, createChallengeActionDto: CreateChallengeActionDto): Promise<ChallengeAction> {
const game = await this.prisma.game.findUnique({ where: { id: 1 } })
const data = { ...createChallengeActionDto, playerId: authenticatedPlayer.id, runId: game.currentRunId }
return await this.prisma.challengeAction.create({
data: data,
})
}
async findAll(queryPagination: QueryPaginationDto, filterChallengeActions: FilterChallengeActionsDto): Promise<[ChallengeAction[], number]> {
console.log(filterChallengeActions)
async findAll(queryPagination: QueryPaginationDto, { playerId, challengeId, success }: FilterChallengeActionsDto): Promise<[ChallengeAction[], number]> {
return [
await this.prisma.challengeAction.findMany({
...paginate(queryPagination),
where: filterChallengeActions,
where: {
playerId: playerId,
challengeId: challengeId,
success: success,
}
}),
await this.prisma.challengeAction.count(),
]
@ -36,7 +41,8 @@ export class ChallengeActionsService {
}
async update(id: number, updateChallengeActionDto: UpdateChallengeActionDto): Promise<ChallengeAction> {
console.log(updateChallengeActionDto)
if (!await this.findOne(id))
throw new NotFoundException(`Aucune action de défi trouvée avec l'identifiant ${id}`)
return await this.prisma.challengeAction.update({
where: { id },
data: updateChallengeActionDto,
@ -44,8 +50,61 @@ export class ChallengeActionsService {
}
async remove(id: number): Promise<ChallengeAction> {
if (!await this.findOne(id))
throw new NotFoundException(`Aucune action de défi trouvée avec l'identifiant ${id}`)
return await this.prisma.challengeAction.delete({
where: { id },
})
}
async endCurrentChallenge(player: Player, success: boolean): Promise<ChallengeAction> {
if (!player.activeChallengeId)
throw new BadRequestException("Aucun défi n'est en cours")
const challengeAction = await this.prisma.challengeAction.findUnique({ where: { id: player.activeChallengeId } })
let data
const now = new Date()
if (success) {
data = {
success: success,
end: now,
}
// Crédit du nombre de points remportés grâce au défi
const challenge = await this.prisma.challenge.findUnique({ where: { id: challengeAction.challengeId } })
await this.prisma.moneyUpdate.create({
data: {
playerId: player.id,
amount: challenge.reward,
reason: MoneyUpdateType.CHALLENGE,
actionId: challengeAction.id,
}
})
}
else {
data = {
success: success,
end: now,
penaltyStart: now,
penaltyEnd: new Date(now.getTime() + Constants.PENALTY_TIME * 60 * 1000),
}
await this.prisma.moneyUpdate.create({
data: {
playerId: player.id,
amount: 0,
reason: MoneyUpdateType.CHALLENGE,
actionId: challengeAction.id,
}
})
}
await this.prisma.player.update({
where: { id: player.id },
data: { activeChallengeId: null },
})
return await this.prisma.challengeAction.update({
where: {
id: challengeAction.id,
},
data: data,
})
}
}

View File

@ -9,12 +9,6 @@ export class CreateChallengeActionDto {
@ApiProperty({ description: "Identifiant du défi rattaché à l'action" })
challengeId: number
@IsOptional()
@IsBoolean()
@BooleanTransform()
@ApiProperty({ description: "Est-ce que le défi est actuellement en train d'être réalisé", default: true })
active: boolean = true
@IsOptional()
@IsBoolean()
@BooleanTransform()

View File

@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger"
import { IsBoolean } from "class-validator"
import { BooleanTransform } from "src/common/utils/transform.utils"
export class EndChallengeActionDto {
@IsBoolean()
@BooleanTransform()
@ApiProperty({ description: "Indique si le défi a été un succès ou non." })
success: boolean
}

View File

@ -7,8 +7,8 @@ export class FilterChallengeActionsDto {
@IsOptional()
@IsInt()
@Type(() => Number)
@ApiProperty({ description: "Identifiant de l'utilisateur⋅rice qui effectue le défi", required: false })
userId?: number
@ApiProperty({ description: "Identifiant de læ joueur⋅se qui effectue le défi", required: false })
playerId?: number
@IsOptional()
@IsInt()
@ -16,12 +16,6 @@ export class FilterChallengeActionsDto {
@ApiProperty({ description: "Identifiant du défi attaché à cette action", required: false })
challengeId?: number
@IsOptional()
@IsBoolean()
@BooleanTransform()
@ApiProperty({ description: "Défi en train d'être accompli", required: false })
active?: boolean
@IsOptional()
@IsBoolean()
@BooleanTransform()

View File

@ -2,38 +2,55 @@ import { ApiProperty } from "@nestjs/swagger"
import { ChallengeAction } from "@prisma/client"
import { IsOptional } from "class-validator"
export default class ChallengeActionEntity implements ChallengeAction {
export class ChallengeActionEntity implements ChallengeAction {
constructor(partial: Partial<ChallengeActionEntity>) {
Object.assign(this, partial)
}
@ApiProperty({ description: "Identifiant unique" })
/**
* Identifiant unique
*/
id: number
@ApiProperty({ description: "Identifiant de l'utilisateur⋅rice effectuant le défi" })
userId: number
/**
* Identifiant de læ joueur⋅se effectuant le défi
*/
playerId: number
@ApiProperty({ description: "Identifiant du défi rattaché à l'action" })
/**
* Identifiant du défi rattaché à l'action
*/
challengeId: number
@ApiProperty({ description: "Est-ce que le défi est actuellement en train d'être réalisé" })
active: boolean
@ApiProperty({ description: "Est-ce que le défi a été réussi" })
/**
* Est-ce que le défi a été réussi
*/
success: boolean
@ApiProperty({ description: "Heure à laquelle le défi a été démarré" })
/**
* Heure à laquelle le défi a été démarré
*/
start: Date
/**
* Heure à laquelle le défi a été terminé
*/
@IsOptional()
@ApiProperty({ description: "Heure à laquelle le défi a été terminé", required: false, nullable: true })
end: Date
end: Date | null = null
@IsOptional()
@ApiProperty({ description: "Heure à laquelle la pénalité a commencé, si applicable", required: false, nullable: true })
penaltyStart: Date
/**
* Heure à laquelle la pénalité a commencé, si applicable
*/
penaltyStart: Date | null = null
/**
* Heure à laquelle la pénalité s'est terminée, si applicable
*/
@IsOptional()
@ApiProperty({ description: "Heure à laquelle la pénalité s'est terminée, si applicable", required: false, nullable: true })
penaltyEnd: Date
penaltyEnd: Date | null = null
/**
* Identifiant de la course pendant laquelle le challenge est réalisé
*/
runId: number
}

View File

@ -3,7 +3,7 @@ import { ChallengesService } from './challenges.service'
import { CreateChallengeDto } from './dto/create-challenge.dto'
import { UpdateChallengeDto } from './dto/update-challenge.dto'
import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { ApiBearerAuth, ApiNoContentResponse } from '@nestjs/swagger'
import { ChallengeEntity } from './entities/challenge.entity'
import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
@ -13,36 +13,44 @@ import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto'
export class ChallengesController {
constructor(private readonly challengesService: ChallengesService) {}
/**
* Création d'un défi
*
* @throws {400} Erreurs dans le formulaire de création
* @throws {401} Non authentifié⋅e
*/
@Post()
@HttpCode(201)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: ChallengeEntity, description: "Objet créé avec succès" })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
async create(@Body() createChallengeDto: CreateChallengeDto): Promise<ChallengeEntity> {
const challenge = await this.challengesService.create(createChallengeDto)
return new ChallengeEntity(challenge)
}
/**
* Recherche de défis
*
* @throws {401} Non authentifié⋅e
*/
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponsePaginated(ChallengeEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<ChallengeEntity>> {
const [challenges, total] = await this.challengesService.findAll(queryPagination)
return paginateOutput<ChallengeEntity>(challenges.map(challenge => new ChallengeEntity(challenge)), total, queryPagination)
}
/**
* Recherche d'un défi par identifiant
*
* @throws {401} Non authentifié⋅e
* @throws {404} Défi non trouvé
*/
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ChallengeEntity> {
const challenge = await this.challengesService.findOne(id)
if (!challenge)
@ -50,36 +58,47 @@ export class ChallengesController {
return new ChallengeEntity(challenge)
}
/**
* Modification d'un défi par identifiant
*
* @throws {400} Erreurs dans le formulaire de modification
* @throws {401} Non authentifié⋅e
* @throws {404} Défi non trouvé
*/
@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async update(@Param('id', ParseIntPipe) id: number, @Body() updateChallengeDto: UpdateChallengeDto) {
return await this.challengesService.update(id, updateChallengeDto)
}
/**
* Suppression d'un défi par identifiant
*
* @throws {401} Non authentifié⋅e
* @throws {404} Défi non trouvé
*/
@Delete(':id')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
@ApiNoContentResponse({ description: "Le défi a bien été supprimé" })
async remove(@Param('id', ParseIntPipe) id: number) {
await this.challengesService.remove(id)
}
/**
* Tirage d'un nouveau défi aléatoire
*
* @remarks Aucun défi ne doit être en cours.
*
* @throws {401} Non authentifié⋅e
* @throws {404} Plus aucun défi n'est disponible
* @throws {409} Un défi est déjà en cours d'accomplissement, ou bien vous n'êtes pas en course
*/
@Post('/draw-random')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: ChallengeEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async drawRandom(@Req() request: AuthenticatedRequest): Promise<ChallengeEntity> {
const challenge = await this.challengesService.drawRandom(request.user)
return new ChallengeEntity(challenge)

View File

@ -1,7 +1,7 @@
import { Injectable, NotAcceptableException, NotFoundException } from '@nestjs/common'
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CreateChallengeDto } from './dto/create-challenge.dto'
import { UpdateChallengeDto } from './dto/update-challenge.dto'
import { Challenge, User } from '@prisma/client'
import { Challenge, Player } from '@prisma/client'
import { PrismaService } from 'src/prisma/prisma.service'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { paginate } from 'src/common/utils/pagination.utils'
@ -13,6 +13,8 @@ export class ChallengesService {
async create(createChallengeDto: CreateChallengeDto): Promise<Challenge> {
const data = { ...createChallengeDto }
if (await this.prisma.challenge.findFirst({ where: { title: createChallengeDto.title } }))
throw new BadRequestException(`Un défi existe déjà avec pour titre « ${createChallengeDto.title} ».`)
return await this.prisma.challenge.create({ data: data })
}
@ -38,6 +40,10 @@ export class ChallengesService {
}
async update(id: number, updateChallengeDto: UpdateChallengeDto): Promise<Challenge> {
if (!await this.findOne(id))
throw new NotFoundException(`Aucun défi n'existe avec l'identifiant ${id}`)
if (await this.prisma.challenge.findFirst({ where: { title: updateChallengeDto.title, id: { not: id } } }))
throw new BadRequestException(`Un défi existe déjà avec pour titre « ${updateChallengeDto.title} ».`)
return await this.prisma.challenge.update({
where: { id },
data: updateChallengeDto,
@ -48,6 +54,8 @@ export class ChallengesService {
}
async remove(id: number): Promise<Challenge> {
if (!await this.findOne(id))
throw new NotFoundException(`Aucun défi n'existe avec l'identifiant ${id}`)
return await this.prisma.challenge.delete({
where: { id },
include: {
@ -56,15 +64,12 @@ export class ChallengesService {
})
}
async drawRandom(user: User): Promise<ChallengeEntity> {
const currentChallengeAction = await this.prisma.challengeAction.findFirst({
where: {
userId: user.id,
active: true,
}
})
if (currentChallengeAction)
throw new NotAcceptableException("Un défi est déjà en cours d'accomplissement")
async drawRandom(player: Player): Promise<ChallengeEntity> {
const game = await this.prisma.game.findUnique({ where: { id: 1 }, include: { currentRun: true } })
if (game.currentRun?.runnerId !== player.id)
throw new ConflictException("Vous n'êtes pas en course, ce n'est pas à vous de tirer un défi.")
if (player.activeChallengeId)
throw new ConflictException("Un défi est déjà en cours d'accomplissement")
const remaningChallenges = await this.prisma.challenge.count({
where: {
action: null,
@ -82,12 +87,16 @@ export class ChallengesService {
const challengeEntity: ChallengeEntity = new ChallengeEntity(challenge)
const action = await this.prisma.challengeAction.create({
data: {
userId: user.id,
playerId: player.id,
challengeId: challenge.id,
active: true,
runId: game.currentRunId,
success: false,
}
})
await this.prisma.player.update({
where: { id: player.id },
data: { activeChallengeId: action.id },
})
challengeEntity.action = action
return challengeEntity
}

View File

@ -1,9 +1,9 @@
import { Type } from "class-transformer"
import { IsNumber, IsOptional } from "class-validator"
export class UserFilterDto {
export class PlayerFilterDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
userId?: number
playerId?: number
}

View File

@ -0,0 +1,23 @@
const EARTH_RADIUS = 6378137
type Coordinates = {
latitude: number
longitude: number
}
type UseDistanceTypes = {
from: Coordinates
to: Coordinates
}
export function toRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
export function distanceCoordinates({ from, to }: UseDistanceTypes) {
const distance = EARTH_RADIUS * Math.acos(
Math.sin(toRadians(to.latitude)) * Math.sin(toRadians(from.latitude)) +
Math.cos(toRadians(to.latitude)) * Math.cos(toRadians(from.latitude)) * Math.cos(toRadians(from.longitude) - toRadians(to.longitude)),
)
return distance
}

View File

@ -0,0 +1,26 @@
export const Constants = {
/**
* Nombre de points attribués au début de la partie pour la première joueuse
*/
INITIAL_MONEY_1ST_PLAYER: 1500,
/**
* Nombre de points attribués au début de la partie pour la première joueuse
*/
INITIAL_MONEY_2ND_PLAYER: 1000,
/**
* Nombre de points attribués lors d'une nouvelle tentative
*/
NEW_RUN_MONEY: 300,
/**
* Nombre de points requis pour l'achat d'un train par kilomètre (arrondi au supérieur)
*/
PRICE_PER_KILOMETER: 10,
/**
* Temps de pénalité en minutes en cas d'échec d'un défi
*/
PENALTY_TIME: 30,
}

Some files were not shown because too many files have changed in this diff Show More