Compare commits

...

42 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
53 changed files with 1977 additions and 1262 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

@@ -5,7 +5,7 @@ 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 { useRouter } from 'expo-router'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@@ -91,10 +91,11 @@ function ChallengeScreenBody() {
style={{ flex: 1, margin: 20 }} />} 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

View File

@@ -1,15 +1,92 @@
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 { useChallengeActions } from '@/hooks/useChallengeActions'
import { useChallenges } from '@/hooks/useChallenges' import { useChallenges } from '@/hooks/useChallenges'
import { useMoneyUpdates } from '@/hooks/useMoneyUpdates' import { useMoneyUpdates } from '@/hooks/useMoneyUpdates'
import { useTrain } from '@/hooks/useTrain' import { useTrain } from '@/hooks/useTrain'
import { MoneyUpdate } from '@/utils/features/moneyUpdates/moneyUpdatesSlice' import { MoneyUpdate } from '@/utils/features/moneyUpdates/moneyUpdatesSlice'
import { FontAwesome6 } from '@expo/vector-icons' import { FontAwesome6 } from '@expo/vector-icons'
import { useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { FlatList } from 'react-native' import { FlatList } from 'react-native'
import { Divider, FAB, List, Surface, Text } from 'react-native-paper' 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 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 style={{ flex :1 }}> <Surface style={{ flex :1 }}>
@@ -17,12 +94,47 @@ export default function HistoryScreen() {
data={moneyUpdates} data={moneyUpdates}
keyExtractor={(moneyUpdate) => `money-update-list-item-${moneyUpdate.id}`} keyExtractor={(moneyUpdate) => `money-update-list-item-${moneyUpdate.id}`}
ItemSeparatorComponent={() => <Divider />} ItemSeparatorComponent={() => <Divider />}
renderItem={(item) => <MoneyUpdateListItem moneyUpdate={item.item} />} /> 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 }: { moneyUpdate: MoneyUpdate }) { function MoneyUpdateListItem({ moneyUpdate, onDelete }: { moneyUpdate: MoneyUpdate, onDelete?: () => void }) {
const trains = useTrain() const trains = useTrain()
const challengeActions = useChallengeActions() const challengeActions = useChallengeActions()
const challenges = useChallenges() const challenges = useChallenges()
@@ -32,7 +144,7 @@ function MoneyUpdateListItem({ moneyUpdate }: { moneyUpdate: MoneyUpdate }) {
case 'START': return 'star' case 'START': return 'star'
case 'NEW_RUN': return 'run' case 'NEW_RUN': return 'run'
case 'BUY_TRAIN': return 'train' case 'BUY_TRAIN': return 'train'
case 'WIN_CHALLENGE': return 'cards' case 'CHALLENGE': return 'cards'
} }
}, [moneyUpdate.reason]) }, [moneyUpdate.reason])
@@ -50,7 +162,7 @@ function MoneyUpdateListItem({ moneyUpdate }: { moneyUpdate: MoneyUpdate }) {
const arrDateTime = new Date(train.arrivalTime) const arrDateTime = new Date(train.arrivalTime)
const arrTime = arrDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) const arrTime = arrDateTime.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
return `${train.from} ${depTime} => ${train.to} ${arrTime}` return `${train.from} ${depTime} => ${train.to} ${arrTime}`
case 'WIN_CHALLENGE': case 'CHALLENGE':
const challengeAction = challengeActions.find((challengeAction) => challengeAction.id === moneyUpdate.actionId) const challengeAction = challengeActions.find((challengeAction) => challengeAction.id === moneyUpdate.actionId)
if (!challengeAction) return "Défi" if (!challengeAction) return "Défi"
const challenge = challenges.find((challenge) => challenge.id === challengeAction.challengeId) const challenge = challenges.find((challenge) => challenge.id === challengeAction.challengeId)
@@ -62,9 +174,9 @@ function MoneyUpdateListItem({ moneyUpdate }: { moneyUpdate: MoneyUpdate }) {
const description = useMemo(() => { const description = useMemo(() => {
const earnDate = new Date(moneyUpdate.timestamp).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' }) 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 earnTime = new Date(moneyUpdate.timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const verb = moneyUpdate.amount > 0 ? "Gagné" : "Dépensé" const verb = moneyUpdate.amount >= 0 ? "Gagné" : "Dépensé"
return <Text> return <Text>
{verb} {moneyUpdate.amount} <FontAwesome6 name='coins' /> le {earnDate} à {earnTime} {verb} {Math.abs(moneyUpdate.amount)} <FontAwesome6 name='coins' /> le {earnDate} à {earnTime}
</Text> </Text>
}, [moneyUpdate.amount]) }, [moneyUpdate.amount])
@@ -73,6 +185,6 @@ function MoneyUpdateListItem({ moneyUpdate }: { moneyUpdate: MoneyUpdate }) {
left={(props) => <List.Icon {...props} icon={icon} />} left={(props) => <List.Icon {...props} icon={icon} />}
title={title} title={title}
description={description} description={description}
right={(props) => <FAB mode='elevated' icon='delete' size='small' {...props} onPress={() => {}} />} /> onLongPress={onDelete} />
) )
} }

