Working EN traduction + wait page + permission page

This commit is contained in:
Sebastien Riviere
2026-02-21 02:46:58 +01:00
parent 76ee9674de
commit 28e81894ce
19 changed files with 299 additions and 91 deletions

View File

@@ -1,7 +1,39 @@
import { Text } from 'react-native';
//React
import { StyleSheet, Text, View, Image } from 'react-native';
import { useTranslation } from 'react-i18next';
const LocationPermission = () => {
return <Text>{"Veuillez activer la géolocalisation en arrière plan dans les paramètres, puis relancez l'application."}</Text>;
const { t } = useTranslation();
return (<>
<View style={styles.container}>
<Image style={styles.image} source={require("@/assets/images/placement.png")} />
<Text style={styles.title}>{t("location-permission.title")}</Text>
<Text style={styles.subtitle}>{t("location-permission.subtitle")}</Text>
</View>
</>);
};
export default LocationPermission;
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
gap: 20
},
image: {
width: 150,
height: 150,
marginTop: -100,
},
title: {
fontSize: 20,
fontWeight: "bold",
},
subtitle: {
fontSize: 15,
},
});

View File

@@ -1,6 +1,6 @@
// React
import { useState } from 'react';
import { ScrollView, View, Text, StyleSheet, Image, Alert, TouchableHighlight } from 'react-native';
import { Keyboard, ScrollView, View, Text, StyleSheet, Image, Alert, TouchableHighlight } from 'react-native';
import { useTranslation } from 'react-i18next';
// Components
import { TouchableImage } from '@/components/common/Image';
@@ -28,7 +28,9 @@ const Login = () => {
const regex = /^\d{6}$/;
if (!regex.test(teamId)) {
setTimeout(() => Alert.alert(t("error.title"), t("error.invalid_team_id")), 100);
Keyboard.dismiss();
Alert.alert(t("error.default.title"), t("error.default.invalid_team_id"));
setIsSubmitting(false);
return;
}
@@ -39,10 +41,12 @@ const Login = () => {
uploadTeamImage(teamId, image?.uri);
setTeamId("");
} else {
setTimeout(() => Alert.alert(t("error.title"), t("error.unknown_team_id")), 100);
Keyboard.dismiss();
Alert.alert(t("error.default.title"), t("error.default.unknown_team_id"));
}
} catch (error) {
setTimeout(() => Alert.alert(t("error.title"), t("error.server_connection")), 100);
Keyboard.dismiss();
Alert.alert(t("error.default.title"), t("error.default.server_connection"));
} finally {
setIsSubmitting(false);
}
@@ -53,20 +57,20 @@ const Login = () => {
<View style={styles.transitionContainer}>
<View style={styles.subContainer}>
<Image style={styles.logoImage} source={require('@/assets/images/logo/logo_traque.png')}/>
<Text style={styles.logoText}>{t("index.header.title")}</Text>
<Text style={styles.logoText}>{t("login.header.title")}</Text>
</View>
<View style={styles.subContainer}>
<CustomTextInput value={teamId} inputMode="numeric" placeholder={t("index.form.team_id_input")} style={styles.input} onChangeText={setTeamId}/>
<CustomTextInput value={teamId} inputMode="numeric" placeholder={t("login.form.team_id_input")} style={styles.input} onChangeText={setTeamId}/>
</View>
<View style={styles.subContainer}>
<Text style={{fontSize: 15}}>{t("index.form.image_label")}</Text>
<Text style={{fontSize: 13, marginBottom: 3}}>{t("index.form.image_sublabel")}</Text>
<Text style={{fontSize: 15}}>{t("login.form.image_label")}</Text>
<Text style={{fontSize: 13, marginBottom: 3}}>{t("login.form.image_sublabel")}</Text>
<TouchableImage source={image ? {uri: image.uri} : require('@/assets/images/missing_image.jpg')} onPress={pickImage}/>
</View>
<View style={styles.subContainer}>
<View style={styles.buttonContainer}>
<TouchableHighlight style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonLabel}>{isSubmitting ? "..." : t("index.form.validate_button")}</Text>
<Text style={styles.buttonLabel}>{isSubmitting ? "..." : t("login.form.validate_button")}</Text>
</TouchableHighlight>
</View>
</View>

