diff --git a/back/prisma/migrations/20230227220027_/migration.sql b/back/prisma/migrations/20230227220027_/migration.sql new file mode 100644 index 0000000..e37a741 --- /dev/null +++ b/back/prisma/migrations/20230227220027_/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "UserSettings" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "pushNotification" BOOLEAN NOT NULL DEFAULT true, + "emailNotification" BOOLEAN NOT NULL DEFAULT true, + "trainingNotification" BOOLEAN NOT NULL DEFAULT true, + "newsongNotification" BOOLEAN NOT NULL DEFAULT true, + "dataCollection" BOOLEAN NOT NULL DEFAULT true, + "CustomAdds" BOOLEAN NOT NULL DEFAULT true, + "Recommendations" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSettings_userId_key" ON "UserSettings"("userId"); + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/back/prisma/migrations/20230328185436_/migration.sql b/back/prisma/migrations/20230328185436_/migration.sql new file mode 100644 index 0000000..99c5f59 --- /dev/null +++ b/back/prisma/migrations/20230328185436_/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `CustomAdds` on the `UserSettings` table. All the data in the column will be lost. + - You are about to drop the column `Recommendations` on the `UserSettings` table. All the data in the column will be lost. + - You are about to drop the column `dataCollection` on the `UserSettings` table. All the data in the column will be lost. + - You are about to drop the column `newsongNotification` on the `UserSettings` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "UserSettings" DROP CONSTRAINT "UserSettings_userId_fkey"; + +-- AlterTable +ALTER TABLE "UserSettings" DROP COLUMN "CustomAdds", +DROP COLUMN "Recommendations", +DROP COLUMN "dataCollection", +DROP COLUMN "newsongNotification", +ADD COLUMN "leaderBoard" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "newSongNotification" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "recommendations" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "showActivity" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "weeklyReport" BOOLEAN NOT NULL DEFAULT true; + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/back/prisma/schema.prisma b/back/prisma/schema.prisma index 77708f4..81a6526 100644 --- a/back/prisma/schema.prisma +++ b/back/prisma/schema.prisma @@ -10,15 +10,30 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - username String @unique - password String - email String - isGuest Boolean @default(false) - partyPlayed Int @default(0) - LessonHistory LessonHistory[] - SongHistory SongHistory[] - searchHistory SearchHistory[] + id Int @id @default(autoincrement()) + username String @unique + password String + email String + isGuest Boolean @default(false) + partyPlayed Int @default(0) + LessonHistory LessonHistory[] + SongHistory SongHistory[] + searchHistory SearchHistory[] + settings UserSettings? +} + +model UserSettings { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @unique + pushNotification Boolean @default(true) + emailNotification Boolean @default(true) + trainingNotification Boolean @default(true) + newSongNotification Boolean @default(true) + recommendations Boolean @default(true) + weeklyReport Boolean @default(true) + leaderBoard Boolean @default(true) + showActivity Boolean @default(true) } model SearchHistory { diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 5f98408..8142e0d 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -7,13 +7,11 @@ import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { SongModule } from './song/song.module'; import { LessonModule } from './lesson/lesson.module'; -import { ArtistController } from './artist/artist.controller'; +import { SettingsModule } from './settings/settings.module'; import { ArtistService } from './artist/artist.service'; import { GenreModule } from './genre/genre.module'; import { ArtistModule } from './artist/artist.module'; import { AlbumModule } from './album/album.module'; -import { SearchController } from './search/search.controller'; -import { SearchService } from './search/search.service'; import { SearchModule } from './search/search.module'; import { HistoryModule } from './history/history.module'; @@ -28,6 +26,7 @@ import { HistoryModule } from './history/history.module'; ArtistModule, AlbumModule, SearchModule, + SettingsModule, HistoryModule, ], controllers: [AppController], diff --git a/back/src/auth/auth.controller.ts b/back/src/auth/auth.controller.ts index 5bc7335..710d980 100644 --- a/back/src/auth/auth.controller.ts +++ b/back/src/auth/auth.controller.ts @@ -10,6 +10,8 @@ import { HttpCode, Put, InternalServerErrorException, + Patch, + NotFoundException, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -28,6 +30,9 @@ import { User } from '../models/user'; import { JwtToken } from './models/jwt'; import { LoginDto } from './dto/login.dto'; import { Profile } from './dto/profile.dto'; +import { Setting } from 'src/models/setting'; +import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto'; +import { SettingsService } from 'src/settings/settings.service'; @ApiTags('auth') @Controller('auth') @@ -35,12 +40,15 @@ export class AuthController { constructor( private authService: AuthService, private usersService: UsersService, + private settingsService: SettingsService, ) {} @Post('register') async register(@Body() registerDto: RegisterDto): Promise { try { - await this.usersService.createUser(registerDto); + await this.usersService.createUser(registerDto).then((user) => { + this.settingsService.createUserSetting(user.id); + }); } catch { throw new BadRequestException(); } @@ -57,12 +65,8 @@ export class AuthController { @HttpCode(200) @Post('guest') async guest(): Promise { - try { - const user = await this.usersService.createGuest(); - return this.authService.login(user); - } catch { - throw new BadRequestException(); - } + const user = await this.usersService.createGuest(); + return this.authService.login(user); } @UseGuards(JwtAuthGuard) @@ -109,4 +113,29 @@ export class AuthController { deleteSelf(@Request() req: any): Promise { return this.usersService.deleteUser({ id: req.user.id }); } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOkResponse({description: 'Successfully edited settings', type: Setting}) + @ApiUnauthorizedResponse({description: 'Invalid token'}) + @Patch('me/settings') + udpateSettings( + @Request() req: any, + @Body() settingUserDto: UpdateSettingDto): Promise { + return this.settingsService.updateUserSettings({ + where: { userId: +req.user.id}, + data: settingUserDto, + }); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOkResponse({description: 'Successfully edited settings', type: Setting}) + @ApiUnauthorizedResponse({description: 'Invalid token'}) + @Get('me/settings') + async getSettings(@Request() req: any): Promise { + const result = await this.settingsService.getUserSetting({ userId: +req.user.id }); + if (!result) throw new NotFoundException(); + return result; + } } diff --git a/back/src/auth/auth.module.ts b/back/src/auth/auth.module.ts index 3f09593..7ac9c82 100644 --- a/back/src/auth/auth.module.ts +++ b/back/src/auth/auth.module.ts @@ -8,11 +8,13 @@ import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config'; import { ConfigService } from '@nestjs/config'; import { JwtStrategy } from './jwt.strategy'; +import { SettingsModule } from 'src/settings/settings.module'; @Module({ imports: [ ConfigModule, UsersModule, + SettingsModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/back/src/models/setting.ts b/back/src/models/setting.ts new file mode 100644 index 0000000..6af8db7 --- /dev/null +++ b/back/src/models/setting.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class Setting { + @ApiProperty() + id: number; + @ApiProperty() + userId: number; + @ApiProperty() + pushNotification: boolean; + @ApiProperty() + emailNotification: boolean; + @ApiProperty() + trainingNotification: boolean; + @ApiProperty() + newSongNotification: boolean; + @ApiProperty() + recommendations: boolean; + @ApiProperty() + weeklyReport: boolean; + @ApiProperty() + leaderBoard: boolean; + @ApiProperty() + showActivity: boolean; +} diff --git a/back/src/settings/dto/update-setting.dto.ts b/back/src/settings/dto/update-setting.dto.ts new file mode 100644 index 0000000..e033831 --- /dev/null +++ b/back/src/settings/dto/update-setting.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateSettingDto { + @ApiProperty() + pushNotification?: boolean; + @ApiProperty() + emailNotification?: boolean; + @ApiProperty() + trainingNotification?: boolean; + @ApiProperty() + newSongNotification?: boolean; + @ApiProperty() + recommendations?: boolean; + @ApiProperty() + weeklyReport?: boolean; + @ApiProperty() + leaderBoard?: boolean; + @ApiProperty() + showActivity?: boolean; +} diff --git a/back/src/settings/settings.controller.spec.ts b/back/src/settings/settings.controller.spec.ts new file mode 100644 index 0000000..2e6067c --- /dev/null +++ b/back/src/settings/settings.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SettingsController } from './settings.controller'; + +describe('SettingsController', () => { + let controller: SettingsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SettingsController], + }).compile(); + + controller = module.get(SettingsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/back/src/settings/settings.module.ts b/back/src/settings/settings.module.ts new file mode 100644 index 0000000..bd3000f --- /dev/null +++ b/back/src/settings/settings.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SettingsService } from './settings.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/back/src/settings/settings.service.spec.ts b/back/src/settings/settings.service.spec.ts new file mode 100644 index 0000000..9001518 --- /dev/null +++ b/back/src/settings/settings.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SettingsService], + }).compile(); + + service = module.get(SettingsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/back/src/settings/settings.service.ts b/back/src/settings/settings.service.ts new file mode 100644 index 0000000..cda9072 --- /dev/null +++ b/back/src/settings/settings.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, UserSettings } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Injectable() +export class SettingsService { + constructor(private prisma: PrismaService) {} + + async getUserSetting( + settingWhereUniqueInput: Prisma.UserSettingsWhereUniqueInput, + ): Promise { + return this.prisma.userSettings.findUnique({ + where: settingWhereUniqueInput, + }); + } + + async createUserSetting(userId: number): Promise { + return this.prisma.userSettings.create({ + data: { + user: { + connect: { + id: userId, + } + } + } + }) + } + + async updateUserSettings(params: { + where: Prisma.UserSettingsWhereUniqueInput; + data: Prisma.UserSettingsUpdateInput; + }): Promise { + const { where, data } = params; + return this.prisma.userSettings.update({ + data, + where, + }); + } + + async deleteUserSettings(where: Prisma.UserSettingsWhereUniqueInput): Promise { + return this.prisma.userSettings.delete({ + where, + }); + } +} diff --git a/back/src/users/users.controller.ts b/back/src/users/users.controller.ts index c505546..f049e46 100644 --- a/back/src/users/users.controller.ts +++ b/back/src/users/users.controller.ts @@ -8,18 +8,23 @@ import { NotFoundException, } from '@nestjs/common'; import { UsersService } from './users.service'; +import { SettingsService } from 'src/settings/settings.service'; import { CreateUserDto } from './dto/create-user.dto'; import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; import { User } from 'src/models/user'; +import { resolve } from 'path'; @ApiTags('users') @Controller('users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor(private readonly usersService: UsersService, private readonly settingsService: SettingsService) {} @Post() create(@Body() createUserDto: CreateUserDto): Promise { - return this.usersService.createUser(createUserDto); + return this.usersService.createUser(createUserDto).then((user) => { + this.settingsService.createUserSetting(user.id); + return user; + }).catch((e) => e); } @Get() diff --git a/back/src/users/users.module.ts b/back/src/users/users.module.ts index d1e76e7..e117ce3 100644 --- a/back/src/users/users.module.ts +++ b/back/src/users/users.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { PrismaModule } from 'src/prisma/prisma.module'; +import { SettingsService } from 'src/settings/settings.service'; @Module({ imports: [PrismaModule], controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, SettingsService], exports: [UsersService], }) export class UsersModule {} diff --git a/back/src/users/users.service.ts b/back/src/users/users.service.ts index 0284a8f..0bcfab8 100644 --- a/back/src/users/users.service.ts +++ b/back/src/users/users.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { User, Prisma } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import * as bcrypt from 'bcryptjs'; +import { randomUUID } from 'crypto'; @Injectable() export class UsersService { @@ -42,7 +43,7 @@ export class UsersService { async createGuest(): Promise { return this.prisma.user.create({ data: { - username: 'Guest', + username: `Guest ${randomUUID()}`, isGuest: true, // Not realyl clean but better than a separate table or breaking the api by adding nulls. email: '', diff --git a/back/test/robot/auth/guest.robot b/back/test/robot/auth/guest.robot index 0d87f88..f6fa40d 100644 --- a/back/test/robot/auth/guest.robot +++ b/back/test/robot/auth/guest.robot @@ -23,6 +23,36 @@ LoginAsGuest [Teardown] DELETE /auth/me +TwoGuests + [Documentation] Login as a guest + &{res}= POST /auth/guest + Output + Integer response status 200 + String response body access_token + Set Headers {"Authorization": "Bearer ${res.body.access_token}"} + + GET /auth/me + Output + Integer response status 200 + Boolean response body isGuest true + Integer response body partyPlayed 0 + + &{res2}= POST /auth/guest + Output + Integer response status 200 + String response body access_token + Set Headers {"Authorization": "Bearer ${res2.body.access_token}"} + + GET /auth/me + Output + Integer response status 200 + Boolean response body isGuest true + Integer response body partyPlayed 0 + + [Teardown] Run Keywords DELETE /auth/me + ... AND Set Headers {"Authorization": "Bearer ${res.body.access_token}"} + ... AND DELETE /auth/me + GuestToNormal [Documentation] Login as a guest and convert to a normal account &{res}= POST /auth/guest @@ -36,7 +66,7 @@ GuestToNormal Integer response status 200 Boolean response body isGuest true - ${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "a@b.c"} + ${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "a@b.c"} Output Integer response status 200 String response body username "toto" diff --git a/back/test/robot/settings/settings.robot b/back/test/robot/settings/settings.robot new file mode 100644 index 0000000..1c436b6 --- /dev/null +++ b/back/test/robot/settings/settings.robot @@ -0,0 +1,27 @@ +*** Settings *** +Documentation Tests of the /settings route. +... Ensures that the settings CRUD works corectly as well as the automation with the user creation. + +Resource ../rest.resource +Resource ../auth/auth.resource + + +*** Test Cases *** +Get settings + [Documentation] Create a user and get associated settings + ${userID}= RegisterLogin 2na-min-faranssa-wa-2na-adrus-allu3'at-al3rabia + &{get}= GET /auth/me/settings/ + Output + Should Be True ${get.body.emailNotification} + Integer response status 200 + [Teardown] DELETE /users/${userID} + +Patch settingspushNotification + ${userID}= RegisterLogin 2na-min-faranssa-wa-2na-adrus-allu3'at-al3rabia + &{patch}= PATCH + ... /auth/me/settings/ + ... {"pushNotification": true, "emailNotification": true, "trainingNotification": true, "newSongNotification": true, "recommendations": true, "weeklyReport": true, "leaderBoard": false, "showActivity": true} + Output + Should Not Be True ${patch.body.leaderBoard} + Integer response status 200 + [Teardown] DELETE /users/${userID} diff --git a/front/API.ts b/front/API.ts index 40c993d..ea13034 100644 --- a/front/API.ts +++ b/front/API.ts @@ -10,16 +10,17 @@ import Constants from "expo-constants"; import store from "./state/Store"; import { Platform } from "react-native"; import { en } from "./i18n/Translations"; -import { useQuery, QueryClient } from "react-query"; +import { QueryClient } from "react-query"; type AuthenticationInput = { username: string; password: string }; type RegistrationInput = AuthenticationInput & { email: string }; + export type AccessToken = string; type FetchParams = { route: string; body?: Object; - method?: "GET" | "POST" | "DELETE"; + method?: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; // If true, No JSON parsing is done, the raw response's content is returned raw?: true; }; @@ -32,9 +33,9 @@ export class APIError extends Error { constructor( message: string, public status: number, - // Set the message to the correct error this is a placeholder + // Set the message to the correct error this is a placeholder // when the error is only used internally (middleman) - public userMessage : keyof typeof en = "unknownError" + public userMessage: keyof typeof en = "unknownError" ) { super(message); } @@ -53,7 +54,8 @@ const dummyIllustrations = [ "https://upload.wikimedia.org/wikipedia/en/b/ba/David_Guetta_2U.jpg", ]; -const getDummyIllustration = () => dummyIllustrations[Math.floor(Math.random() * dummyIllustrations.length)]; +const getDummyIllustration = () => + dummyIllustrations[Math.floor(Math.random() * dummyIllustrations.length)]; // we will need the same thing for the scorometer API url const baseAPIUrl = @@ -62,7 +64,7 @@ const baseAPIUrl = : Constants.manifest?.extra?.apiUrl; export default class API { - private static async fetch(params: FetchParams) { + public static async fetch(params: FetchParams) { const jwtToken = store.getState().user.accessToken; const header = { "Content-Type": "application/json", @@ -108,7 +110,8 @@ export default class API { .catch((e) => { if (!(e instanceof APIError)) throw e; - if (e.status == 401) throw new APIError("invalidCredentials", 401, "invalidCredentials"); + if (e.status == 401) + throw new APIError("invalidCredentials", 401, "invalidCredentials"); throw e; }); } @@ -125,24 +128,38 @@ export default class API { body: registrationInput, method: "POST", }); + // In the Future we should move autheticate out of this function + // and maybe create a new function to create and login in one go return API.authenticate({ username: registrationInput.username, password: registrationInput.password, }); } + public static async createAndGetGuestAccount(): Promise { + let response = await API.fetch({ + route: "/auth/guest", + method: "POST", + }); + if (!response.access_token) + throw new APIError("No access token", response.status); + return response.access_token; + } + + public static async transformGuestToUser(registrationInput: RegistrationInput): Promise { + await API.fetch({ + route: "/auth/me", + body: registrationInput, + method: "PUT", + }); + } + /*** * Retrieve information of the currently authentified user */ public static async getUserInfo(): Promise { - let me = await API.fetch({ - route: "/auth/me", - }); - - // /auth/me only returns username and id (it needs to be changed) - let user = await API.fetch({ - route: `/users/${me.id}`, + route: "/auth/me", }); // this a dummy settings object, we will need to fetch the real one from the API @@ -150,9 +167,15 @@ export default class API { id: user.id as number, name: (user.username ?? user.name) as string, email: user.email as string, - xp: 0, premium: false, - metrics: {}, + isGuest: user.isGuest as boolean, + data: { + gamesPlayed: user.partyPlayed as number, + xp: 0, + createdAt: new Date("2023-04-09T00:00:00.000Z"), + avatar: + "https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn", + }, settings: { preferences: { deviceId: 1, @@ -202,16 +225,19 @@ export default class API { }); // this is a dummy illustration, we will need to fetch the real one from the API - return songs.data.map((song: any) => ({ - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: getDummyIllustration(), - metrics: {}, - } as Song)); + return songs.data.map( + (song: any) => + ({ + id: song.id as number, + name: song.name as string, + artistId: song.artistId as number, + albumId: song.albumId as number, + genreId: song.genreId as number, + details: song.difficulties, + cover: getDummyIllustration(), + metrics: {}, + } as Song) + ); } /** @@ -232,7 +258,6 @@ export default class API { genreId: song.genreId as number, details: song.difficulties, cover: getDummyIllustration(), - metrics: {}, } as Song; } /** @@ -301,7 +326,7 @@ export default class API { */ public static async searchSongs(query: string): Promise { return API.fetch({ - route: `/search/guess/song/${query}` + route: `/search/guess/song/${query}`, }); } @@ -325,7 +350,10 @@ export default class API { */ public static async getSearchHistory(): Promise { const queryClient = new QueryClient(); - let songs = await queryClient.fetchQuery(["API", "allsongs"], API.getAllSongs); + let songs = await queryClient.fetchQuery( + ["API", "allsongs"], + API.getAllSongs + ); const shuffled = [...songs].sort(() => 0.5 - Math.random()); return shuffled.slice(0, 2); @@ -414,4 +442,38 @@ export default class API { ], ]; } + + public static async updateUserEmail(newEmail: string): Promise { + const rep = await API.fetch({ + route: "/auth/me", + method: "PUT", + body: { + email: newEmail, + }, + }); + + if (rep.error) { + throw new Error(rep.error); + } + return rep; + } + + public static async updateUserPassword( + oldPassword: string, + newPassword: string + ): Promise { + const rep = await API.fetch({ + route: "/auth/me", + method: "PUT", + body: { + oldPassword: oldPassword, + password: newPassword, + }, + }); + + if (rep.error) { + throw new Error(rep.error); + } + return rep; + } } diff --git a/front/Navigation.tsx b/front/Navigation.tsx index e82ec1b..21e1244 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -1,13 +1,15 @@ -import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { NativeStackScreenProps, createNativeStackNavigator } from '@react-navigation/native-stack'; +import { NavigationProp, ParamListBase, useNavigation as navigationHook } from "@react-navigation/native"; import React from 'react'; import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native'; import { RootState, useSelector } from './state/Store'; import { translate } from './i18n/i18n'; import SongLobbyView from './views/SongLobbyView'; import AuthenticationView from './views/AuthenticationView'; +import StartPageView from './views/StartPageView'; import HomeView from './views/HomeView'; import SearchView from './views/SearchView'; -import SetttingsNavigator from './views/SettingsView'; +import SetttingsNavigator from './views/settings/SettingsView'; import { useQuery } from 'react-query'; import API from './API'; import PlayView from './views/PlayView'; @@ -17,21 +19,52 @@ import LoadingComponent from './components/Loading'; import ProfileView from './views/ProfileView'; import useColorScheme from './hooks/colorScheme'; -const Stack = createNativeStackNavigator(); -export const protectedRoutes = <> - - - - - - - -; +const protectedRoutes = () => ({ + Home: { component: HomeView, options: { title: translate('welcome') } }, + Settings: { component: SetttingsNavigator, options: { title: 'Settings' } }, + Song: { component: SongLobbyView, options: { title: translate('play') } }, + Play: { component: PlayView, options: { title: translate('play') } }, + Score: { component: ScoreView, options: { title: translate('score') } }, + Search: { component: SearchView, options: { title: translate('search') } }, + User: { component: ProfileView, options: { title: translate('user') } }, +}) as const; -export const publicRoutes = - -; +const publicRoutes = () => ({ + Start: { component: StartPageView, options: { title: "Chromacase", headerShown: false } }, + Login: { component: AuthenticationView, options: { title: translate('signInBtn') } }, +}) as const; + +type Route = { + component: (arg: RouteProps) => JSX.Element | (() => JSX.Element), + options: any +} + +type OmitOrUndefined = T extends undefined ? T : Omit + +type RouteParams> = { + [RouteName in keyof Routes]: OmitOrUndefined[0], keyof NativeStackScreenProps<{}>>; +} + +type PrivateRoutesParams = RouteParams>; +type PublicRoutesParams = RouteParams>; +type AppRouteParams = PrivateRoutesParams & PublicRoutesParams; + +const Stack = createNativeStackNavigator(); + +const RouteToScreen = (component: Route['component']) => (props: NativeStackScreenProps) => + <> + {component({ ...props.route.params, route: props.route } as Parameters['component']>[0])} + + +const routesToScreens = (routes: Partial>) => Object.entries(routes) + .map(([name, route]) => ( + + )) export const Router = () => { const accessToken = useSelector((state: RootState) => state.user.accessToken); @@ -53,11 +86,16 @@ export const Router = () => { }/> - : userProfile.isSuccess && accessToken - ? protectedRoutes - : publicRoutes + : routesToScreens(userProfile.isSuccess && accessToken + ? protectedRoutes() + : publicRoutes()) } ); } + +export type RouteProps = T & Pick, 'route'>; + + +export const useNavigation = () => navigationHook>(); \ No newline at end of file diff --git a/front/components/BigActionButton.tsx b/front/components/BigActionButton.tsx new file mode 100644 index 0000000..aa19eb2 --- /dev/null +++ b/front/components/BigActionButton.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { + Box, + Center, + Heading, + View, + Image, + Text, + Pressable, + useBreakpointValue, + Icon, + Row, + PresenceTransition, +} from "native-base"; +import { StyleProp, ViewStyle } from "react-native"; +import useColorScheme from "../hooks/colorScheme"; + +type BigActionButtonProps = { + title: string; + subtitle: string; + image: string; + style?: StyleProp; + iconName?: string; + iconProvider?: any; + onPress: () => void; +}; + +const BigActionButton = ({ + title, + subtitle, + image, + style, + iconName, + iconProvider, + onPress, +}: BigActionButtonProps) => { + const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + + return ( + + {({ isHovered, isPressed }) => { + return ( + + + image + + + + + + + {title} + + + {isHovered && ( + + + {subtitle} + + + )} + + + + ); + }} + + {/* The text should be visible on the bottom left corner and when hovering the +button the image will darken and the subtitle will be show in a transition */} + + ); +}; + +export default BigActionButton; diff --git a/front/components/CompetenciesTable.tsx b/front/components/CompetenciesTable.tsx index 120ac72..acea685 100644 --- a/front/components/CompetenciesTable.tsx +++ b/front/components/CompetenciesTable.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from "@react-navigation/core"; +import { useNavigation } from "../Navigation"; import { HStack, VStack, Text, Progress } from "native-base"; import { translate } from "../i18n/i18n"; import Card from './Card'; diff --git a/front/components/GtkUI/Element.tsx b/front/components/GtkUI/Element.tsx new file mode 100644 index 0000000..ab60f74 --- /dev/null +++ b/front/components/GtkUI/Element.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { ElementProps } from "./ElementList"; +import { RawElement } from "./RawElement"; +import { Pressable } from "native-base"; + +export const Element = (props: ElementProps) => { + let actionFunction = null as null | Function; + + switch (props.type) { + case "text": + actionFunction = props.data?.onPress; + break; + case "toggle": + actionFunction = props.data?.onToggle; + break; + default: + break; + } + + if (!props?.disabled && actionFunction) { + return ( + + {({ isHovered }) => { + return ; + }} + + ); + } + return ; +}; diff --git a/front/components/GtkUI/ElementList.tsx b/front/components/GtkUI/ElementList.tsx new file mode 100644 index 0000000..4a196e6 --- /dev/null +++ b/front/components/GtkUI/ElementList.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { StyleProp, ViewStyle } from "react-native"; +import { Element } from "./Element"; +import useColorScheme from "../../hooks/colorScheme"; + +import { + ElementTextProps, + ElementToggleProps, + ElementDropdownProps, + ElementRangeProps, + ElementType, +} from "./ElementTypes"; + +import { + Box, + Column, + Divider, +} from "native-base"; + +export type ElementProps = { + title: string; + icon?: React.ReactNode; + type?: ElementType; + helperText?: string; + description?: string; + disabled?: boolean; + data?: + | ElementTextProps + | ElementToggleProps + | ElementDropdownProps + | ElementRangeProps + | React.ReactNode; +}; + +type ElementListProps = { + elements: ElementProps[]; + style?: StyleProp; +}; + +const ElementList = ({ elements, style }: ElementListProps) => { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const elementStyle = { + borderRadius: 10, + boxShadow: isDark ? "0px 0px 3px 0px rgba(255,255,255,0.6)" : "0px 0px 3px 0px rgba(0,0,0,0.4)", + overflow: "hidden", + }; + + return ( + + {elements.map((element, index, __) => ( + + + { index < elements.length - 1 && + + } + + ))} + + ); +}; + +export default ElementList; diff --git a/front/components/GtkUI/ElementTypes.tsx b/front/components/GtkUI/ElementTypes.tsx new file mode 100644 index 0000000..4d3eccf --- /dev/null +++ b/front/components/GtkUI/ElementTypes.tsx @@ -0,0 +1,137 @@ +import { Select, Switch, Text, Icon, Row, Slider } from "native-base"; +import { MaterialIcons } from "@expo/vector-icons"; +export type ElementType = + | "custom" + | "default" + | "text" + | "toggle" + | "dropdown" + | "range"; + +export type DropdownOption = { + label: string; + value: string; +}; + +export type ElementTextProps = { + text: string; + onPress?: () => void; +}; + +export type ElementToggleProps = { + onToggle: () => void; + value: boolean; + defaultValue?: boolean; +}; + +export type ElementDropdownProps = { + options: DropdownOption[]; + onSelect: (value: string) => void; + value: string; + defaultValue?: string; +}; + +export type ElementRangeProps = { + onChange: (value: number) => void; + value: number; + defaultValue?: number; + min: number; + max: number; + step?: number; +}; + +export const getElementTextNode = ( + { text, onPress }: ElementTextProps, + disabled: boolean +) => { + return ( + + + {text} + + {onPress && ( + + )} + + ); +}; + +export const getElementToggleNode = ( + { onToggle, value, defaultValue }: ElementToggleProps, + disabled: boolean +) => { + return ( + + ); +}; + +export const getElementDropdownNode = ( + { options, onSelect, value, defaultValue }: ElementDropdownProps, + disabled: boolean +) => { + return ( + + ); +}; + +export const getElementRangeNode = ( + { onChange, value, defaultValue, min, max, step }: ElementRangeProps, + disabled: boolean, + title: string +) => { + return ( + + + + + + + ); +}; diff --git a/front/components/GtkUI/RawElement.tsx b/front/components/GtkUI/RawElement.tsx new file mode 100644 index 0000000..e4be9c7 --- /dev/null +++ b/front/components/GtkUI/RawElement.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { + Box, + Button, + Column, + Divider, + Icon, + Popover, + Row, + Text, + useBreakpointValue, +} from "native-base"; +import useColorScheme from "../../hooks/colorScheme"; +import { Ionicons } from "@expo/vector-icons"; +import { ElementProps } from "./ElementList"; +import { + getElementDropdownNode, + getElementTextNode, + getElementToggleNode, + getElementRangeNode, + ElementDropdownProps, + ElementTextProps, + ElementToggleProps, + ElementRangeProps, +} from "./ElementTypes"; + +type RawElementProps = { + element: ElementProps; + isHovered?: boolean; +}; + +export const RawElement = ({ element, isHovered }: RawElementProps) => { + const { title, icon, type, helperText, description, disabled, data } = + element; + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const isSmallScreen = screenSize === "small"; + return ( + + + {icon} + + + {title} + + {description && ( + + {description} + + )} + + + + + {helperText && ( + ( + + + + + ); +} + +export default ChangeEmailForm; diff --git a/front/components/forms/changePasswordForm.tsx b/front/components/forms/changePasswordForm.tsx new file mode 100644 index 0000000..647f9aa --- /dev/null +++ b/front/components/forms/changePasswordForm.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { translate } from "../../i18n/i18n"; +import { string } from "yup"; +import { + FormControl, + Input, + Stack, + WarningOutlineIcon, + Box, + Button, + useToast, +} from "native-base"; + + +interface ChangePasswordFormProps { + onSubmit: ( + oldPassword: string, + newPassword: string + ) => Promise; +} + +const ChangePasswordForm = ({ onSubmit }: ChangePasswordFormProps) => { + const [formData, setFormData] = React.useState({ + oldPassword: { + value: "", + error: null as string | null, + }, + newPassword: { + value: "", + error: null as string | null, + }, + confirmNewPassword: { + value: "", + error: null as string | null, + }, + }); + const [submittingForm, setSubmittingForm] = React.useState(false); + + const validationSchemas = { + password: string() + .min(4, translate("passwordTooShort")) + .max(100, translate("passwordTooLong")) + .required("Password is required"), + }; + const toast = useToast(); + + return ( + + + + + {translate("oldPassword")} + { + let error: null | string = null; + validationSchemas.password + .validate(t) + .catch((e) => (error = e.message)) + .finally(() => { + setFormData({ ...formData, oldPassword: { value: t, error } }); + }); + }} + /> + } > + {formData.oldPassword.error} + + + {translate("newPassword")} + { + let error: null | string = null; + validationSchemas.password + .validate(t) + .catch((e) => (error = e.message)) + .finally(() => { + setFormData({ ...formData, newPassword: { value: t, error } }); + }); + }} + /> + } > + {formData.newPassword.error} + + + {translate("confirmNewPassword")} + { + let error: null | string = null; + validationSchemas.password + .validate(t) + .catch((e) => (error = e.message)) + if (!error && t !== formData.newPassword.value) { + error = translate("passwordsDontMatch"); + } + setFormData({ + ...formData, + confirmNewPassword: { value: t, error }, + }); + }} + /> + } > + {formData.confirmNewPassword.error} + + + + + + + + ); +} + +export default ChangePasswordForm; diff --git a/front/components/forms/signupform.tsx b/front/components/forms/signupform.tsx index 7abf098..85a12a1 100644 --- a/front/components/forms/signupform.tsx +++ b/front/components/forms/signupform.tsx @@ -21,7 +21,7 @@ interface SignupFormProps { ) => Promise; } -const LoginForm = ({ onSubmit }: SignupFormProps) => { +const SignUpForm = ({ onSubmit }: SignupFormProps) => { const [formData, setFormData] = React.useState({ username: { value: "", @@ -210,4 +210,4 @@ const LoginForm = ({ onSubmit }: SignupFormProps) => { ); }; -export default LoginForm; +export default SignUpForm; diff --git a/front/components/navigators/TabRowNavigator.tsx b/front/components/navigators/TabRowNavigator.tsx new file mode 100644 index 0000000..fa5ef26 --- /dev/null +++ b/front/components/navigators/TabRowNavigator.tsx @@ -0,0 +1,225 @@ +import * as React from "react"; +import { StyleProp, ViewStyle, StyleSheet } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { + View, + Text, + Pressable, + Box, + Row, + Icon, + Button, + useBreakpointValue, +} from "native-base"; +import { + createNavigatorFactory, + DefaultNavigatorOptions, + ParamListBase, + CommonActions, + TabActionHelpers, + TabNavigationState, + TabRouter, + TabRouterOptions, + useNavigationBuilder, +} from "@react-navigation/native"; +import IconButton from "../IconButton"; + +const TabRowNavigatorInitialComponentName = "TabIndex"; + +export {TabRowNavigatorInitialComponentName}; + +// Props accepted by the view +type TabNavigationConfig = { + tabBarStyle: StyleProp; + contentStyle: StyleProp; +}; + +// Supported screen options +type TabNavigationOptions = { + title?: string; + iconProvider?: any; + iconName?: string; +}; + +// Map of event name and the type of data (in event.data) +// +// canPreventDefault: true adds the defaultPrevented property to the +// emitted events. +type TabNavigationEventMap = { + tabPress: { + data: { isAlreadyFocused: boolean }; + canPreventDefault: true; + }; +}; + +// The props accepted by the component is a combination of 3 things +type Props = DefaultNavigatorOptions< + ParamListBase, + TabNavigationState, + TabNavigationOptions, + TabNavigationEventMap +> & + TabRouterOptions & + TabNavigationConfig; + +function TabNavigator({ + initialRouteName, + children, + screenOptions, + tabBarStyle, + contentStyle, +}: Props) { + const { state, navigation, descriptors, NavigationContent } = + useNavigationBuilder< + TabNavigationState, + TabRouterOptions, + TabActionHelpers, + TabNavigationOptions, + TabNavigationEventMap + >(TabRouter, { + children, + screenOptions, + initialRouteName, + }); + + const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const [isPanelView, setIsPanelView] = React.useState(false); + const isMobileView = screenSize == "small"; + + React.useEffect(() => { + if (state.index === 0) { + if (isMobileView) { + setIsPanelView(true); + } else { + navigation.reset( + { + ...state, + index: 1, + } + ); + } + } + }, [state.index]); + + React.useEffect(() => { + navigation.setOptions({ + headerShown: !isMobileView || isPanelView, + }); + }, [isMobileView, isPanelView]); + + return ( + + + {(!isMobileView || isPanelView) && ( + + {state.routes.map((route, idx) => { + if (idx === 0) { + return null; + } + const isSelected = route.key === state.routes[state.index]?.key; + const { options } = descriptors[route.key]; + + return ( + + ); + })} + + )} + {(!isMobileView || !isPanelView) && ( + + {isMobileView && ( + - - - - - - - - - - - - - - ) -} - -export const PreferencesView = ({navigation}) => { - const dispatch = useDispatch(); - const language: AvailableLanguages = useSelector((state: RootState) => state.language.value); - const settings = useSelector((state: RootState) => (state.settings.settings as SettingsState)); - return ( -
- - - - navigation.navigate('Main')} style={{ margin: 10 }} - translate={{ translationKey: 'backBtn' }} - /> - - - - - - - - - - - - - - Color blind mode - { dispatch(updateSettings({ colorBlind: enabled })) }} - /> - - - - Mic volume - { dispatch(updateSettings({ micLevel: value })) }} - > - - - - - - - - - - -
- ) -} - -const NotificationsView = ({navigation}) => { - const dispatch = useDispatch(); - const settings: SettingsState = useSelector((state: RootState) => state.settings); - return ( -
- - - - - - - Push notifications - { dispatch(updateSettings({ enablePushNotifications: value })) }} - /> - - - Email notifications - { dispatch(updateSettings({ enableMailNotifications: value })) }} - /> - - - Training reminder - { dispatch(updateSettings({ enableLessongsReminders: value })) }} - /> - - - New songs - { dispatch(updateSettings({ enableReleaseAlerts: value })) }} - /> - -
- ) -} - -export const PrivacyView = ({navigation}) => { - return ( -
- - - - - - - - Data Collection - - - - - Custom Adds - - - - - Recommendations - - -
- ) -} - -export const ChangePasswordView = ({navigation}) => { - return ( -
- - ChangePassword -
- ) -} - -export const ChangeEmailView = ({navigation}) => { - return ( -
- - ChangeEmail -
- ) -} - -export const GoogleAccountView = ({navigation}) => { - return ( -
- - GoogleAccount -
- ) -} - -const SetttingsNavigator = () => { - return ( - - - - - - - - - - ) -} - -export default SetttingsNavigator; \ No newline at end of file diff --git a/front/views/SongLobbyView.tsx b/front/views/SongLobbyView.tsx index a07a585..8700f72 100644 --- a/front/views/SongLobbyView.tsx +++ b/front/views/SongLobbyView.tsx @@ -1,5 +1,4 @@ -import { useNavigation, useRoute } from "@react-navigation/native"; -import { Button, Divider, Box, Center, Image, Text, VStack, PresenceTransition, Icon, Stack } from "native-base"; +import { Divider, Box, Center, Image, Text, VStack, PresenceTransition, Icon, Stack } from "native-base"; import { useQuery } from 'react-query'; import LoadingComponent from "../components/Loading"; import React, { useEffect, useState } from "react"; @@ -8,16 +7,15 @@ import formatDuration from "format-duration"; import { Ionicons } from '@expo/vector-icons'; import API from "../API"; import TextButton from "../components/TextButton"; +import { useNavigation, RouteProps } from "../Navigation"; interface SongLobbyProps { // The unique identifier to find a song songId: number; } -const SongLobbyView = () => { - const route = useRoute(); +const SongLobbyView = (props: RouteProps) => { const navigation = useNavigation(); - const props: SongLobbyProps = route.params as any; const songQuery = useQuery(['song', props.songId], () => API.getSong(props.songId)); const chaptersQuery = useQuery(['song', props.songId, 'chapters'], () => API.getSongChapters(props.songId)); const scoresQuery = useQuery(['song', props.songId, 'scores'], () => API.getSongHistory(props.songId)); @@ -47,11 +45,11 @@ const SongLobbyView = () => { /> navigation.navigate('Play', { songId: songQuery.data?.id, type: 'normal' })} + onPress={() => navigation.navigate('Play', { songId: songQuery.data!.id, type: 'normal' })} rightIcon={} /> navigation.navigate('Play', { songId: songQuery.data?.id, type: 'practice' })} + onPress={() => navigation.navigate('Play', { songId: songQuery.data!.id, type: 'practice' })} rightIcon={} colorScheme='secondary' /> diff --git a/front/views/StartPageView.tsx b/front/views/StartPageView.tsx new file mode 100644 index 0000000..cd9c1af --- /dev/null +++ b/front/views/StartPageView.tsx @@ -0,0 +1,227 @@ +import React from "react"; +import { useNavigation } from "../Navigation"; +import { + View, + Text, + Stack, + Box, + useToast, + AspectRatio, + Column, + useBreakpointValue, + Image, + Link, + Center, + Row, + Heading, + Icon, +} from "native-base"; +import { FontAwesome5 } from "@expo/vector-icons"; +import BigActionButton from "../components/BigActionButton"; +import API, { APIError } from "../API"; +import { setAccessToken } from "../state/UserSlice"; +import { useDispatch } from "../state/Store"; +import { translate } from "../i18n/i18n"; + +const handleGuestLogin = async ( + apiSetter: (accessToken: string) => void +): Promise => { + const apiAccess = await API.createAndGetGuestAccount(); + apiSetter(apiAccess); + return translate("loggedIn"); +}; + +const imgLogin = + "https://media.discordapp.net/attachments/717080637038788731/1095980610981478470/Octopus_a_moder_style_image_of_a_musician_showing_a_member_card_c0b9072c-d834-40d5-bc83-796501e1382c.png?width=657&height=657"; +const imgGuest = + "https://media.discordapp.net/attachments/717080637038788731/1095996800835539014/Chromacase_guest_2.png?width=865&height=657"; +const imgRegister = + "https://media.discordapp.net/attachments/717080637038788731/1095991220267929641/chromacase_register.png?width=1440&height=511"; + +const imgBanner = + "https://chromacase.studio/wp-content/uploads/2023/03/music-sheet-music-color-2462438.jpg"; + +const imgLogo = + "https://chromacase.studio/wp-content/uploads/2023/03/cropped-cropped-splashLogo-280x300.png"; + +const StartPageView = () => { + const navigation = useNavigation(); + const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const isSmallScreen = screenSize === "small"; + const dispatch = useDispatch(); + const toast = useToast(); + + return ( + +
+ + + } + size={isSmallScreen ? "5xl" : "6xl"} + /> + Chromacase + +
+ + navigation.navigate("Login", { isSignup: false })} + style={{ + width: isSmallScreen ? "90%" : "clamp(100px, 33.3%, 600px)", + height: "300px", + margin: "clamp(10px, 2%, 50px)", + }} + /> + { + try { + handleGuestLogin((accessToken: string) => { + dispatch(setAccessToken(accessToken)); + }); + } catch (error) { + if (error instanceof APIError) { + toast.show({ description: translate(error.userMessage) }); + return; + } + toast.show({ description: error as string }); + } + }} + style={{ + width: isSmallScreen ? "90%" : "clamp(100px, 33.3%, 600px)", + height: "300px", + margin: "clamp(10px, 2%, 50px)", + }} + /> + +
+ navigation.navigate("Login", { isSignup: true })} + style={{ + height: "150px", + width: isSmallScreen ? "90%" : "clamp(150px, 50%, 600px)", + }} + /> +
+ + + + What is Chromacase? + + + Chromacase is a free and open source project that aims to provide a + complete learning experience for anyone willing to learn piano. + + + + + + + + Chromacase Banner + + + + Click here for more infos + + + + + +
+ ); +}; + +export default StartPageView; diff --git a/front/views/settings/GuestToUserView.tsx b/front/views/settings/GuestToUserView.tsx new file mode 100644 index 0000000..d9bcfb2 --- /dev/null +++ b/front/views/settings/GuestToUserView.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import SignUpForm from "../../components/forms/signupform"; +import { Center, Heading, Text } from "native-base"; +import API, { APIError } from "../../API"; +import { translate } from "../../i18n/i18n"; + +const handleSubmit = async ( + username: string, + password: string, + email: string +) => { + try { + await API.transformGuestToUser({ username, password, email }); + } catch (error) { + if (error instanceof APIError) return translate(error.userMessage); + if (error instanceof Error) return error.message; + return translate("unknownError"); + } + return translate("loggedIn"); +}; + +const GuestToUserView = () => { + return ( +
+
+ {translate("signUp")} + + {translate("transformGuestToUserExplanations")} + + + handleSubmit(username, password, email) + } + /> +
+
+ ); +}; + +export default GuestToUserView; diff --git a/front/views/settings/NotificationView.tsx b/front/views/settings/NotificationView.tsx new file mode 100644 index 0000000..e713dae --- /dev/null +++ b/front/views/settings/NotificationView.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { Center, Heading } from "native-base"; +import { translate, Translate } from "../../i18n/i18n"; +import { useDispatch } from "react-redux"; +import { RootState, useSelector } from "../../state/Store"; +import { SettingsState, updateSettings } from "../../state/SettingsSlice"; +import ElementList from "../../components/GtkUI/ElementList"; + +const NotificationsView = ({ navigation }) => { + const dispatch = useDispatch(); + const settings: SettingsState = useSelector( + (state: RootState) => state.settings.settings as SettingsState + ); + + return ( +
+ + + + { + dispatch( + updateSettings({ + enablePushNotifications: !settings.enablePushNotifications, + }) + ); + }, + }, + }, + { + type: "toggle", + title: translate("SettingsNotificationsEmailNotifications"), + data: { + value: settings.enableMailNotifications, + onToggle: () => { + dispatch( + updateSettings({ + enableMailNotifications: !settings.enableMailNotifications, + }) + ); + }, + }, + }, + { + type: "toggle", + title: translate("SettingsNotificationsTrainingReminder"), + data: { + value: settings.enableLessongsReminders, + onToggle: () => { + dispatch( + updateSettings({ + enableLessongsReminders: !settings.enableLessongsReminders, + }) + ); + }, + }, + }, + { + type: "toggle", + title: translate("SettingsNotificationsReleaseAlert"), + data: { + value: settings.enableReleaseAlerts, + onToggle: () => { + dispatch( + updateSettings({ + enableReleaseAlerts: !settings.enableReleaseAlerts, + }) + ); + }, + }, + }, + ]} + /> +
+ ); +}; + +export default NotificationsView; diff --git a/front/views/settings/PreferencesView.tsx b/front/views/settings/PreferencesView.tsx new file mode 100644 index 0000000..4b63bbd --- /dev/null +++ b/front/views/settings/PreferencesView.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { View } from "react-native"; +import { useDispatch } from "react-redux"; +import { + Center, + Button, + Text, + Switch, + Slider, + Select, + Heading, +} from "native-base"; +import { useLanguage } from "../../state/LanguageSlice"; +import i18n, { + AvailableLanguages, + DefaultLanguage, + translate, + Translate, +} from "../../i18n/i18n"; +import { RootState, useSelector } from "../../state/Store"; +import { SettingsState, updateSettings } from "../../state/SettingsSlice"; +import ElementList from "../../components/GtkUI/ElementList"; + +const PreferencesView = ({ navigation }) => { + const dispatch = useDispatch(); + const language: AvailableLanguages = useSelector( + (state: RootState) => state.language.value + ); + const settings = useSelector( + (state: RootState) => state.settings.settings as SettingsState + ); + return ( +
+ + + + { + dispatch( + updateSettings({ colorScheme: newColorScheme as any }) + ); + }, + options: [ + { label: translate("dark"), value: "dark" }, + { label: translate("light"), value: "light" }, + { label: translate("system"), value: "system" }, + ], + }, + }, + { + type: "dropdown", + title: translate("SettingsPreferencesLanguage"), + data: { + value: language, + defaultValue: DefaultLanguage, + onSelect: (itemValue) => { + dispatch(useLanguage(itemValue as AvailableLanguages)); + }, + options: [ + { label: "Français", value: "fr" }, + { label: "English", value: "en" }, + { label: "Espanol", value: "sp" }, + ], + }, + }, + { + type: "dropdown", + title: translate("SettingsPreferencesDifficulty"), + data: { + value: settings.preferedLevel, + defaultValue: "medium", + onSelect: (itemValue) => { + dispatch(updateSettings({ preferedLevel: itemValue as any })); + }, + options: [ + { label: translate("easy"), value: "easy" }, + { label: translate("medium"), value: "medium" }, + { label: translate("hard"), value: "hard" }, + ], + }, + }, + ]} + /> + { + dispatch(updateSettings({ colorBlind: !settings.colorBlind })); + }, + }, + }, + ]} + /> + { + dispatch(updateSettings({ micLevel: value })); + }, + }, + }, + { + type: "dropdown", + title: translate("SettingsPreferencesDevice"), + data: { + value: settings.preferedInputName || "0", + defaultValue: "0", + onSelect: (itemValue: string) => { + dispatch(updateSettings({ preferedInputName: itemValue })); + }, + options: [ + { label: "Mic_0", value: "0" }, + { label: "Mic_1", value: "1" }, + { label: "Mic_2", value: "2" }, + ], + }, + }, + ]} + /> +
+ ); +}; + +export default PreferencesView; diff --git a/front/views/settings/PrivacyView.tsx b/front/views/settings/PrivacyView.tsx new file mode 100644 index 0000000..87f86f4 --- /dev/null +++ b/front/views/settings/PrivacyView.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Center, Heading } from "native-base"; +import { translate } from "../../i18n/i18n"; +import ElementList from "../../components/GtkUI/ElementList"; +import { useDispatch } from "react-redux"; +import { RootState, useSelector } from "../../state/Store"; +import { SettingsState, updateSettings } from "../../state/SettingsSlice"; + +const PrivacyView = () => { + const dispatch = useDispatch(); + const settings: SettingsState = useSelector( + (state: RootState) => state.settings.settings as SettingsState + ); + + return ( +
+ {translate("privBtn")} + + + dispatch( + updateSettings({ dataCollection: !settings.dataCollection }) + ), + }, + }, + { + type: "toggle", + title: translate("customAds"), + data: { + value: settings.customAds, + onToggle: () => + dispatch(updateSettings({ customAds: !settings.customAds })), + }, + }, + { + type: "toggle", + title: translate("recommendations"), + data: { + value: settings.recommandations, + onToggle: () => + dispatch( + updateSettings({ recommandations: !settings.recommandations }) + ), + }, + }, + ]} + /> +
+ ); +}; + +export default PrivacyView; diff --git a/front/views/settings/SettingsProfileView.tsx b/front/views/settings/SettingsProfileView.tsx new file mode 100644 index 0000000..1e0698b --- /dev/null +++ b/front/views/settings/SettingsProfileView.tsx @@ -0,0 +1,244 @@ +import API from "../../API"; +import { useDispatch } from "react-redux"; +import { unsetAccessToken } from "../../state/UserSlice"; + +import React, { useEffect, useState } from "react"; +import { + Column, + Text, + Button, + Icon, + Box, + IconButton, + Flex, + Row, + Center, + Heading, + Avatar, + Popover, +} from "native-base"; +import { FontAwesome5 } from "@expo/vector-icons"; +import User from "../../models/User"; +import TextButton from "../../components/TextButton"; +import LoadingComponent from "../../components/Loading"; +import ElementList from "../../components/GtkUI/ElementList"; +import { translate } from "../../i18n/i18n"; +import { useQuery } from "react-query"; + +const getInitials = (name: string) => { + return name.split(" ").map((n) => n[0]).join(""); +}; + +const ProfileSettings = ({ navigation }: { navigation: any }) => { + const userQuery = useQuery(["appSettings", "user"], API.getUserInfo); + const dispatch = useDispatch(); + const user = userQuery.data; + + if (userQuery.isError) { + return ( +
+ {translate("errorLoadingUser")} +
+ ); + } + + if (!userQuery || userQuery.isLoading || !userQuery.data) { + return ( +
+ +
+ ); + } + + return ( + + +
+ + {getInitials(user.name)} + +
+ { + navigation.navigate("ChangeEmail"); + }, + }, + }, + ]} + /> + + + + Fonctionnalités premium + + {}, + }, + }, + { + type: "dropdown", + title: "Thème de piano", + disabled: true, + data: { + value: "default", + onValueChange: () => {}, + options: [ + { + label: "Default", + value: "default", + }, + { + label: "Catpuccino", + value: "catpuccino", + }, + ], + }, + }, + ]} + /> +
+ + + {!user.isGuest && ( + dispatch(unsetAccessToken())} + translate={{ + translationKey: "signOutBtn", + }} + /> + )} + {user.isGuest && ( + ( + + )} + > + + + + + {translate("Attention")} + + + {translate( + "YouAreCurrentlyConnectedWithAGuestAccountWarning" + )} + + + + + + + + + )} + +
+ ); +}; + +export default ProfileSettings; diff --git a/front/views/settings/SettingsView.tsx b/front/views/settings/SettingsView.tsx new file mode 100644 index 0000000..dd7893d --- /dev/null +++ b/front/views/settings/SettingsView.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Center, Button, Text, Switch, Slider, Select, Heading, Box } from "native-base"; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { unsetAccessToken } from '../../state/UserSlice'; +import { useDispatch } from "react-redux"; +import { RootState, useSelector } from '../../state/Store'; +import { useLanguage } from "../../state/LanguageSlice"; +import { SettingsState, updateSettings } from '../../state/SettingsSlice'; +import { AvailableLanguages, translate, Translate } from "../../i18n/i18n"; +import TextButton from '../../components/TextButton'; +import createTabRowNavigator from '../../components/navigators/TabRowNavigator'; +import { FontAwesome, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons'; +import ChangePasswordForm from '../../components/forms/changePasswordForm'; +import ChangeEmailForm from '../../components/forms/changeEmailForm'; +import ProfileSettings from './SettingsProfileView'; +import NotificationsView from './NotificationView'; +import PrivacyView from './PrivacyView'; +import PreferencesView from './PreferencesView'; +import GuestToUserView from './GuestToUserView'; +import { useQuery } from 'react-query'; + +import API, { APIError } from '../../API'; +import User from '../../models/User'; + + +const SettingsStack = createNativeStackNavigator(); + +const handleChangeEmail = async (newEmail: string): Promise => { + try { + let response = await API.updateUserEmail(newEmail); + return translate('emailUpdated'); + } catch (e) { + throw e; + } +} + +const handleChangePassword = async (oldPassword: string, newPassword: string): Promise => { + try { + let response = await API.updateUserPassword(oldPassword, newPassword); + return translate('passwordUpdated'); + } catch (e) { + throw e; + } +} + +const MainView = ({navigation}) => { + const dispatch = useDispatch(); + + return ( +
+ + + + + + + + + + + + + +
+ ) +} + +export const ChangePasswordView = ({navigation}) => { + return ( +
+ {translate('changePassword')} + handleChangePassword(oldPassword, newPassword)}/> +
+ ) +} + +export const ChangeEmailView = ({navigation}) => { + return ( +
+ {translate('changeEmail')} + handleChangeEmail(newEmail)}/> +
+ ) +} + +export const GoogleAccountView = ({navigation}) => { + return ( +
+ GoogleAccount +
+ ) +} + +export const PianoSettingsView = ({navigation}) => { + return ( +
+ Global settings for the virtual piano +
+ ) +} + +const TabRow = createTabRowNavigator(); + +const SetttingsNavigator = () => { + const userQuery = useQuery(["appSettings", 'user'], API.getUserInfo); + const user = userQuery.data; + + if (userQuery.isError) { + user.isGuest = false; + } + + if (userQuery.isLoading) { + return ( +
+ Loading... +
+ ) + } + + return ( + + {/* I'm doing this to be able to land on the summary of settings when clicking on settings and directly to the + wanted settings page if needed so I need to do special work with the 0 index */} + + {user && user.isGuest && + + } + + + + + + + + + + ) +} + +export default SetttingsNavigator; \ No newline at end of file