View File

@@ -1,10 +1,12 @@
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 { 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, MD3Colors, Portal, Snackbar, 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 [successVisible, setSuccessVisible] = useState(false)
@@ -17,6 +19,17 @@ export default function HistoryScreen() {
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,
@@ -68,7 +81,6 @@ export default function HistoryScreen() {
}) })
const gameRepairMutation = useGameRepairMutation({ const gameRepairMutation = useGameRepairMutation({
auth, auth,
updateGameState,
onPostSuccess: () => { onPostSuccess: () => {
setSuccessVisible(true) setSuccessVisible(true)
setSuccessMessage("Réparation du jeu effectuée avec succès") setSuccessMessage("Réparation du jeu effectuée avec succès")
@@ -113,6 +125,11 @@ export default function HistoryScreen() {
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={() => <List.Icon icon="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

View File

@@ -5,7 +5,8 @@ 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 { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' 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, MD3Colors, Portal, Snackbar, Surface, Text, TextInput } from 'react-native-paper' import { Button, Dialog, Divider, FAB, HelperText, List, MD3Colors, Portal, Snackbar, Surface, Text, TextInput } from 'react-native-paper'
@@ -38,6 +39,23 @@ export default function TrainScreen() {
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 />

View File

@@ -1,20 +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'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -27,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()
@@ -44,24 +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
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> theme={colorScheme === 'dark' ? MD3DarkTheme : MD3LightTheme} >
<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="challenges-list" options={{ headerShown: false }} />
</Stack> <Stack.Screen name="+not-found" />
<StatusBar style="auto" /> </Stack>
</ThemeProvider> <StatusBar style="auto" />
</PaperProvider> </ThemeProvider>
</GameProvider> </PaperProvider>
</GeolocationProvider> </GameProvider>
</LoginProvider> </GeolocationProvider>
</LoginProvider>
</ShareIntentProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
</StoreProvider> </StoreProvider>
) )

View File

@@ -1,20 +1,29 @@
import ChallengeCard from "@/components/ChallengeCard" import ChallengeCard from "@/components/ChallengeCard"
import { useAddChallengeMutation, useDeleteChallengeMutation, useEditChallengeMutation } from "@/hooks/mutations/useChallengeMutation" import { useAddChallengeMutation, useAttachNewChallenge, useDeleteChallengeMutation, useEditChallengeMutation } from "@/hooks/mutations/useChallengeMutation"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import { useChallengeActions } from "@/hooks/useChallengeActions"
import { useChallenges } from "@/hooks/useChallenges" import { useChallenges } from "@/hooks/useChallenges"
import { useGame } from "@/hooks/useGame"
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 { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { useRouter } from "expo-router" import { useRouter } from "expo-router"
import { useState } from "react" import React, { ReactNode, useMemo, useState } from "react"
import { FlatList, StyleSheet } from "react-native" import { FlatList, StyleSheet } from "react-native"
import { Appbar, Button, Dialog, Divider, FAB, List, MD3Colors, Modal, Portal, Snackbar, Surface, Text, TextInput } from "react-native-paper" import { ActivityIndicator, Appbar, Button, Dialog, Divider, FAB, List, MD3Colors, Modal, Portal, Snackbar, Surface, Text, TextInput, Tooltip } from "react-native-paper"
export default function ChallengesList() { export default function ChallengesList() {
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const auth = useAuth() const auth = useAuth()
const game = useGame()
const challenges = useChallenges() 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 [editChallengeVisible, setEditChallengeVisible] = useState(false)
const [editChallengeTitle, setEditChallengeTitle] = useState("") const [editChallengeTitle, setEditChallengeTitle] = useState("")
@@ -23,6 +32,8 @@ export default function ChallengesList() {
const [editChallengeId, setEditChallengeId] = useState<number |null>(null) const [editChallengeId, setEditChallengeId] = useState<number |null>(null)
const [displayedChallenge, setDisplayedChallenge] = useState<Challenge | null>(null) const [displayedChallenge, setDisplayedChallenge] = useState<Challenge | null>(null)
const [confirmDeletedVisible, setConfirmDeleteVisible] = useState(false) const [confirmDeletedVisible, setConfirmDeleteVisible] = useState(false)
const [challengeToAttach, setChallengeToAttach] = useState<Challenge | null>(null)
const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false) const [successSnackbarVisible, setSuccessSnackbarVisible] = useState(false)
const [successMessage, setSuccessMessage] = useState("") const [successMessage, setSuccessMessage] = useState("")
const [errorVisible, setErrorVisible] = useState(false) const [errorVisible, setErrorVisible] = useState(false)
@@ -80,6 +91,22 @@ export default function ChallengesList() {
setError([400, error.message]) 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() { function sendEditChallenge() {
if (editChallengeId) { if (editChallengeId) {
@@ -103,17 +130,26 @@ export default function ChallengesList() {
displayedChallenge && deleteChallengeMutation.mutate(displayedChallenge) displayedChallenge && deleteChallengeMutation.mutate(displayedChallenge)
} }
function sendAttachNewChallenge() {
if (!challengeToAttach || !currentChallengeAction) return
attachNewChallengeMutation.mutate({ challengeActionId: currentChallengeAction.id, newChallengeId: challengeToAttach.id })
}
return ( return (
<Surface style={{ flex: 1 }}> <Surface style={{ flex: 1 }}>
<Appbar.Header> <Appbar.Header>
{router.canGoBack() ? <Appbar.BackAction onPress={() => router.back()} /> : undefined} <Appbar.BackAction onPress={() => router.canGoBack() ? router.back() : router.navigate('/(tabs)/challenges')} />
<Appbar.Content title={"Liste des défis"} /> <Appbar.Content title={"Liste des défis"} />
</Appbar.Header> </Appbar.Header>
<FlatList <FlatList
data={challenges} data={challenges}
keyExtractor={(challenge) => `challenge-list-item-${challenge.id}`} keyExtractor={(challenge) => `challenge-list-item-${challenge.id}`}
ItemSeparatorComponent={() => <Divider />} ItemSeparatorComponent={() => <Divider />}
renderItem={(item) => <ChallengeListItem challenge={item.item} onPress={() => setDisplayedChallenge(item.item)} />} /> renderItem={(item) =>
<ChallengeListItem challenge={item.item}
activeRunner={game.currentRunner}
onPress={() => setDisplayedChallenge(item.item)}
onLongPress={!currentChallengeAction ? undefined : () => setChallengeToAttach(item.item)} />} />
<Snackbar <Snackbar
key='success-snackbar' key='success-snackbar'
visible={successSnackbarVisible} visible={successSnackbarVisible}
@@ -206,18 +242,53 @@ export default function ChallengesList() {
<Button onPress={sendDeleteChallenge} disabled={deleteChallengeMutation.isPending}>Confirmer</Button> <Button onPress={sendDeleteChallenge} disabled={deleteChallengeMutation.isPending}>Confirmer</Button>
</Dialog.Actions> </Dialog.Actions>
</Dialog> </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> </Portal>
</Surface> </Surface>
) )
} }
function ChallengeListItem({ challenge, onPress }: { challenge: Challenge, onPress?: () => void }) { 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 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 ( return (
<List.Item <List.Item
title={challenge.title} title={challenge.title}
description={description} description={description}
onPress={onPress} /> right={() => icon}
onPress={onPress}
onLongPress={attachedAction ? undefined : onLongPress} />
) )
} }

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

@@ -7,13 +7,13 @@ import { Banner, MD3Colors, ProgressBar, Text } from "react-native-paper"
export default function FreeChaseBanner() { export default function FreeChaseBanner() {
const game = useGame() const game = useGame()
const chaseFreeTime = game.chaseFreeTime const chaseFreeTime = game.chaseFreeTime
const chaser = !game.gameStarted && !game.currentRunner && chaseFreeTime !== null const chaser = game.gameStarted && !game.currentRunner && chaseFreeTime !== null
const chaseFreeDate = useMemo(() => new Date(chaseFreeTime || 0), [chaseFreeTime]) const chaseFreeDate = useMemo(() => new Date(chaseFreeTime || 0), [chaseFreeTime])
const chaseFreePretty = chaseFreeDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) const chaseFreePretty = chaseFreeDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const [remainingTime, setRemainingTime] = useState(0) 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 prettyRemainingTime = useMemo(() => `${Math.floor(remainingTime / 60).toString().padStart(2, '0')}:${Math.floor(remainingTime % 60).toString().padStart(2, '0')}`, [remainingTime])
const iconName = useMemo(() => { const iconName = useMemo(() => {
switch (Math.abs(remainingTime % 4)) { switch (Math.abs(Math.floor(remainingTime) % 4)) {
case 0: return 'hourglass-empty' case 0: return 'hourglass-empty'
case 1: return 'hourglass-end' case 1: return 'hourglass-end'
case 2: return 'hourglass-half' case 2: return 'hourglass-half'
@@ -25,7 +25,7 @@ export default function FreeChaseBanner() {
const now = new Date().getTime() const now = new Date().getTime()
if (!chaser || (chaseFreeTime < now && remainingTime < 0)) if (!chaser || (chaseFreeTime < now && remainingTime < 0))
return return
const interval = setInterval(() => setRemainingTime(Math.floor(chaseFreeTime - now) / 1000), 1000) const interval = setInterval(() => setRemainingTime(Math.floor(chaseFreeTime - new Date().getTime()) / 1000), 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [game.gameStarted, game.currentRunner, chaseFreeDate]) }, [game.gameStarted, game.currentRunner, chaseFreeDate])

View File

@@ -1,54 +0,0 @@
import { ReactNode, useEffect } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { useQueuedLocations, useSetLastPlayerLocations, useUnqueueLocation } from '@/hooks/useLocation'
import { useGeolocationMutation } from '@/hooks/mutations/useGeolocationMutation'
import { useStartGeolocationServiceEffect } from '@/utils/geolocation'
import { Platform } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { isAuthValid } from '@/utils/features/auth/authSlice'
export default function GeolocationProvider({ children }: { children: ReactNode }) {
useStartGeolocationServiceEffect()
const auth = useAuth()
const geolocationsQueue = useQueuedLocations()
const unqueueLocation = useUnqueueLocation()
const setLastPlayerLocations = useSetLastPlayerLocations()
const geolocationMutation = useGeolocationMutation({
auth,
onPostSuccess: (data) => {
unqueueLocation(data)
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])
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: 5000,
})
useEffect(() => {
if (lastLocationsQuery.isSuccess && lastLocationsQuery.data)
setLastPlayerLocations(lastLocationsQuery.data)
}, [lastLocationsQuery.status, lastLocationsQuery.dataUpdatedAt])
return <>
{children}
</>
}

View File

@@ -1,44 +1,91 @@
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, useLastPlayerLocations } from '@/hooks/useLocation' import { reverseGeocodeAsync } from 'expo-location'
import { useMemo } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { PlayerLocation } from '@/utils/features/location/locationSlice' 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 { 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 userLocation = useLastOwnLocation()
return ( return (
<MapView <MapView
logoEnabled={false} logoEnabled={false}
compassViewPosition={2} 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}} />
<UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} /> <UserLocation animated={true} renderMode="native" androidRenderMode="compass" showsUserHeadingIndicator={true} />
<PlayerLocationsMarkers /> <PlayerLocationsMarkers setDisplayedPlayerId={setDisplayedPlayerId} />
</MapView> </MapView>
) )
} }
function PlayerLocationsMarkers() { function PlayerLocationsMarkers({ setDisplayedPlayerId }: { setDisplayedPlayerId: React.Dispatch<React.SetStateAction<number | null>> }) {
const game = useGame() const game = useGame()
const lastPlayerLocations = useLastPlayerLocations() const lastPlayerLocations = useLastPlayerLocations()
return lastPlayerLocations ? lastPlayerLocations return lastPlayerLocations ? lastPlayerLocations
.filter(() => !game.currentRunner || !game.gameStarted)
.filter(playerLoc => playerLoc.playerId !== game.playerId) .filter(playerLoc => playerLoc.playerId !== game.playerId)
.map(playerLoc => <PlayerLocationMarker key={`player-${playerLoc.playerId}-loc`} playerLocation={playerLoc} />) : <></> .map(playerLoc => <PlayerLocationMarker key={`player-${playerLoc.playerId}-loc`} playerLocation={playerLoc} setDisplayedPlayerId={setDisplayedPlayerId} />) : <></>
} }
function PlayerLocationMarker({ playerLocation }: { playerLocation: PlayerLocation }) { 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]) const accuracyCircle = useMemo(() => circle([playerLocation.longitude, playerLocation.latitude], playerLocation.accuracy, {steps: 64, units: 'meters'}), [playerLocation])
return <> return <>
<ShapeSource <ShapeSource
@@ -55,7 +102,8 @@ function PlayerLocationMarker({ playerLocation }: { playerLocation: PlayerLocati
style={{lineOpacity: 0.4, lineColor: 'red'}} style={{lineOpacity: 0.4, lineColor: 'red'}}
aboveLayerID={`accuracy-radius-fill-${playerLocation.playerId}`} /> aboveLayerID={`accuracy-radius-fill-${playerLocation.playerId}`} />
<PointAnnotation id={`player-location-marker-${playerLocation.playerId}`} <PointAnnotation id={`player-location-marker-${playerLocation.playerId}`}
coordinate={[playerLocation.longitude, playerLocation.latitude]}> coordinate={[playerLocation.longitude, playerLocation.latitude]}
onSelected={() => { setDisplayedPlayerId(playerLocation.playerId) }}>
<FontAwesome5 name="map-marker-alt" size={24} color="red" /> <FontAwesome5 name="map-marker-alt" size={24} color="red" />
</PointAnnotation> </PointAnnotation>
</> </>
@@ -67,3 +115,68 @@ const styles = StyleSheet.create({
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,3 +1,4 @@
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'
@@ -8,7 +9,9 @@ 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()
@@ -29,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)
@@ -42,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) {
@@ -58,7 +61,7 @@ export default function GameProvider({ children }: { children: ReactNode }) {
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth) && !!game.playerId, enabled: isAuthValid(auth) && !!game.playerId,
initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } }, initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: 5000, refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (trainsQuery.isSuccess && trainsQuery.data) if (trainsQuery.isSuccess && trainsQuery.data)
@@ -72,7 +75,7 @@ export default function GameProvider({ children }: { children: ReactNode }) {
).then(resp => resp.json()), ).then(resp => resp.json()),
enabled: isAuthValid(auth), enabled: isAuthValid(auth),
initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } }, initialData: { data: [], meta: { currentPage: 0, lastPage: 0, nextPage: 0, prevPage: 0, total: 0, totalPerPage: 0 } },
refetchInterval: 5000, refetchInterval: Constants.QUERY_REFETCH_INTERVAL * 1000,
}) })
useEffect(() => { useEffect(() => {
if (challengesQuery.isSuccess && challengesQuery.data) { if (challengesQuery.isSuccess && challengesQuery.data) {
@@ -89,7 +92,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 (moneyUpdatesQuery.isSuccess && moneyUpdatesQuery.data) if (moneyUpdatesQuery.isSuccess && moneyUpdatesQuery.data)
@@ -101,13 +104,22 @@ export default function GameProvider({ children }: { children: ReactNode }) {
const activeChallenge: ChallengeAction | undefined = 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

@@ -81,6 +81,63 @@ 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) => { export const useAddChallengeMutation = ({ auth, onPostSuccess, onError }: ChallengeProps) => {
return useMutation({ return useMutation({
mutationFn: async (challenge: Omit<Challenge, 'id'>) => { mutationFn: async (challenge: Omit<Challenge, 'id'>) => {
@@ -154,12 +211,12 @@ export const useDeleteChallengeMutation = ({ auth, onPostSuccess, onError }: Cha
"Authorization": `Bearer ${auth.token}`, "Authorization": `Bearer ${auth.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}).then(resp => resp.json()) })
}, },
onSuccess: async (data) => { onSuccess: async (resp) => {
if (data.statusCode) { if (resp.status >= 400) {
if (onError) if (onError)
onError({ response: data }) onError({ response: await resp.json() })
return return
} }
if (onPostSuccess) if (onPostSuccess)

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

@@ -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,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,6 +1,6 @@
import { LocationObject } from "expo-location" import { LocationObject } from "expo-location"
import { useAppDispatch, useAppSelector } from "./useStore" import { useAppDispatch, useAppSelector } from "./useStore"
import { PlayerLocation, setLastLocation, setLastPlayerLocations, 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)
@@ -18,3 +18,7 @@ export const useSetLastPlayerLocations = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return (playerLocations: PlayerLocation[]) => dispatch(setLastPlayerLocations(playerLocations)) return (playerLocations: PlayerLocation[]) => dispatch(setLastPlayerLocations(playerLocations))
} }
export const useSetLastPlayerLocation = () => {
const dispatch = useAppDispatch()
return (playerLocation: PlayerLocation) => dispatch(setLastPlayerLocation(playerLocation))
}

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,9 +1,10 @@
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 = { export type PlayerLocation = {
id: number id?: number
playerId: number playerId: number
longitude: number longitude: number
latitude: number latitude: number
@@ -44,15 +45,19 @@ export const locationSlice = createSlice({
const sentLoc = action.payload const sentLoc = action.payload
state.queuedLocations = state.queuedLocations state.queuedLocations = state.queuedLocations
.filter(loc => new Date(loc.timestamp).getTime() !== sentLoc.timestamp .filter(loc => new Date(loc.timestamp).getTime() !== sentLoc.timestamp
|| loc.coords.latitude !== sentLoc.coords.latitude && loc.coords.latitude !== sentLoc.coords.latitude
|| loc.coords.longitude !== sentLoc.coords.latitude) && loc.coords.longitude !== sentLoc.coords.longitude)
}, },
setLastPlayerLocations: (state, action: PayloadAction<PlayerLocation[]>) => { setLastPlayerLocations: (state, action: PayloadAction<PlayerLocation[]>) => {
state.lastPlayerLocations = action.payload 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, setLastPlayerLocations } = locationSlice.actions export const { setLastLocation, unqueueLocation, setLastPlayerLocation, setLastPlayerLocations } = locationSlice.actions
export default locationSlice.reducer export default locationSlice.reducer

View File

@@ -5,7 +5,7 @@ export interface MoneyUpdate {
id: number id: number
playerId: number playerId: number
amount: number amount: number
reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'WIN_CHALLENGE' reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'CHALLENGE'
timestamp: number timestamp: number
runId: number | null runId: number | null
actionId: number | null actionId: number | null
@@ -24,7 +24,7 @@ export interface MoneyUpdatePayload {
id: number id: number
playerId: number playerId: number
amount: number amount: number
reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'WIN_CHALLENGE' reason: 'START' | 'NEW_RUN' | 'BUY_TRAIN' | 'CHALLENGE'
timestamp: string timestamp: string
runId: number | null runId: number | null
actionId: number | null actionId: number | null

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

@@ -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,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

@@ -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,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

@@ -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,
@@ -164,14 +164,14 @@ export class GameService {
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,
} }
}) })
@@ -184,14 +184,32 @@ export class GameService {
}) })
modified.push(modifiedMoneyUpdate) modified.push(modifiedMoneyUpdate)
} }
else if (!challengeAction.success && challengeAction.moneyUpdate) { 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, modified: modified, 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()