View File

@@ -35,8 +35,8 @@ const Play = () => {
<Header/>
<Show when={userState == USER_STATE.PLAYING}>
<View style={styles.infoContainer}>
<TimerMMSS style={{width: "50%"}} title={t("interface.zone_reduction_label")} date={nextZoneDate} />
<TimerMMSS style={{width: "50%"}} title={t("interface.send_position_label")} date={locationSendDeadline} />
<TimerMMSS style={{width: "50%"}} title={t("play.info.zone_reduction_label")} date={nextZoneDate} />
<TimerMMSS style={{width: "50%"}} title={t("play.info.send_position_label")} date={locationSendDeadline} />
</View>
</Show>
</View>
@@ -49,8 +49,8 @@ const Play = () => {
<GameZone/>
</Show>
<Show when={userState == USER_STATE.PLAYING && !hasHandicap}>
<PositionMarker position={lastSentLocation} color={"grey"} onPress={() => Alert.alert(t("interface.map.previous_marker_title"), t("interface.map.previous_marker_description"))} />
<PositionMarker position={enemyLocation} color={"red"} onPress={() => Alert.alert(t("interface.map.enemy_marker_title"), t("interface.map.enemy_marker_description"))} />
<PositionMarker position={lastSentLocation} color={"grey"} onPress={() => Alert.alert(t("play.map.previous_marker_title"), t("play.map.previous_marker_description"))} />
<PositionMarker position={enemyLocation} color={"red"} onPress={() => Alert.alert(t("play.map.enemy_marker_title"), t("play.map.enemy_marker_description"))} />
</Show>
</Map>
<LinearGradient colors={['rgba(0,0,0,0.3)', 'rgba(0,0,0,0)']} style={styles.gradient}/>

View File

@@ -1,16 +1,35 @@
// React
import { View, Text, StyleSheet } from 'react-native';
import { View, Text, StyleSheet, Image } from 'react-native';
import { useTranslation } from 'react-i18next';
// Components
import { Header } from '@/components/game/Header';
// Constants
import { COLORS } from '@/constants';
const Wait = () => {
const { t } = useTranslation();
return (
<View style={styles.globalContainer}>
<View style={styles.topContainer}>
<Header/>
<Text>Veuillez patienter, la partie va bientôt commencer !</Text>
<Header/>
<View style={styles.rulesContainer}>
<Text style={styles.title}>{t("wait.title")}</Text>
<View style={styles.section}>
<Image style={styles.image} source={require("@/assets/images/flag.png")} />
<Text style={styles.description}>{t("wait.placement_rule")}</Text>
</View>
<View style={styles.section}>
<Text style={styles.description}>{t("wait.capture_rule")}</Text>
<Image style={styles.image} source={require("@/assets/images/target/black.png")} />
</View>
<View style={styles.section}>
<Image style={styles.image} source={require("@/assets/images/running.png")} />
<Text style={styles.description}>{t("wait.zone_rule")}</Text>
</View>
<View style={styles.section}>
<Text style={styles.description}>{t("wait.team_rule")}</Text>
<Image style={styles.image} source={require("@/assets/images/team.png")} />
</View>
</View>
</View>
);
@@ -22,10 +41,35 @@ const styles = StyleSheet.create({
globalContainer: {
backgroundColor: COLORS.background,
flex: 1,
padding: 20,
},
topContainer: {
width: '100%',
rulesContainer: {
flex: 1,
alignItems: 'center',
padding: 15,
gap: 30
},
title: {
backgroundColor: "white",
textAlign: 'center',
fontSize: 30,
fontWeight: "bold",
borderWidth: 2,
borderRadius: 10,
padding: 10,
},
section: {
width: '100%',
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 20,
},
image: {
width: 100,
height: 100,
},
description: {
flex: 1,
}
});

View File

@@ -1,17 +1,22 @@
// React
import { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
// Expo
import { Slot, useRouter, usePathname } from 'expo-router';
// Components
import { IconButton } from '@/components/common/IconButton';
// Contexts
import { AuthProvider } from "@/contexts/authContext";
import { TeamProvider } from "@/contexts/teamContext";
// Hook
import { useUserState } from '@/hooks/useUserState';
// Services
import { startLocationTracking , stopLocationTracking } from '@/services/tasks/backgroundLocation';
// Constants
import { USER_STATE } from '@/constants';
// Traduction
import '@/i18n/config';
import { useUserState } from '@/hooks/useUserState';
const NavigationManager = () => {
const router = useRouter();
@@ -72,15 +77,43 @@ const NavigationManager = () => {
return null;
};
const Language = () => {
const { i18n } = useTranslation();
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language === 'fr' ? 'en' : 'fr');
};
return (
<View style={styles.languageButton}>
<IconButton source={require('@/assets/images/language.png')} onPress={toggleLanguage} />
</View>
);
};
const RootLayout = () => {
return (
<AuthProvider>
<TeamProvider>
<Slot/>
<NavigationManager/>
<Language/>
</TeamProvider>
</AuthProvider>
);
};
export default RootLayout;
const styles = StyleSheet.create({
languageButton: {
position: 'absolute',
top: 0,
right: 0,
backgroundColor: "rgb(126, 182, 199)",
borderBottomLeftRadius: 20,
padding: 5,
justifyContent: 'center',
alignItems: 'center',
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,5 +1,6 @@
// React
import { View, Text, Alert, StyleSheet } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
// Contexts
import { useAuth } from '@/contexts/authContext';
import { useTeam } from '@/contexts/teamContext';
@@ -7,18 +8,16 @@ import { useTeam } from '@/contexts/teamContext';
import { IconButton } from '@/components/common/IconButton';
export const Header = () => {
const { t } = useTranslation();
const { logout } = useAuth();
const { teamInfos } = useTeam();
const { name } = teamInfos;
return (
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<IconButton source={require('@/assets/images/logout.png')} onPress={logout} />
<IconButton source={require('@/assets/images/cogwheel.png')} onPress={() => Alert.alert("Settings")} />
</View>
<IconButton source={require('@/assets/images/logout.png')} onPress={logout} />
<View style={styles.nameContainer}>
<Text style={styles.name}>{name ?? "Inconnue"}</Text>
<Text style={styles.name}>{name ?? t("common.no_value")}</Text>
</View>
</View>
);
@@ -27,17 +26,12 @@ export const Header = () => {
const styles = StyleSheet.create({
container: {
width: '100%',
alignItems: 'center'
},
buttonsContainer: {
width: "100%",
flexDirection: "row",
justifyContent: 'space-between'
},
nameContainer: {
width: '100%',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
marginBottom: 20
},
name: {
fontSize: 36,

View File

@@ -13,7 +13,7 @@ export const StartZone = () => {
const { startingArea } = teamInfos;
return useMemo(() => {
if (startingArea) return null;
if (!startingArea) return null;
return (
<Circle

View File

@@ -1,6 +1,6 @@
// React
import { useState } from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Keyboard, View, Text, Image, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { useTranslation } from 'react-i18next';
// Components
import { ExpandableImage } from '@/components/common/Image';
@@ -31,14 +31,17 @@ export const TargetInfoDrawer = ({ height }) => {
emitCapture(enemyCaptureCode)
.then((response) => {
if (response.hasCaptured) {
Alert.alert("Bravo !", "Vous avez réussi à capturer votre cible. Une nouvelle cible vient de vous être attribuée.");
Keyboard.dismiss();
Alert.alert(t("info.success.title"), t("info.success.capture_success"));
setEnemyCaptureCode("");
} else {
Alert.alert("Échec !", "Le code que vous venez de rentrer n'est pas celui de votre cible.");
Keyboard.dismiss();
Alert.alert(t("info.failure.title"), t("info.failure.capture_failure"));
}
})
.catch(() => {
Alert.alert(t("error.title"), t("error.server_connection"));
Keyboard.dismiss();
Alert.alert(t("error.default.title"), t("error.default.server_connection"));
})
.finally(() => setIsCapturing(false));
};
@@ -46,16 +49,16 @@ export const TargetInfoDrawer = ({ height }) => {
return (
<Drawer height={height}>
<Text style={{fontSize: 22, fontWeight: "bold", textAlign: "center"}}>
{t("interface.drawer.capture_code", {name: name ?? t("general.no_value"), code: String(captureCode).padStart(4,"0")})}
{t("play.drawer.capture_code", {name: name ?? t("common.no_value"), code: String(captureCode).padStart(4,"0")})}
</Text>
<Show when={!hasHandicap}>
<View style={styles.imageContainer}>
<Text style={{fontSize: 15, margin: 5}}>{t("interface.drawer.target_name", {name: enemyName ?? t("general.no_value")})}</Text>
<Text style={{fontSize: 15, margin: 5}}>{t("play.drawer.target_name", {name: enemyName ?? t("common.no_value")})}</Text>
<ExpandableImage source={enemyImage(teamId)}/>
</View>
<View style={styles.actionsContainer}>
<View style={styles.actionsLeftContainer}>
<CustomTextInput value={enemyCaptureCode} inputMode="numeric" placeholder={t("interface.drawer.target_code_input")} onChangeText={setEnemyCaptureCode}/>
<CustomTextInput value={enemyCaptureCode} inputMode="numeric" placeholder={t("play.drawer.target_code_input")} onChangeText={setEnemyCaptureCode}/>
</View>
<View style={styles.actionsRightContainer}>
<TouchableOpacity style={styles.button} onPress={handleCapture}>

View File

@@ -10,8 +10,10 @@ import { useTimeSinceSeconds } from '@/hooks/useTimeDelta';
import { secondsToHHMMSS } from '@/utils/functions';
const Stat = ({ children, source, description }) => {
const { t } = useTranslation();
return (
<TouchableOpacity style={styles.statContainer} onPress={description ? () => Alert.alert("Info", description) : null}>
<TouchableOpacity style={styles.statContainer} onPress={description ? () => Alert.alert(t("info.default.title"), description) : null}>
<Image style={styles.image} source={source} resizeMode="contain"/>
<Text style={styles.text}>{children}</Text>
</TouchableOpacity>
@@ -36,13 +38,13 @@ export const TeamStats = () => {
return (
<View style={styles.statsContainer}>
<View style={styles.row}>
<Stat source={require('@/assets/images/distance.png')} description={t("interface.drawer.stat_distance_label")}>{Math.floor(distance / 100) / 10}km</Stat>
<Stat source={require('@/assets/images/time.png')} description={t("interface.drawer.stat_time_label")}>{secondsToHHMMSS((finishDate ? Math.floor((finishDate - startDate) / 1000) : timeSinceGameStart))}</Stat>
<Stat source={require('@/assets/images/running.png')} description={t("interface.drawer.stat_speed_label")}>{avgSpeed}km/h</Stat>
<Stat source={require('@/assets/images/distance.png')} description={t("play.drawer.stat_distance_label")}>{Math.floor(distance / 100) / 10}km</Stat>
<Stat source={require('@/assets/images/time.png')} description={t("play.drawer.stat_time_label")}>{secondsToHHMMSS((finishDate ? Math.floor((finishDate - startDate) / 1000) : timeSinceGameStart))}</Stat>
<Stat source={require('@/assets/images/running.png')} description={t("play.drawer.stat_speed_label")}>{avgSpeed}km/h</Stat>
</View>
<View style={styles.row}>
<Stat source={require('@/assets/images/target/black.png')} description={t("interface.drawer.stat_capture_label")}>{nCaptures}</Stat>
<Stat source={require('@/assets/images/update_position.png')} description={t("interface.drawer.stat_reveal_label")}>{nSentLocation}</Stat>
<Stat source={require('@/assets/images/target/black.png')} description={t("play.drawer.stat_capture_label")}>{nCaptures}</Stat>
<Stat source={require('@/assets/images/update_position.png')} description={t("play.drawer.stat_reveal_label")}>{nSentLocation}</Stat>
</View>
</View>
);

View File

@@ -22,28 +22,28 @@ export const Toasts = () => {
{
condition: userState === USER_STATE.PLACEMENT,
id: 'placement',
text: ready ? t("interface.placed") : t("interface.not_placed"),
text: ready ? t("play.toast.placed") : t("play.toast.not_placed"),
toastColor: ready ? "rgb(25, 165, 25)" : "rgb(204, 51, 51)" ,
textColor: "white"
},
{
condition: userState === USER_STATE.PLAYING && !outOfZone && enemyHasHandicap,
id: 'enemy_revealed',
text: t("interface.enemy_position_revealed"),
text: t("play.toast.enemy_position_revealed"),
toastColor: "white",
textColor: "black"
},
{
condition: userState === USER_STATE.PLAYING && outOfZone && hasHandicap,
id: 'out_of_zone',
text: `${t("interface.go_in_zone")}\n${t("interface.team_position_revealed")}`,
text: `${t("play.toast.go_in_zone")}\n${t("play.toast.team_position_revealed")}`,
toastColor: "white",
textColor: "black"
},
{
condition: userState === USER_STATE.PLAYING && outOfZone && !hasHandicap,
id: 'has_handicap',
text: `${t("interface.go_in_zone")}\n${t("interface.out_of_zone_message", {time: secondsToMMSS(outOfZoneTimeLeft)})}`,
text: `${t("play.toast.go_in_zone")}\n${t("play.toast.out_of_zone_message", {time: secondsToMMSS(outOfZoneTimeLeft)})}`,
toastColor: "white",
textColor: "black"
}

View File

@@ -33,7 +33,7 @@ export const usePickImage = () => {
}
} catch (error) {
console.error('Error picking image;', error);
Alert.alert(t("error.title"), t("error.image_selection"));
Alert.alert(t("error.default.title"), t("error.default.image_selection"));
}
}, [t]);

View File

@@ -1,3 +1,83 @@
{
"common": {
"no_value": "Unavailable"
},
"location-permission": {
"title": "Please enable background location in settings and restart the app.",
"subtitle": "Each team's location must be known by the server and organizers to ensure the game runs smoothly."
},
"login": {
"header": {
"title": "LA TRAQUE"
},
"form": {
"team_id_input": "Team ID",
"image_label": "Tap to change team photo",
"image_sublabel": "Upper body must be visible",
"validate_button": "Validate"
}
},
"wait": {
"title": "Rules Reminder !",
"placement_rule": "Go to your starting zone and wait for the game to begin.",
"capture_rule": "Track your target using their photo and the positions they reveal. To obtain your target's last known position, you must reveal your own. Once captured, enter their code in the app.",
"zone_rule": "Move on foot while making sure to stay within the game zone to avoid penalties ! If you stay outside the zone for too long, your interface will be disabled and your hunter will know your exact position.",
"team_rule": "Set up team strategies ! But always stay within earshot, do not communicate with other teams, and remain in public areas."
},
"play": {
"info": {
"zone_reduction_label": "Zone reduction in",
"send_position_label": "Position sent in"
},
"toast": {
"placed": "Placed",
"not_placed": "Not placed",
"enemy_position_revealed": "Enemy position continuously revealed !",
"go_in_zone": "Return to the zone !",
"team_position_revealed": "Position continuously revealed.",
"out_of_zone_message": "Handicap in {{time}}"
},
"map": {
"previous_marker_title": "Position sent",
"previous_marker_description": "This is your last position known by the server",
"enemy_marker_title": "Enemy position",
"enemy_marker_description": "This is the last known position of your enemies"
},
"drawer": {
"capture_code": "{{name}}'s code : {{code}}",
"target_name": "Target ({{name}})",
"target_code_input": "Target code",
"stat_distance_label": "Distance traveled",
"stat_time_label": "Elapsed time (HH:MM:SS)",
"stat_speed_label": "Average speed",
"stat_capture_label": "Total captures by your team",
"stat_reveal_label": "Total times your position was sent"
}
},
"info": {
"default": {
"title": "Info"
},
"succes": {
"title": "Success !",
"capture_success": "You have successfully captured your target. A new target has been assigned to you."
},
"failure": {
"title": "Failure...",
"capture_failure": "The capture failed. Please check the code and try again."
}
},
"error": {
"default": {
"title": "Error",
"invalid_team_id": "Please enter a valid team ID.",
"unknown_team_id": "Unknown team ID.",
"server_connection": "Server connection failed.",
"image_selection": "An error occurred during image selection."
},
"permission": {
"title": "Permission denied",
"storage_acces": "Enable storage or gallery access in settings."
}
}
}

View File

@@ -1,8 +1,12 @@
{
"general": {
"common": {
"no_value": "Indisponible"
},
"index": {
"location-permission": {
"title": "Veuillez activer la localisation en arrière plan dans les paramètres et relancer l'application.",
"subtitle": "La localisation de chaque équipe doit être connue par le serveur et les organisateurs pour veiller au bon déroulement du jeu."
},
"login": {
"header": {
"title": "LA TRAQUE"
},
@@ -13,24 +17,27 @@
"validate_button": "Valider"
}
},
"interface": {
"placed": "Placé",
"not_placed": "Non placé",
"zone_reduction_label": "Réduction de la zone dans",
"send_position_label": "Position envoyée dans",
"enemy_position_revealed": "Position ennemie révélée en continue !",
"waiting_default_message": "Préparation de la partie",
"placement_default_message": "Phase de placement",
"go_in_zone": "Retournez dans la zone !",
"team_position_revealed": "Position révélée en continue.",
"out_of_zone_message": "Handicap dans {{time}}",
"playing_message": "La partie est en cours",
"winner_message": "Vous avez gagné !",
"loser_message": "Vous avez perdu...",
"captured_message": "Vous avez été éliminé...",
"wait": {
"title": "Rappel des règles !",
"placement_rule": "Rejoignez votre zone de départ et attendez le début de la partie.",
"capture_rule": "Traquez votre cible grâce à sa photo et aux positions qu'elle révèle. Pour obtenir la dernière position connue de votre cible, vous devrez révéler la vôtre. Une fois capturée, entrez son code dans l'application.",
"zone_rule": "Déplacez-vous à pied tout en veillant à rester dans la zone de jeu pour ne pas être pénalisé ! Si vous restez trop longtemps en dehors de la zone, votre interface sera désactivée et votre chasseur connaîtra précisément votre position.",
"team_rule": "Mettez en place des stratégies d'équipe ! Mais restez toujours à portée de voix, ne communiquez pas avec les autres équipes et restez dans les lieux publics."
},
"play": {
"info": {
"zone_reduction_label": "Réduction de la zone dans",
"send_position_label": "Position envoyée dans"
},
"toast": {
"placed": "Placé",
"not_placed": "Non placé",
"enemy_position_revealed": "Position ennemie révélée en continue !",
"go_in_zone": "Retournez dans la zone !",
"team_position_revealed": "Position révélée en continue.",
"out_of_zone_message": "Handicap dans {{time}}"
},
"map": {
"team_marker_title": "Position actuelle",
"team_marker_description": "Ceci est votre position",
"previous_marker_title": "Position envoyée",
"previous_marker_description": "Ceci est votre dernière position connue par le serveur",
"enemy_marker_title": "Position ennemie",
@@ -41,18 +48,33 @@
"target_name": "Cible ({{name}})",
"target_code_input": "Code cible",
"stat_distance_label": "Distance parcourue",
"stat_time_label": "Temps écoulé au format HH:MM:SS",
"stat_time_label": "Temps écoulé (HH:MM:SS)",
"stat_speed_label": "Vitesse moyenne",
"stat_capture_label": "Nombre total de captures par votre équipe",
"stat_reveal_label": "Nombre total d'envois de votre position"
}
},
"info": {
"default": {
"title": "Info"
},
"succes": {
"title": "Bravo !",
"capture_success": "Vous avez réussi à capturer votre cible. Une nouvelle cible vient de vous être attribuée."
},
"failure": {
"title": "Raté...",
"capture_failure": "La capture a échoué. Vérifiez le code et réessayez."
}
},
"error": {
"title": "Erreur",
"invalid_team_id": "Veuillez entrer un ID d'équipe valide.",
"unknown_team_id": "L'ID d'équipe est inconnu.",
"server_connection": "La connexion au serveur a échoué.",
"image_selection": "Une erreur est survenue lors de la sélection d'une image.",
"default": {
"title": "Erreur",
"invalid_team_id": "Veuillez entrer un ID d'équipe valide.",
"unknown_team_id": "L'ID d'équipe est inconnu.",
"server_connection": "La connexion au serveur a échoué.",
"image_selection": "Une erreur est survenue lors de la sélection d'une image."
},
"permission": {
"title": "Permission refusée",
"storage_acces": "Activez l'accès au stockage ou à la gallerie dans les paramètres."