Add JWT OAuth

pull/18/head
Anthony RICHARD 4 weeks ago
parent a3e567f291
commit 90b1e450e1

@ -1,13 +1,13 @@
import axios from 'axios'; import axios from "axios";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: 'https://codefirst.iut.uca.fr/containers/Optifit-optifit-ef-api/api/v1/', baseURL:
timeout: 10000, "https://codefirst.iut.uca.fr/containers/Optifit-optifit-ef-api/api/v1/",
headers: { timeout: 10000,
'Accept': 'application/json', headers: {
'Content-Type': 'application/json', Accept: "application/json",
// ajoute ici les headers supplémentaires, ex. Auth "Content-Type": "application/json",
}, },
}); });
export default apiClient; export default apiClient;

@ -1,30 +1,41 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { getItemAsync } from "expo-secure-store";
import apiClient from "../client"; import apiClient from "../client";
import { AUTH } from "../endpoints"; import { AUTH } from "../endpoints";
const ACCESS_TOKEN_PATH = "access-token";
const REFRESH_TOKEN_PATH = "refresh-token";
export abstract class AbstractService { export abstract class AbstractService {
protected async request<T>(callback: () => Promise<T>): Promise<T> { protected IDP_URL =
try { "https://codefirst.iut.uca.fr/containers/Optifit-optifit-ef-api/api/v1";
return await callback(); protected URL =
} catch (error: any) { "https://codefirst.iut.uca.fr/containers/Optifit-optifit-ef-api/api/v1";
if (error.response?.status === 401) {
const success = await this.tryRefreshToken(); protected CLIENT_ID = "mobile-app";
if (success) { protected CLIENT_SECRET = "super-secret";
return await callback();
} else { protected SCOPES =
throw new Error("Session expired"); "openid profile training.generate training.read offline_access";
}
} protected ACCESS_TOKEN_PATH = "access-token";
throw error; protected REFRESH_TOKEN_PATH = "refresh-token";
}
protected async request(path: string, options: RequestInit = {}) {
const token = await getItemAsync(this.ACCESS_TOKEN_PATH);
const res = await fetch(`${this.URL}${path}`, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${token}`,
},
});
return token && res.status === 401
? await this.tryRefreshToken()
: await res.json();
} }
private async tryRefreshToken(): Promise<boolean> { private async tryRefreshToken(): Promise<boolean> {
try { try {
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_PATH); const refreshToken = await AsyncStorage.getItem(this.REFRESH_TOKEN_PATH);
if (!refreshToken) return false; if (!refreshToken) return false;
@ -34,18 +45,15 @@ export abstract class AbstractService {
const { accessToken, refreshToken: newRefreshToken } = response.data; const { accessToken, refreshToken: newRefreshToken } = response.data;
// Save new tokens await AsyncStorage.setItem(this.ACCESS_TOKEN_PATH, accessToken);
await AsyncStorage.setItem(ACCESS_TOKEN_PATH, accessToken); await AsyncStorage.setItem(this.REFRESH_TOKEN_PATH, newRefreshToken);
await AsyncStorage.setItem("refreshToken", newRefreshToken);
// Update apiClient headers
apiClient.defaults.headers.common[ apiClient.defaults.headers.common[
"Authorization" "Authorization"
] = `Bearer ${accessToken}`; ] = `Bearer ${accessToken}`;
return true; return true;
} catch (e) { } catch (e) {
console.error("Refresh token failed", e);
return false; return false;
} }
} }

@ -1,12 +0,0 @@
import apiClient from "@/api/client";
import { EXERCICES } from "@/api/endpoints";
import { AbstractService } from "../abstract.service";
export class ExerciceService extends AbstractService {
async getExercices() {
return this.request(async () => {
const response = await apiClient.get(EXERCICES.GETALL);
return response.data.data;
});
}
}

@ -0,0 +1,14 @@
import { EXERCICES } from "@/api/endpoints";
import { Workout } from "@/model/Workout";
import { AbstractService } from "../abstract.service";
import { IExerciceInterface } from "./exercice.service.interface";
export class ExerciceAPIService
extends AbstractService
implements IExerciceInterface
{
async getExercices(): Promise<Workout[]> {
const data = await this.request(EXERCICES.GETALL);
return data.data.map((item: any) => Workout.fromJson(item));
}
}

@ -0,0 +1,3 @@
export interface IExerciceInterface {
getExercices(): Promise<any>;
}

@ -0,0 +1,11 @@
import { AbstractService } from "../abstract.service";
import { IExerciceInterface } from "./exercice.service.interface";
export class ExerciceStubService
extends AbstractService
implements IExerciceInterface
{
async getExercices() {
return [];
}
}

@ -1,14 +1,31 @@
import apiClient from "@/api/client";
import { AUTH } from "@/api/endpoints";
import { User } from "@/model/User"; import { User } from "@/model/User";
import { setItemAsync } from "expo-secure-store";
import { AbstractService as AbstractAPIService } from "../abstract.service"; import { AbstractService as AbstractAPIService } from "../abstract.service";
import { IUserService } from "./user.service.interface"; import { IUserService } from "./user.service.interface";
export class UserAPIService extends AbstractAPIService implements IUserService { export class UserAPIService extends AbstractAPIService implements IUserService {
async login(email: string, password: string): Promise<User> { async login(email: string, password: string): Promise<User> {
return this.request(async () => { const body = new URLSearchParams({
const response = await apiClient.get(AUTH.LOGIN); grant_type: "password",
return response.data.data; client_id: email,
client_secret: this.CLIENT_SECRET,
email,
password,
scope: this.SCOPES,
}); });
const res = await fetch(`${this.IDP_URL}/connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!res.ok) throw new Error(`Auth failed: ${res.status}`);
const json = await res.json();
await setItemAsync(this.ACCESS_TOKEN_PATH, json.access_token);
await setItemAsync(this.REFRESH_TOKEN_PATH, json.refresh_token);
return json;
} }
} }

