From 4ecd556918bdb1ee16545e6f3dd83f4e64cc1303 Mon Sep 17 00:00:00 2001 From: Arthi-chaud Date: Fri, 4 Nov 2022 09:18:48 +0000 Subject: [PATCH 01/11] User Slice: Use API Instance instead of access token --- front/state/UserSlice.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/front/state/UserSlice.ts b/front/state/UserSlice.ts index a7904f7..fdac788 100644 --- a/front/state/UserSlice.ts +++ b/front/state/UserSlice.ts @@ -1,19 +1,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import AuthToken from '../models/AuthToken'; +import API from '../API'; export const userSlice = createSlice({ name: 'user', initialState: { - token: undefined as AuthToken | undefined + apiAccess: undefined as API | undefined }, reducers: { - setUserToken: (state, action: PayloadAction) => { - state.token = action.payload; + setAPIAccess: (state, action: PayloadAction) => { + state.apiAccess = action.payload; }, - unsetUserToken: (state) => { - state.token = undefined; + unsetAPIAccess: (state) => { + state.apiAccess = undefined; }, }, }); -export const { setUserToken, unsetUserToken } = userSlice.actions; +export const { setAPIAccess, unsetAPIAccess } = userSlice.actions; export default userSlice.reducer; \ No newline at end of file From 889d07cfe5a1b28408587734fbb623eb6ec90a57 Mon Sep 17 00:00:00 2001 From: Arthi-chaud Date: Sun, 13 Nov 2022 15:48:52 +0000 Subject: [PATCH 02/11] Front: Fix API calls with JWT Token --- .env.example | 1 + back/src/main.ts | 1 + docker-compose.yml | 4 +- front/API.ts | 222 ++++++++++++++-------- front/Navigation.tsx | 2 +- front/app.config.ts | 40 ++++ front/app.json | 41 ---- front/components/SearchBar.tsx | 2 +- front/components/SearchBarSuggestions.tsx | 4 +- front/components/SongCard.tsx | 2 +- front/components/forms/signinform.tsx | 1 - front/components/forms/signupform.tsx | 2 +- front/models/Song.ts | 8 +- front/package.json | 3 + front/state/UserSlice.ts | 14 +- front/tsconfig.json | 2 +- front/views/AuthenticationView.tsx | 30 ++- front/views/HomeView.tsx | 68 +++---- front/views/SearchView.tsx | 75 +++----- front/views/SettingsView.tsx | 7 +- front/views/SongLobbyView.tsx | 10 +- front/yarn.lock | 77 ++++++++ 22 files changed, 368 insertions(+), 248 deletions(-) create mode 100644 front/app.config.ts delete mode 100644 front/app.json diff --git a/.env.example b/.env.example index 7f48820..c3123a6 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ POSTGRES_NAME= POSTGRES_HOST= DATABASE_URL= JWT_SECRET= +API_URL= \ No newline at end of file diff --git a/back/src/main.ts b/back/src/main.ts index f9d8c18..354fbed 100644 --- a/back/src/main.ts +++ b/back/src/main.ts @@ -18,6 +18,7 @@ async function bootstrap() { SwaggerModule.setup('api', app, document); app.useGlobalPipes(new ValidationPipe()); + app.enableCors(); await app.listen(3000); } bootstrap(); diff --git a/docker-compose.yml b/docker-compose.yml index 461d3e5..b69c965 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: front: build: ./front ports: - - "8080:80" + - "80:80" depends_on: - "back" + env_file: + - .env diff --git a/front/API.ts b/front/API.ts index edc4c18..f7c28f0 100644 --- a/front/API.ts +++ b/front/API.ts @@ -1,3 +1,4 @@ +import Artist from "./models/Artist"; import AuthToken from "./models/AuthToken"; import Chapter from "./models/Chapter"; import Lesson from "./models/Lesson"; @@ -5,51 +6,88 @@ import LessonHistory from "./models/LessonHistory"; import Song from "./models/Song"; import SongHistory from "./models/SongHistory"; import User from "./models/User"; -import { translate } from "./i18n/i18n"; +import Constants from 'expo-constants'; +import store from "./state/Store"; -const delay = (seconds: number) => new Promise(resolve => setTimeout(resolve, seconds * 1000)); +type AuthenticationInput = { username: string, password: string }; +type RegistrationInput = AuthenticationInput & { email: string }; +export type AccessToken = string; -declare type AuthenticationInput = { email: string, password: string }; +type FetchParams = { + route: string; + body?: Object; + method?: 'GET' | 'POST' | 'DELETE' +} + +const dummyIllustration = "https://i.discogs.com/syRCX8NaLwK2SMk8X6TVU_DWc8RRqE4b-tebAQ6kVH4/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTgyNTQz/OC0xNjE3ODE0NDI2/LTU1MjUuanBlZw.jpeg"; export default class API { + private static async fetch(params: FetchParams) { + const jwtToken = store.getState().user.accessToken; + const header = { + 'Content-Type': 'application/json' + } + const response = await fetch(`${Constants.manifest?.extra?.apiUrl}${params.route}`, { + headers: jwtToken && { ...header, 'Authorization': `Bearer ${jwtToken}` } || header, + body: JSON.stringify(params.body), + method: params.method ?? 'GET' + }); + const jsonResponse = await response.json().catch(() => { + throw new Error("Error while parsing Server's response"); + }); + if (!response.ok) { + throw new Error(jsonResponse.error ?? response.statusText) + } + return jsonResponse; + } + + public static async authenticate(authenticationInput: AuthenticationInput): Promise { + return API.fetch({ + route: '/auth/login', + body: authenticationInput, + method: 'POST' + }).then((responseBody) => responseBody.access_token) + } + /** + * Create a new user profile, with an email and a password + * @param registrationInput the credentials to create a new profile + * @returns A Promise. On success, will be resolved into an instance of the API wrapper + */ + public static async createAccount(registrationInput: RegistrationInput): Promise { + await API.fetch({ + route: '/auth/register', + body: registrationInput, + method: 'POST' + }); + return API.authenticate(registrationInput); + } + /*** * Retrieve information of the currently authentified user */ - static async getUserInfo(): Promise { + public static async getUserInfo(): Promise { + return API.fetch({ + route: '/auth/me' + }); + } + + public static async getUserSkills() { return { - name: "User", - email: "user@chromacase.com", - xp: 2345, - premium: false, - metrics: {}, - settings: {}, - id: 1 + pedalsCompetency: Math.random() * 100, + rightHandCompetency: Math.random() * 100, + leftHandCompetency: Math.random() * 100, + accuracyCompetency: Math.random() * 100, + arpegeCompetency: Math.random() * 100, + chordsCompetency: Math.random() * 100, } } - /** - * Logs the user in, with an email and a password - * @param _credentials the credentials to get an authentication token - * @returns an authentication token, that must be used for authentified requests - */ - static async login(_credentials: AuthenticationInput): Promise { - return "12345"; - } - - /** - * Create a new user profile, with an email and a password - * @param _credentials the credentials to create a new profile - * @returns an empty promise. On error, the promise will not be resolved - */ - static async register(_credentials: AuthenticationInput): Promise { - return; - } - /** * Authentify a new user through Google */ - static async authWithGoogle(): Promise { + public static async authWithGoogle(): Promise { + //TODO return "11111"; } @@ -57,22 +95,27 @@ export default class API { * Retrive a song * @param songId the id to find the song */ - static async getSong(songId: number): Promise { - return delay(1).then(() => ({ - title: "Song", - description: "A very very very very very very very very very very very very very very very very very very very very very very very very good song", - album: "Album", - metrics: {}, - id: songId - })); - + public static async getSong(songId: number): Promise { + return API.fetch({ + route: `/song/${songId}` + }); } + /** + * Retrive an artist + */ + public static async getArtist(artistId: number): Promise { + return API.fetch({ + route: `/artist/${artistId}` + }); + } + + /** * Retrive a song's chapters * @param songId the id to find the song */ - static async getSongChapters(songId: number): Promise { + public static async getSongChapters(songId: number): Promise { return [1, 2, 3, 4, 5].map((value) => ({ start: 100 * (value - 1), end: 100 * value, @@ -89,7 +132,7 @@ export default class API { * Retrieve a song's play history * @param songId the id to find the song */ - static async getSongHistory(songId: number): Promise { + public static async getSongHistory(songId: number): Promise { return [6, 1, 2, 3, 4, 5].map((value) => ({ songId: songId, userId: 1, @@ -101,21 +144,23 @@ export default class API { * Search a song by its name * @param query the string used to find the songs */ - static async searchSongs(query: string): Promise { - return [{ - title: "Song", - description: "A song", - album: "Album", - metrics: {}, - id: 1 - }]; + public static async searchSongs(query: string): Promise { + return Array.of(4).map((i) => ({ + id: i, + name: `Searched Song ${i}`, + artistId: i, + genreId: i, + albumId: i, + cover: dummyIllustration, + metrics: {} + })); } /** * Retrieve a lesson * @param lessonId the id to find the lesson */ - static async getLesson(lessonId: number): Promise { + public static async getLesson(lessonId: number): Promise { return { title: "Song", description: "A song", @@ -125,43 +170,60 @@ export default class API { }; } + /** + * Retrieve the authenticated user's search history + * @param lessonId the id to find the lesson + */ + public static async getSearchHistory(): Promise { + return Array.of(4).map((i) => ({ + id: i, + name: `Song in history ${i}`, + artistId: i, + genreId: i, + albumId: i, + cover: dummyIllustration, + metrics: {} + })); + } + + /** + * Retrieve the authenticated user's recommendations + */ + public static async getUserRecommendations(): Promise { + return Array.of(4).map((i) => ({ + id: i, + name: `Recommended Song ${i}`, + artistId: i, + genreId: i, + albumId: i, + cover: dummyIllustration, + metrics: {} + })); + } + + /** + * Retrieve the authenticated user's play history + */ + public static async getUserPlayHistory(): Promise { + return Array.of(4).map((i) => ({ + id: i, + name: `played Song ${i}`, + artistId: i, + genreId: i, + albumId: i, + cover: dummyIllustration, + metrics: {} + })); + } + /** * Retrieve a lesson's history * @param lessonId the id to find the lesson */ - static async getLessonHistory(lessonId: number): Promise { + public static async getLessonHistory(lessonId: number): Promise { return [{ lessonId, userId: 1 }]; } - - /** - * Get the login information status - * - */ - static async checkSigninCredentials(username: string, password: string): Promise { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (username === "katerina" && password === "1234") { - return resolve("token signin"); - } - return reject(translate("invalidCredentials")); - }, 1000); - }); - }; - - /** - * Get the register information status - */ - static async checkSignupCredentials(username: string, password: string, email: string): Promise { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (username === "bluub") { - return reject(translate("usernameTaken")); - } - return resolve("token signup"); - }, 1000); - }); - } } \ No newline at end of file diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 3292f76..76a924b 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -23,7 +23,7 @@ export const publicRoutes = ; export const Router = () => { - const isAuthentified = useSelector((state) => state.user.token !== undefined) + const isAuthentified = useSelector((state) => state.user.accessToken !== undefined) return ( diff --git a/front/app.config.ts b/front/app.config.ts new file mode 100644 index 0000000..eb231cb --- /dev/null +++ b/front/app.config.ts @@ -0,0 +1,40 @@ +module.exports = { + "name": "Chromacase", + "slug": "Chromacase", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/cover.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF", + "package": "com.chromacase.chromacase", + "versionCode": 1 + }, + "package": "build.apk" + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "extra": { + apiUrl: process.env.API_URL, + "eas": { + "projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2" + } + } +} diff --git a/front/app.json b/front/app.json deleted file mode 100644 index 53f6efb..0000000 --- a/front/app.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "expo": { - "name": "Chromacase", - "slug": "Chromacase", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "light", - "splash": { - "image": "./assets/splash.png", - "resizeMode": "contain", - "backgroundColor": "#ffffff" - }, - "updates": { - "fallbackToCacheTimeout": 0 - }, - "assetBundlePatterns": [ - "**/*" - ], - "ios": { - "supportsTablet": true - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#FFFFFF", - "package": "com.chromacase.chromacase", - "versionCode": 1 - }, - "package": "build.apk" - }, - "web": { - "favicon": "./assets/favicon.png" - }, - "extra": { - "eas": { - "projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2" - } - } - } -} diff --git a/front/components/SearchBar.tsx b/front/components/SearchBar.tsx index 2429814..cdf7159 100644 --- a/front/components/SearchBar.tsx +++ b/front/components/SearchBar.tsx @@ -11,7 +11,7 @@ import { Square, } from "native-base"; import React from "react"; -import { Ionicons, FontAwesome } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; export enum SuggestionType { TEXT, diff --git a/front/components/SearchBarSuggestions.tsx b/front/components/SearchBarSuggestions.tsx index 3772a95..9818dd8 100644 --- a/front/components/SearchBarSuggestions.tsx +++ b/front/components/SearchBarSuggestions.tsx @@ -1,5 +1,5 @@ import React from "react"; -import SearchBar from "../components/SearchBar"; +import SearchBar, { IllustratedSuggestionProps } from "../components/SearchBar"; import { SuggestionList, SuggestionType } from "../components/SearchBar"; interface SearchBarSuggestionsProps { onTextSubmit: (text: string) => void; @@ -15,7 +15,7 @@ const filterSuggestions = (text: string, suggestions: SuggestionList) => { case SuggestionType.ILLUSTRATED: return ( suggestion.data.text.toLowerCase().includes(text.toLowerCase()) || - suggestion.data.subtext.toLowerCase().includes(text.toLowerCase()) + (suggestion.data as IllustratedSuggestionProps).subtext.toLowerCase().includes(text.toLowerCase()) ); } }); diff --git a/front/components/SongCard.tsx b/front/components/SongCard.tsx index 3216fa0..c128b52 100644 --- a/front/components/SongCard.tsx +++ b/front/components/SongCard.tsx @@ -22,7 +22,7 @@ const SongCard = (props: SongCardProps) => { > {[props.songTitle, diff --git a/front/components/forms/signinform.tsx b/front/components/forms/signinform.tsx index 88d3db5..2574f26 100644 --- a/front/components/forms/signinform.tsx +++ b/front/components/forms/signinform.tsx @@ -112,7 +112,6 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => { toast.show({ description: resp, colorScheme: 'secondary' }) } catch (e) { toast.show({ description: e as string, colorScheme: 'red', avoidKeyboard: true }) - } finally { setSubmittingForm(false); } }} diff --git a/front/components/forms/signupform.tsx b/front/components/forms/signupform.tsx index 146e23a..327b56b 100644 --- a/front/components/forms/signupform.tsx +++ b/front/components/forms/signupform.tsx @@ -52,7 +52,7 @@ const LoginForm = ({ onSubmit }: SignupFormProps) => { .min(4, translate("passwordTooShort")) .max(100, translate("passwordTooLong")) .matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%\^&\*])(?=.{8,})/, + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$-_%\^&\*])(?=.{8,})/, translate( "Must Contain 8 Characters, One Uppercase, One Lowercase, One Number and One Special Case Character" ) diff --git a/front/models/Song.ts b/front/models/Song.ts index 009eedf..11b30d7 100644 --- a/front/models/Song.ts +++ b/front/models/Song.ts @@ -2,9 +2,11 @@ import Metrics from "./Metrics"; import Model from "./Model"; interface Song extends Model { - title: string; - description: string; - album: string; + name: string + artistId: number | null + albumId: number | null + genreId: number | null; + cover: string; metrics: Metrics; } diff --git a/front/package.json b/front/package.json index cbfe5fa..0d06935 100644 --- a/front/package.json +++ b/front/package.json @@ -48,8 +48,11 @@ "devDependencies": { "@babel/core": "^7.12.9", "@testing-library/react-native": "^11.0.0", + "@types/node": "^18.11.8", "@types/react": "^18.0.18", "@types/react-native": "^0.69.6", + "@types/react-navigation": "^3.4.0", + "babel-plugin-transform-inline-environment-variables": "^0.4.4", "react-test-renderer": "17.0.2", "typescript": "~4.3.5" }, diff --git a/front/state/UserSlice.ts b/front/state/UserSlice.ts index fdac788..44cbc25 100644 --- a/front/state/UserSlice.ts +++ b/front/state/UserSlice.ts @@ -1,19 +1,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import API from '../API'; +import { AccessToken } from '../API'; export const userSlice = createSlice({ name: 'user', initialState: { - apiAccess: undefined as API | undefined + accessToken: undefined as AccessToken | undefined }, reducers: { - setAPIAccess: (state, action: PayloadAction) => { - state.apiAccess = action.payload; + setAccessToken: (state, action: PayloadAction) => { + state.accessToken = action.payload; }, - unsetAPIAccess: (state) => { - state.apiAccess = undefined; + unsetAccessToken: (state) => { + state.accessToken = undefined; }, }, }); -export const { setAPIAccess, unsetAPIAccess } = userSlice.actions; +export const { setAccessToken, unsetAccessToken } = userSlice.actions; export default userSlice.reducer; \ No newline at end of file diff --git a/front/tsconfig.json b/front/tsconfig.json index ed7f7e9..db05a4e 100644 --- a/front/tsconfig.json +++ b/front/tsconfig.json @@ -32,7 +32,7 @@ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ - "types": ["react-native", "jest"], /* Specify type package names to be included without being referenced in a source file. */ + "types": ["react-native", "jest", "node"], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ "resolveJsonModule": true, /* Enable importing .json files */ // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ diff --git a/front/views/AuthenticationView.tsx b/front/views/AuthenticationView.tsx index 21cea9a..ffd78bb 100644 --- a/front/views/AuthenticationView.tsx +++ b/front/views/AuthenticationView.tsx @@ -2,28 +2,28 @@ import React from "react"; import { useDispatch } from '../state/Store'; import { translate } from "../i18n/i18n"; import API from "../API"; -import { setUserToken } from "../state/UserSlice"; +import { setAccessToken } from "../state/UserSlice"; import { Center, Button, Text } from 'native-base'; import SigninForm from "../components/forms/signinform"; import SignupForm from "../components/forms/signupform"; -const hanldeSignin = async (username: string, password: string, tokenSetter: (token: string) => void): Promise => { +const hanldeSignin = async (username: string, password: string, apiSetter: (accessToken: string) => void): Promise => { try { - const response = await API.checkSigninCredentials(username, password); - tokenSetter(response); + const apiAccess = await API.authenticate({ username, password }); + apiSetter(apiAccess); return translate("loggedIn"); } catch (error) { - return error as string; + return "Username of password incorrect"; } }; -const handleSignup = async (username: string, password: string, email: string, tokenSetter: (t: string) => void): Promise => { +const handleSignup = async (username: string, password: string, email: string, apiSetter: (accessToken: string) => void): Promise => { try { - const response = await API.checkSignupCredentials(username, password, email); - tokenSetter(response); + const apiAccess = await API.createAccount({ username, password, email }); + apiSetter(apiAccess); return translate("loggedIn"); } catch (error) { - return error as string; + return "User already exists"; } }; @@ -33,14 +33,10 @@ const AuthenticationView = () => { return (
- {translate('welcome')} - {mode === "signin" ? (<> - username, password: - katerina, 1234 - hanldeSignin(username, password, (token) => dispatch(setUserToken(token)))} /> - ) : ( - handleSignup(username, password, email, (token) => dispatch(setUserToken(token)))} /> - )} + {mode === "signin" + ? hanldeSignin(username, password, (accessToken) => dispatch(setAccessToken(accessToken)))} /> + : handleSignup(username, password, email, (accessToken) => dispatch(setAccessToken(accessToken)))} /> + } { mode ==="signin" && } -
diff --git a/front/views/SongLobbyView.tsx b/front/views/SongLobbyView.tsx index 4bad7aa..5906aa6 100644 --- a/front/views/SongLobbyView.tsx +++ b/front/views/SongLobbyView.tsx @@ -1,13 +1,13 @@ import { useRoute } from "@react-navigation/native"; import { Button, Divider, Box, Center, Image, Text, VStack, PresenceTransition, Icon } from "native-base"; -import API from "../API"; import { useQuery } from 'react-query'; import LoadingComponent from "../components/Loading"; import React, { useEffect, useState } from "react"; -import logo from '../assets/cover.png'; import { translate } from "../i18n/i18n"; import formatDuration from "format-duration"; import { Ionicons } from '@expo/vector-icons'; +import { useSelector } from "../state/Store"; +import API from "../API"; interface SongLobbyProps { // The unique identifier to find a song @@ -34,12 +34,12 @@ const SongLobbyView = () => { - + - {songQuery.data!.title} + {songQuery.data!.name} {'3:20'} - {translate('level')} { chaptersQuery.data!.reduce((a, b) => a + b.difficulty, 0) / chaptersQuery.data!.length } @@ -55,7 +55,7 @@ const SongLobbyView = () => { {scoresQuery.data!.slice(-1)[0]!.score} - {songQuery.data!.description} + {/* {songQuery.data!.description} */} - From 9b823f722f25fb5e42bd24b3ec685ec0a8cd93c7 Mon Sep 17 00:00:00 2001 From: Arthi-chaud Date: Mon, 14 Nov 2022 09:42:50 +0000 Subject: [PATCH 04/11] Front: Fix User Model --- front/models/User.ts | 2 +- front/views/HomeView.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/front/models/User.ts b/front/models/User.ts index aaf5e28..c7b5a94 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -3,7 +3,7 @@ import Model from "./Model"; import UserSettings from "./UserSettings"; interface User extends Model { - name: string; + username: string; email: string; xp: number; premium: boolean; diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index f621af5..26a9ed0 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { useQueries, useQuery } from "react-query"; import API from "../API"; import LoadingComponent from "../components/Loading"; @@ -43,10 +43,11 @@ const HomeView = () => { } + console.log(userQuery.data); return - {`${translate('welcome')} ${userQuery.data.name}!`} + {`${translate('welcome')} ${userQuery.data.username}!`} From b83a369799ad06cc0322fd6193fda7c439f0e7a9 Mon Sep 17 00:00:00 2001 From: Arthi-chaud Date: Thu, 17 Nov 2022 18:22:32 +0000 Subject: [PATCH 05/11] Front: Signin Form: Fix submittingstate on error --- front/components/forms/signinform.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/front/components/forms/signinform.tsx b/front/components/forms/signinform.tsx index 7f7d985..24aa310 100644 --- a/front/components/forms/signinform.tsx +++ b/front/components/forms/signinform.tsx @@ -110,6 +110,7 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => { formData.password.value ); toast.show({ description: resp, colorScheme: 'secondary' }); + setSubmittingForm(false); } catch (e) { toast.show({ description: e as string, colorScheme: 'red', avoidKeyboard: true }) setSubmittingForm(false); From 6dedb3faccf9186f6fd659658157d5a65848cd26 Mon Sep 17 00:00:00 2001 From: Arthi-chaud Date: Fri, 18 Nov 2022 13:39:17 +0000 Subject: [PATCH 06/11] Front: Remove logs --- front/views/AuthenticationView.tsx | 1 - front/views/HomeView.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/front/views/AuthenticationView.tsx b/front/views/AuthenticationView.tsx index 2b8dba6..ffd78bb 100644 --- a/front/views/AuthenticationView.tsx +++ b/front/views/AuthenticationView.tsx @@ -23,7 +23,6 @@ const handleSignup = async (username: string, password: string, email: string, a apiSetter(apiAccess); return translate("loggedIn"); } catch (error) { - console.log(error); return "User already exists"; } }; diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index 26a9ed0..faefc22 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -43,7 +43,6 @@ const HomeView = () => { } - console.log(userQuery.data); return From 8546c863327787b62c6f48505b67a745c19cf384 Mon Sep 17 00:00:00 2001 From: Arthur Jamet <60505370+Arthi-chaud@users.noreply.github.com> Date: Thu, 24 Nov 2022 17:20:45 +0000 Subject: [PATCH 07/11] Front/translate refactor (#110) * Front: i18n: Create component * Front: Use new translation component * Front: Translation COmpoent: Change props name * Front: Fix merge --- front/components/SongCardGrid.tsx | 2 +- front/components/Translate.tsx | 20 +++++++++++ front/components/forms/signinform.tsx | 12 ++++--- front/components/forms/signupform.tsx | 20 +++++++---- front/i18n/i18n.ts | 11 ++++-- front/models/User.ts | 2 +- front/views/AuthenticationView.tsx | 7 ++-- front/views/HomeView.tsx | 30 ++++++++++------ front/views/SettingsView.tsx | 51 +++++++++++++++------------ front/views/SongLobbyView.tsx | 25 ++++++++----- 10 files changed, 124 insertions(+), 56 deletions(-) create mode 100644 front/components/Translate.tsx diff --git a/front/components/SongCardGrid.tsx b/front/components/SongCardGrid.tsx index 89ffeb7..7f816ea 100644 --- a/front/components/SongCardGrid.tsx +++ b/front/components/SongCardGrid.tsx @@ -6,7 +6,7 @@ import { Heading, VStack } from 'native-base'; type SongCardGrid = { songs: Parameters[0][]; maxItemPerRow?: number, - heading?: string + heading?: JSX.Element, } const SongCardGrid = (props: SongCardGrid) => { diff --git a/front/components/Translate.tsx b/front/components/Translate.tsx new file mode 100644 index 0000000..ef6da7c --- /dev/null +++ b/front/components/Translate.tsx @@ -0,0 +1,20 @@ +import { translate } from "../i18n/i18n"; +import { en } from "../i18n/Translations"; +import { RootState, useSelector } from "../state/Store"; + +type TranslateProps = { + translationKey: keyof typeof en; + format?: (translated: string) => string; +} +/** + * Translation component + * @param param0 + * @returns + */ +const Translate = ({ translationKey, format }: TranslateProps) => { + const selectedLanguage = useSelector((state: RootState) => state.language.value); + const translated = translate(translationKey, selectedLanguage); + return <>{format ? format(translated) : translated}; +} + +export default Translate; \ No newline at end of file diff --git a/front/components/forms/signinform.tsx b/front/components/forms/signinform.tsx index 24aa310..1b3005a 100644 --- a/front/components/forms/signinform.tsx +++ b/front/components/forms/signinform.tsx @@ -1,7 +1,7 @@ // a form for sign in import React from "react"; -import { translate } from "../../i18n/i18n"; +import { Translate, translate } from "../../i18n/i18n"; import { string } from "yup"; import { FormControl, @@ -51,7 +51,9 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => { formData.password.error !== null } > - {translate("username")} + + + { > {formData.username.error} - {translate("password")} + + + { } }} > - {translate("login")} + diff --git a/front/components/forms/signupform.tsx b/front/components/forms/signupform.tsx index f04f6c9..2b1c0db 100644 --- a/front/components/forms/signupform.tsx +++ b/front/components/forms/signupform.tsx @@ -1,7 +1,7 @@ // a form for sign up import React from "react"; -import { translate } from "../../i18n/i18n"; +import { Translate, translate } from "../../i18n/i18n"; import { string } from "yup"; import { FormControl, @@ -74,7 +74,9 @@ const LoginForm = ({ onSubmit }: SignupFormProps) => { formData.email.error !== null } > - {translate("username")} + + + { > {formData.username.error} - {translate("email")} + + + { > {formData.email.error} - {translate("password")} + + + { > {formData.password.error} - {translate("repeatPassword")} + + + { } }} > - {translate("signUp")} + diff --git a/front/i18n/i18n.ts b/front/i18n/i18n.ts index 132ca3d..7cbf9f5 100644 --- a/front/i18n/i18n.ts +++ b/front/i18n/i18n.ts @@ -1,7 +1,7 @@ import { en, fr, sp } from './Translations'; import i18n from "i18next"; import { initReactI18next } from "react-i18next"; - +import Translate from '../components/Translate'; export type AvailableLanguages = 'en' | 'fr' | 'sp'; export const DefaultLanguage: AvailableLanguages = 'en'; @@ -28,9 +28,16 @@ i18n }); export default i18n; + /** * Typesafe translation method * @param textKey the key of th text to translate * @returns the translated text */ -export const translate = (textKey: keyof typeof en) => i18n.t(textKey); \ No newline at end of file +export const translate = (key: keyof typeof en, language?: AvailableLanguages) => { + return i18n.t(key, { + lng: language + }); +} + +export { Translate }; \ No newline at end of file diff --git a/front/models/User.ts b/front/models/User.ts index c7b5a94..aaf5e28 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -3,7 +3,7 @@ import Model from "./Model"; import UserSettings from "./UserSettings"; interface User extends Model { - username: string; + name: string; email: string; xp: number; premium: boolean; diff --git a/front/views/AuthenticationView.tsx b/front/views/AuthenticationView.tsx index ffd78bb..29c89c8 100644 --- a/front/views/AuthenticationView.tsx +++ b/front/views/AuthenticationView.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useDispatch } from '../state/Store'; -import { translate } from "../i18n/i18n"; +import { Translate, translate } from "../i18n/i18n"; import API from "../API"; import { setAccessToken } from "../state/UserSlice"; import { Center, Button, Text } from 'native-base'; @@ -33,13 +33,16 @@ const AuthenticationView = () => { return (
+ {mode === "signin" ? hanldeSignin(username, password, (accessToken) => dispatch(setAccessToken(accessToken)))} /> : handleSignup(username, password, email, (accessToken) => dispatch(setAccessToken(accessToken)))} /> } { mode ==="signin" && }
); diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index faefc22..6ee0a30 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -6,7 +6,7 @@ import { Box, ScrollView, Flex, useBreakpointValue, Text, VStack, Progress, Butt import { useNavigation } from "@react-navigation/native"; import SongCardGrid from '../components/SongCardGrid'; import CompetenciesTable from '../components/CompetenciesTable' -import { translate } from "../i18n/i18n"; +import { Translate } from "../i18n/i18n"; const ProgressBar = ({ xp }: { xp: number}) => { const level = Math.floor(xp / 1000); @@ -16,11 +16,17 @@ const ProgressBar = ({ xp }: { xp: number}) => { return ( - {`${translate('level')} ${level}`} + + `${id} ${level}`}/> + - {xp} / {nextLevelThreshold} {translate('levelProgress')} + + `${xp} / ${nextLevelThreshold} ${levelProgress}`} + /> + ); } @@ -46,7 +52,9 @@ const HomeView = () => { return - {`${translate('welcome')} ${userQuery.data.username}!`} + + `${welcome} ${userQuery.data.name}!`}/> + @@ -55,7 +63,7 @@ const HomeView = () => { } songs={nextStepQuery.data?.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)) .map((song) => ({ albumCover: song.cover, @@ -68,7 +76,7 @@ const HomeView = () => { - {translate('mySkillsToImprove')} + @@ -76,7 +84,7 @@ const HomeView = () => { } maxItemPerRow={2} songs={playHistoryQuery.data?.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)) .map((song) => ({ @@ -96,11 +104,11 @@ const HomeView = () => { - + } songs={searchHistoryQuery.data?.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)) .map((song) => ({ albumCover: song.cover, @@ -113,7 +121,9 @@ const HomeView = () => { - +
diff --git a/front/views/SettingsView.tsx b/front/views/SettingsView.tsx index 4ff0a37..b6facef 100644 --- a/front/views/SettingsView.tsx +++ b/front/views/SettingsView.tsx @@ -6,7 +6,7 @@ import { unsetAccessToken } from '../state/UserSlice'; import { useDispatch } from "react-redux"; import { useSelector } from '../state/Store'; import { useLanguage } from "../state/LanguageSlice"; -import i18n, { AvailableLanguages, DefaultLanguage, translate } from "../i18n/i18n"; +import { AvailableLanguages, DefaultLanguage, translate, Translate } from "../i18n/i18n"; const SettingsStack = createNativeStackNavigator(); @@ -16,31 +16,31 @@ const MainView = ({navigation}) => { return (
) @@ -52,9 +52,12 @@ const PreferencesView = ({navigation}) => { return (
- { translate('prefBtn')} - - + + + + @@ -131,24 +134,24 @@ const NotificationsView = ({navigation}) => { return (
- { translate('notifBtn')} - - + + + + Push notifications - Email notifications - Training reminder - New songs @@ -160,9 +163,13 @@ const NotificationsView = ({navigation}) => { const PrivacyView = ({navigation}) => { return (
- { translate('privBtn')} + + + - + Data Collection diff --git a/front/views/SongLobbyView.tsx b/front/views/SongLobbyView.tsx index 5906aa6..088ae39 100644 --- a/front/views/SongLobbyView.tsx +++ b/front/views/SongLobbyView.tsx @@ -3,10 +3,9 @@ import { Button, Divider, Box, Center, Image, Text, VStack, PresenceTransition, import { useQuery } from 'react-query'; import LoadingComponent from "../components/Loading"; import React, { useEffect, useState } from "react"; -import { translate } from "../i18n/i18n"; +import { Translate, translate } from "../i18n/i18n"; import formatDuration from "format-duration"; import { Ionicons } from '@expo/vector-icons'; -import { useSelector } from "../state/Store"; import API from "../API"; interface SongLobbyProps { @@ -39,19 +38,29 @@ const SongLobbyView = () => { - {songQuery.data!.name} - {'3:20'} - {translate('level')} { chaptersQuery.data!.reduce((a, b) => a + b.difficulty, 0) / chaptersQuery.data!.length } - + {songQuery.data!.title} + + `3:20 - ${level} - ${ chaptersQuery.data!.reduce((a, b) => a + b.difficulty, 0) / chaptersQuery.data!.length }`} + /> + + - {translate('bestScore') } + + + {scoresQuery.data!.sort()[0]?.score} - {translate('lastScore') } + + + {scoresQuery.data!.slice(-1)[0]!.score} @@ -62,7 +71,7 @@ const SongLobbyView = () => { onPress={() => setChaptersOpen(!chaptersOpen)} endIcon={} > - {translate('chapters')} + From 55526dbadcf5c3d3f758e8a205fe53b9763060fa Mon Sep 17 00:00:00 2001 From: Arthur Jamet <60505370+Arthi-chaud@users.noreply.github.com> Date: Sat, 26 Nov 2022 14:18:06 +0000 Subject: [PATCH 08/11] Front: settings persistance (#108) * Front: Add peristance dependencies * Front: Fix Cross-platform persistance * Front: Create Settings Slice * Front: Use Redux State for settings * Front: Check if access token is still valid * Front: Create Language Gate to set correct language at startup * Front: BEtter handling of Access Token validity --- front/App.tsx | 18 +++++--- front/Navigation.tsx | 18 ++++++-- front/i18n/LanguageGate.ts | 18 ++++++++ front/package.json | 3 ++ front/state/LanguageSlice.ts | 4 +- front/state/SettingsSlice.ts | 36 +++++++++++++++ front/state/Store.ts | 27 +++++++++--- front/views/AuthenticationView.tsx | 2 +- front/views/SettingsView.tsx | 71 ++++++++++++++++++------------ front/yarn.lock | 29 ++++++++++++ 10 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 front/i18n/LanguageGate.ts create mode 100644 front/state/SettingsSlice.ts diff --git a/front/App.tsx b/front/App.tsx index 46a8bd2..638964c 100644 --- a/front/App.tsx +++ b/front/App.tsx @@ -3,20 +3,26 @@ import Theme from './Theme'; import React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { Provider } from 'react-redux'; -import store from './state/Store'; +import store, { persistor } from './state/Store'; import { Router } from './Navigation'; import './i18n/i18n'; +import { PersistGate } from "redux-persist/integration/react"; +import LanguageGate from "./i18n/LanguageGate"; const queryClient = new QueryClient(); export default function App() { return ( - - - - - + + + + + + + + + ); } diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 76a924b..d267777 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -8,6 +8,8 @@ import { NavigationContainer } from '@react-navigation/native'; import { useSelector } from './state/Store'; import SongLobbyView from './views/SongLobbyView'; import { translate } from './i18n/i18n'; +import { useQuery } from 'react-query'; +import API from './API'; const Stack = createNativeStackNavigator(); @@ -23,12 +25,20 @@ export const publicRoutes = ; export const Router = () => { - const isAuthentified = useSelector((state) => state.user.accessToken !== undefined) + const isAuthentified = useSelector((state) => state.user.accessToken !== undefined); + const userProfile = useQuery(['user', 'me'], () => API.getUserInfo(), { + enabled: isAuthentified + }); return ( - - {isAuthentified ? protectedRoutes : publicRoutes} - + {isAuthentified && !userProfile.isError + ? + {protectedRoutes} + + : + {publicRoutes} + + } ) } diff --git a/front/i18n/LanguageGate.ts b/front/i18n/LanguageGate.ts new file mode 100644 index 0000000..12f9d4e --- /dev/null +++ b/front/i18n/LanguageGate.ts @@ -0,0 +1,18 @@ +import { RootState, useSelector } from "../state/Store"; +import i18n from "./i18n"; + +type LanguageGateProps = { + children: any; +} + +/** + * Gate to handle language update at startup and on every dispatch + * @param props the children to render + */ +const LanguageGate = (props: LanguageGateProps) => { + const language = useSelector((state: RootState) => state.language.value); + i18n.changeLanguage(language); + return props.children; +} + +export default LanguageGate; \ No newline at end of file diff --git a/front/package.json b/front/package.json index 0d06935..e4f1440 100644 --- a/front/package.json +++ b/front/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@expo/vector-icons": "^13.0.0", + "@react-native-async-storage/async-storage": "^1.17.11", "@react-navigation/native": "^6.0.11", "@react-navigation/native-stack": "^6.7.0", "@reduxjs/toolkit": "^1.8.3", @@ -25,6 +26,7 @@ "expo": "~45.0.0", "expo-asset": "~8.5.0", "expo-dev-client": "~1.0.0", + "expo-secure-store": "~11.2.0", "expo-status-bar": "~1.3.0", "format-duration": "^2.0.0", "i18next": "^21.8.16", @@ -43,6 +45,7 @@ "react-native-testing-library": "^6.0.0", "react-native-web": "0.17.7", "react-redux": "^8.0.2", + "redux-persist": "^6.0.0", "yup": "^0.32.11" }, "devDependencies": { diff --git a/front/state/LanguageSlice.ts b/front/state/LanguageSlice.ts index 1f1b571..9ecbaa7 100644 --- a/front/state/LanguageSlice.ts +++ b/front/state/LanguageSlice.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import i18n, { AvailableLanguages, DefaultLanguage } from "../i18n/i18n"; +import { AvailableLanguages, DefaultLanguage } from "../i18n/i18n"; export const languageSlice = createSlice({ @@ -10,11 +10,9 @@ export const languageSlice = createSlice({ reducers: { useLanguage: (state, action: PayloadAction) => { state.value = action.payload; - i18n.changeLanguage(state.value); }, resetLanguage: (state) => { state.value = DefaultLanguage; - i18n.changeLanguage(DefaultLanguage); }, }, }); diff --git a/front/state/SettingsSlice.ts b/front/state/SettingsSlice.ts new file mode 100644 index 0000000..a49f2ea --- /dev/null +++ b/front/state/SettingsSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export type SettingsState = { + colorScheme: "dark" | "light" | "system", + enablePushNotifications: boolean, + enableMailNotifications: boolean, + enableLessongsReminders: boolean, + enableReleaseAlerts: boolean, + preferedLevel: 'easy' | 'medium' | 'hard', + colorBlind: boolean, + micLevel: number, + preferedInputName?: string +} + +export const settingsSlice = createSlice({ + name: 'settings', + initialState: { + settings: { + enablePushNotifications: true, + enableMailNotifications: true, + enableLessongsReminders: true, + enableReleaseAlerts: true, + preferedLevel: 'easy', + colorBlind: false, + micLevel: 50, + colorScheme: "system" + }, + }, + reducers: { + updateSettings: (state, action: PayloadAction>) => { + state.settings = { ...state.settings, ...action.payload }; + } + } +}); +export const { updateSettings } = settingsSlice.actions; +export default settingsSlice.reducer; \ No newline at end of file diff --git a/front/state/Store.ts b/front/state/Store.ts index 9a2d390..3af6bdd 100644 --- a/front/state/Store.ts +++ b/front/state/Store.ts @@ -1,14 +1,30 @@ import userReducer from '../state/UserSlice'; +import settingsReduder from '../state/SettingsSlice'; import { configureStore } from '@reduxjs/toolkit'; import languageReducer from './LanguageSlice'; import { TypedUseSelectorHook, useDispatch as reduxDispatch, useSelector as reduxSelector } from 'react-redux' +import { persistStore, persistCombineReducers, FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE } from "redux-persist"; +import AsyncStorage from '@react-native-async-storage/async-storage'; -const store = configureStore({ - reducer: { +const persistConfig = { + key: 'root', + storage: AsyncStorage +} + +let store = configureStore({ + reducer: persistCombineReducers(persistConfig, { user: userReducer, - language: languageReducer - }, + language: languageReducer, + settings: settingsReduder + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), }) +let persistor = persistStore(store); // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType @@ -18,4 +34,5 @@ export type AppDispatch = typeof store.dispatch; export const useDispatch: () => AppDispatch = reduxDispatch export const useSelector: TypedUseSelectorHook = reduxSelector -export default store \ No newline at end of file +export default store +export { persistor } diff --git a/front/views/AuthenticationView.tsx b/front/views/AuthenticationView.tsx index 29c89c8..6f394ff 100644 --- a/front/views/AuthenticationView.tsx +++ b/front/views/AuthenticationView.tsx @@ -13,7 +13,7 @@ const hanldeSignin = async (username: string, password: string, apiSetter: (acce apiSetter(apiAccess); return translate("loggedIn"); } catch (error) { - return "Username of password incorrect"; + return "Username or password incorrect"; } }; diff --git a/front/views/SettingsView.tsx b/front/views/SettingsView.tsx index b6facef..1f58785 100644 --- a/front/views/SettingsView.tsx +++ b/front/views/SettingsView.tsx @@ -4,9 +4,10 @@ import { Center, Button, Text, Switch, Slider, Select, Heading } from "native-ba import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { unsetAccessToken } from '../state/UserSlice'; import { useDispatch } from "react-redux"; -import { useSelector } from '../state/Store'; +import { RootState, useSelector } from '../state/Store'; import { useLanguage } from "../state/LanguageSlice"; -import { AvailableLanguages, DefaultLanguage, translate, Translate } from "../i18n/i18n"; +import { SettingsState, updateSettings } from '../state/SettingsSlice'; +import { AvailableLanguages, translate, Translate } from "../i18n/i18n"; const SettingsStack = createNativeStackNavigator(); @@ -48,8 +49,8 @@ const MainView = ({navigation}) => { const PreferencesView = ({navigation}) => { const dispatch = useDispatch(); - const language: AvailableLanguages = useSelector((state) => state.language.value); - + const language: AvailableLanguages = useSelector((state: RootState) => state.language.value); + const settings = useSelector((state: RootState) => (state.settings.settings as SettingsState)); return (
@@ -60,14 +61,16 @@ const PreferencesView = ({navigation}) => { - @@ -75,10 +78,8 @@ const PreferencesView = ({navigation}) => { change level} - > - + onValueChange={(itemValue) => { + dispatch(updateSettings({ preferedLevel: itemValue as any })); + }}> @@ -102,12 +103,16 @@ const PreferencesView = ({navigation}) => { Color blind mode - + { dispatch(updateSettings({ colorBlind: enabled })) }} + /> Mic volume - + { dispatch(updateSettings({ micLevel: value })) }} + > @@ -116,11 +121,11 @@ const PreferencesView = ({navigation}) => { -