Compare commits

..

59 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
66 changed files with 2888 additions and 1322 deletions

View File

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

View File

@ -1 +1,2 @@
EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER=http://localhost:3000/ 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/ web-build/
expo-env.d.ts expo-env.d.ts
# Android output
*.aab
*.apk
*.apks
# Native # Native
*.orig.* *.orig.*
*.jks *.jks

View File

@ -2,29 +2,23 @@
"expo": { "expo": {
"name": "Traintrape-moi", "name": "Traintrape-moi",
"slug": "traintrape-moi-client", "slug": "traintrape-moi-client",
"version": "1.0.0", "version": "1.1.1",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "myapp", "scheme": "traintrapemoi",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"bundleIdentifier": "traintrapemoi",
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#0033fe"
}, },
"package": "eu.luemy.traintrapemoi", "package": "eu.luemy.traintrapemoi",
"permissions": [ "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",
"android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_BACKGROUND_LOCATION", "android.permission.ACCESS_BACKGROUND_LOCATION",
@ -40,7 +34,6 @@
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-background-fetch",
[ [
"expo-location", "expo-location",
{ {
@ -49,16 +42,16 @@
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location." "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
} }
], ],
"expo-notifications",
"expo-router", "expo-router",
"expo-secure-store", "expo-secure-store",
"expo-share-intent",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splash-icon.png",
"imageWidth": 200, "imageWidth": 128,
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#0033fe"
} }
], ],
"expo-task-manager", "expo-task-manager",

View File

@ -25,7 +25,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="challenges" name="challenges"
options={{ options={{
title: 'Défis', title: 'Défi en cours',
headerShown: false, headerShown: false,
tabBarIcon: ({ color }) => <FontAwesome6 name="coins" size={24} color={color} />, tabBarIcon: ({ color }) => <FontAwesome6 name="coins" size={24} color={color} />,
}} }}

View File