@ -3,166 +3,154 @@ import { IUserService } from "./user.service.interface";
export class UserStubService implements IUserService { export class UserStubService implements IUserService {
private readonly users: User[] = [ private readonly users: User[] = [
new User( new User({
"Alice", name: "Alice",
28, age: 28,
165, height: 165,
58, weight: 58,
false, sexe: false,
"alice.png", logo: "alice.png",
3, nbSessionPerWeek: 3,
"Perdre du poids", goal: "Perdre du poids",
[], healthProblems: [],
"YOGA", sport: "YOGA",
"GOOD", sleepLevel: "GOOD",
"BEGINNER", sportLevel: "BEGINNER",
"test@1.com", email: "test@1.com",
"password1" password: "password1",
), }),
new User( new User({
undefined, email: "test@2.com",
undefined, password: "password2",
undefined, }),
undefined, new User({
undefined, name: "Charlie",
undefined, age: 22,
undefined, height: 172,
undefined, weight: 70,
undefined, sexe: true,
undefined, logo: "charlie.png",
undefined, nbSessionPerWeek: 2,
undefined, goal: "Se remettre en forme",
"test@2.com", healthProblems: [],
"password2" sport: "BIKING",
), sleepLevel: "GOOD",
new User( sportLevel: "BEGINNER",
"Charlie", email: "test@3.com",
22, password: "password3",
172, }),
70, new User({
true, name: "Diana",
"charlie.png", age: 31,
2, height: 160,
"Se remettre en forme", weight: 55,
[], sexe: false,
"BIKING", logo: "diana.png",
"GOOD", nbSessionPerWeek: 5,
"BEGINNER", goal: "Préparer un marathon",
"test@3.com", healthProblems: [],
"password3" sport: "RUNNING",
), sleepLevel: "GOOD",
new User( sportLevel: "VERY_SPORTY",
"Diana", email: "test@4.com",
31, password: "password4",
160, }),
55, new User({
false, name: "Ethan",
"diana.png", age: 40,
5, height: 180,
"Préparer un marathon", weight: 88,
[], sexe: true,
"RUNNING", logo: "ethan.png",
"GOOD", nbSessionPerWeek: 1,
"VERY_SPORTY", goal: "Maintenir sa forme",
"test@4.com", healthProblems: ["MIGRAINE"],
"password4" sport: "WALKING",
), sleepLevel: "BAD",
new User( sportLevel: "SPORTY",
"Ethan", email: "test@5.com",
40, password: "password5",
180, }),
88, new User({
true, name: "Fiona",
"ethan.png", age: 26,
1, height: 167,
"Maintenir sa forme", weight: 62,
["MIGRAINE"], sexe: false,
"WALKING", logo: "fiona.png",
"BAD", nbSessionPerWeek: 3,
"SPORTY", goal: "Renforcer le dos",
"test@5.com", healthProblems: ["MIGRAINE"],
"password5" sport: "CARDIO",
), sleepLevel: "BAD",
new User( sportLevel: "BEGINNER",
"Fiona", email: "test@6.com",
26, password: "password6",
167, }),
62, new User({
false, name: "George",
"fiona.png", age: 30,
3, height: 185,
"Renforcer le dos", weight: 90,
["MIGRAINE"], sexe: true,
"CARDIO", logo: "george.png",
"BAD", nbSessionPerWeek: 4,
"BEGINNER", goal: "Perdre du gras",
"test@6.com", healthProblems: [],
"password6" sport: "BIKING",
), sleepLevel: "TERRIBLE",
new User( sportLevel: "SPORTY",
"George", email: "test@7.com",
30, password: "password7",
185, }),
90, new User({
true, name: "Hanna",
"george.png", age: 24,
4, height: 158,
"Perdre du gras", weight: 54,
[], sexe: false,
"BIKING", logo: "hanna.png",
"TERRIBLE", nbSessionPerWeek: 2,
"SPORTY", goal: "Se tonifier",
"test@7.com", healthProblems: [],
"password7" sport: "RANDO",
), sleepLevel: "GOOD",
new User( sportLevel: "BEGINNER",
"Hanna", email: "test@8.com",
24, password: "password8",
158, }),
54, new User({
false, name: "Ivan",
"hanna.png", age: 50,
2, height: 175,
"Se tonifier", weight: 95,
[], sexe: true,
"RANDO", logo: "ivan.png",
"GOOD", nbSessionPerWeek: 1,
"BEGINNER", goal: "Rééducation",
"test@8.com", healthProblems: ["ARTHROSE"],
"password8" sport: "WALKING",
), sleepLevel: "BAD",
new User( sportLevel: "BEGINNER",
"Ivan", email: "test@9.com",
50, password: "password9",
175, }),
95, new User({
true, name: "Julia",
"ivan.png", age: 29,
1, height: 170,
"Rééducation", weight: 60,
["ARTHROSE"], sexe: false,
"WALKING", logo: "julia.png",
"BAD", nbSessionPerWeek: 3,
"BEGINNER", goal: "Rester active",
"test@9.com", healthProblems: [],
"password9" sport: "ELSE",
), sleepLevel: "GOOD",
new User( sportLevel: "SPORTY",
"Julia", email: "test@10.com",
29, password: "password10",
170, }),
60,
false,
"julia.png",
3,
"Rester active",
[],
"ELSE",
"GOOD",
"SPORTY",
"test@10.com",
"password10"
),
]; ];
async login(email: string, password: string): Promise<User> { async login(email: string, password: string): Promise<User> {
@ -171,7 +159,7 @@ export class UserStubService implements IUserService {
); );
if (!user) { if (!user) {
throw new Error("No user."); throw new Error("User not found.");
} }
return user; return user;

@ -1,13 +1,13 @@
import BackButton from "@/components/BackButton";
import { EMPTY_FIELD, INVALID_EMAIL } from "@/components/Errors";
import FormError from "@/components/form/FormError";
import TextInput from "@/components/form/FormInput";
import CodeSent from "@/components/modals/CodeSent";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Screen from "@/components/ui/Screen"; import Screen from "@/components/ui/Screen";
import Text from "@/components/ui/Text"; import Text from "@/components/ui/Text";
import TextInput from "@/components/form/FormInput";
import BackButton from "@/components/BackButton";
import { View } from "react-native";
import React from "react"; import React from "react";
import CodeSent from "@/components/modals/CodeSent"; import { View } from "react-native";
import FormError from "@/components/form/FormError";
import { EMPTY_FIELD, INVALID_EMAIL } from "@/components/Errors";
import { isEmail } from "validator"; import { isEmail } from "validator";
export default function ResetPasswordWithEmail() { export default function ResetPasswordWithEmail() {

@ -1,16 +1,16 @@
import {SafeAreaView, View, Text} from "react-native";
import React from "react";
import InternalError from "@/components/error/InternalErrorProblem"; import InternalError from "@/components/error/InternalErrorProblem";
import React from "react";
import { SafeAreaView, View } from "react-native";
export default function AddScreen() { export default function AddScreen() {
return ( return (
<SafeAreaView> <SafeAreaView>
<View> <View>
<InternalError/> <InternalError />
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
} }
//<Text className="m-7 font-extrabold">Welcome to Add Screen </Text> //<Text className="m-7 font-extrabold">Welcome to Add Screen </Text>
// <Text>We will do it soon</Text> // <Text>We will do it soon</Text>

@ -1,11 +1,13 @@
import React from "react";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React from "react";
export default function RootoLayout() { export default function RootoLayout() {
return ( return (
<Stack screenOptions={{ <Stack
headerShown: false, screenOptions={{
}}> headerShown: false,
}}
>
<Stack.Screen name="index" /> <Stack.Screen name="index" />
</Stack> </Stack>
); );

@ -1,11 +1,11 @@
import { ExerciceService } from "@/api/services/exercice/ExercicesServices"; import { ExerciceAPIService } from "@/api/services/exercice/exercice.service.api";
import WorkoutCardComponent from "@/components/WorkoutCardComponent"; import WorkoutCardComponent from "@/components/WorkoutCardComponent";
import { Workout } from "@/model/Workout"; import { Workout } from "@/model/Workout";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FlatList, Text, TouchableOpacity, View } from "react-native"; import { FlatList, Text, TouchableOpacity, View } from "react-native";
const service = new ExerciceService(); const service = new ExerciceAPIService();
export default function ExercicesScreen() { export default function ExercicesScreen() {
const [exercices, setExercices] = useState<Workout[]>([]); const [exercices, setExercices] = useState<Workout[]>([]);

@ -1,10 +1,10 @@
import { ExerciceService } from "@/api/services/exercice/ExercicesServices"; import { ExerciceAPIService } from "@/api/services/exercice/exercice.service.api";
import WorkoutPresentationComponent from "@/components/WorkoutPresentationComponent"; import WorkoutPresentationComponent from "@/components/WorkoutPresentationComponent";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
const service = new ExerciceService(); const service = new ExerciceAPIService();
export default function WorkoutScreen() { export default function WorkoutScreen() {
const router = useRouter(); const router = useRouter();

@ -1,19 +1,17 @@
import { Stack } from "expo-router";
import React from "react"; import React from "react";
import {Stack} from "expo-router";
export default function RootoLayout() { export default function RootoLayout() {
return ( return (
<Stack screenOptions={{ <Stack
headerShown: false, screenOptions={{
}} headerShown: false,
initialRouteName={"ExercicesScreen"} }}
> initialRouteName={"ExercicesScreen"}
<Stack.Screen name="ExercicesScreen" /> >
<Stack.Screen name="ExercicesScreen" />
<Stack.Screen name="WorkoutScreen"/> <Stack.Screen name="WorkoutScreen" />
</Stack>
</Stack> );
}
);
}

@ -1,14 +1,13 @@
import {SafeAreaView, Text, View} from "react-native";
import React from "react";
import Blocked from "@/components/error/BlockedProblem"; import Blocked from "@/components/error/BlockedProblem";
import React from "react";
import { SafeAreaView, View } from "react-native";
export default function HelpsScreen() { export default function HelpsScreen() {
return ( return (
<SafeAreaView> <SafeAreaView>
<View> <View>
<Blocked/> <Blocked />
</View> </View>
</SafeAreaView> </SafeAreaView>
);
); }
}

@ -1,15 +1,14 @@
import { Stack } from "expo-router";
import React from "react"; import React from "react";
import {Stack} from "expo-router";
import HelpsScreen from "@/app/(tabs)/(help)/HelpsScreen";
export default function RootoLayout() { export default function RootoLayout() {
return ( return (
<Stack screenOptions={{ <Stack
headerShown: false, screenOptions={{
}}> headerShown: false,
<Stack.Screen name="HelpsScreen" /> }}
</Stack> >
<Stack.Screen name="HelpsScreen" />
); </Stack>
);
} }

@ -1,4 +1,4 @@
import { ExerciceService } from "@/api/services/exercice/ExercicesServices"; import { ExerciceAPIService } from "@/api/services/exercice/exercice.service.api";
import ActivitiesComponent from "@/components/ActivitiesComponent"; import ActivitiesComponent from "@/components/ActivitiesComponent";
import CalendarComponent from "@/components/CalendarComponent"; import CalendarComponent from "@/components/CalendarComponent";
import WelcomeComponent from "@/components/WelcomeComponent"; import WelcomeComponent from "@/components/WelcomeComponent";
@ -8,7 +8,7 @@ import { Workout } from "@/model/Workout";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { ScrollView, Text, View } from "react-native"; import { ScrollView, Text, View } from "react-native";
const service = new ExerciceService(); const service = new ExerciceAPIService();
export default function HomeScreen() { export default function HomeScreen() {
const [exercices, setExercices] = useState<Workout[]>([]); const [exercices, setExercices] = useState<Workout[]>([]);

@ -3,9 +3,11 @@ import React from "react";
export default function RootoLayout() { export default function RootoLayout() {
return ( return (
<Stack screenOptions={{ <Stack
headerShown: false, screenOptions={{
}}> headerShown: false,
}}
>
<Stack.Screen name="HomeScreen" /> <Stack.Screen name="HomeScreen" />
</Stack> </Stack>
); );

@ -1,10 +1,6 @@
import {SafeAreaView, Text, View} from "react-native";
import React from "react";
import HomeScreen from "@/app/(tabs)/(home)/HomeScreen"; import HomeScreen from "@/app/(tabs)/(home)/HomeScreen";
import React from "react";
export default function App() { export default function App() {
return <HomeScreen />;
return ( }
<HomeScreen/>
);
}

@ -1,8 +1,8 @@
import { SafeAreaView, Text, View } from "react-native";
import React from "react";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { useSession } from "@/ctx"; import { useSession } from "@/ctx";
import { router } from "expo-router"; import { router } from "expo-router";
import React from "react";
import { SafeAreaView, Text, View } from "react-native";
export default function ProfileScreen() { export default function ProfileScreen() {
const { signOut } = useSession(); const { signOut } = useSession();

@ -3,10 +3,12 @@ import React from "react";
export default function RootoLayout() { export default function RootoLayout() {
return ( return (
<Stack screenOptions={{ <Stack
headerShown: false, screenOptions={{
}}> headerShown: false,
<Stack.Screen name="ProfileScreen" /> }}
</Stack> >
<Stack.Screen name="ProfileScreen" />
</Stack>
); );
} }

@ -1,37 +1,50 @@
import Text from "@/components/ui/Text";
import React, {forwardRef} from "react";
import {Image, View, ViewProps} from "react-native";
import BackButton from "@/components/BackButton"; import BackButton from "@/components/BackButton";
import Text from "@/components/ui/Text";
import React, { forwardRef } from "react";
import { Image, View, ViewProps } from "react-native";
import {Entypo} from "@expo/vector-icons"; import { Entypo } from "@expo/vector-icons";
export interface ProblemProps extends ViewProps{ export interface ProblemProps extends ViewProps {
picture: string; picture: string;
problem: string; problem: string;
description: string; description: string;
information: string; information: string;
isVisible?: boolean; isVisible?: boolean;
} }
export default forwardRef<any, ProblemProps> (({className, ...Props}, ref) => { export default forwardRef<any, ProblemProps>(({ className, ...Props }, ref) => {
return ( return (
<View className={"gap-4 justify-between h-3/4" + " " + className} {...Props} ref={ref}> <View
<View className="flex-row justify-between items-center p-4"> className={"gap-4 justify-between h-3/4" + " " + className}
<BackButton/> {...Props}
</View> ref={ref}
>
<View className="flex-row justify-between items-center p-4">
<BackButton />
</View>
<View className="flex-row justify-center"> <View className="flex-row justify-center">
<Image className="aspect-square w-3/5 h-3/5" source={Props.picture}/> <Image className="aspect-square w-3/5 h-3/5" source={Props.picture} />
</View> </View>
<Text position="center" weight="bold" size="3xl"> {Props.problem} </Text> <Text position="center" weight="bold" size="3xl">
<Text size="lg" position="center" className="text-gray-400"> {Props.description} </Text> {" "}
<View className="flex-row justify-center"> {Props.problem}{" "}
<View className="flex-row items-center border-2 rounded-2xl bg-red-300 border-red-600 p-4"> </Text>
<Entypo name="warning" size={30} color="red"/> <Text size="lg" position="center" className="text-gray-400">
<Text size="lg" position="center"> {Props.information} </Text> {" "}
</View> {Props.description}{" "}
</View> </Text>
<View className="flex-row justify-center">
<View className="flex-row items-center border-2 rounded-2xl bg-red-300 border-red-600 p-4">
<Entypo name="warning" size={30} color="red" />
<Text size="lg" position="center">
{" "}
{Props.information}{" "}
</Text>
</View> </View>
); </View>
}); </View>
);
});

@ -1,113 +1,114 @@
import {Text, TouchableOpacity, View} from "react-native"; import React, { useRef, useState } from "react";
import {CurveType, LineChart} from "react-native-gifted-charts"; import { Text, TouchableOpacity, View } from "react-native";
import React, {useRef, useState} from "react"; import { CurveType, LineChart } from "react-native-gifted-charts";
export default function ActivitiesComponent() { export default function ActivitiesComponent() {
const ref = useRef(null) const ref = useRef(null);
const [dateSelected, setDateSelected] = useState('1d'); const [dateSelected, setDateSelected] = useState("1d");
const [lineData, setLineData] = useState([ const [lineData, setLineData] = useState([
{value: 4}, { value: 4 },
{value: 14}, { value: 14 },
{value: 8}, { value: 8 },
{value: 38}, { value: 38 },
{value: 36}, { value: 36 },
{value: 28}, { value: 28 },
]); ]);
const months = ['1d','1w','1m','1y','All'] const months = ["1d", "1w", "1m", "1y", "All"];
const changeMonthSelected = (ind: number) => { const changeMonthSelected = (ind: number) => {
const selectedMonth = months[ind]; const selectedMonth = months[ind];
setDateSelected(selectedMonth); setDateSelected(selectedMonth);
// Update lineData based on the selected month // Update lineData based on the selected month
let newData: React.SetStateAction<{ value: number; }[]>; let newData: React.SetStateAction<{ value: number }[]>;
switch (selectedMonth) { switch (selectedMonth) {
case '1d': case "1d":
newData = [ newData = [
{value: 4}, { value: 4 },
{value: 14}, { value: 14 },
{value: 8}, { value: 8 },
{value: 38}, { value: 38 },
{value: 36}, { value: 36 },
{value: 28}, { value: 28 },
]; ];
break; break;
case '1w': case "1w":
newData = [ newData = [
{value: 8}, { value: 8 },
{value: 14}, { value: 14 },
{value: 8}, { value: 8 },
{value: 38}, { value: 38 },
{value: 14}, { value: 14 },
{value: 28}, { value: 28 },
{value: 4}, { value: 4 },
]; ];
break; break;
case '1m': case "1m":
newData = [ newData = [
{value: 10}, { value: 10 },
{value: 20}, { value: 20 },
{value: 30}, { value: 30 },
{value: 40}, { value: 40 },
{value: 50}, { value: 50 },
{value: 60}, { value: 60 },
]; ];
break; break;
case '1y': case "1y":
newData = [ newData = [
{value: 15}, { value: 15 },
{value: 25}, { value: 25 },
{value: 35}, { value: 35 },
{value: 45}, { value: 45 },
{value: 55}, { value: 55 },
{value: 65}, { value: 65 },
]; ];
break; break;
case 'All': case "All":
newData = [ newData = [
{value: 5}, { value: 5 },
{value: 15}, { value: 15 },
{value: 25}, { value: 25 },
{value: 35}, { value: 35 },
{value: 45}, { value: 45 },
{value: 55}, { value: 55 },
]; ];
break; break;
default: default:
newData = []; newData = [];
} }
setLineData(newData); setLineData(newData);
}; };
return ( return (
<View className="bg-gray-200 rounded-2xl p-1 h-full"> <View className="bg-gray-200 rounded-2xl p-1 h-full">
<View className=" m-2 flex-row justify-center rounded-2xl bg-white"> <View className=" m-2 flex-row justify-center rounded-2xl bg-white">
{months.map((item, index) => { {months.map((item, index) => {
return ( return (
<TouchableOpacity <TouchableOpacity
key={index} key={index}
className={`w-16 h-10 flex items-center justify-center rounded-xl ${ className={`w-16 h-10 flex items-center justify-center rounded-xl ${
dateSelected === item dateSelected === item
? "bg-orange-500 border-2 border-orange-300" ? "bg-orange-500 border-2 border-orange-300"
: "bg-transparent " : "bg-transparent "
}`} }`}
onPress={() => changeMonthSelected(index)}> onPress={() => changeMonthSelected(index)}
<Text className="font-extrabold">{months[index]}</Text> >
</TouchableOpacity> <Text className="font-extrabold">{months[index]}</Text>
); </TouchableOpacity>
})} );
</View> })}
<LineChart </View>
scrollRef={ref} <LineChart
data={lineData} scrollRef={ref}
areaChart data={lineData}
curved areaChart
initialSpacing={0} curved
rotateLabel initialSpacing={0}
isAnimated={true} rotateLabel
startFillColor="orange" isAnimated={true}
curveType={CurveType.QUADRATIC} startFillColor="orange"
/> curveType={CurveType.QUADRATIC}
</View> />
); </View>
} );
}

@ -1,51 +1,51 @@
import {FlatList, TouchableOpacity,Text, View} from "react-native";
import {useState} from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
export default function CalendarComponent() { export default function CalendarComponent() {
const [selectedDay] = useState(dayjs().date()); const [selectedDay] = useState(dayjs().date());
const days = Array.from({ length: 7 }, (_, index) => { const days = Array.from({ length: 7 }, (_, index) => {
const day = dayjs().add(index, "day"); const day = dayjs().add(index, "day");
return { return {
id: day.date(), id: day.date(),
label: day.format("ddd"), label: day.format("ddd"),
}; };
}); });
return ( return (
<View className="bg-transparent"> <View className="bg-transparent">
<FlatList <FlatList
horizontal horizontal
data={days} data={days}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 12 }} // Espacement entre les items contentContainerStyle={{ gap: 12 }} // Espacement entre les items
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity
className={`w-16 h-20 flex items-center justify-center rounded-xl ${ className={`w-16 h-20 flex items-center justify-center rounded-xl ${
selectedDay === item.id selectedDay === item.id
? "bg-orange-500 border-2 border-orange-300" ? "bg-orange-500 border-2 border-orange-300"
: "bg-black" : "bg-black"
}`} }`}
> >
<Text <Text
className={`text-sm ${ className={`text-sm ${
selectedDay === item.id ? "text-white" : "text-gray-400" selectedDay === item.id ? "text-white" : "text-gray-400"
}`} }`}
> >
{item.label} {item.label}
</Text> </Text>
<Text <Text
className={`text-lg font-bold ${ className={`text-lg font-bold ${
selectedDay === item.id ? "text-white" : "text-gray-200" selectedDay === item.id ? "text-white" : "text-gray-200"
}`} }`}
> >
{item.id} {item.id}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
/> />
</View> </View>
); );
} }

@ -1,6 +1,3 @@
import React, { forwardRef } from "react";
import { View, TouchableOpacity, ViewProps } from "react-native";
import Text from "./ui/Text";
import { import {
AntDesign, AntDesign,
Entypo, Entypo,
@ -8,6 +5,8 @@ import {
Ionicons, Ionicons,
MaterialCommunityIcons, MaterialCommunityIcons,
} from "@expo/vector-icons"; } from "@expo/vector-icons";
import React, { forwardRef } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { import {
AntDesignIconNames, AntDesignIconNames,
CommunityIconNames, CommunityIconNames,
@ -16,6 +15,7 @@ import {
FontAwesomeIconNames, FontAwesomeIconNames,
IonIconNames, IonIconNames,
} from "./Icons"; } from "./Icons";
import Text from "./ui/Text";
export type CheckBoxDirection = "row" | "col"; export type CheckBoxDirection = "row" | "col";

@ -5,6 +5,8 @@ export const NOT_MATCHING_PASSWORD = "Les mots de passe sont différents";
export const NOT_FOUND = "Ressource introuvable :<"; export const NOT_FOUND = "Ressource introuvable :<";
export const NO_INTERNET = "Pas de connexion à internet"; export const NO_INTERNET = "Pas de connexion à internet";
export const INTERNAL_ERROR = "Erreur interne, veuillez nous pardonner"; export const INTERNAL_ERROR = "Erreur interne, veuillez nous pardonner";
export const MAINTENANCE = "Le serveur est en maintenance, veuillez réessayer plus tard"; export const MAINTENANCE =
export const NOT_AUTHORIZED = "Vous n'êtes pas autorisé à accéder à cette ressource"; "Le serveur est en maintenance, veuillez réessayer plus tard";
export const FEATURE_LOCKED = "Cette fonctionnalité est verrouillée"; export const NOT_AUTHORIZED =
"Vous n'êtes pas autorisé à accéder à cette ressource";
export const FEATURE_LOCKED = "Cette fonctionnalité est verrouillée";

@ -49,9 +49,7 @@ export default function LinearProgressBar({ duration = 10 }) {
<TouchableOpacity <TouchableOpacity
onPress={startAnimation} onPress={startAnimation}
disabled={isRunning} disabled={isRunning}
className={`px-4 py-2 rounded-full ${ className={"px-4 py-2 rounded-full bg-orange-400"}
isRunning ? "bg-orange-400" : "bg-orange-400"
}`}
> >
<Text className="text-white font-bold"> <Text className="text-white font-bold">
{isRunning ? "En cours..." : "Play"} {isRunning ? "En cours..." : "Play"}

@ -7,23 +7,15 @@ import { ImageBackground, TouchableOpacity, View } from "react-native";
interface WorkoutCardComponentProps { interface WorkoutCardComponentProps {
exercise?: Workout; exercise?: Workout;
background?: String; background?: string;
height?: number; height?: number;
} }
export default function WorkoutCardComponent({ export default function WorkoutCardComponent({
exercise, exercise,
height, }: Readonly<WorkoutCardComponentProps>) {
background,
}: WorkoutCardComponentProps) {
const style = () => {
return `h-full rounded-2xl overflow-hidden ${background ?? "bg-black"}`;
};
const styleImage = () => {
return `w-full h-full `;
};
const router = useRouter(); const router = useRouter();
return ( return (
<View className="h-full rounded-2xl overflow-hidden bg-black"> <View className="h-full rounded-2xl overflow-hidden bg-black">
<ImageBackground <ImageBackground

@ -9,12 +9,12 @@ import { ImageBackground, Text, TouchableOpacity, View } from "react-native";
type WorkoutPresentationComponentProps = { type WorkoutPresentationComponentProps = {
workout: Workout; workout: Workout;
dataExercise: Workout[]; dataExercise: Workout[];
router: Router; // Typage précis recommandé selon ta navigation router: Router;
}; };
export default function WorkoutPresentationComponent({ export default function WorkoutPresentationComponent({
workout, workout,
}: WorkoutPresentationComponentProps) { }: Readonly<WorkoutPresentationComponentProps>) {
const router = useRouter(); const router = useRouter();
return ( return (
<ImageBackground <ImageBackground
@ -38,10 +38,8 @@ export default function WorkoutPresentationComponent({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Permet de pousser le reste du contenu vers le bas */}
<View className="flex-grow" /> <View className="flex-grow" />
{/* Texte en bas */}
<View className="items-center mb-10"> <View className="items-center mb-10">
<Text className="text-white bg-transparent border-2 border-white px-3 py-1 rounded-full text-2xl font-bold"> <Text className="text-white bg-transparent border-2 border-white px-3 py-1 rounded-full text-2xl font-bold">
{workout.nbSeries} x {workout.nbRepetitions} {workout.nbSeries} x {workout.nbRepetitions}
@ -52,7 +50,6 @@ export default function WorkoutPresentationComponent({
</Text> </Text>
</View> </View>
{/* Barre de progression */}
<View className="mb-5"> <View className="mb-5">
<LinearProgressBar duration={workout.duration} /> <LinearProgressBar duration={workout.duration} />
</View> </View>

@ -1,15 +1,16 @@
import React from "react";
import {FEATURE_LOCKED} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import blockedPict from "@/assets/images/Blocked.png"; import blockedPict from "@/assets/images/Blocked.png";
import { FEATURE_LOCKED } from "@/components/Errors";
import React from "react";
export default function Blocked() { export default function Blocked() {
return ( return (
<Error <Error
picture={blockedPict} picture={blockedPict}
problem="Fonctionnalité bloquée" problem="Fonctionnalité bloquée"
description={FEATURE_LOCKED} description={FEATURE_LOCKED}
information="Devenez PREMIUM pour débloquer" information="Devenez PREMIUM pour débloquer"
/> />
); );
} }

@ -1,15 +1,16 @@
import React from "react";
import {INTERNAL_ERROR} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import internalErrorPict from "@/assets/images/InternalError.png"; import internalErrorPict from "@/assets/images/InternalError.png";
import { INTERNAL_ERROR } from "@/components/Errors";
import React from "react";
export default function InternalError() { export default function InternalError() {
return ( return (
<Error <Error
picture={internalErrorPict} picture={internalErrorPict}
problem="Problème interne" problem="Problème interne"
description={INTERNAL_ERROR} description={INTERNAL_ERROR}
information="Contactez le support" information="Contactez le support"
/> />
); );
} }

@ -1,15 +1,16 @@
import React from "react";
import {MAINTENANCE} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import maintenancePict from "@/assets/images/Maintenance.png"; import maintenancePict from "@/assets/images/Maintenance.png";
import { MAINTENANCE } from "@/components/Errors";
import React from "react";
export default function Maintenance() { export default function Maintenance() {
return ( return (
<Error <Error
picture={maintenancePict} picture={maintenancePict}
problem="Maintenance" problem="Maintenance"
description={MAINTENANCE} description={MAINTENANCE}
information="Revenez plus tard" information="Revenez plus tard"
/> />
); );
} }

@ -1,15 +1,16 @@
import React from "react";
import {NO_INTERNET} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import noInternetPict from "@/assets/images/NoInternet.png"; import noInternetPict from "@/assets/images/NoInternet.png";
import { NO_INTERNET } from "@/components/Errors";
import React from "react";
export default function NoInternet() { export default function NoInternet() {
return ( return (
<Error <Error
picture={noInternetPict} picture={noInternetPict}
problem="Pas d'internet" problem="Pas d'internet"
description={NO_INTERNET} description={NO_INTERNET}
information="Réessayez plus tard" information="Réessayez plus tard"
/> />
); );
} }

@ -1,15 +1,16 @@
import React from "react";
import {NOT_AUTHORIZED} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import notAllowedPict from "@/assets/images/NotAllowed.png"; import notAllowedPict from "@/assets/images/NotAllowed.png";
import { NOT_AUTHORIZED } from "@/components/Errors";
import React from "react";
export default function NotAllowedProblem() { export default function NotAllowedProblem() {
return ( return (
<Error <Error
picture={notAllowedPict} picture={notAllowedPict}
problem="Pas autorisé" problem="Pas autorisé"
description={NOT_AUTHORIZED} description={NOT_AUTHORIZED}
information="Connectez vous avec plus de privilèges" information="Connectez vous avec plus de privilèges"
/> />
); );
} }

@ -1,15 +1,16 @@
import React from "react";
import {NOT_FOUND} from "@/components/Errors";
import Error from "@/app/(utility)/Error"; import Error from "@/app/(utility)/Error";
//@ts-ignore
import notFoundPict from "@/assets/images/NotFound.png"; import notFoundPict from "@/assets/images/NotFound.png";
import { NOT_FOUND } from "@/components/Errors";
import React from "react";
export default function NotFound() { export default function NotFound() {
return ( return (
<Error <Error
picture={notFoundPict} picture={notFoundPict}
problem="Introuvable" problem="Introuvable"
description={NOT_FOUND} description={NOT_FOUND}
information="Status Code : 404" information="Status Code : 404"
/> />
); );
} }

@ -35,13 +35,13 @@ export default React.forwardRef<any, ViewProps>(
if (isEmail(email)) { if (isEmail(email)) {
if (password != "") { if (password != "") {
validateForm(); validateForm();
const user = userService.login(email, password); const user = userService.login(email, password).catch((e) => {
invalidateForm("Email ou mot de passe incorrect");
});
user.then((u) => { user.then((u) => {
if (u) { if (u) {
signIn(u); signIn(u);
router.replace("/HomeScreen"); router.replace("/HomeScreen");
} else {
invalidateForm("Email ou mot de passe incorrect");
} }
}); });
} else { } else {

@ -1,9 +1,9 @@
import { AntDesign } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CustomText from "./Text";
import { AntDesign } from "@expo/vector-icons";
import { AntDesignIconNames } from "../Icons";
import { Size } from "../Constants"; import { Size } from "../Constants";
import { AntDesignIconNames } from "../Icons";
import CustomText from "./Text";
export type ButtonStyle = "default" | "outline" | "secondary"; export type ButtonStyle = "default" | "outline" | "secondary";

@ -1,6 +1,6 @@
import React from "react";
import { View, ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import React from "react";
export default React.forwardRef<any, ViewProps>( export default React.forwardRef<any, ViewProps>(
({ children, ...props }, ref): React.ReactElement => { ({ children, ...props }, ref): React.ReactElement => {

@ -1,7 +1,7 @@
import React from "react";
import SegmentedControl, { import SegmentedControl, {
SegmentedControlProps, SegmentedControlProps,
} from "@react-native-segmented-control/segmented-control"; } from "@react-native-segmented-control/segmented-control";
import React from "react";
export default React.forwardRef<any, SegmentedControlProps>( export default React.forwardRef<any, SegmentedControlProps>(
(props, ref): React.ReactElement => { (props, ref): React.ReactElement => {

@ -1,5 +1,5 @@
import React from "react";
import Slider, { SliderProps } from "@react-native-community/slider"; import Slider, { SliderProps } from "@react-native-community/slider";
import React from "react";
export default React.forwardRef<any, SliderProps>( export default React.forwardRef<any, SliderProps>(
({ ...props }, ref): React.ReactElement => { ({ ...props }, ref): React.ReactElement => {

@ -27,7 +27,7 @@ export function SessionProvider(props: Readonly<React.PropsWithChildren>) {
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
signIn: (user: User) => { signIn: (user: User) => {
setSessionStr(JSON.stringify(user)); setSessionStr(user.toJSON());
}, },
signOut: () => { signOut: () => {
setSessionStr(null); setSessionStr(null);

@ -6,37 +6,52 @@ import {
} from "./enums/Enums"; } from "./enums/Enums";
export class User { export class User {
private _name: string | undefined; private _name?: string;
private _age: number | undefined; private _age?: number;
private _height: number | undefined; private _height?: number;
private _weight: number | undefined; private _weight?: number;
private _sexe: boolean | undefined; // true = Male, false = Female private _sexe?: boolean; // true = Male, false = Female
private _logo: string | undefined; private _logo?: string;
private _nbSessionPerWeek: number | undefined; private _nbSessionPerWeek?: number;
private _goal: string | undefined; private _goal?: string;
private _healthProblems: EHealthProblem[] | undefined; private _healthProblems?: EHealthProblem[];
private _sport: ESport | undefined; private _sport?: ESport;
private _sleepLevel: ESleepLevel | undefined; private _sleepLevel?: ESleepLevel;
private _sportLevel: ESportLevel | undefined; private _sportLevel?: ESportLevel;
private _email: string; private _email?: string;
private _password: string; private _password?: string;
constructor( constructor({
name: string | undefined, name,
age: number | undefined, age,
height: number | undefined, height,
weight: number | undefined, weight,
sexe: boolean | undefined, sexe,
logo: string | undefined, logo,
nbSessionPerWeek: number | undefined, nbSessionPerWeek,
goal: string | undefined, goal,
healthProblems: EHealthProblem[] | undefined, healthProblems,
sport: ESport | undefined, sport,
sleepLevel: ESleepLevel | undefined, sleepLevel,
sportLevel: ESportLevel | undefined, sportLevel,
email: string, email,
password: string password,
) { }: {
name?: string;
age?: number;
height?: number;
weight?: number;
sexe?: boolean;
logo?: string;
nbSessionPerWeek?: number;
goal?: string;
healthProblems?: EHealthProblem[];
sport?: ESport;
sleepLevel?: ESleepLevel;
sportLevel?: ESportLevel;
email?: string;
password?: string;
} = {}) {
this._name = name; this._name = name;
this._age = age; this._age = age;
this._height = height; this._height = height;
@ -82,7 +97,7 @@ export class User {
return this._nbSessionPerWeek; return this._nbSessionPerWeek;
} }
get goals(): string | undefined { get goal(): string | undefined {
return this._goal; return this._goal;
} }
@ -90,7 +105,7 @@ export class User {
return this._healthProblems; return this._healthProblems;
} }
get sports(): ESport | undefined { get sport(): ESport | undefined {
return this._sport; return this._sport;
} }
@ -102,11 +117,11 @@ export class User {
return this._sportLevel; return this._sportLevel;
} }
get email(): string { get email(): string | undefined {
return this._email; return this._email;
} }
get password(): string { get password(): string | undefined {
return this._password; return this._password;
} }
@ -139,7 +154,7 @@ export class User {
this._nbSessionPerWeek = value; this._nbSessionPerWeek = value;
} }
set goals(value: string | undefined) { set goal(value: string | undefined) {
this._goal = value; this._goal = value;
} }
@ -147,7 +162,7 @@ export class User {
this._healthProblems = value; this._healthProblems = value;
} }
set sports(value: ESport | undefined) { set sport(value: ESport | undefined) {
this._sport = value; this._sport = value;
} }
@ -184,21 +199,40 @@ export class User {
} }
static fromJSON(json: any): User { static fromJSON(json: any): User {
return new User( return new User({
json._name, name: json.name,
json._age, age: json.age,
json._height, height: json.height,
json._weight, weight: json.weight,
json._sexe, sexe: json.sexe,
json._logo, logo: json.logo,
json._nbSessionPerWeek, nbSessionPerWeek: json.nbSessionPerWeek,
json._goal, goal: json.goal,
json._healthProblems, healthProblems: json.healthProblems,
json._sport, sport: json.sport,
json._sleepLevel, sleepLevel: json.sleepLevel,
json._sportLevel, sportLevel: json.sportLevel,
json._email, email: json.email,
json._password password: json.password,
); });
}
toJSON(): any {
return JSON.stringify({
name: this.name,
age: this.age,
height: this.height,
weight: this.weight,
sexe: this.sexe,
logo: this.logo,
nbSessionPerWeek: this.nbSessionPerWeek,
goal: this.goal,
healthProblems: this.healthProblems,
sport: this.sport,
sleepLevel: this.sleepLevel,
sportLevel: this.sportLevel,
email: this.email,
password: this.password,
});
} }
} }

@ -1,10 +1,118 @@
export interface Workout { export class Workout {
id: string; private _id?: string;
name: string; private _name?: string;
description: string; private _description?: string;
duration: number; private _duration?: number;
image: string; private _image?: string;
video: string; private _video?: string;
nbSeries: number; private _nbSeries?: number;
nbRepetitions: number; private _nbRepetitions?: number;
constructor({
id,
name,
description,
duration,
image,
video,
nbSeries,
nbRepetitions,
}: {
id?: string;
name?: string;
description?: string;
duration?: number;
image?: string;
video?: string;
nbSeries?: number;
nbRepetitions?: number;
} = {}) {
this._id = id;
this._name = name;
this._description = description;
this._duration = duration;
this._image = image;
this._video = video;
this._nbSeries = nbSeries;
this._nbRepetitions = nbRepetitions;
}
// Getters
get id(): string | undefined {
return this._id;
}
get name(): string | undefined {
return this._name;
}
get description(): string | undefined {
return this._description;
}
get duration(): number | undefined {
return this._duration;
}
get image(): string | undefined {
return this._image;
}
get video(): string | undefined {
return this._video;
}
get nbSeries(): number | undefined {
return this._nbSeries;
}
get nbRepetitions(): number | undefined {
return this._nbRepetitions;
}
// Setters
set id(value: string | undefined) {
this._id = value;
}
set name(value: string | undefined) {
this._name = value;
}
set description(value: string | undefined) {
this._description = value;
}
set duration(value: number | undefined) {
this._duration = value;
}
set image(value: string | undefined) {
this._image = value;
}
set video(value: string | undefined) {
this._video = value;
}
set nbSeries(value: number | undefined) {
this._nbSeries = value;
}
set nbRepetitions(value: number | undefined) {
this._nbRepetitions = value;
}
static fromJson(json: any): Workout {
return new Workout({
id: json.id,
name: json.name,
description: json.description,
duration: json.duration,
image: json.image,
video: json.video,
nbSeries: json.nbSeries,
nbRepetitions: json.nbRepetitions,
});
}
} }

2
package-lock.json generated

@ -28,7 +28,7 @@
"expo-linear-gradient": "^14.0.2", "expo-linear-gradient": "^14.0.2",
"expo-linking": "^7.0.5", "expo-linking": "^7.0.5",
"expo-router": "~5.0.7", "expo-router": "~5.0.7",
"expo-secure-store": "^14.0.1", "expo-secure-store": "^14.2.3",
"expo-splash-screen": "~0.30.8", "expo-splash-screen": "~0.30.8",
"expo-status-bar": "^2.0.1", "expo-status-bar": "^2.0.1",
"expo-symbols": "~0.4.4", "expo-symbols": "~0.4.4",

@ -35,7 +35,7 @@
"expo-linear-gradient": "^14.0.2", "expo-linear-gradient": "^14.0.2",
"expo-linking": "^7.0.5", "expo-linking": "^7.0.5",
"expo-router": "~5.0.7", "expo-router": "~5.0.7",
"expo-secure-store": "^14.0.1", "expo-secure-store": "^14.2.3",
"expo-splash-screen": "~0.30.8", "expo-splash-screen": "~0.30.8",
"expo-status-bar": "^2.0.1", "expo-status-bar": "^2.0.1",
"expo-symbols": "~0.4.4", "expo-symbols": "~0.4.4",

Loading…
Cancel
Save