@ -5,17 +5,19 @@ import { useAuth } from '@/hooks/useAuth'
import { useChallengeActions } from '@/hooks/useChallengeActions' import { useChallengeActions } from '@/hooks/useChallengeActions'
import { useChallenges } from '@/hooks/useChallenges' import { useChallenges } from '@/hooks/useChallenges'
import { useGame } from '@/hooks/useGame' import { useGame } from '@/hooks/useGame'
import { FontAwesome6 } from '@expo/vector-icons' import { FontAwesome6, MaterialCommunityIcons } from '@expo/vector-icons'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'expo-router'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { View } from 'react-native' import { View } from 'react-native'
import { ActivityIndicator, Appbar, Banner, FAB, MD3Colors, Snackbar, Surface, Text, TouchableRipple } from 'react-native-paper' import { ActivityIndicator, Appbar, Banner, FAB, MD3Colors, Snackbar, Surface, Text, TouchableRipple } from 'react-native-paper'
function ChallengeScreenHeader() { function ChallengeScreenHeader() {
const router = useRouter()
return <> return <>
<Appbar.Header> <Appbar.Header>
<Appbar.Content title={"Défis"} /> <Appbar.Content title={"Défi en cours"} />
<Appbar.Action icon='format-list-bulleted' /> <Appbar.Action icon='format-list-bulleted' onPress={() => router.navigate('/challenges-list')} />
</Appbar.Header> </Appbar.Header>
<PenaltyBanner /> <PenaltyBanner />
</> </>
@ -30,22 +32,31 @@ function ChallengeScreenBody() {
const currentChallengeAction = useMemo(() => { const currentChallengeAction = useMemo(() => {
if (!game.activeChallengeId) if (!game.activeChallengeId)
return null return null
return challengeActions.challengeActions.find((action) => action.id === game.activeChallengeId) return challengeActions.find((action) => action.id === game.activeChallengeId)
}, [game, challengeActions]) }, [game, challengeActions])
const currentChallenge = useMemo(() => { const currentChallenge = useMemo(() => {
if (!currentChallengeAction) if (!currentChallengeAction)
return null return null
return challenges.challenges.find((challenge) => challenge.id === currentChallengeAction.challengeId) return challenges.find((challenge) => challenge.id === currentChallengeAction.challengeId)
}, [currentChallengeAction, challenges]) }, [currentChallengeAction, challenges])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false) const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false)
const [errorVisible, setErrorVisible] = useState(false)
const [error, setError] = useState([200, ""])
const drawRandomChallengeMutation = useDrawRandomChallengeMutation({ const drawRandomChallengeMutation = useDrawRandomChallengeMutation({
auth, auth,
onPostSuccess: () => { onPostSuccess: () => {
setLoading(true) setLoading(true)
setSuccessSnackbarVisible(true) setSuccessSnackbarVisible(true)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' || query.queryKey[0] === 'get-player' }) 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({ const endChallenge = useEndChallenge({
auth, auth,
@ -54,6 +65,13 @@ function ChallengeScreenBody() {
setSuccessSnackbarVisible(true) setSuccessSnackbarVisible(true)
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'get-challenges' || query.queryKey[0] === 'get-player' }) 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(() => { useEffect(() => {
if (challengeActions) if (challengeActions)
@ -69,13 +87,15 @@ function ChallengeScreenBody() {
<ChallengeCard <ChallengeCard
challenge={currentChallenge} challenge={currentChallenge}
onSuccess={() => { setLoading(true); endChallenge.mutate({ success: true }) }} onSuccess={() => { setLoading(true); endChallenge.mutate({ success: true }) }}
onFail={() => endChallenge.mutate({ success: false })} />} onFail={() => endChallenge.mutate({ success: false })}
style={{ flex: 1, margin: 20 }} />}
{!loading && !game.penaltyEnd && !currentChallenge && game.currentRunner && <> {!loading && !game.penaltyEnd && !currentChallenge && game.currentRunner && <>
<Banner <Banner
elevation={4}
visible={!currentChallenge && game.currentRunner && !loading} visible={!currentChallenge && game.currentRunner && !loading}
icon='cancel' icon='vanish'>
style={{ backgroundColor: MD3Colors.error40 }}> Aucun défi n'est en cours. Veuillez tirer un défi en cliquant sur le bouton central.
Aucun défi n'est en cours. Pour rappel, il faut être hors d'un train pour tirer un défi.
</Banner> </Banner>
<View style={{ flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}> <View style={{ flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<FAB <FAB
@ -95,11 +115,23 @@ function ChallengeScreenBody() {
Vous êtes poursuiveuse, et n'avez donc pas de défi à accomplir. Vous êtes poursuiveuse, et n'avez donc pas de défi à accomplir.
</Banner> </Banner>
<Snackbar <Snackbar
key='success-snackbar'
visible={successSnackbarVisible} visible={successSnackbarVisible}
icon={'close'}
onDismiss={() => setSuccessSnackbarVisible(false)} onDismiss={() => setSuccessSnackbarVisible(false)}
action={{ label: "Fermer", onPress: () => setSuccessSnackbarVisible(false) }}> onIconPress={() => setSuccessSnackbarVisible(false)}>
Jeu actualisé Jeu actualisé
</Snackbar> </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>
</> </>
} }

View File

@ -1,9 +1,190 @@
import { Surface, Text } from 'react-native-paper' 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() { 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 ( return (
<Surface> <Surface style={{ flex :1 }}>
<Text>Ici on aura la gestion de l'historique des trains empruntés et des challenges effectués</Text> <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> </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

@ -5,6 +5,8 @@ import Map from '@/components/Map'
import { FAB, Surface, Text } from 'react-native-paper' import { FAB, Surface, Text } from 'react-native-paper'
import { useGame } from '@/hooks/useGame' import { useGame } from '@/hooks/useGame'
import { FontAwesome6 } from '@expo/vector-icons' import { FontAwesome6 } from '@expo/vector-icons'
import FreeChaseBanner from '@/components/FreeChaseBanner'
import { View } from 'react-native'
export default function MapScreen() { export default function MapScreen() {
const [backgroundStatus, requestBackgroundPermission] = useBackgroundPermissions() const [backgroundStatus, requestBackgroundPermission] = useBackgroundPermissions()
@ -15,20 +17,25 @@ export default function MapScreen() {
return ( return (
<Surface style={styles.page}> <Surface style={styles.page}>
{backgroundStatus?.granted ? <Map /> : <Text>La géolocalisation est requise pour utiliser la carte.</Text>} <View style={styles.container}>
<FAB {backgroundStatus?.granted ? <Map /> : <Text>La géolocalisation est requise pour utiliser la carte.</Text>}
style={styles.moneyBadge} <FAB
visible={game.gameStarted || game.money > 0} style={styles.moneyBadge}
icon={(props) => <FontAwesome6 {...props} name='coins' size={20} />} visible={game.gameStarted || game.money > 0}
color='black' icon={(props) => <FontAwesome6 {...props} name='coins' size={20} />}
label={`${game.money}`} /> color='black'
<FAB label={`${game.money}`}
style={styles.statusBadge} onPress={() => {}} />
visible={game.gameStarted || game.money > 0} <FAB
size='small' style={styles.statusBadge}
color='black' visible={game.gameStarted || game.money > 0}
icon={game.currentRunner ? 'run-fast' : () => <FontAwesome6 name='cat' size={20} />} size='small'
label={game.currentRunner ? "Coureuse" : "Poursuiveuse"} /> color='black'
icon={game.currentRunner ? 'run-fast' : () => <FontAwesome6 name='cat' size={20} />}
label={game.currentRunner ? "Coureuse" : "Poursuiveuse"}
onPress={() => {}} />
</View>
<FreeChaseBanner />
</Surface> </Surface>
) )
} }
@ -36,8 +43,11 @@ export default function MapScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
flex: 1, flex: 1,
},
container: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center'
}, },
map: { map: {
flex: 1, flex: 1,

View File

@ -1,35 +1,116 @@
import { useGameRepairMutation, useGameResetMutation, useGameStartMutation, useGameStopMutation, useGameSwitchPlayerMutation } from '@/hooks/mutations/useGameMutation' import { useGameRepairMutation, useGameResetMutation, useGameStartMutation, useGameStopMutation, useGameSwitchPlayerMutation } from '@/hooks/mutations/useGameMutation'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useGame, useUpdateGameState } from '@/hooks/useGame' import { useGame, useSetLocationAccuracy, useUpdateGameState } from '@/hooks/useGame'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import { useState } from 'react' import { useState } from 'react'
import { Button, Dialog, FAB, List, Portal, Surface, Text } from 'react-native-paper' 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() { 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 router = useRouter()
const queryClient = useQueryClient()
const auth = useAuth() const auth = useAuth()
const game = useGame() const game = useGame()
const updateGameState = useUpdateGameState() 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({ const gameStartMutation = useGameStartMutation({
auth, auth,
updateGameState, 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({ const gameStopMutation = useGameStopMutation({
auth, auth,
updateGameState, 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({ const gameSwitchMutation = useGameSwitchPlayerMutation({
auth, auth,
updateGameState, 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({ const gameRepairMutation = useGameRepairMutation({
auth, auth,
updateGameState, 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({ const gameResetMutation = useGameResetMutation({
auth, auth,
updateGameState, 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) const [resetConfirmVisible, setResetConfirmVisible] = useState(false)
@ -42,28 +123,33 @@ export default function HistoryScreen() {
key={"login"} key={"login"}
title="Connexion au serveur" title="Connexion au serveur"
description={auth.loggedIn ? "Vous êtes déjà connecté⋅e" : "Vous n'êtes pas connecté⋅e"} description={auth.loggedIn ? "Vous êtes déjà connecté⋅e" : "Vous n'êtes pas connecté⋅e"}
right={() => <FAB icon="login" size="small" onPress={() => router.navigate('/login')} />} right={() => <List.Icon icon="login" />}
onPress={() => router.navigate('/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>
<List.Section title={"Gestion du jeu"}> <List.Section title={"Gestion du jeu"}>
<List.Item <List.Item
key={"start"} key={"start"}
title="Démarrer le jeu" title="Démarrer le jeu"
disabled={game.gameStarted} disabled={game.gameStarted}
right={() => <FAB icon="play" size="small" disabled={game.gameStarted} />} right={() => <List.Icon icon="play" color={!game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameStartMutation.mutate()} /> onPress={() => gameStartMutation.mutate()} />
<List.Item <List.Item
key={"stop"} key={"stop"}
title="Arrêter le jeu" title="Arrêter le jeu"
disabled={!game.gameStarted} disabled={!game.gameStarted}
right={() => <FAB icon="stop" size="small" disabled={!game.gameStarted} />} right={() => <List.Icon icon="stop" color={game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameStopMutation.mutate()} /> onPress={() => gameStopMutation.mutate()} />
<List.Item <List.Item
key={"switch"} key={"switch"}
title="Changer de joueur⋅se en course" title="Changer de joueur⋅se en course"
description="À utiliser après une capture" description="À utiliser après une capture"
disabled={!game.gameStarted} disabled={!game.gameStarted}
right={() => <FAB icon="exit-run" size="small" disabled={!game.gameStarted} />} right={() => <List.Icon icon="exit-run" color={game.gameStarted ? undefined : MD3Colors.secondary30} />}
onPress={() => gameSwitchMutation.mutate()} /> onPress={() => gameSwitchMutation.mutate()} />
</List.Section> </List.Section>
<List.Section title={"Avancé"}> <List.Section title={"Avancé"}>
@ -71,15 +157,35 @@ export default function HistoryScreen() {
key={"repair"} key={"repair"}
title="Réparer" 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." 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={() => <FAB icon="reload-alert" size="small" variant={'tertiary'} />} right={() => <List.Icon icon='tools' color={MD3Colors.error60} />}
onPress={() => gameRepairMutation.mutate()} /> onPress={() => gameRepairMutation.mutate()} />
<List.Item <List.Item
key={"reset"} key={"reset"}
title="Réinitialiser les données de jeu" title="Réinitialiser les données de jeu"
description="Permet de détruire toutes les données. À manipuler avec précaution." description="Permet de détruire toutes les données. À manipuler avec précaution."
right={() => <FAB icon="reload-alert" size="small" variant={'tertiary'} />} right={() => <List.Icon icon="reload-alert" color={MD3Colors.error60} />}
onPress={() => setResetConfirmVisible(true)} /> onPress={() => setResetConfirmVisible(true)} />
</List.Section> </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> <Portal>
<Dialog key="confirmReset" visible={resetConfirmVisible} onDismiss={() => setResetConfirmVisible(false)}> <Dialog key="confirmReset" visible={resetConfirmVisible} onDismiss={() => setResetConfirmVisible(false)}>
<Dialog.Title>Confirmer</Dialog.Title> <Dialog.Title>Confirmer</Dialog.Title>

View File

@ -4,28 +4,63 @@ import { useAuth } from '@/hooks/useAuth'
import { useTrain } from '@/hooks/useTrain' import { useTrain } from '@/hooks/useTrain'
import { TrainTrip } from '@/utils/features/train/trainSlice' import { TrainTrip } from '@/utils/features/train/trainSlice'
import { FontAwesome6 } from '@expo/vector-icons' import { FontAwesome6 } from '@expo/vector-icons'
import { useMemo, useState } from 'react' 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 { FlatList, StyleSheet } from 'react-native'
import { Button, Dialog, Divider, FAB, HelperText, List, Portal, Surface, Text, TextInput } from 'react-native-paper' import { Button, Dialog, Divider, FAB, HelperText, List, MD3Colors, Portal, Snackbar, Surface, Text, TextInput } from 'react-native-paper'
export default function TrainScreen() { export default function TrainScreen() {
const [addTrainVisible, setAddTrainVisible] = useState(false) const [addTrainVisible, setAddTrainVisible] = useState(false)
const [addTrainUrl, setAddTrainUrl] = useState("") 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 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 auth = useAuth()
const queryClient = useQueryClient()
const addTrainMutation = useAddTrainMutation({ const addTrainMutation = useAddTrainMutation({
auth, auth,
onPostSuccess: () => setAddTrainVisible(false) 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 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 ( return (
<Surface style={{ flex: 1 }}> <Surface style={{ flex: 1 }}>
<PenaltyBanner /> <PenaltyBanner />
<FlatList <FlatList
data={trains.trains} data={trains}
keyExtractor={(train) => train.id} keyExtractor={(train) => train.id}
ItemSeparatorComponent={() => <Divider />} ItemSeparatorComponent={() => <Divider />}
renderItem={(item) => <TrainListItem train={item.item} />} /> renderItem={(item) => <TrainListItem train={item.item} />} />
@ -33,6 +68,24 @@ export default function TrainScreen() {
icon='plus' icon='plus'
style={styles.addTrainButton} style={styles.addTrainButton}
onPress={() => setAddTrainVisible(true)} /> 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> <Portal>
<Dialog visible={addTrainVisible} onDismiss={() => setAddTrainVisible(false)}> <Dialog visible={addTrainVisible} onDismiss={() => setAddTrainVisible(false)}>
<Dialog.Title>Ajout d'un train</Dialog.Title> <Dialog.Title>Ajout d'un train</Dialog.Title>

View File

@ -1,21 +1,20 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack, useNavigationContainerRef } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { Provider as StoreProvider } from 'react-redux'
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { useReactNavigationDevTools } from '@dev-plugins/react-navigation' import { useReactNavigationDevTools } from '@dev-plugins/react-navigation'
import { useReactQueryDevTools } from '@dev-plugins/react-query' 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 { 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 { 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 { useColorScheme } from '@/hooks/useColorScheme'
import store from '@/utils/store' import store from '@/utils/store'
import { useStartBackgroundFetchServiceEffect } from '@/utils/background'
import LoginProvider from '@/components/LoginProvider'
import GeolocationProvider from '@/components/GeolocationProvider'
import GameProvider from '@/components/GameProvider'
import { FontAwesome6 } from '@expo/vector-icons'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -28,7 +27,6 @@ const queryClient = new QueryClient({
}) })
export default function RootLayout() { export default function RootLayout() {
useStartBackgroundFetchServiceEffect()
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const navigationRef = useNavigationContainerRef() const navigationRef = useNavigationContainerRef()
@ -45,26 +43,26 @@ export default function RootLayout() {
client={queryClient} client={queryClient}
persistOptions={{ persister: asyncStoragePersister }} persistOptions={{ persister: asyncStoragePersister }}
onSuccess={() => queryClient.resumePausedMutations().then(() => queryClient.invalidateQueries())}> onSuccess={() => queryClient.resumePausedMutations().then(() => queryClient.invalidateQueries())}>
<LoginProvider loginRedirect={'/login'}> <ShareIntentProvider>
<GeolocationProvider> <LoginProvider loginRedirect={'/login'}>
<GameProvider> <GeolocationProvider>
<PaperProvider <GameProvider>
theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme} <PaperProvider
settings={{ theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme} >
// icon: (props) => <FontAwesome6 {...props} /> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
}}> <Stack>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack> <Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="challenges-list" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="+not-found" />
<Stack.Screen name="+not-found" /> </Stack>
</Stack> <StatusBar style="auto" />
<StatusBar style="auto" /> </ThemeProvider>
</ThemeProvider> </PaperProvider>
</PaperProvider> </GameProvider>
</GameProvider> </GeolocationProvider>
</GeolocationProvider> </LoginProvider>
</LoginProvider> </ShareIntentProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
</StoreProvider> </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,
}
})

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

View File

@ -2,10 +2,5 @@ module.exports = function(api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'], presets: ['babel-preset-expo'],
env: {
production: {
plugins: ['react-native-paper/babel'],
},
},
} }
} }

View File

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

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

@ -1,33 +0,0 @@
import { ReactNode, useEffect } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { useQueuedLocations, useUnqueueLocation } from '@/hooks/useLocation'
import { useGeolocationMutation } from '@/hooks/mutations/useGeolocationMutation'
import { useStartGeolocationServiceEffect } from '@/utils/geolocation'
import { Platform } from 'react-native'
export default function GeolocationProvider({ children }: { children: ReactNode }) {
useStartGeolocationServiceEffect()
const auth = useAuth()
const geolocationsQueue = useQueuedLocations()
const unqueueLocation = useUnqueueLocation()
const geolocationMutation = useGeolocationMutation({
auth,
onPostSuccess: ({ data, variables: location }) => {
unqueueLocation(location)
geolocationMutation.reset()
},
onError: ({ response, error }) => { console.error(response, error) }
})
useEffect(() => {
if (geolocationsQueue.length === 0 || geolocationMutation.isPending || Platform.OS === "web")
return
const locToSend = geolocationsQueue[0]
geolocationMutation.mutate(locToSend)
}, [auth, geolocationsQueue])
return <>
{children}
</>
}

View File

@ -1,39 +1,182 @@
import { StyleSheet } from 'react-native' import { FontAwesome5, MaterialIcons } from '@expo/vector-icons'
import MapLibreGL, { Camera, FillLayer, LineLayer, MapView, PointAnnotation, RasterLayer, RasterSource, ShapeSource, UserLocation } from '@maplibre/maplibre-react-native' import MapLibreGL, { Camera, FillLayer, LineLayer, MapView, PointAnnotation, RasterLayer, RasterSource, ShapeSource, UserLocation, UserTrackingMode } from '@maplibre/maplibre-react-native'
import { FontAwesome5 } from '@expo/vector-icons' import { useQuery } from '@tanstack/react-query'
import { circle } from '@turf/circle' import { circle } from '@turf/circle'
import { useLastOwnLocation } 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() { export default function Map() {
const userLocation = useLastOwnLocation() 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) 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 ( return (
<MapView <MapView
logoEnabled={false} logoEnabled={false}
compassViewPosition={2}
style={styles.map} style={styles.map}
styleURL="https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json"> 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 {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> <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}} /> <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 */} <UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} />
<ShapeSource id="accuracy-radius" shape={accuracyCircle} /> <PlayerLocationsMarkers setDisplayedPlayerId={setDisplayedPlayerId} />
<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} /> */}
</MapView> </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({ const styles = StyleSheet.create({
map: { map: {
flex: 1, flex: 1,
alignSelf: 'stretch', 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,10 +1,10 @@
import { useAuth } from "@/hooks/useAuth" import { useGame } from "@/hooks/useGame"
import { useLastOwnLocation } from "@/hooks/useLocation" import { useLastOwnLocation, useLastPlayerLocations } from "@/hooks/useLocation"
import { useQuery } from "@tanstack/react-query" import { PlayerLocation } from "@/utils/features/location/locationSlice"
import { circle } from "@turf/circle" import { circle } from "@turf/circle"
import { type Map as MaplibreGLMap } from "maplibre-gl" import { type Map as MaplibreGLMap } from "maplibre-gl"
import { RLayer, RMap, RMarker, RNavigationControl, RSource, useMap } from "maplibre-react-components" import { RLayer, RMap, RMarker, RNavigationControl, RSource, useMap } from "maplibre-react-components"
import { useEffect, useMemo, useState } from "react" import { useMemo, useState } from "react"
export default function Map() { export default function Map() {
return ( return (
@ -18,7 +18,7 @@ export default function Map() {
<RLayer id="railwaymap-layer" type="raster" source="railwaymap-source" paint={{"raster-opacity": 0.7}} /> <RLayer id="railwaymap-layer" type="raster" source="railwaymap-source" paint={{"raster-opacity": 0.7}} />
<UserLocation /> <UserLocation />
<DownloadedLocation /> <PlayerLocationsMarkers />
</RMap> </RMap>
) )
} }
@ -34,43 +34,44 @@ function UserLocation() {
const accuracyCircle = circle([userLocation?.coords.longitude ?? 0, userLocation?.coords.latitude ?? 0], userLocation?.coords.accuracy ?? 0, {steps: 64, units: 'meters'}) 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} /> : <></> const marker = userLocation ? <RMarker longitude={userLocation?.coords.longitude} latitude={userLocation?.coords.latitude} /> : <></>
return <> return <>
<RSource id="accuracy-radius" type="geojson" data={accuracyCircle} /> <RSource id={'accuracy-radius-own'} 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-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" paint={{"line-color": "blue", "line-opacity": 0.4}} /> <RLayer id={'accuracy-radius-border'} type="line" source='accuracy-radius-own' paint={{"line-color": "blue", "line-opacity": 0.4}} />
{marker} {marker}
</> </>
} }
function DownloadedLocation() { function PlayerLocationsMarkers() {
const auth = useAuth() const game = useGame()
const query = useQuery({ const lastPlayerLocations = useLastPlayerLocations()
queryKey: ['get-last-locations'], return lastPlayerLocations
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/geolocations/last-locations/`, { // .filter(playerLoc => playerLoc.playerId !== game.playerId)
method: "GET", .map(playerLoc => <PlayerLocationMarker key={`player-${playerLoc.playerId}-loc`} playerLocation={playerLoc} />)
headers: { }
"Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json", function PlayerLocationMarker({ playerLocation }: { playerLocation: PlayerLocation }) {
}}
).then(resp => resp.json()),
})
useEffect(() => {
const interval = setInterval(() => query.refetch(), 5000)
return () => clearInterval(interval)
}, [])
console.log(query.data)
const userLocation = query.isSuccess ? query.data[0] : { longitude: 0, latitude: 0, accuracy: 0 }
const [firstUserPositionFetched, setFirstUserPositionFetched] = useState(false)
const map: MaplibreGLMap = useMap() const map: MaplibreGLMap = useMap()
if (userLocation != null && !firstUserPositionFetched) { const accuracyCircle = useMemo(() => circle(
setFirstUserPositionFetched(true) [playerLocation.longitude, playerLocation.latitude],
map.flyTo({center: [userLocation.longitude, userLocation.latitude], zoom: 15}) playerLocation.accuracy,
} {steps: 64, units: 'meters'}), [playerLocation])
const accuracyCircle = useMemo(() => circle([userLocation?.longitude ?? 0, userLocation?.latitude ?? 0], userLocation?.accuracy ?? 0, {steps: 64, units: 'meters'}), [userLocation])
const marker = userLocation ? <RMarker longitude={userLocation?.longitude} latitude={userLocation?.latitude} /> : <></>
return <> return <>
<RSource id="accuracy-radius-2" type="geojson" data={accuracyCircle} /> <RSource
<RLayer id="accuracy-radius-fill-2" type="fill" source="accuracy-radius-2" paint={{"fill-color": "pink", "fill-opacity": 0.4}} /> id={`accuracy-radius-${playerLocation.playerId}`}
<RLayer id="accuracy-radius-border-2" type="line" source="accuracy-radius-2" paint={{"line-color": "red", "line-opacity": 0.4}} /> type="geojson"
{marker} 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

@ -1,13 +1,17 @@
import { Constants } from '@/constants/Constants'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useChallengeActions, useDownloadChallengeActions } from '@/hooks/useChallengeActions' import { useChallengeActions, useDownloadChallengeActions } from '@/hooks/useChallengeActions'
import { useDownloadChallenges } from '@/hooks/useChallenges' import { useDownloadChallenges } from '@/hooks/useChallenges'
import { useGame, useUpdateActiveChallengeId, useUpdateGameState, useUpdateMoney, useUpdatePenalty } from '@/hooks/useGame' import { useGame, useUpdateActiveChallengeId, useUpdateGameState, useUpdateMoney, useUpdatePenalty } from '@/hooks/useGame'
import { useDownloadMoneyUpdates } from '@/hooks/useMoneyUpdates'
import { useDownloadTrains } from '@/hooks/useTrain' import { useDownloadTrains } from '@/hooks/useTrain'
import { isAuthValid } from '@/utils/features/auth/authSlice' import { isAuthValid } from '@/utils/features/auth/authSlice'
import { ChallengeAction, ChallengeActionPayload } from '@/utils/features/challengeActions/challengeActionsSlice' import { ChallengeAction, ChallengeActionPayload } from '@/utils/features/challengeActions/challengeActionsSlice'
import { Challenge } from '@/utils/features/challenges/challengesSlice' import { Challenge } from '@/utils/features/challenges/challengesSlice'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ReactNode, useEffect } from 'react' import { router } from 'expo-router'
import { useShareIntentContext } from 'expo-share-intent'
import React, { ReactNode, useEffect } from 'react'
export default function GameProvider({ children }: { children: ReactNode }) { export default function GameProvider({ children }: { children: ReactNode }) {
const auth = useAuth() const auth = useAuth()
@ -20,6 +24,7 @@ export default function GameProvider({ children }: { children: ReactNode }) {
const downloadTrains = useDownloadTrains() const downloadTrains = useDownloadTrains()
const downloadChallenges = useDownloadChallenges() const downloadChallenges = useDownloadChallenges()
const downloadChallengeActions = useDownloadChallengeActions() const downloadChallengeActions = useDownloadChallengeActions()
const downloadMoneyUpdates = useDownloadMoneyUpdates()
const gameQuery = useQuery({ const gameQuery = useQuery({
queryKey: ['get-game', auth.token], queryKey: ['get-game', auth.token],
@ -27,7 +32,7 @@ export default function GameProvider({ children }: { children: ReactNode }) {
headers: { "Authorization": `Bearer ${auth.token}` }} headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth), enabled: isAuthValid(auth),
refetchInterval: 5000, refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (gameQuery.isSuccess && gameQuery.data) if (gameQuery.isSuccess && gameQuery.data)
@ -40,7 +45,7 @@ export default function GameProvider({ children }: { children: ReactNode }) {
headers: { "Authorization": `Bearer ${auth.token}` }} headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId, enabled: isAuthValid(auth) && !!game.playerId,
refetchInterval: 5000, refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (playerQuery.isSuccess && playerQuery.data) { if (playerQuery.isSuccess && playerQuery.data) {
@ -55,7 +60,8 @@ export default function GameProvider({ children }: { children: ReactNode }) {
headers: { "Authorization": `Bearer ${auth.token}` }} headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId, enabled: isAuthValid(auth) && !!game.playerId,
refetchInterval: 5000, initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (trainsQuery.isSuccess && trainsQuery.data) if (trainsQuery.isSuccess && trainsQuery.data)
@ -63,12 +69,13 @@ export default function GameProvider({ children }: { children: ReactNode }) {
}, [trainsQuery.status, trainsQuery.dataUpdatedAt]) }, [trainsQuery.status, trainsQuery.dataUpdatedAt])
const challengesQuery = useQuery({ const challengesQuery = useQuery({
queryKey: ['get-challenges', game.playerId, auth.token], queryKey: ['get-challenges', auth.token],
queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/?size=10000`, { queryFn: () => fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/challenges/?size=10000`, {
headers: { "Authorization": `Bearer ${auth.token}` }} headers: { "Authorization": `Bearer ${auth.token}` }}
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId, enabled: isAuthValid(auth),
refetchInterval: 5000, initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (challengesQuery.isSuccess && challengesQuery.data) { if (challengesQuery.isSuccess && challengesQuery.data) {
@ -79,17 +86,40 @@ export default function GameProvider({ children }: { children: ReactNode }) {
} }
}, [challengesQuery.status, challengesQuery.dataUpdatedAt]) }, [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(() => { useEffect(() => {
const now = new Date().getTime() const now = new Date().getTime()
const activeChallenge: ChallengeAction | undefined = challengeActions.challengeActions const activeChallenge: ChallengeAction | undefined = challengeActions
.find(challengeAction => challengeAction.penaltyStart && challengeAction.penaltyEnd .find(challengeAction => challengeAction.penaltyStart && challengeAction.penaltyEnd
&& challengeAction.penaltyStart <= now && now <= challengeAction.penaltyEnd) && challengeAction.penaltyStart <= now && now <= challengeAction.penaltyEnd)
if (!activeChallenge || !game.currentRunner) if (!activeChallenge || !game.currentRunner || game.runId !== activeChallenge.runId)
updatePenalty({ penaltyStart: null, penaltyEnd: null }) updatePenalty({ penaltyStart: null, penaltyEnd: null })
else if (activeChallenge && (activeChallenge.penaltyStart !== game.penaltyStart || activeChallenge.penaltyEnd)) else if (activeChallenge && (activeChallenge.penaltyStart !== game.penaltyStart || activeChallenge.penaltyEnd)) {
updatePenalty({ penaltyStart: activeChallenge.penaltyStart, penaltyEnd: activeChallenge.penaltyEnd }) updatePenalty({ penaltyStart: activeChallenge.penaltyStart, penaltyEnd: activeChallenge.penaltyEnd })
}
}, [game.currentRunner, challengeActions]) }, [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 <> return <>
{children} {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

@ -1,6 +1,6 @@
import { Href, useRouter } from 'expo-router' import { Href, useRouter } from 'expo-router'
import { useRouteInfo } from 'expo-router/build/hooks' import { useRouteInfo } from 'expo-router/build/hooks'
import { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect } from 'react'
import { useAuth, useAuthLogin } from '@/hooks/useAuth' import { useAuth, useAuthLogin } from '@/hooks/useAuth'
import * as SecureStore from '@/utils/SecureStore' import * as SecureStore from '@/utils/SecureStore'
import { useLoginMutation } from '@/hooks/mutations/useLoginMutation' import { useLoginMutation } from '@/hooks/mutations/useLoginMutation'
@ -47,7 +47,7 @@ export default function LoginProvider({ loginRedirect, children }: Props) {
// Renouvellement auto du jeton d'authentification // Renouvellement auto du jeton d'authentification
const { name, token } = auth const { name, token } = auth
const password = SecureStore.getItem('apiPassword') const password = SecureStore.getItem('apiPassword')
if (name === null || (password === null && token === null)) if (name === null || (password === null && token === null) || loginMutation.isPending)
return return
let waitTime = 0 let waitTime = 0
if (token !== null && token !== undefined) { if (token !== null && token !== undefined) {
@ -69,7 +69,7 @@ export default function LoginProvider({ loginRedirect, children }: Props) {
authLogin({ name: name, token: null }) authLogin({ name: name, token: null })
}, waitTime) }, waitTime)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [auth, authLogin, game, setPlayerId]) }, [auth, authLogin, loginMutation.status, game, setPlayerId])
return <> return <>
{children} {children}

View File

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

View File

@ -1,4 +1,5 @@
import { AuthState } from "@/utils/features/auth/authSlice" import { AuthState } from "@/utils/features/auth/authSlice"
import { Challenge } from "@/utils/features/challenges/challengesSlice"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
type ErrorResponse = { type ErrorResponse = {
@ -17,6 +18,12 @@ type ChallengeActionProps = {
onError?: onErrorFunc onError?: onErrorFunc
} }
type ChallengeProps = {
auth: AuthState
onPostSuccess?: onPostSuccessFunc
onError?: onErrorFunc
}
export const useDrawRandomChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => { export const useDrawRandomChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeActionProps) => {
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -73,3 +80,151 @@ export const useEndChallenge = ({ auth, onPostSuccess, onError }: ChallengeActio
} }
}) })
} }
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

@ -103,7 +103,7 @@ export const useGameSwitchPlayerMutation = ({ auth, updateGameState, onPostSucce
}) })
} }
export const useGameRepairMutation = ({ auth, onPostSuccess, onError }: GameProps) => { export const useGameRepairMutation = ({ auth, onPostSuccess, onError }: Omit<GameProps, 'updateGameState'>) => {
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {
return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/repair/`, { return fetch(`${process.env.EXPO_PUBLIC_TRAINTRAPE_MOI_SERVER}/game/repair/`, {

View File

@ -40,6 +40,11 @@ export const useGeolocationMutation = ({ auth, onPostSuccess, onError }: PostPro
}, },
networkMode: 'offlineFirst', networkMode: 'offlineFirst',
onSuccess: async (data, location: LocationObject, context: unknown) => { onSuccess: async (data, location: LocationObject, context: unknown) => {
if (data.statusCode) {
if (onError)
onError({ response: data })
return
}
if (onPostSuccess) if (onPostSuccess)
onPostSuccess(data, location, context) onPostSuccess(data, location, context)
}, },

View File

@ -47,3 +47,30 @@ export const useAddTrainMutation = ({ auth, onPostSuccess, onError }: TrainProps
} }
}) })
} }
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 })
}
})
}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { LocationObject } from "expo-location" import { LocationObject } from "expo-location"
import { useAppDispatch, useAppSelector } from "./useStore" import { useAppDispatch, useAppSelector } from "./useStore"
import { setLastLocation, unqueueLocation } from "@/utils/features/location/locationSlice" import { PlayerLocation, setLastLocation, setLastPlayerLocation, setLastPlayerLocations, unqueueLocation } from "@/utils/features/location/locationSlice"
export const useLastOwnLocation = () => useAppSelector((state) => state.location.lastOwnLocation) export const useLastOwnLocation = () => useAppSelector((state) => state.location.lastOwnLocation)
export const useQueuedLocations = () => useAppSelector((state) => state.location.queuedLocations) export const useQueuedLocations = () => useAppSelector((state) => state.location.queuedLocations)
export const useLastPlayerLocations = () => useAppSelector((state) => state.location.lastPlayerLocations)
export const useSetLastLocation = () => { export const useSetLastLocation = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -13,3 +14,11 @@ export const useUnqueueLocation = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return (location: LocationObject) => dispatch(unqueueLocation(location)) 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,7 +1,7 @@
import { downloadTrains, TrainsPayload } from "@/utils/features/train/trainSlice" import { downloadTrains, TrainsPayload } from "@/utils/features/train/trainSlice"
import { useAppDispatch, useAppSelector } from "./useStore" import { useAppDispatch, useAppSelector } from "./useStore"
export const useTrain = () => useAppSelector((state) => state.train) export const useTrain = () => useAppSelector((state) => state.train.trains)
export const useDownloadTrains = () => { export const useDownloadTrains = () => {
const dispath = useAppDispatch() const dispath = useAppDispatch()
return (trainsData: TrainsPayload) => dispath(downloadTrains(trainsData)) return (trainsData: TrainsPayload) => dispath(downloadTrains(trainsData))

1931
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", "name": "traintrape-moi-client",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.1.1",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test": "jest --watchAll", "test": "jest --watchAll",
"lint": "expo lint" "lint": "expo lint"
@ -17,8 +17,7 @@
"@dev-plugins/react-navigation": "^0.1.0", "@dev-plugins/react-navigation": "^0.1.0",
"@dev-plugins/react-query": "^0.1.0", "@dev-plugins/react-query": "^0.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@maplibre/maplibre-react-native": "^10.0.0-alpha.28", "@maplibre/maplibre-react-native": "^10.0.0-beta.8",
"@pchmn/expo-material3-theme": "github:pchmn/expo-material3-theme",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
@ -28,24 +27,21 @@
"@tanstack/react-query-persist-client": "^5.62.7", "@tanstack/react-query-persist-client": "^5.62.7",
"@turf/circle": "^7.1.0", "@turf/circle": "^7.1.0",
"expo": "~52.0.11", "expo": "~52.0.11",
"expo-background-fetch": "~13.0.3",
"expo-blur": "~14.0.1", "expo-blur": "~14.0.1",
"expo-constants": "~17.0.3", "expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.4", "expo-dev-client": "~5.0.4",
"expo-font": "~13.0.1", "expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.3", "expo-linking": "~7.0.3",
"expo-location": "^18.0.2", "expo-location": "^18.0.2",
"expo-notifications": "~0.29.11",
"expo-router": "~4.0.9", "expo-router": "~4.0.9",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
"expo-share-intent": "^3.1.1",
"expo-splash-screen": "~0.29.13", "expo-splash-screen": "~0.29.13",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0", "expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.4", "expo-system-ui": "~4.0.4",
"expo-task-manager": "^12.0.3", "expo-task-manager": "^12.0.3",
"expo-updates": "~0.26.10", "expo-updates": "~0.26.10",
"expo-web-browser": "~14.0.1",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
"maplibre-react-components": "^0.1.9", "maplibre-react-components": "^0.1.9",
"react": "18.3.1", "react": "18.3.1",
@ -54,12 +50,12 @@
"react-native": "0.76.3", "react-native": "0.76.3",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.20.2",
"react-native-paper": "^5.12.5", "react-native-paper": "^5.12.5",
"react-native-reanimated": "~3.16.1", "react-native-paper-dropdown": "^2.3.1",
"react-native-safe-area-context": "~4.12.0", "react-native-safe-area-context": "~4.12.0",
"react-native-screens": "~4.1.0", "react-native-screens": "~4.1.0",
"react-native-web": "~0.19.13", "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": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

View File

@ -1,40 +0,0 @@
import * as BackgroundFetch from 'expo-background-fetch'
import * as TaskManager from 'expo-task-manager'
import { Platform } from 'react-native'
import { useEffect } from 'react'
const BACKGROUND_FETCH_TASK = "background-fetch"
const BACKGROUND_FETCH_INTERVAL = 60000
async function backgroundUpdate() {
const now = Date.now()
console.log(`Got background fetch call at date: ${new Date(now).toISOString()}`)
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData
}
TaskManager.defineTask(BACKGROUND_FETCH_TASK, backgroundUpdate)
export async function startBackgroundFetchService(): Promise<void | (() => void)> {
if (Platform.OS === "web") {
const interval = setInterval(backgroundUpdate, BACKGROUND_FETCH_INTERVAL)
return () => clearInterval(interval)
}
if (await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK))
return async () => await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK)
await BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: BACKGROUND_FETCH_INTERVAL,
stopOnTerminate: false,
startOnBoot: true,
})
return async () => await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK)
}
export const useStartBackgroundFetchServiceEffect = () => useEffect(() => {
let cleanup: void | (() => void) = () => {}
startBackgroundFetchService().then(result => cleanup = result)
return cleanup
}, [])

View File

@ -9,6 +9,7 @@ export interface ChallengeAction {
end: number | null, // date end: number | null, // date
penaltyStart: number | null, // date penaltyStart: number | null, // date
penaltyEnd: number | null, // date penaltyEnd: number | null, // date
runId: number,
} }
export interface ActionsState { export interface ActionsState {
@ -28,6 +29,7 @@ export interface ChallengeActionPayload {
end: string | null, end: string | null,
penaltyStart: string | null, penaltyStart: string | null,
penaltyEnd: string | null, penaltyEnd: string | null,
runId: number,
} }
export interface ChallengeActionsPayload { export interface ChallengeActionsPayload {
@ -51,6 +53,7 @@ export const challengeActionsSlice = createSlice({
end: dlChallenge.action.end ? new Date(dlChallenge.action.end).getTime() : null, end: dlChallenge.action.end ? new Date(dlChallenge.action.end).getTime() : null,
penaltyStart: dlChallenge.action.penaltyStart ? new Date(dlChallenge.action.penaltyStart).getTime() : null, penaltyStart: dlChallenge.action.penaltyStart ? new Date(dlChallenge.action.penaltyStart).getTime() : null,
penaltyEnd: dlChallenge.action.penaltyEnd ? new Date(dlChallenge.action.penaltyEnd).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) state.challengeActions.sort((c1, c2) => c2.id - c1.id)

View File

@ -37,7 +37,7 @@ export const challengesSlice = createSlice({
reward: dlChallenge.reward, reward: dlChallenge.reward,
}) })
} }
state.challenges.sort((c1, c2) => c2.id - c1.id) state.challenges.sort((c1, c2) => c1.title.localeCompare(c2.title))
}, },
}, },
}) })

View File

@ -1,4 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { Accuracy } from 'expo-location'
import * as SecureStore from '@/utils/SecureStore'
export interface RunPayload { export interface RunPayload {
id: number id: number
@ -20,8 +22,16 @@ export interface PenaltyPayload {
penaltyEnd: number | null penaltyEnd: number | null
} }
export interface Player {
id: number
name: string
money: number
activeChallengeId: number | null
}
export interface GameState { export interface GameState {
playerId: number | null playerId: number | null
runId: number | null
gameStarted: boolean gameStarted: boolean
money: number money: number
currentRunner: boolean currentRunner: boolean
@ -29,10 +39,16 @@ export interface GameState {
chaseFreeTime: number | null // date chaseFreeTime: number | null // date
penaltyStart: number | null // date penaltyStart: number | null // date
penaltyEnd: number | null //date penaltyEnd: number | null //date
settings: Settings
}
export interface Settings {
locationAccuracy: Accuracy | null
} }
const initialState: GameState = { const initialState: GameState = {
playerId: null, playerId: null,
runId: null,
gameStarted: false, gameStarted: false,
money: 0, money: 0,
currentRunner: false, currentRunner: false,
@ -40,6 +56,9 @@ const initialState: GameState = {
chaseFreeTime: null, chaseFreeTime: null,
penaltyStart: null, penaltyStart: null,
penaltyEnd: null, penaltyEnd: null,
settings: {
locationAccuracy: Accuracy.Highest,
}
} }
export const gameSlice = createSlice({ export const gameSlice = createSlice({
@ -58,6 +77,7 @@ export const gameSlice = createSlice({
updateGameState: (state, action: PayloadAction<GamePayload>) => { updateGameState: (state, action: PayloadAction<GamePayload>) => {
const game: GamePayload = action.payload const game: GamePayload = action.payload
state.gameStarted = game.started state.gameStarted = game.started
state.runId = game.currentRunId
state.currentRunner = state.playerId === game.currentRun?.runnerId state.currentRunner = state.playerId === game.currentRun?.runnerId
if (state.currentRunner) if (state.currentRunner)
state.chaseFreeTime = null state.chaseFreeTime = null
@ -67,10 +87,14 @@ export const gameSlice = createSlice({
updatePenalty: (state, action: PayloadAction<PenaltyPayload>) => { updatePenalty: (state, action: PayloadAction<PenaltyPayload>) => {
state.penaltyStart = action.payload.penaltyStart state.penaltyStart = action.payload.penaltyStart
state.penaltyEnd = action.payload.penaltyEnd 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 { setPlayerId, updateMoney, updateActiveChallengeId, updateGameState, updatePenalty } = gameSlice.actions export const { setLocationAccuracy, setPlayerId, updateMoney, updateActiveChallengeId, updateGameState, updatePenalty } = gameSlice.actions
export default gameSlice.reducer export default gameSlice.reducer

View File

@ -1,17 +1,32 @@
import { Constants } from '@/constants/Constants'
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { LocationObject } from 'expo-location' 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 { interface LocationState {
lastOwnLocation: LocationObject | null lastOwnLocation: LocationObject | null
lastSentLocation: LocationObject | null lastSentLocation: LocationObject | null
queuedLocations: LocationObject[] queuedLocations: LocationObject[]
lastPlayerLocations: PlayerLocation[]
} }
const initialState: LocationState = { const initialState: LocationState = {
lastOwnLocation: null, lastOwnLocation: null,
lastSentLocation: null, lastSentLocation: null,
queuedLocations: [] queuedLocations: [],
lastPlayerLocations: []
} }
export const locationSlice = createSlice({ export const locationSlice = createSlice({
@ -27,11 +42,22 @@ export const locationSlice = createSlice({
} }
}, },
unqueueLocation: (state, action: PayloadAction<LocationObject>) => { unqueueLocation: (state, action: PayloadAction<LocationObject>) => {
state.queuedLocations.pop() 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 { setLastLocation, unqueueLocation } = locationSlice.actions export const { setLastLocation, unqueueLocation, setLastPlayerLocation, setLastPlayerLocations } = locationSlice.actions
export default locationSlice.reducer 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

@ -1,11 +1,15 @@
import * as Location from 'expo-location' import * as Location from 'expo-location'
import * as TaskManager from 'expo-task-manager' import * as TaskManager from 'expo-task-manager'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { setLastLocation } from './features/location/locationSlice' import { PlayerLocation, setLastLocation } from './features/location/locationSlice'
import store from './store' import store from './store'
import { useEffect } from 'react' 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) => { TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => {
if (error) { if (error) {
@ -13,12 +17,32 @@ TaskManager.defineTask(LOCATION_TASK, async ({ data, error }: any) => {
return return
} }
const { locations } = data const { locations } = data
store.dispatch(setLastLocation(locations.at(-1))) 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)) 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)) await Location.enableNetworkProviderAsync().catch(error => alert(error))
@ -32,10 +56,10 @@ export async function startGeolocationService(): Promise<void | (() => void)> {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
await Location.startLocationUpdatesAsync(LOCATION_TASK, { await Location.startLocationUpdatesAsync(LOCATION_TASK, {
accuracy: Location.Accuracy.BestForNavigation, accuracy: locationAccuracy,
activityType: Location.ActivityType.OtherNavigation, activityType: Location.ActivityType.OtherNavigation,
deferredUpdatesInterval: 100, distanceInterval: 10,
timeInterval: 100, timeInterval: 1000,
foregroundService: { foregroundService: {
killServiceOnDestroy: false, killServiceOnDestroy: false,
notificationBody: "Géolocalisation activée pour « Traintrape-moi »", notificationBody: "Géolocalisation activée pour « Traintrape-moi »",
@ -46,13 +70,19 @@ export async function startGeolocationService(): Promise<void | (() => void)> {
return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK) return async () => await Location.stopLocationUpdatesAsync(LOCATION_TASK)
} }
else { else {
const locationSubscription = await Location.watchPositionAsync({accuracy: Location.Accuracy.BestForNavigation}, location_nouveau => store.dispatch(setLastLocation(location_nouveau))) const locationSubscription = await Location.watchPositionAsync({ accuracy: locationAccuracy }, location_nouveau => store.dispatch(setLastLocation(location_nouveau)))
return locationSubscription.remove return locationSubscription.remove
} }
} }
export const useStartGeolocationServiceEffect = () => useEffect(() => { export const useStartGeolocationServiceEffect = () => {
let cleanup: void | (() => void) = () => {} const auth = useAuth()
startGeolocationService().then(result => cleanup = result) const settings = useSettings()
return cleanup 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

@ -4,6 +4,7 @@ import challengesReducer from './features/challenges/challengesSlice'
import challengeActionsReducer from './features/challengeActions/challengeActionsSlice' import challengeActionsReducer from './features/challengeActions/challengeActionsSlice'
import gameReducer from './features/game/gameSlice' import gameReducer from './features/game/gameSlice'
import locationReducer from './features/location/locationSlice' import locationReducer from './features/location/locationSlice'
import moneyUpdatesReducer from './features/moneyUpdates/moneyUpdatesSlice'
import trainReducer from './features/train/trainSlice' import trainReducer from './features/train/trainSlice'
const store = configureStore({ const store = configureStore({
@ -13,6 +14,7 @@ const store = configureStore({
challengeActions: challengeActionsReducer, challengeActions: challengeActionsReducer,
game: gameReducer, game: gameReducer,
location: locationReducer, location: locationReducer,
moneyUpdates: moneyUpdatesReducer,
train: trainReducer, train: trainReducer,
}, },
}) })

View File

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

225
server/package-lock.json generated
View File

@ -14,7 +14,10 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0", "@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/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.15",
"@prisma/client": "^6.0.1", "@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@ -1815,6 +1818,65 @@
"@nestjs/core": "^10.0.0" "@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": { "node_modules/@nestjs/schematics": {
"version": "10.2.3", "version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2077,6 +2162,12 @@
"@sinonjs/commons": "^3.0.0" "@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": { "node_modules/@tsconfig/node10": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@ -2181,6 +2272,12 @@
"@types/node": "*" "@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": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@ -2188,6 +2285,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -3285,6 +3391,15 @@
], ],
"license": "MIT" "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": { "node_modules/bcrypt": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -4304,6 +4419,45 @@
"node": ">= 0.8" "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": { "node_modules/enhanced-resolve": {
"version": "5.17.1", "version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
@ -7408,6 +7562,15 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.3", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
@ -8590,6 +8753,47 @@
"node": ">=8" "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": { "node_modules/source-map": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@ -9802,6 +10006,27 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

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

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

@ -12,7 +12,7 @@ model Player {
name String @unique name String @unique
password String password String
money Int @default(0) money Int @default(0)
activeChallenge ChallengeAction? @relation("ActiveChallenge", fields: [activeChallengeId], references: [id]) activeChallenge ChallengeAction? @relation("ActiveChallenge", fields: [activeChallengeId], references: [id], onDelete: SetNull)
activeChallengeId Int? @unique activeChallengeId Int? @unique
actions ChallengeAction[] actions ChallengeAction[]
geolocations Geolocation[] geolocations Geolocation[]
@ -24,17 +24,17 @@ model Player {
model Game { model Game {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
started Boolean @default(false) started Boolean @default(false)
currentRun PlayerRun? @relation("CurrentRun", fields: [currentRunId], references: [id]) currentRun PlayerRun? @relation("CurrentRun", fields: [currentRunId], references: [id], onDelete: SetNull)
currentRunId Int? @unique currentRunId Int? @unique
runs PlayerRun[] @relation("GameRuns") runs PlayerRun[] @relation("GameRuns")
} }
model PlayerRun { model PlayerRun {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
game Game @relation("GameRuns", fields: [gameId], references: [id]) game Game @relation("GameRuns", fields: [gameId], references: [id], onDelete: Cascade)
runningGame Game? @relation("CurrentRun") runningGame Game? @relation("CurrentRun")
gameId Int gameId Int
runner Player @relation(fields: [runnerId], references: [id]) runner Player @relation(fields: [runnerId], references: [id], onDelete: Cascade)
runnerId Int runnerId Int
start DateTime @default(now()) @db.Timestamptz(3) start DateTime @default(now()) @db.Timestamptz(3)
end DateTime? @db.Timestamptz(3) end DateTime? @db.Timestamptz(3)
@ -45,7 +45,7 @@ model PlayerRun {
model Geolocation { model Geolocation {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id]) player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
playerId Int playerId Int
longitude Float longitude Float
latitude Float latitude Float
@ -66,16 +66,16 @@ model Challenge {
model ChallengeAction { model ChallengeAction {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id]) player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int playerId Int
challenge Challenge @relation(fields: [challengeId], references: [id]) challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)
challengeId Int @unique challengeId Int @unique
success Boolean @default(false) success Boolean @default(false)
start DateTime @default(now()) @db.Timestamptz(3) start DateTime @default(now()) @db.Timestamptz(3)
end DateTime? @db.Timestamptz(3) end DateTime? @db.Timestamptz(3)
penaltyStart DateTime? @db.Timestamptz(3) penaltyStart DateTime? @db.Timestamptz(3)
penaltyEnd DateTime? @db.Timestamptz(3) penaltyEnd DateTime? @db.Timestamptz(3)
run PlayerRun @relation(fields: [runId], references: [id]) run PlayerRun @relation(fields: [runId], references: [id], onDelete: Restrict)
runId Int runId Int
activePlayer Player? @relation("ActiveChallenge") activePlayer Player? @relation("ActiveChallenge")
moneyUpdate MoneyUpdate? moneyUpdate MoneyUpdate?
@ -83,7 +83,7 @@ model ChallengeAction {
model TrainTrip { model TrainTrip {
id String @id id String @id
player Player @relation(fields: [playerId], references: [id]) player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int playerId Int
distance Float distance Float
from String from String
@ -91,22 +91,22 @@ model TrainTrip {
departureTime DateTime @db.Timestamptz(3) departureTime DateTime @db.Timestamptz(3)
arrivalTime DateTime @db.Timestamptz(3) arrivalTime DateTime @db.Timestamptz(3)
infoJson Json infoJson Json
run PlayerRun @relation(fields: [runId], references: [id]) run PlayerRun @relation(fields: [runId], references: [id], onDelete: Restrict)
runId Int runId Int
moneyUpdate MoneyUpdate? moneyUpdate MoneyUpdate?
} }
model MoneyUpdate { model MoneyUpdate {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id]) player Player @relation(fields: [playerId], references: [id], onDelete: Restrict)
playerId Int playerId Int
amount Int amount Int
reason MoneyUpdateType reason MoneyUpdateType
action ChallengeAction? @relation(fields: [actionId], references: [id]) action ChallengeAction? @relation(fields: [actionId], references: [id], onDelete: Cascade)
actionId Int? @unique actionId Int? @unique
trip TrainTrip? @relation(fields: [tripId], references: [id]) trip TrainTrip? @relation(fields: [tripId], references: [id], onDelete: Cascade)
tripId String? @unique tripId String? @unique
run PlayerRun? @relation(fields: [runId], references: [id]) run PlayerRun? @relation(fields: [runId], references: [id], onDelete: Cascade)
runId Int? @unique runId Int? @unique
timestamp DateTime @default(now()) @db.Timestamptz(3) timestamp DateTime @default(now()) @db.Timestamptz(3)
} }
@ -114,6 +114,6 @@ model MoneyUpdate {
enum MoneyUpdateType { enum MoneyUpdateType {
START START
NEW_RUN NEW_RUN
WIN_CHALLENGE CHALLENGE
BUY_TRAIN BUY_TRAIN
} }

View File

@ -75,7 +75,7 @@ export class ChallengeActionsService {
data: { data: {
playerId: player.id, playerId: player.id,
amount: challenge.reward, amount: challenge.reward,
reason: MoneyUpdateType.WIN_CHALLENGE, reason: MoneyUpdateType.CHALLENGE,
actionId: challengeAction.id, actionId: challengeAction.id,
} }
}) })
@ -87,6 +87,14 @@ export class ChallengeActionsService {
penaltyStart: now, penaltyStart: now,
penaltyEnd: new Date(now.getTime() + Constants.PENALTY_TIME * 60 * 1000), 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({ await this.prisma.player.update({
where: { id: player.id }, where: { id: player.id },

View File

@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CreateChallengeDto } from './dto/create-challenge.dto' import { CreateChallengeDto } from './dto/create-challenge.dto'
import { UpdateChallengeDto } from './dto/update-challenge.dto' import { UpdateChallengeDto } from './dto/update-challenge.dto'
import { Challenge, Player } from '@prisma/client' import { Challenge, Player } from '@prisma/client'
@ -13,6 +13,8 @@ export class ChallengesService {
async create(createChallengeDto: CreateChallengeDto): Promise<Challenge> { async create(createChallengeDto: CreateChallengeDto): Promise<Challenge> {
const data = { ...createChallengeDto } 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 }) return await this.prisma.challenge.create({ data: data })
} }
@ -40,6 +42,8 @@ export class ChallengesService {
async update(id: number, updateChallengeDto: UpdateChallengeDto): Promise<Challenge> { async update(id: number, updateChallengeDto: UpdateChallengeDto): Promise<Challenge> {
if (!await this.findOne(id)) if (!await this.findOne(id))
throw new NotFoundException(`Aucun défi n'existe avec l'identifiant ${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({ return await this.prisma.challenge.update({
where: { id }, where: { id },
data: updateChallengeDto, data: updateChallengeDto,

View File

@ -1,8 +1,13 @@
export const Constants = { export const Constants = {
/** /**
* Nombre de points attribués au début de la partie * Nombre de points attribués au début de la partie pour la première joueuse
*/ */
INITIAL_MONEY: 2000, 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 * Nombre de points attribués lors d'une nouvelle tentative

View File

@ -2,5 +2,6 @@ import { MoneyUpdate } from "@prisma/client"
export interface RepairGame { export interface RepairGame {
added: MoneyUpdate[] added: MoneyUpdate[]
modified: MoneyUpdate[]
deleted: MoneyUpdate[] deleted: MoneyUpdate[]
} }

View File

@ -20,16 +20,16 @@ export class GameService {
const alreadyStarted = game.currentRunId !== null const alreadyStarted = game.currentRunId !== null
let run let run
if (!alreadyStarted) { if (!alreadyStarted) {
const runnerId = players[Math.floor(players.length * Math.random())].id
for (const player of players) { for (const player of players) {
await this.prisma.moneyUpdate.create({ await this.prisma.moneyUpdate.create({
data: { data: {
playerId: player.id, playerId: player.id,
amount: Constants.INITIAL_MONEY, amount: player.id === runnerId ? Constants.INITIAL_MONEY_1ST_PLAYER : Constants.INITIAL_MONEY_2ND_PLAYER,
reason: MoneyUpdateType.START, reason: MoneyUpdateType.START,
} }
}) })
} }
const runnerId = players[Math.floor(players.length * Math.random())].id
run = await this.prisma.playerRun.create({ run = await this.prisma.playerRun.create({
data: { data: {
gameId: game.id, gameId: game.id,
@ -133,6 +133,7 @@ export class GameService {
async repair(): Promise<RepairGame> { async repair(): Promise<RepairGame> {
const added: MoneyUpdate[] = [] const added: MoneyUpdate[] = []
const modified: MoneyUpdate[] = []
const deleted: MoneyUpdate[] = [] const deleted: MoneyUpdate[] = []
const trains = await this.prisma.trainTrip.findMany({ include: { moneyUpdate: true } }) const trains = await this.prisma.trainTrip.findMany({ include: { moneyUpdate: true } })
for (const train of trains) { for (const train of trains) {
@ -147,34 +148,70 @@ export class GameService {
}) })
added.push(trainMoneyUpdate) added.push(trainMoneyUpdate)
} }
else {
const moneyUpdate = train.moneyUpdate
const cost = Constants.PRICE_PER_KILOMETER * Math.ceil(train.distance / 1000)
if (moneyUpdate.amount != -cost) {
const modifiedMoneyUpdate = await this.prisma.moneyUpdate.update({
where: { id: moneyUpdate.id },
data: { amount: -cost },
})
modified.push(modifiedMoneyUpdate)
}
}
} }
const orpanTrainMoneyUpdates = await this.prisma.moneyUpdate.findMany({ where: { reason: MoneyUpdateType.BUY_TRAIN, tripId: null } }) const orpanTrainMoneyUpdates = await this.prisma.moneyUpdate.findMany({ where: { reason: MoneyUpdateType.BUY_TRAIN, tripId: null } })
await this.prisma.moneyUpdate.deleteMany({ where: { reason: MoneyUpdateType.BUY_TRAIN, tripId: null } }) await this.prisma.moneyUpdate.deleteMany({ where: { reason: MoneyUpdateType.BUY_TRAIN, tripId: null } })
deleted.push(...orpanTrainMoneyUpdates) deleted.push(...orpanTrainMoneyUpdates)
const challengeActions = await this.prisma.challengeAction.findMany({ include: { moneyUpdate: true, challenge: true } }) const challengeActions = await this.prisma.challengeAction.findMany({ include: { moneyUpdate: true, challenge: true, activePlayer: true } })
for (const challengeAction of challengeActions) { for (const challengeAction of challengeActions) {
if (challengeAction.success && !challengeAction.moneyUpdate) { if (challengeAction.success && !challengeAction.moneyUpdate) {
const challengeMoneyUpdate = await this.prisma.moneyUpdate.create({ const challengeMoneyUpdate = await this.prisma.moneyUpdate.create({
data: { data: {
playerId: challengeAction.playerId, playerId: challengeAction.playerId,
amount: challengeAction.challenge.reward, amount: challengeAction.challenge.reward,
reason: MoneyUpdateType.WIN_CHALLENGE, reason: MoneyUpdateType.CHALLENGE,
actionId: challengeAction.id, actionId: challengeAction.id,
} }
}) })
added.push(challengeMoneyUpdate) added.push(challengeMoneyUpdate)
} }
else if (!challengeAction.success && challengeAction.moneyUpdate) { else if (challengeAction.success && challengeAction.moneyUpdate.amount !== challengeAction.challenge.reward) {
const modifiedMoneyUpdate = await this.prisma.moneyUpdate.update({
where: { id: challengeAction.moneyUpdate.id },
data: { amount: challengeAction.challenge.reward },
})
modified.push(modifiedMoneyUpdate)
}
else if (!challengeAction.success && !challengeAction.activePlayer && !challengeAction.moneyUpdate) {
const challengeMoneyUpdate = await this.prisma.moneyUpdate.create({
data: {
playerId: challengeAction.playerId,
amount: 0,
reason: MoneyUpdateType.CHALLENGE,
actionId: challengeAction.id,
}
})
added.push(challengeMoneyUpdate)
}
else if (!challengeAction.success && !challengeAction.activePlayer && challengeAction.moneyUpdate.amount !== 0) {
const modifiedMoneyUpdate = await this.prisma.moneyUpdate.update({
where: { id: challengeAction.moneyUpdate.id },
data: { amount: 0 },
})
modified.push(modifiedMoneyUpdate)
}
else if (!challengeAction.success && challengeAction.activePlayer && challengeAction.moneyUpdate) {
deleted.push(challengeAction.moneyUpdate) deleted.push(challengeAction.moneyUpdate)
await this.prisma.moneyUpdate.delete({ where: { id: challengeAction.moneyUpdate.id } }) await this.prisma.moneyUpdate.delete({ where: { id: challengeAction.moneyUpdate.id } })
} }
} }
const orpanChallengeMoneyUpdates = await this.prisma.moneyUpdate.findMany({ where: { reason: MoneyUpdateType.WIN_CHALLENGE, actionId: null } }) const orpanChallengeMoneyUpdates = await this.prisma.moneyUpdate.findMany({ where: { reason: MoneyUpdateType.CHALLENGE, actionId: null } })
await this.prisma.moneyUpdate.deleteMany({ where: { reason: MoneyUpdateType.WIN_CHALLENGE, actionId: null } }) await this.prisma.moneyUpdate.deleteMany({ where: { reason: MoneyUpdateType.CHALLENGE, actionId: null } })
deleted.push(...orpanChallengeMoneyUpdates) deleted.push(...orpanChallengeMoneyUpdates)
return { added: added, deleted: deleted } return { added: added, modified: modified, deleted: deleted }
} }
} }

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { GeolocationsGateway } from './geolocations.gateway'
describe('GeolocationsGateway', () => {
let gateway: GeolocationsGateway
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GeolocationsGateway],
}).compile();
gateway = module.get<GeolocationsGateway>(GeolocationsGateway)
});
it('should be defined', () => {
expect(gateway).toBeDefined()
})
})

View File

@ -0,0 +1,15 @@
import { SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'
import { Server, Socket } from 'socket.io'
@WebSocketGateway({
cors: { allowedHeaders: process.env.API_HOSTNAME },
})
export class GeolocationsGateway {
@WebSocketServer()
server: Server
@SubscribeMessage('last-location')
handleLastLocation(client: Socket, payload: any) {
return this.server.emit('last-location', payload)
}
}

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { GeolocationsService } from './geolocations.service'
import { GeolocationsController } from './geolocations.controller'
import { PrismaModule } from 'src/prisma/prisma.module' import { PrismaModule } from 'src/prisma/prisma.module'
import { GeolocationsController } from './geolocations.controller'
import { GeolocationsService } from './geolocations.service'
import { GeolocationsGateway } from './geolocations.gateway'
@Module({ @Module({
controllers: [GeolocationsController], controllers: [GeolocationsController],
providers: [GeolocationsService], providers: [GeolocationsService, GeolocationsGateway],
imports: [PrismaModule], imports: [PrismaModule],
}) })
export class GeolocationsModule {} export class GeolocationsModule {}

View File

@ -6,18 +6,20 @@ import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true }) const app = await NestFactory.create(AppModule, { cors: true })
app.setGlobalPrefix(process.env.API_GLOBAL_PREFIX ?? '')
app.useGlobalPipes(new ValidationPipe({ transform: true })) app.useGlobalPipes(new ValidationPipe({ transform: true }))
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
const { version } = require('../../package.json')
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Traintrape-moi') .setTitle('Traintrape-moi')
.setDescription('API permettant de stocker les données de Traintrape-moi') .setDescription('API permettant de stocker les données de Traintrape-moi')
.setVersion('1.0') .setVersion(version)
.addBearerAuth() .addBearerAuth()
.build() .build()
const document = SwaggerModule.createDocument(app, config) const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('', app, document) SwaggerModule.setup(process.env.API_GLOBAL_PREFIX ?? '', app, document)
await app.listen(process.env.PORT ?? 3000) await app.listen(process.env.PORT ?? 3000)
} }
bootstrap() bootstrap()