29 Commits

Author SHA1 Message Date
Arthur Jamet
cec07b7e99 Front: Handle Error when guest username is already taken 2024-01-04 11:46:13 +01:00
Arthur Jamet
f93968c3eb Front: Add Username for Guest Mode 2024-01-04 11:33:11 +01:00
Arthur Jamet
f80253cea3 Back: Require Username for Guest Account Creation 2024-01-04 09:55:45 +01:00
Arthur Jamet
60a73781bd Front: Lint + format 2023-12-29 18:13:40 +01:00
Arthur Jamet
4e3b378d6a Front: Lint + format 2023-12-29 18:13:40 +01:00
Clément Le Bihan
2bf1e783a9 removed unused var 2023-12-29 18:13:40 +01:00
Clément Le Bihan
375d36f6c5 Fixed google logo for mobile 2023-12-29 18:13:40 +01:00
Clément Le Bihan
495380ec43 Fix CI 2023-12-29 18:13:40 +01:00
Clément Le Bihan
af0531bb0c Fixed the like button and now desactivated the click on card to go to song and changed default display for score from '?' to '-' 2023-12-29 18:13:40 +01:00
Arthur Jamet
c5124fa6ad Front: MusicView: Fix Wrong Mutation 2023-12-29 18:13:40 +01:00
Arthur Jamet
962cf58e77 Front: DiscoveryView: USe Like status 2023-12-29 18:13:40 +01:00
Arthur Jamet
60988dd599 Front: Use Mutations to update 'liked' state 2023-12-29 18:13:40 +01:00
Arthur Jamet
004a541302 Front: Lint + format 2023-12-28 12:07:35 +01:00
Arthur Jamet
f4cd9e18ea Front: Explain how to DL the APK 2023-12-28 12:07:35 +01:00
Arthur Jamet
2dc301addf Front: add Button to Download APK From Web 2023-12-28 12:07:35 +01:00
Arthur Jamet
e85a959c26 Front: remove Visible IDs 2023-12-22 17:37:21 +01:00
Arthur Jamet
339e808d27 Front: SettingsView: Fox ordering of tabs 2023-12-21 17:17:47 +01:00
Arthur Jamet
22d1a97abd Front: SettingsView: Fox ordering of tabs 2023-12-21 17:17:47 +01:00
Arthur Jamet
ce4baa61dc Front: serve Google logo ourselves 2023-12-21 17:17:47 +01:00
Arthur Jamet
e90c7f05a8 Front: Remove use of external images for placeholders 2023-12-21 17:17:47 +01:00
Arthur Jamet
fb0e43af88 Front: Prettier 2023-12-21 17:17:47 +01:00
Arthur Jamet
4577997b1c Front :add spanish translations 2023-12-21 17:17:47 +01:00
Arthur Jamet
9bb256f2ee front: add missing translation components 2023-12-21 17:17:47 +01:00
Arthur Jamet
d3994ff26e Front: First Pass on translations + remove unused setting tabs 2023-12-21 17:17:47 +01:00
Clément Le Bihan
00d097f643 Fixes prettier 2023-12-20 12:01:55 +01:00
Arthur Jamet
99da77f23e Front: Fix cirular dependecy between validators 2023-12-20 12:01:55 +01:00
Arthur Jamet
7a6dc8b0c9 Front: Use history include to get best/last score for a song 2023-12-20 12:01:55 +01:00
Clément Le Bihan
b4f04f9b71 Fixed number of lignes on DiscoveryCard 2023-12-19 17:06:30 +01:00
Arthur Jamet
9df0c98100 Front: DiscoveryView: Remove Dummy Data 2023-12-19 15:03:18 +01:00
49 changed files with 828 additions and 678 deletions

View File

@@ -51,6 +51,7 @@ import { PasswordResetDto } from "./dto/password_reset.dto ";
import { mapInclude } from "src/utils/include";
import { SongController } from "src/song/song.controller";
import { ChromaAuthGuard } from "./chroma-auth.guard";
import { GuestDto } from "./dto/guest.dto";
@ApiTags("auth")
@Controller("auth")
@@ -162,8 +163,8 @@ export class AuthController {
@HttpCode(200)
@ApiOperation({ description: "Login as a guest account" })
@ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
async guest(): Promise<JwtToken> {
const user = await this.usersService.createGuest();
async guest(@Body() guestdto: GuestDto): Promise<JwtToken> {
const user = await this.usersService.createGuest(guestdto.username);
await this.settingsService.createUserSetting(user.id);
return this.authService.login(user);
}

View File

@@ -0,0 +1,8 @@
import { IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class GuestDto {
@ApiProperty()
@IsNotEmpty()
username: string;
}

View File

@@ -6,7 +6,7 @@ import {
import { User, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import * as bcrypt from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { createHash } from "crypto";
import { createReadStream, existsSync } from "fs";
import fetch from "node-fetch";
@@ -46,10 +46,10 @@ export class UsersService {
});
}
async createGuest(): Promise<User> {
async createGuest(displayName: string): Promise<User> {
return this.prisma.user.create({
data: {
username: `Guest ${randomUUID()}`,
username: displayName,
isGuest: true,
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
email: null,

View File

@@ -9,7 +9,7 @@ Resource ./auth.resource
*** Test Cases ***
LoginAsGuest
[Documentation] Login as a guest
&{res}= POST /auth/guest
&{res}= POST /auth/guest {"username": "i-am-a-guest"}
Output
Integer response status 200
String response body access_token
@@ -20,12 +20,13 @@ LoginAsGuest
Integer response status 200
Boolean response body isGuest true
Integer response body partyPlayed 0
String response body username "i-am-a-guest"
[Teardown] DELETE /auth/me
TwoGuests
[Documentation] Login as a guest
&{res}= POST /auth/guest
&{res}= POST /auth/guest {"username": "i-am-another-guest"}
Output
Integer response status 200
String response body access_token
@@ -36,8 +37,9 @@ TwoGuests
Integer response status 200
Boolean response body isGuest true
Integer response body partyPlayed 0
String response body username "i-am-another-guest"
&{res2}= POST /auth/guest
&{res2}= POST /auth/guest {"username": "i-am-a-third-guest"}
Output
Integer response status 200
String response body access_token
@@ -48,6 +50,7 @@ TwoGuests
Integer response status 200
Boolean response body isGuest true
Integer response body partyPlayed 0
String response body username "i-am-a-third-guest"
[Teardown] Run Keywords DELETE /auth/me
... AND Set Headers {"Authorization": "Bearer ${res.body.access_token}"}
@@ -55,7 +58,7 @@ TwoGuests
GuestToNormal
[Documentation] Login as a guest and convert to a normal account
&{res}= POST /auth/guest
&{res}= POST /auth/guest {"username": "i-will-be-a-real-user"}
Output
Integer response status 200
String response body access_token
@@ -65,11 +68,13 @@ GuestToNormal
Output
Integer response status 200
Boolean response body isGuest true
String response body username "i-will-be-a-real-user"
${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "awdaw@b.c"}
${res}= PUT /auth/me { "password": "toto", "email": "awdaw@b.c"}
Output
Integer response status 200
String response body username "toto"
Boolean response body isGuest false
String response body username "i-will-be-a-real-user"
[Teardown] DELETE /auth/me

View File

@@ -187,12 +187,12 @@ export default class API {
});
}
public static async createAndGetGuestAccount(): Promise<AccessToken> {
public static async createAndGetGuestAccount(username: string): Promise<AccessToken> {
return API.fetch(
{
route: '/auth/guest',
method: 'POST',
body: undefined,
body: { username },
},
{ handler: AccessTokenResponseHandler }
)

BIN
front/assets/google.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,40 @@
import { ArrowCircleDown2 } from 'iconsax-react-native';
import ButtonBase from './UI/ButtonBase';
import { translate } from '../i18n/i18n';
import { Linking } from 'react-native';
import { useState } from 'react';
import PopupCC from './UI/PopupCC';
const APKDownloadButton = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<ButtonBase
style={{}}
icon={ArrowCircleDown2}
type={'filled'}
title={translate('downloadAPK')}
onPress={() => setIsOpen(true)}
/>
<PopupCC
title={translate('downloadAPK')}
description={translate('downloadAPKInstructions')}
isVisible={isOpen}
setIsVisible={setIsOpen}
>
<ButtonBase
style={{}}
icon={ArrowCircleDown2}
type={'filled'}
title={translate('downloadAPK')}
onPress={() =>
Linking.openURL('https://github.com/Chroma-Case/Chromacase/releases')
}
/>
</PopupCC>
</>
);
};
export default APKDownloadButton;

View File

@@ -3,7 +3,7 @@ import Card, { CardBorderRadius } from './Card';
import { VStack, Text, Image } from 'native-base';
type ArtistCardProps = {
image: string;
image?: string;
name: string;
id: number;
onPress: () => void;
@@ -18,6 +18,7 @@ const ArtistCard = (props: ArtistCardProps) => {
<Image
style={{ zIndex: 0, aspectRatio: 1, borderRadius: CardBorderRadius }}
source={{ uri: image }}
fallbackSource={{ uri: require('../assets/icon.jpg') }}
alt={name}
/>
<VStack>
@@ -30,11 +31,4 @@ const ArtistCard = (props: ArtistCardProps) => {
);
};
ArtistCard.defaultProps = {
image: 'https://picsum.photos/200',
name: 'Artist',
id: 0,
onPress: () => {},
};
export default ArtistCard;

View File

@@ -2,9 +2,9 @@ import { HStack, IconButton, Image, Text } from 'native-base';
import RowCustom from './RowCustom';
import TextButton from './TextButton';
import { MaterialIcons } from '@expo/vector-icons';
import API from '../API';
import DurationComponent from './DurationComponent';
import Song from '../models/Song';
import { useLikeSongMutation } from '../utils/likeSongMutation';
type FavSongRowProps = {
song: Song;
@@ -13,6 +13,8 @@ type FavSongRowProps = {
};
const FavSongRow = ({ song, addedDate, onPress }: FavSongRowProps) => {
const { mutate } = useLikeSongMutation();
return (
<RowCustom width={'100%'}>
<HStack px={2} space={5} justifyContent={'space-between'}>
@@ -63,7 +65,7 @@ const FavSongRow = ({ song, addedDate, onPress }: FavSongRowProps) => {
variant={'ghost'}
borderRadius={'full'}
onPress={() => {
API.removeLikedSong(song.id);
mutate({ songId: song.id, like: false });
}}
_icon={{
as: MaterialIcons,

View File

@@ -30,6 +30,7 @@ const GenreCard = (props: GenreCardProps) => {
source={{
uri: image,
}}
fallbackSource={{ uri: require('../assets/icon.jpg') }}
size="md"
/>
</Box>
@@ -43,10 +44,4 @@ const GenreCard = (props: GenreCardProps) => {
);
};
GenreCard.defaultProps = {
icon: 'https://picsum.photos/200',
name: 'Genre',
onPress: () => {},
};
export default GenreCard;

View File

@@ -20,6 +20,7 @@ import {
} from './ElementTypes';
import { ArrowDown2 } from 'iconsax-react-native';
import { useWindowDimensions } from 'react-native';
import Translate from '../Translate';
type RawElementProps = {
element: ElementProps;
@@ -149,7 +150,7 @@ export const RawElement = ({ element }: RawElementProps) => {
/>
);
default:
return <Text>Unknown type</Text>;
return <Translate translationKey="unknownError" />;
}
})()}
</Row>

View File

@@ -0,0 +1,135 @@
// credit to https://gist.github.com/ianmartorell/32bb7df95e5eff0a5ee2b2f55095e6a6
// this file was repurosed from there
// via this issue https://gist.github.com/necolas/1c494e44e23eb7f8c5864a2fac66299a
// because RNW's pressable doesn't bubble events to parent pressables: https://github.com/necolas/react-native-web/issues/1875
/* eslint-disable no-inner-declarations */
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
let isEnabled = false;
if (canUseDOM) {
/**
* Web browsers emulate mouse events (and hover states) after touch events.
* This code infers when the currently-in-use modality supports hover
* (including for multi-modality devices) and considers "hover" to be enabled
* if a mouse movement occurs more than 1 second after the last touch event.
* This threshold is long enough to account for longer delays between the
* browser firing touch and mouse events on low-powered devices.
*/
const HOVER_THRESHOLD_MS = 1000;
let lastTouchTimestamp = 0;
function enableHover() {
if (isEnabled || Date.now() - lastTouchTimestamp < HOVER_THRESHOLD_MS) {
return;
}
isEnabled = true;
}
function disableHover() {
lastTouchTimestamp = Date.now();
if (isEnabled) {
isEnabled = false;
}
}
document.addEventListener('touchstart', disableHover, true);
document.addEventListener('touchmove', disableHover, true);
document.addEventListener('mousemove', enableHover, true);
}
function isHoverEnabled(): boolean {
return isEnabled;
}
import React, { useCallback, ReactChild, useRef } from 'react';
import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated';
import { Platform } from 'react-native';
export interface HoverableProps {
onHoverIn?: () => void;
onHoverOut?: () => void;
onPressIn?: () => void;
onPressOut?: () => void;
children: NonNullable<ReactChild>;
}
export default function Hoverable({
onHoverIn,
onHoverOut,
children,
onPressIn,
onPressOut,
}: HoverableProps) {
const showHover = useSharedValue(true);
const isHovered = useSharedValue(false);
const hoverIn = useRef<undefined | (() => void)>(() => onHoverIn?.());
const hoverOut = useRef<undefined | (() => void)>(() => onHoverOut?.());
const pressIn = useRef<undefined | (() => void)>(() => onPressIn?.());
const pressOut = useRef<undefined | (() => void)>(() => onPressOut?.());
hoverIn.current = onHoverIn;
hoverOut.current = onHoverOut;
pressIn.current = onPressIn;
pressOut.current = onPressOut;
useAnimatedReaction(
() => {
return Platform.OS === 'web' && showHover.value && isHovered.value;
},
(hovered, previouslyHovered) => {
if (hovered !== previouslyHovered) {
if (hovered && hoverIn.current) {
// no need for runOnJS, it's always web
hoverIn.current();
} else if (hoverOut.current) {
hoverOut.current();
}
}
},
[]
);
const handleMouseEnter = useCallback(() => {
if (isHoverEnabled() && !isHovered.value) {
isHovered.value = true;
}
}, [isHovered]);
const handleMouseLeave = useCallback(() => {
if (isHovered.value) {
isHovered.value = false;
}
}, [isHovered]);
const handleGrant = useCallback(() => {
showHover.value = false;
pressIn.current?.();
}, [showHover]);
const handleRelease = useCallback(() => {
showHover.value = true;
pressOut.current?.();
}, [showHover]);
let webProps = {};
if (Platform.OS === 'web') {
webProps = {
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
// prevent hover showing while responder
onResponderGrant: handleGrant,
onResponderRelease: handleRelease,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return React.cloneElement(React.Children.only(children) as any, {
...webProps,
// if child is Touchable
onPressIn: handleGrant,
onPressOut: handleRelease,
});
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { Slider, Text, View, IconButton, Icon } from 'native-base';
import { Slider, View, IconButton, Icon } from 'native-base';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import { VolumeHigh, VolumeSlash } from 'iconsax-react-native';
import { Translate } from '../i18n/i18n';
export const MetronomeControls = ({ paused = false, bpm }: { paused?: boolean; bpm: number }) => {
const audio = useRef<Audio.Sound | null>(null);
@@ -43,7 +44,7 @@ export const MetronomeControls = ({ paused = false, bpm }: { paused?: boolean; b
justifyContent: 'space-between',
}}
>
<Text>Metronome</Text>
<Translate translationKey="metronome" />
<Icon as={<MaterialCommunityIcons name="metronome" size={24} color="white" />} />
</View>
<View

View File

@@ -89,9 +89,11 @@ const PlayViewControlBar = ({
<Text color={textColor[800]} fontSize={14} maxW={'100%'} isTruncated>
{song.name}
</Text>
<Text color={textColor[900]} fontSize={12} maxW={'100%'} isTruncated>
{song.artistId}
</Text>
{song.artist && (
<Text color={textColor[900]} fontSize={12} maxW={'100%'} isTruncated>
{song.artist?.name}
</Text>
)}
</View>
</View>
</View>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { translate } from '../i18n/i18n';
import { Box, Text, VStack, Progress, Stack } from 'native-base';
import { Translate } from '../i18n/i18n';
import { Box, VStack, Progress, Stack } from 'native-base';
import { useNavigation } from '../Navigation';
import Card from '../components/Card';
import UserAvatar from './UserAvatar';
@@ -18,13 +18,14 @@ const ProgressBar = ({ xp }: { xp: number }) => {
<Stack padding={4} space={2} direction="row" alignItems="center">
<UserAvatar />
<VStack alignItems={'center'} flexGrow={1} space={2}>
<Text>{`${translate('level')} ${level}`}</Text>
<Translate translationKey="level" format={(e) => `${e} ${level}`} />
<Box w="100%">
<Progress value={progessValue} mx="4" />
</Box>
<Text>
{xp} / {nextLevelThreshold} {translate('levelProgress')}
</Text>
<Translate
translationKey="levelProgress"
format={(e) => `${xp} / ${nextLevelThreshold} ${e}`}
/>
</VStack>
</Stack>
</Card>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import {
VStack,
Heading,
Text,
Box,
Card,
Flex,
@@ -13,7 +12,7 @@ import {
import { SafeAreaView } from 'react-native';
import { SearchContext } from '../views/SearchView';
import { useQuery } from '../Queries';
import { translate } from '../i18n/i18n';
import { Translate, translate } from '../i18n/i18n';
import API from '../API';
import LoadingComponent, { LoadingView } from './Loading';
import ArtistCard from './ArtistCard';
@@ -25,12 +24,13 @@ import Song from '../models/Song';
import { useNavigation } from '../Navigation';
import SongRow from '../components/SongRow';
import FavSongRow from './FavSongRow';
import { useLikeSongMutation } from '../utils/likeSongMutation';
const swaToSongCardProps = (song: Song) => ({
songId: song.id,
name: song.name,
artistName: song.artist!.name,
cover: song.cover ?? 'https://picsum.photos/200',
cover: song.cover,
});
const HomeSearchComponent = () => {
@@ -84,18 +84,12 @@ type SongsSearchComponentProps = {
const SongsSearchComponent = (props: SongsSearchComponentProps) => {
const navigation = useNavigation();
const { songData } = React.useContext(SearchContext);
const favoritesQuery = useQuery(API.getLikedSongs());
const handleFavoriteButton = async (state: boolean, songId: number): Promise<void> => {
if (state == false) await API.removeLikedSong(songId);
else await API.addLikedSong(songId);
};
const favoritesQuery = useQuery(API.getLikedSongs(['artist']));
const { mutate } = useLikeSongMutation();
return (
<ScrollView>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('songsFilter')}
</Text>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{songData?.length ? (
songData.slice(0, props.maxRows).map((comp, index) => (
@@ -105,8 +99,8 @@ const SongsSearchComponent = (props: SongsSearchComponentProps) => {
isLiked={
!favoritesQuery.data?.find((query) => query?.songId == comp.id)
}
handleLike={(state: boolean, songId: number) =>
handleFavoriteButton(state, songId)
handleLike={async (state: boolean, songId: number) =>
mutate({ songId: songId, like: state })
}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');
@@ -115,7 +109,7 @@ const SongsSearchComponent = (props: SongsSearchComponentProps) => {
/>
))
) : (
<Text>{translate('errNoResults')}</Text>
<Translate translationKey="errNoResults" />
)}
</Box>
</ScrollView>
@@ -132,9 +126,7 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
return (
<Box>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('artistFilter')}
</Text>
<Translate translationKey="artistFilter" fontSize="xl" fontWeight="bold" mt={4} />
{artistData?.length ? (
<CardGridCustom
content={artistData
@@ -151,7 +143,7 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
cardComponent={ArtistCard}
/>
) : (
<Text>{translate('errNoResults')}</Text>
<Translate translationKey="errNoResults" />
)}
</Box>
);
@@ -163,9 +155,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
return (
<Box>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('genreFilter')}
</Text>
<Translate translationKey="genreFilter" fontSize="xl" fontWeight="bold" mt={4} />
{genreData?.length ? (
<CardGridCustom
content={genreData.slice(0, props.maxItems ?? genreData.length).map((g) => ({
@@ -180,7 +170,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
cardComponent={GenreCard}
/>
) : (
<Text>{translate('errNoResults')}</Text>
<Translate translationKey="errNoResults" />
)}
</Box>
);
@@ -200,9 +190,7 @@ const FavoritesComponent = () => {
return (
<ScrollView>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('songsFilter')}
</Text>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{favoritesQuery.data?.map((songData) => (
<FavSongRow
@@ -268,7 +256,9 @@ const FilterSwitch = () => {
case 'favorites':
return <FavoritesComponent />;
default:
return <Text>Something very bad happened: {currentFilter}</Text>;
return (
<Translate translationKey="unknownError" format={(e) => `${e}: ${currentFilter}`} />
);
}
};

View File

@@ -54,7 +54,7 @@ const SongRow = ({ song, onPress, handleLike, isLiked }: SongRowProps) => {
}}
fontSize={'sm'}
>
{song.artistId ?? 'artist'}
{song.artist?.name ?? ''}
</Text>
{/* <DurationInfo length={song.details.length} /> */}
<DurationComponent length={song.details.length} />

View File

@@ -195,12 +195,12 @@ const IconButton: React.FC<IconButtonProps> = ({
Animated.timing(scaleValue, {
toValue: scaleFactor,
duration: animationDuration,
useNativeDriver: true,
useNativeDriver: false,
}),
Animated.timing(scaleValue, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
useNativeDriver: false,
}),
]),
];

View File

@@ -124,7 +124,7 @@ const InteractiveCC: React.FC<InteractiveCCProps> = ({
Animated.timing(animatedValues[key]!, {
toValue: stateValue,
duration: duration,
useNativeDriver: true,
useNativeDriver: false,
}).start();
});
};

View File

@@ -9,6 +9,7 @@ import SignUpForm from '../../components/forms/signupform';
import API, { APIError } from '../../API';
import PopupCC from './PopupCC';
import { StyleProp, ViewStyle } from 'react-native';
import { useQuery } from '../../Queries';
const handleSubmit = async (username: string, password: string, email: string) => {
try {
@@ -36,6 +37,7 @@ const LogoutButtonCC = ({
}: LogoutButtonCCProps) => {
const dispatch = useDispatch();
const [isVisible, setIsVisible] = useState(false);
const user = useQuery(API.getUserInfo);
return (
<>
@@ -54,7 +56,7 @@ const LogoutButtonCC = ({
isVisible={isVisible}
setIsVisible={setIsVisible}
>
<SignUpForm onSubmit={handleSubmit} />
<SignUpForm onSubmit={handleSubmit} defaultValues={{ username: user.data?.name }} />
<ButtonBase
style={!collapse ? { width: '100%' } : {}}
type="outlined"

View File

@@ -20,14 +20,11 @@ export interface MusicItemType {
/** The URL for the song's cover image. */
image: string;
/** The level of the song difficulty . */
level: number;
/** The last score achieved for this song. */
lastScore: number;
lastScore: number | null | undefined;
/** The highest score achieved for this song. */
bestScore: number;
bestScore: number | null | undefined;
/** Indicates whether the song is liked/favorited by the user. */
liked: boolean;
@@ -141,9 +138,8 @@ function MusicItemComponent(props: MusicItemType) {
);
// Memoizing formatted numbers to avoid unnecessary computations.
const formattedLevel = useMemo(() => formatNumber(props.level), [props.level]);
const formattedLastScore = useMemo(() => formatNumber(props.lastScore), [props.lastScore]);
const formattedBestScore = useMemo(() => formatNumber(props.bestScore), [props.bestScore]);
const formattedLastScore = useMemo(() => formatNumber(props.lastScore ?? 0), [props.lastScore]);
const formattedBestScore = useMemo(() => formatNumber(props.bestScore ?? 0), [props.bestScore]);
return (
<HStack space={screenSize === 'xl' ? 2 : 1} style={[styles.container, props.style]}>
@@ -179,7 +175,7 @@ function MusicItemComponent(props: MusicItemType) {
/>
</Row>
</Column>
{[formattedLevel, formattedLastScore, formattedBestScore].map((value, index) => (
{[formattedLastScore, formattedBestScore].map((value, index) => (
<Text key={index} style={styles.stats}>
{value}
</Text>

View File

@@ -3,7 +3,7 @@ import { FlatList, HStack, View, useBreakpointValue, useTheme, Text, Row } from
import { ActivityIndicator, StyleSheet } from 'react-native';
import MusicItem, { MusicItemType } from './MusicItem';
import ButtonBase from './ButtonBase';
import { ArrowDown2, Chart2, ArrowRotateLeft, Cup, Icon } from 'iconsax-react-native';
import { ArrowDown2, ArrowRotateLeft, Cup, Icon } from 'iconsax-react-native';
import { translate } from '../../i18n/i18n';
// Props type definition for MusicItemTitle.
@@ -176,7 +176,6 @@ function MusicListComponent({
{translate('musicListTitleSong')}
</Text>
{[
{ text: translate('musicListTitleLevel'), icon: Chart2 },
{ text: translate('musicListTitleLastScore'), icon: ArrowRotateLeft },
{ text: translate('musicListTitleBestScore'), icon: Cup },
].map((value) => (
@@ -237,6 +236,10 @@ const styles = StyleSheet.create({
// Using `memo` to optimize rendering performance by memorizing the component's output.
// This ensures that the component only re-renders when its props change.
const MusicList = memo(MusicListComponent);
const MusicList = memo(MusicListComponent, (prev, next) => {
console.log('AAAAA');
console.log(prev.initialMusics, next.initialMusics);
return prev.initialMusics.length == next.initialMusics.length;
});
export default MusicList;

View File

@@ -1,9 +1,9 @@
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, View, Text, Wrap, Image, Row, Column, ScrollView, useToast } from 'native-base';
import { FunctionComponent } from 'react';
import { Linking, useWindowDimensions } from 'react-native';
import { Stack, View, Text, Wrap, Image, Row, Column, ScrollView } from 'native-base';
import { FunctionComponent, useState } from 'react';
import { Linking, Platform, useWindowDimensions } from 'react-native';
import ButtonBase from './ButtonBase';
import { translate } from '../../i18n/i18n';
import { Translate, translate } from '../../i18n/i18n';
import API, { APIError } from '../../API';
import SeparatorBase from './SeparatorBase';
import LinkBase from './LinkBase';
@@ -11,9 +11,15 @@ import { useDispatch } from '../../state/Store';
import { setAccessToken } from '../../state/UserSlice';
import useColorScheme from '../../hooks/colorScheme';
import { useAssets } from 'expo-asset';
import APKDownloadButton from '../APKDownloadButton';
import PopupCC from './PopupCC';
import GuestForm from '../forms/guestForm';
const handleGuestLogin = async (apiSetter: (accessToken: string) => void): Promise<string> => {
const apiAccess = await API.createAndGetGuestAccount();
const handleGuestLogin = async (
username: string,
apiSetter: (accessToken: string) => void
): Promise<string> => {
const apiAccess = await API.createAndGetGuestAccount(username);
apiSetter(apiAccess);
return translate('loggedIn');
};
@@ -35,8 +41,8 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
}) => {
const layout = useWindowDimensions();
const dispatch = useDispatch();
const toast = useToast();
const colorScheme = useColorScheme();
const [guestModalIsOpen, openGuestModal] = useState(false);
const [logo] = useAssets(
colorScheme == 'light'
? require('../../assets/icon_light.png')
@@ -44,6 +50,8 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const [banner] = useAssets(require('../../assets/banner.jpg'));
// eslint-disable-next-line @typescript-eslint/no-var-requires
const [googleLogo] = useAssets(require('../../assets/google.png'));
return (
<View
@@ -81,22 +89,33 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
)}
</Row>
<ButtonBase
title="guest mode"
onPress={async () => {
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 });
}
}}
title={translate('guestMode')}
onPress={() => openGuestModal(true)}
/>
</Wrap>
<PopupCC
title={translate('guestMode')}
isVisible={guestModalIsOpen}
setIsVisible={openGuestModal}
>
<GuestForm
onSubmit={(username) =>
handleGuestLogin(username, (accessToken: string) => {
dispatch(setAccessToken(accessToken));
})
.then(() => {
openGuestModal(false);
return translate('loggedIn');
})
.catch((error) => {
if (error instanceof APIError) {
return translate('usernameTaken');
}
return error as string;
})
}
/>
</PopupCC>
<ScrollView
contentContainerStyle={{
padding: 16,
@@ -145,11 +164,13 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
<ButtonBase
style={{ width: '100%' }}
type="outlined"
iconImage="https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/2008px-Google_%22G%22_Logo.svg.png"
iconImage={googleLogo?.at(0)?.uri}
title={translate('continuewithgoogle')}
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
/>
<SeparatorBase>or</SeparatorBase>
<SeparatorBase>
<Translate translationKey="or" />
</SeparatorBase>
<Stack
space={3}
justifyContent="center"
@@ -164,6 +185,7 @@ const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
<Text>{link.label}</Text>
<LinkBase text={link.text} onPress={link.onPress} />
</Wrap>
{Platform.OS === 'web' && <APKDownloadButton />}
</Stack>
</View>
</ScrollView>

View File

@@ -6,7 +6,7 @@ import API from '../../API';
import ButtonBase from './ButtonBase';
import { Icon } from 'iconsax-react-native';
import { LoadingView } from '../Loading';
import { TranslationKey, translate } from '../../i18n/i18n';
import { Translate, TranslationKey, translate } from '../../i18n/i18n';
import { useNavigation } from '../../Navigation';
import Spacer from './Spacer';
import User from '../../models/User';
@@ -55,7 +55,10 @@ const SongHistory = (props: { quantity: number }) => {
return (
<View>
{musics.length === 0 ? (
<Text style={{ paddingHorizontal: 16 }}>{translate('menuNoSongsPlayedYet')}</Text>
<Translate
style={{ paddingHorizontal: 16 }}
translationKey="menuNoSongsPlayedYet"
/>
) : (
musics.map((song) => (
<View
@@ -117,7 +120,7 @@ const ScaffoldDesktopCC = (props: ScaffoldDesktopCCProps) => {
/>
{!isSmallScreen && (
<Text fontSize={'xl'} selectable={false}>
Chromacase
ChromaCase
</Text>
)}
</Row>

View File

@@ -3,62 +3,42 @@ import { View } from 'react-native';
import { useBreakpointValue } from 'native-base';
import HomeMainSongCard from './HomeMainSongCard';
import GoldenRatioPanel from './GoldenRatioPanel';
import Song from '../../models/Song';
import { useNavigation } from '../../Navigation';
type HomeCardProps = {
image: string;
title: string;
artist: string;
fontSize: number;
onPress?: () => void;
type GoldenRatioProps = {
songs: Song[];
};
const cards = [
{
image: 'https://media.discordapp.net/attachments/717080637038788731/1153688155292180560/image_homeview1.png',
title: 'Beethoven',
artist: 'Synphony No. 9',
fontSize: 46,
},
{
image: 'https://media.discordapp.net/attachments/717080637038788731/1153688154923090093/image_homeview2.png',
title: 'Mozart',
artist: 'Lieder Kantate KV 619',
fontSize: 36,
},
{
image: 'https://media.discordapp.net/attachments/717080637038788731/1153688154499457096/image_homeview3.png',
title: 'Back',
artist: 'Truc Truc',
fontSize: 26,
},
{
image: 'https://media.discordapp.net/attachments/717080637038788731/1153688154109394985/image_homeview4.png',
title: 'Mozart',
artist: 'Machin Machin',
fontSize: 22,
},
] as [HomeCardProps, HomeCardProps, HomeCardProps, HomeCardProps];
const GoldenRatio = () => {
const GoldenRatio = (props: GoldenRatioProps) => {
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';
const navigation = useNavigation();
const fontSizes = [46, 36, 26, 22];
const cards = props.songs.map((s, i) => ({
image: s.cover,
title: s.name,
artist: s.artist?.name ?? '',
fontSize: fontSizes.at(i) ?? fontSizes.at(-1)!,
onPress: () => navigation.navigate('Play', { songId: s.id }),
}));
return (
<GoldenRatioPanel
direction={isPhone ? 'column' : 'row'}
header={<HomeMainSongCard {...cards[0]} />}
header={<HomeMainSongCard {...cards[0]!} />}
>
<GoldenRatioPanel
direction={isPhone ? 'row' : 'column'}
header={<HomeMainSongCard {...cards[1]} />}
header={<HomeMainSongCard {...cards[1]!} />}
>
<GoldenRatioPanel
direction={isPhone ? 'column-reverse' : 'row-reverse'}
header={<HomeMainSongCard {...cards[2]} />}
header={<HomeMainSongCard {...cards[2]!} />}
>
<GoldenRatioPanel
direction={isPhone ? 'row-reverse' : 'column-reverse'}
header={<HomeMainSongCard {...cards[3]} />}
header={<HomeMainSongCard numLinesHeader={1} {...cards[3]!} />}
>
<View style={{ display: 'flex', width: '100%', height: '100%' }}></View>
</GoldenRatioPanel>

View File

@@ -6,6 +6,7 @@ type HomeMainSongCardProps = {
title: string;
artist: string;
fontSize: number;
numLinesHeader: number;
onPress: () => void;
};
@@ -66,7 +67,7 @@ const HomeMainSongCard = (props: HomeMainSongCardProps) => {
fontSize: props.fontSize,
fontWeight: 'bold',
}}
numberOfLines={2}
numberOfLines={props.numLinesHeader}
selectable={false}
>
{props.title}
@@ -94,6 +95,7 @@ const HomeMainSongCard = (props: HomeMainSongCardProps) => {
HomeMainSongCard.defaultProps = {
onPress: () => {},
fontSize: 16,
numLinesHeader: 2,
};
export default HomeMainSongCard;

View File

@@ -1,8 +1,13 @@
import Song from '../../models/Song';
import React from 'react';
import { Image, View } from 'react-native';
import { LikeButton } from './SongCardInfoLikeBtn';
import { Image, Platform, View } from 'react-native';
import { Pressable, Text, PresenceTransition, Icon, useBreakpointValue } from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '../../Queries';
import API from '../../API';
import { useLikeSongMutation } from '../../utils/likeSongMutation';
import Hoverable from '../Hoverable';
type SongCardInfoProps = {
song: Song;
@@ -10,35 +15,45 @@ type SongCardInfoProps = {
onPlay: () => void;
};
const Scores = [
{
icon: 'warning',
score: 3,
},
{
icon: 'star',
score: -225,
},
{
icon: 'trophy',
score: 100,
},
];
const SongCardInfo = (props: SongCardInfoProps) => {
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';
const [isPlayHovered, setIsPlayHovered] = React.useState(false);
const [isHovered, setIsHovered] = React.useState(false);
const [isSlided, setIsSlided] = React.useState(false);
const user = useQuery(API.getUserInfo);
const [isLiked, setIsLiked] = React.useState(false);
const { mutate } = useLikeSongMutation();
const CardDims = {
height: isPhone ? 160 : 200,
width: isPhone ? 160 : 200,
};
const Scores = [
{
icon: 'time',
score: props.song.lastScore ?? '-',
},
{
icon: 'trophy',
score: props.song.bestScore ?? '-',
},
];
React.useEffect(() => {
if (!user.data) {
return;
}
setIsLiked(
props.song.likedByUsers?.some(({ userId }) => userId === user.data?.id) ?? false
);
}, [user.data, props.song.likedByUsers]);
return (
<View
<Pressable
delayHoverIn={7}
onPress={Platform.OS === 'android' ? props.onPress : undefined}
style={{
width: CardDims.width,
height: CardDims.height,
@@ -48,227 +63,201 @@ const SongCardInfo = (props: SongCardInfoProps) => {
backgroundColor: 'rgba(16, 16, 20, 0.70)',
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
}}
onHoverIn={() => {
setIsHovered(true);
}}
onHoverOut={() => {
setIsHovered(false);
setIsSlided(false);
}}
>
<Pressable
delayHoverIn={7}
isHovered={isPlayHovered ? true : undefined}
onPress={props.onPress}
<View
style={{
width: '100%',
}}
onHoverIn={() => {
setIsHovered(true);
}}
onHoverOut={() => {
setIsHovered(false);
setIsSlided(false);
width: CardDims.width,
height: CardDims.height,
backgroundColor: 'rgba(16, 16, 20, 0.7)',
borderRadius: 12,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<>
<View
<View
style={{
width: '100%',
marginBottom: 8,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{Scores.map((score, idx) => (
<View
key={score.icon + idx}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
}}
>
<Icon as={Ionicons} name={score.icon} size={17} color="white" />
<Text
style={{
color: 'white',
fontSize: 12,
fontWeight: 'bold',
}}
>
{score.score}
</Text>
</View>
))}
</View>
</View>
<PresenceTransition
style={{
width: '100%',
height: '100%',
position: 'absolute',
}}
visible={isHovered}
animate={{
translateY: -55,
}}
onTransitionComplete={() => {
if (isHovered) {
setIsSlided(true);
}
}}
>
<View
style={{
width: CardDims.width,
height: CardDims.height,
position: 'relative',
}}
>
<Image
source={{ uri: props.song.cover }}
style={{
width: CardDims.width,
height: CardDims.height,
backgroundColor: 'rgba(16, 16, 20, 0.7)',
borderRadius: 12,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<View
style={{
width: '100%',
marginBottom: 8,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{Scores.map((score, idx) => (
<View
key={score.icon + idx}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
}}
>
<Icon as={Ionicons} name={score.icon} size={17} color="white" />
<Text
style={{
color: 'white',
fontSize: 12,
fontWeight: 'bold',
}}
>
{score.score}
</Text>
</View>
))}
</View>
</View>
<PresenceTransition
/>
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
position: 'absolute',
}}
visible={isHovered}
initial={{
translateY: 0,
}}
animate={{
translateY: -55,
}}
onTransitionComplete={() => {
if (isHovered) {
setIsSlided(true);
}
backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'flex-end',
alignItems: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 7,
borderRadius: 12,
}}
>
<Image
source={{ uri: props.song.cover }}
style={{
position: 'relative',
width: CardDims.width,
height: CardDims.height,
borderRadius: 12,
}}
/>
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'flex-end',
alignItems: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 7,
borderRadius: 12,
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
}}
>
<View
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
flexShrink: 1,
}}
>
<View
<Text
numberOfLines={2}
style={{
flexShrink: 1,
color: 'white',
fontSize: 14,
fontWeight: 'bold',
marginBottom: 4,
}}
>
<Text
numberOfLines={2}
style={{
color: 'white',
fontSize: 14,
fontWeight: 'bold',
marginBottom: 4,
}}
>
{props.song.name}
</Text>
<Text
numberOfLines={1}
style={{
color: 'white',
fontSize: 12,
fontWeight: 'normal',
}}
>
{props.song.artist?.name}
</Text>
</View>
<Ionicons
{props.song.name}
</Text>
<Text
numberOfLines={1}
style={{
flexShrink: 0,
color: 'white',
fontSize: 12,
fontWeight: 'normal',
}}
name="bookmark-outline"
size={17}
color="#6075F9"
/>
>
{props.song.artist?.name}
</Text>
</View>
<LikeButton
color="#6075F9"
onPress={() => {
console.log('like');
mutate({ songId: props.song.id, like: !isLiked });
}}
isLiked={isLiked}
/>
</View>
</PresenceTransition>
<PresenceTransition
style={{
width: '100%',
height: '100%',
position: 'absolute',
</View>
</View>
</PresenceTransition>
{Platform.OS === 'web' && (
<PresenceTransition
style={{
position: 'absolute',
bottom: 35,
left: CardDims.width / 2 - 20,
}}
visible={isSlided}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
>
<Hoverable
onHoverIn={() => {
setIsPlayHovered(true);
}}
visible={isSlided}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
onHoverOut={() => {
setIsPlayHovered(false);
}}
>
<View
onClick={(e) => {
e.stopPropagation();
props.onPress();
}}
style={{
position: 'absolute',
width: '100%',
height: '100%',
width: 40,
height: 40,
borderRadius: 100,
display: 'flex',
justifyContent: 'flex-end',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isPlayHovered
? 'rgba(96, 117, 249, 0.9)'
: 'rgba(96, 117, 249, 0.7)',
}}
>
<Pressable
onHoverIn={() => {
setIsPlayHovered(true);
}}
onHoverOut={() => {
setIsPlayHovered(false);
}}
borderRadius={100}
marginBottom={35}
onPress={props.onPlay}
>
{({ isPressed, isHovered }) => (
<View
style={{
width: 40,
height: 40,
borderRadius: 100,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: (() => {
if (isPressed) {
return 'rgba(96, 117, 249, 1)';
} else if (isHovered) {
return 'rgba(96, 117, 249, 0.9)';
} else {
return 'rgba(96, 117, 249, 0.7)';
}
})(),
}}
>
<Ionicons
name="play-outline"
color={'white'}
size={20}
rounded="sm"
/>
</View>
)}
</Pressable>
<Ionicons name="play-outline" color={'white'} size={20} rounded="sm" />
</View>
</PresenceTransition>
</>
</Pressable>
</View>
</Hoverable>
</PresenceTransition>
)}
</Pressable>
);
};

View File

@@ -0,0 +1,37 @@
import { Platform, View } from 'react-native';
import { IconButton } from 'native-base';
import { MaterialIcons } from '@expo/vector-icons';
type LikeButtonProps = {
isLiked: boolean;
onPress?: () => void;
color?: string;
};
export const LikeButton = ({ isLiked, color, onPress }: LikeButtonProps) => {
if (Platform.OS === 'web') {
// painful error of no onHover event control
return (
<View onClick={onPress}>
<MaterialIcons
color={color}
name={isLiked ? 'favorite' : 'favorite-outline'}
size={17}
/>
</View>
);
}
return (
<IconButton
variant={'ghost'}
borderRadius={'full'}
size={17}
color={color}
onPress={onPress}
_icon={{
as: MaterialIcons,
name: isLiked ? 'favorite' : 'favorite-outline',
}}
/>
);
};

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { translate } from '../../i18n/i18n';
import { string } from 'yup';
import { useToast, Column } from 'native-base';
import TextFormField from '../UI/TextFormField';
import ButtonBase from '../UI/ButtonBase';
import { Sms } from 'iconsax-react-native';
import { useWindowDimensions } from 'react-native';
interface GuestFormProps {
onSubmit: (username: string) => Promise<string>;
}
const validationSchemas = {
username: string().required('Username is required'),
};
const GuestForm = ({ onSubmit }: GuestFormProps) => {
const [formData, setFormData] = React.useState({
username: {
value: '',
error: null as string | null,
},
});
const toast = useToast();
const layout = useWindowDimensions();
return (
<Column style={{ width: layout.width * 0.5 }}>
<TextFormField
style={{ marginVertical: 10 }}
isRequired
icon={Sms}
placeholder={translate('username')}
value={formData.username.value}
error={formData.username.error}
onChangeText={(t) => {
let error: null | string = null;
validationSchemas.username
.validate(t)
.catch((e) => (error = e.message))
.finally(() => {
setFormData({ ...formData, username: { value: t, error } });
});
}}
/>
<ButtonBase
isDisabled={formData.username.error !== null || formData.username.value === ''}
type={'filled'}
title={translate('submitBtn')}
style={{ marginVertical: 10 }}
onPress={() => {
onSubmit(formData.username.value)
.then((e) => {
toast.show({ description: e as string });
})
.catch((e) => {
toast.show({ description: e as string });
});
}}
/>
</Column>
);
};
export default GuestForm;

View File

@@ -10,13 +10,14 @@ import ButtonBase from '../UI/ButtonBase';
import Spacer from '../UI/Spacer';
interface SignupFormProps {
defaultValues: Partial<{ username: string }>;
onSubmit: (username: string, password: string, email: string) => Promise<string>;
}
const SignUpForm = ({ onSubmit }: SignupFormProps) => {
const SignUpForm = ({ onSubmit, defaultValues }: SignupFormProps) => {
const [formData, setFormData] = React.useState({
username: {
value: '',
value: defaultValues.username || '',
error: null as string | null,
},
password: {

View File

@@ -1,5 +1,8 @@
export const en = {
error: 'Error',
or: 'or',
guestMode: 'Guest Mode',
downloadAPK: 'Download Android App',
goBackHome: 'Go Back Home',
anErrorOccured: 'An Error Occured',
welcome: 'Welcome',
@@ -226,9 +229,6 @@ export const en = {
SettingsPreferencesTabLanguageSectionDescription: 'Set the language of your application',
SettingsPreferencesTabDifficultySectionTitle: 'Difficulty',
SettingsPreferencesTabDifficultySectionDescription: 'The precision of the tempo increases',
SettingsPreferencesTabColorblindModeSectionTitle: 'Colorblind Mode',
SettingsPreferencesTabColorblindModeSectionDescription: 'Increases contrast',
SettingsPreferencesTabMicVolumeSectionTitle: 'Mic Volume',
SettingsPreferencesTabMicVolumeSectionDescription:
'Adjust the volume of your microphone according to your preference',
// Notifications Tab
@@ -277,7 +277,6 @@ export const en = {
SettingsPreferencesTheme: 'Theme',
SettingsPreferencesLanguage: 'Language',
SettingsPreferencesDifficulty: 'Difficulty',
SettingsPreferencesColorblindMode: 'Colorblind mode',
SettingsPreferencesMicVolume: 'Mic volume',
SettingsPreferencesDevice: 'Device',
@@ -308,10 +307,26 @@ export const en = {
leaderBoardHeadingFull:
'The players having the best scores, thanks to their exceptional accuracy, are highlighted here.',
emptySelection: 'None,',
gamesPlayed: 'Games Played',
metronome: 'Metronome',
loading: 'Loading... Please Wait',
emailCheckFailed: 'Email verification failed. The token has expired or is invalid.',
chromacasePitch:
'Chromacase is a free and open source project that aims to provide a complete learning experience for anyone willing to learn piano.',
whatIsChromacase: 'What is Chromacase?',
clickHereForMoreInfo: 'Click here for more info',
forgotPassword: 'I forgot my password',
updateProfile: 'Update Profile',
accountCreatedOn: 'Account Created on',
downloadAPKInstructions:
"Go to the latest release, unfold the 'Assets' section, and click 'android-build.apk'.",
};
export const fr: typeof en = {
error: 'Erreur',
or: 'ou',
downloadAPK: "Télécharger l'App Android",
guestMode: 'Mode Invité',
goBackHome: "Retourner à l'accueil",
anErrorOccured: 'Une erreur est survenue',
welcome: 'Bienvenue',
@@ -371,7 +386,7 @@ export const fr: typeof en = {
menuLeaderBoard: 'Classement',
menuSettings: 'Paramètres',
menuRecentlyPlayed: 'Récemment jouée',
menuRecentlyPlayed: 'Récemment joués',
menuNoSongsPlayedYet: "Aucune chanson jouée pour l'instant",
//signup
@@ -389,7 +404,7 @@ export const fr: typeof en = {
//music
musicTabFavorites: 'Favoris',
musicTabRecentlyPlayed: 'Récemment joué',
musicTabStepUp: 'Recommandation',
musicTabStepUp: 'Recommandations',
//search
allFilter: 'Tout',
@@ -538,9 +553,6 @@ export const fr: typeof en = {
SettingsPreferencesTabDifficultySectionTitle: 'Difficulté',
SettingsPreferencesTabDifficultySectionDescription:
'La précision du tempo est de plus en plus élevée',
SettingsPreferencesTabColorblindModeSectionTitle: 'Mode daltonien',
SettingsPreferencesTabColorblindModeSectionDescription: 'Augmente le contraste',
SettingsPreferencesTabMicVolumeSectionTitle: 'Volume du micro',
SettingsPreferencesTabMicVolumeSectionDescription:
'Réglez le volume de votre micro selon vos préférences',
// Notifications Tab
@@ -582,8 +594,6 @@ export const fr: typeof en = {
SettingsNotificationsPushNotifications: 'Notifications push',
SettingsNotificationsReleaseAlert: 'Alertes de nouvelles Sorties',
SettingsNotificationsTrainingReminder: "Rappel d'entrainement",
SettingsPreferencesColorblindMode: 'Mode daltonien',
SettingsPreferencesDevice: 'Appareil',
SettingsPreferencesDifficulty: 'Difficulté',
SettingsPreferencesLanguage: 'Langue',
@@ -621,10 +631,26 @@ export const fr: typeof en = {
leaderBoardHeadingFull:
'Les joueurs présentant les meilleurs scores, grâce à leur précision exceptionnelle, sont mis en lumière ici.',
emptySelection: 'Aucun',
gamesPlayed: 'Parties Jouées',
metronome: 'Métronome',
loading: 'Chargement en cours... Veuillez Patienter',
emailCheckFailed: 'La vérification du mail a échouée',
chromacasePitch:
"ChromaCase est une solution gratuite et open-source qui cherche à fournir une expérience d'apprentissage complète pour toutes les personnes qui cherchent à apprendre le piano.",
whatIsChromacase: "Chromacase c'est quoi?",
clickHereForMoreInfo: "Cliquez ici pour plus d'info",
forgotPassword: "J'ai oublié mon mot de passe",
updateProfile: 'Changer le Profile',
accountCreatedOn: 'Compte créé le',
downloadAPKInstructions:
"Descargue 'android-build.apk' en la sección 'Assets' de la última versión.",
};
export const sp: typeof en = {
error: 'Error',
or: 'u',
downloadAPK: 'Descarga la Aplicación de Android',
guestMode: 'Modo Invitado',
anErrorOccured: 'ocurrió un error',
goBackHome: 'regresar a casa',
welcomeMessage: 'Benvenido',
@@ -854,9 +880,6 @@ export const sp: typeof en = {
SettingsPreferencesTabLanguageSectionDescription: 'Establece el idioma de tu aplicación',
SettingsPreferencesTabDifficultySectionTitle: 'Dificultad',
SettingsPreferencesTabDifficultySectionDescription: 'La precisión del tempo aumenta',
SettingsPreferencesTabColorblindModeSectionTitle: 'Modo para daltónicos',
SettingsPreferencesTabColorblindModeSectionDescription: 'Aumenta el contraste',
SettingsPreferencesTabMicVolumeSectionTitle: 'Volumen del micrófono',
SettingsPreferencesTabMicVolumeSectionDescription:
'Ajusta el volumen de tu micrófono según tus preferencias',
// Notifications Tab
@@ -900,7 +923,6 @@ export const sp: typeof en = {
SettingsNotificationsReleaseAlert: 'Alertas de nuevas Sorties',
SettingsNotificationsTrainingReminder: 'Recordatorio de entrenamiento',
SettingsPreferencesColorblindMode: 'Modo daltoniano',
SettingsPreferencesDevice: 'Dispositivo',
SettingsPreferencesDifficulty: 'Dificultad',
SettingsPreferencesLanguage: 'Idioma',
@@ -940,4 +962,17 @@ export const sp: typeof en = {
leaderBoardHeadingFull:
'Aquí se destacan los jugadores que tienen las mejores puntuaciones, gracias a su precisión excepcional.',
emptySelection: 'Nada',
gamesPlayed: 'Juegos jugados',
metronome: 'Metrónomo',
loading: 'Cargando por favor espere',
emailCheckFailed: 'Error en la verificación del correo electrónico',
chromacasePitch:
'ChromaCase es una solución gratuita y de código abierto que busca brindar una experiencia de aprendizaje completa para cualquiera que desee aprender a tocar el piano.',
whatIsChromacase: '¿Qué es la cromacasa?',
clickHereForMoreInfo: 'Haga clic aquí para más información',
forgotPassword: 'Olvidé mi contraseña',
updateProfile: 'Cambiar el perfil',
accountCreatedOn: 'Cuenta creada el',
downloadAPKInstructions:
"Télécharger 'android-build.apk' dans la section 'Assets' de la dernière release",
};

View File

@@ -6,11 +6,13 @@ import ResponseHandler from './ResponseHandler';
import API from '../API';
import { AlbumValidator } from './Album';
import { GenreValidator } from './Genre';
import { SongHistoryItemWithoutSongValidator } from './SongHistory';
export type SongInclude = 'artist' | 'album' | 'genre' | 'SongHistory' | 'likedByUsers';
export const SongValidator = yup
.object({
.object()
.shape({
name: yup.string().required(),
midiPath: yup.string().required(),
musicXmlPath: yup.string().required(),
@@ -20,20 +22,42 @@ export const SongValidator = yup
difficulties: SongDetailsValidator.required(),
details: SongDetailsValidator.required(),
cover: yup.string().required(),
SongHistory: yup
.lazy(() => yup.array(SongHistoryItemWithoutSongValidator.default(undefined)))
.optional(),
bestScore: yup.number().optional().nullable(),
lastScore: yup.number().optional().nullable(),
artist: yup.lazy(() => ArtistValidator.default(undefined)).optional(),
album: yup.lazy(() => AlbumValidator.default(undefined)).optional(),
genre: yup.lazy(() => GenreValidator.default(undefined)).optional(),
likedByUsers: yup
.lazy(() =>
yup.array(yup.object({ userId: yup.number().required() })).default(undefined)
)
.optional(),
})
.concat(ModelValidator)
.transform((song: Song) => ({
...song,
cover: `${API.baseUrl}/song/${song.id}/illustration`,
details: song.difficulties,
bestScore:
song.SongHistory?.map(({ info }) => info.score)
.sort()
.at(-1) ?? null,
lastScore:
song.SongHistory?.map(({ info, playDate }) => ({ info, playDate }))
.sort(
(a, b) =>
yup.date().cast(a.playDate)!.getTime() -
yup.date().cast(b.playDate)!.getTime()
)
.at(0)?.info.score ?? null,
}));
export type Song = yup.InferType<typeof SongValidator>;
export const SongHandler: ResponseHandler<Song> = {
export const SongHandler: ResponseHandler<yup.InferType<typeof SongValidator>> = {
validator: SongValidator,
};

View File

@@ -3,10 +3,9 @@ import ResponseHandler from './ResponseHandler';
import { ModelValidator } from './Model';
import { SongValidator } from './Song';
export const SongHistoryItemValidator = yup
export const SongHistoryItemWithoutSongValidator = yup
.object({
songID: yup.number().required(),
song: SongValidator.optional().default(undefined),
userID: yup.number().required(),
info: yup
.object({
@@ -27,6 +26,13 @@ export const SongHistoryItemValidator = yup
difficulties: yup.mixed().required(),
})
.concat(ModelValidator);
export const SongHistoryItemValidator = SongHistoryItemWithoutSongValidator.concat(
yup.object({
song: yup.lazy(() => SongValidator.default(undefined)).optional(),
})
);
export type SongHistoryItem = yup.InferType<typeof SongHistoryItemValidator>;
export const SongHistoryItemHandler: ResponseHandler<SongHistoryItem> = {

View File

@@ -34,6 +34,7 @@
"expo-splash-screen": "~0.20.5",
"expo-status-bar": "~1.6.0",
"expo-system-ui": "~2.4.0",
"fbjs": "^3.0.5",
"i18next": "^23.5.1",
"iconsax-react-native": "^0.0.8",
"native-base": "^3.4.28",
@@ -65,6 +66,7 @@
"devDependencies": {
"@babel/core": "^7.20.0",
"@babel/plugin-transform-export-namespace-from": "^7.22.11",
"@types/fbjs": "^3.0.10",
"@types/lodash": "^4.14.199",
"@types/react": "~18.2.14",
"@typescript-eslint/eslint-plugin": "^6.7.3",

View File

@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from 'react-query';
import API from '../API';
/**
* Mutation to like/unlike a song
*/
export const useLikeSongMutation = () => {
const queryClient = useQueryClient();
return useMutation(({ songId, like }: { songId: number; like: boolean }) => {
const apiCall = like ? API.addLikedSong : API.removeLikedSong;
return apiCall(songId).then(() => {
queryClient.invalidateQueries({ queryKey: ['liked songs'] });
queryClient.invalidateQueries({ queryKey: ['songs'] });
queryClient.invalidateQueries({ queryKey: [songId] });
});
});
};

View File

@@ -7,6 +7,7 @@ import SongRow from '../components/SongRow';
import { Key } from 'react';
import { RouteProps, useNavigation } from '../Navigation';
import { ImageBackground } from 'react-native';
import { useLikeSongMutation } from '../utils/likeSongMutation';
type ArtistDetailsViewProps = {
artistId: number;
@@ -20,11 +21,7 @@ const ArtistDetailsView = ({ artistId }: RouteProps<ArtistDetailsViewProps>) =>
const navigation = useNavigation();
const favoritesQuery = useQuery(API.getLikedSongs());
const handleFavoriteButton = async (state: boolean, songId: number): Promise<void> => {
if (state == false) await API.removeLikedSong(songId);
else await API.addLikedSong(songId);
};
const { mutate } = useLikeSongMutation();
if (artistQuery.isError || songsQuery.isError) {
navigation.navigate('Error');
@@ -49,12 +46,12 @@ const ArtistDetailsView = ({ artistId }: RouteProps<ArtistDetailsViewProps>) =>
{songsQuery.data.map((comp: Song, index: Key | null | undefined) => (
<SongRow
key={index}
song={comp}
song={{ ...comp, artist: artistQuery.data }}
isLiked={
!favoritesQuery.data?.find((query) => query?.songId == comp.id)
}
handleLike={(state: boolean, songId: number) =>
handleFavoriteButton(state, songId)
handleLike={async (state: boolean, songId: number) =>
mutate({ songId: songId, like: state })
}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');

View File

@@ -2,9 +2,9 @@ import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import API from '../API';
import { setAccessToken } from '../state/UserSlice';
import { Text } from 'native-base';
import { useRoute } from '@react-navigation/native';
import { AccessTokenResponseHandler } from '../models/AccessTokenResponse';
import { Translate } from '../i18n/i18n';
const GoogleView = () => {
const dispatch = useDispatch();
@@ -25,7 +25,7 @@ const GoogleView = () => {
run();
}, []);
return <Text>Loading please wait</Text>;
return <Translate translationKey={'loading'} />;
};
export default GoogleView;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Center, Text, useBreakpointValue, useTheme } from 'native-base';
import { Center, useBreakpointValue, useTheme } from 'native-base';
import { useWindowDimensions } from 'react-native';
import {
TabView,
@@ -12,28 +12,29 @@ import {
import { Heart, Clock, StatusUp, FolderCross } from 'iconsax-react-native';
import { Scene } from 'react-native-tab-view/lib/typescript/src/types';
import { RouteProps, useNavigation } from '../Navigation';
import { TranslationKey, translate } from '../i18n/i18n';
import { Translate, TranslationKey } from '../i18n/i18n';
import ScaffoldCC from '../components/UI/ScaffoldCC';
import MusicList from '../components/UI/MusicList';
import { useQuery } from '../Queries';
import API from '../API';
import { LoadingView } from '../components/Loading';
import { useLikeSongMutation } from '../utils/likeSongMutation';
export const FavoritesMusic = () => {
const navigation = useNavigation();
const likedSongs = useQuery(API.getLikedSongs(['artist']));
const likedSongs = useQuery(API.getLikedSongs(['artist', 'SongHistory']));
const { mutateAsync } = useLikeSongMutation();
const musics =
likedSongs.data?.map((x) => ({
artist: x.song.artist!.name,
song: x.song.name,
image: x.song.cover,
level: 42,
lastScore: 42,
bestScore: 42,
lastScore: x.song.lastScore,
bestScore: x.song.bestScore,
liked: true,
onLike: () => {
console.log('onLike');
mutateAsync({ songId: x.song.id, like: false }).then(() => likedSongs.refetch());
},
onPlay: () => navigation.navigate('Play', { songId: x.song.id }),
})) ?? [];
@@ -99,7 +100,7 @@ export const FavoritesMusic = () => {
export const RecentlyPlayedMusic = () => {
return (
<Center style={{ flex: 1 }}>
<Text>RecentlyPlayedMusic</Text>
<Translate translationKey="recentlyPlayed" />
</Center>
);
};
@@ -107,7 +108,7 @@ export const RecentlyPlayedMusic = () => {
export const StepUpMusic = () => {
return (
<Center style={{ flex: 1 }}>
<Text>StepUpMusic</Text>
<Translate translationKey="musicTabStepUp" />
</Center>
);
};
@@ -172,9 +173,10 @@ const MusicTab = (props: RouteProps<object>) => {
}}
renderLabel={({ route, color }) =>
layout.width > 800 && (
<Text style={{ color: color, paddingLeft: 10, overflow: 'hidden' }}>
{translate(route.title as TranslationKey)}
</Text>
<Translate
translationKey={route.title as TranslationKey}
style={{ color: color, paddingLeft: 10, overflow: 'hidden' }}
/>
)
}
tabStyle={{ flexDirection: 'row' }}

View File

@@ -74,7 +74,7 @@ const PlayView = ({ songId, route }: RouteProps<PlayViewProps>) => {
const navigation = useNavigation();
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';
const song = useQuery(API.getSong(songId), { staleTime: Infinity });
const song = useQuery(API.getSong(songId, ['artist']), { staleTime: Infinity });
const toast = useToast();
const [lastScoreMessage, setLastScoreMessage] = useState<ScoreMessage>();
const webSocket = useRef<WebSocket>();
@@ -380,9 +380,7 @@ const PlayView = ({ songId, route }: RouteProps<PlayViewProps>) => {
alignItems: 'center',
}}
>
<Text color={statColor} fontSize={12}>
<Translate translationKey={label} />
</Text>
<Translate translationKey={label} color={statColor} fontSize={12} />
<View
style={{
display: 'flex',

View File

@@ -7,7 +7,7 @@ import { LoadingView } from '../components/Loading';
import { useQuery } from '../Queries';
import API from '../API';
import ButtonBase from '../components/UI/ButtonBase';
import { translate } from '../i18n/i18n';
import { Translate, translate } from '../i18n/i18n';
import ScoreGraph from '../components/ScoreGraph';
import ScaffoldCC from '../components/UI/ScaffoldCC';
@@ -81,14 +81,18 @@ const ProfileView = (props: RouteProps<{}>) => {
{userQuery.data.name}
</Text>
<ButtonBase
title="Modifier profil"
title={translate('updateProfile')}
type={'filled'}
onPress={async () => navigation.navigate('Settings', {})}
/>
</View>
<Text style={{ paddingBottom: 10, fontWeight: 'bold' }}>
Account created on {userQuery.data.data.createdAt.toLocaleDateString()}
</Text>
<Translate
style={{ paddingBottom: 10, fontWeight: 'bold' }}
translationKey="accountCreatedOn"
format={(e) =>
`${e} ${userQuery.data.data.createdAt.toLocaleDateString()}`
}
/>
<Flex
style={{
flexDirection: 'row',
@@ -96,15 +100,19 @@ const ProfileView = (props: RouteProps<{}>) => {
paddingBottom: 10,
}}
>
<Text style={{ paddingRight: 20 }}>
Your client ID is {userQuery.data.id}
</Text>
<Text>{userQuery.data.data.gamesPlayed} Games played</Text>
<Translate
translationKey="gamesPlayed"
format={(e) => `${userQuery.data.data.gamesPlayed} ${e}`}
/>
</Flex>
</Column>
</View>
<Row style={{ alignItems: 'center', paddingBottom: 20 }}>
<Text style={{ paddingRight: 20 }}>{`${translate('level')} ${level}`}</Text>
<Translate
style={{ paddingRight: 20 }}
translationKey="level"
format={(e) => `${e} ${level}`}
/>
<Progress
bgColor={colors.coolGray[500]}
value={progessValue}

View File

@@ -3,7 +3,6 @@ import React from 'react';
import { useNavigation } from '../Navigation';
import {
View,
Text,
Stack,
Box,
useToast,
@@ -21,7 +20,7 @@ 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';
import { Translate, translate } from '../i18n/i18n';
import useColorScheme from '../hooks/colorScheme';
import { useAssets } from 'expo-asset';
@@ -73,7 +72,7 @@ const StartPageView = () => {
}
size={isSmallScreen ? '5xl' : '6xl'}
/>
<Heading fontSize={isSmallScreen ? '3xl' : '5xl'}>Chromacase</Heading>
<Heading fontSize={isSmallScreen ? '3xl' : '5xl'}>ChromaCase</Heading>
</Row>
</Center>
<Stack
@@ -154,12 +153,9 @@ const StartPageView = () => {
}}
>
<Heading fontSize="4xl" style={{ textAlign: 'center' }}>
What is Chromacase?
<Translate translationKey="whatIsChromacase" />
</Heading>
<Text fontSize={'xl'}>
Chromacase is a free and open source project that aims to provide a complete
learning experience for anyone willing to learn piano.
</Text>
<Translate fontSize={'xl'} translationKey="chromacasePitch" />
</Box>
<Box
@@ -177,7 +173,7 @@ const StartPageView = () => {
}}
>
<Link href="http://eip.epitech.eu/2024/chromacase" isExternal>
Click here for more info
<Translate translationKey="clickHereForMoreInfo" />
</Link>
</Box>
</Box>
@@ -195,7 +191,9 @@ const StartPageView = () => {
alignItems: 'center',
}}
>
<Link href="/forgot_password">I forgot my password</Link>
<Link href="/forgot_password">
<Translate translationKey="forgotPassword" />
</Link>
</Box>
</Box>
</Column>

View File

@@ -10,10 +10,14 @@ import GoldenRatio from '../../components/V2/GoldenRatio';
// eslint-disable-next-line @typescript-eslint/ban-types
const HomeView = (props: RouteProps<{}>) => {
const songsQuery = useQuery(API.getSongSuggestions(['artist']));
const suggestionsQuery = useQuery(
API.getSongSuggestions(['artist', 'likedByUsers', 'SongHistory'])
);
const navigation = useNavigation();
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';
const topSuggestions = suggestionsQuery.data?.slice(0, 4) ?? [];
const suggestions = suggestionsQuery.data?.slice(4) ?? [];
return (
<ScaffoldCC routeName={props.route.name}>
@@ -33,7 +37,7 @@ const HomeView = (props: RouteProps<{}>) => {
aspectRatio: isPhone ? 0.618 : 1.618,
}}
>
<GoldenRatio />
<GoldenRatio songs={topSuggestions} />
</View>
<View
style={{
@@ -64,7 +68,7 @@ const HomeView = (props: RouteProps<{}>) => {
gap: 16,
}}
>
{songsQuery.data?.map((song) => (
{suggestions.map((song) => (
<SongCardInfo
key={song.id}
song={song}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import API from '../API';
import { Text } from 'native-base';
import { useNavigation } from '../Navigation';
import { useRoute } from '@react-navigation/native';
import { Translate } from '../i18n/i18n';
const VerifiedView = () => {
const navigation = useNavigation();
@@ -26,9 +26,9 @@ const VerifiedView = () => {
}, []);
return failed ? (
<Text>Email verification failed. The token has expired or is invalid.</Text>
<Translate translationKey={'emailCheckFailed'} />
) : (
<Text>Loading please wait</Text>
<Translate translationKey={'loading'} />
);
};

View File

@@ -1,95 +0,0 @@
import React from 'react';
import { translate } from '../../i18n/i18n';
import ElementList from '../../components/GtkUI/ElementList';
import useUserSettings from '../../hooks/userSettings';
import { LoadingView } from '../../components/Loading';
import { Calendar1, MonitorMobbile, Send2, Warning2 } from 'iconsax-react-native';
const NotificationsSettings = () => {
const { settings, updateSettings } = useUserSettings();
if (!settings.data) {
return <LoadingView />;
}
return (
<ElementList
style={{ width: '100%' }}
elements={[
{
type: 'toggle',
icon: MonitorMobbile,
title: translate('SettingsNotificationsTabPushNotificationsSectionTitle'),
description: translate(
'SettingsNotificationsTabPushNotificationsSectionDescription'
),
data: {
value: settings.data.notifications.pushNotif,
onToggle: () => {
updateSettings({
notifications: {
pushNotif: !settings.data.notifications.pushNotif,
},
});
},
},
},
{
type: 'toggle',
icon: Send2,
title: translate('SettingsNotificationsTabEmailNotificationsSectionTitle'),
description: translate(
'SettingsNotificationsTabEmailNotificationsSectionDescription'
),
data: {
value: settings.data.notifications.emailNotif,
onToggle: () => {
updateSettings({
notifications: {
emailNotif: !settings.data.notifications.emailNotif,
},
});
},
},
},
{
type: 'toggle',
icon: Calendar1,
title: translate('SettingsNotificationsTabTrainingReminderSectionTitle'),
description: translate(
'SettingsNotificationsTabTrainingReminderSectionDescription'
),
data: {
value: settings.data.notifications.trainNotif,
onToggle: () => {
updateSettings({
notifications: {
trainNotif: !settings.data.notifications.trainNotif,
},
});
},
},
},
{
type: 'toggle',
icon: Warning2,
title: translate('SettingsNotificationsTabReleaseAlertSectionTitle'),
description: translate(
'SettingsNotificationsTabReleaseAlertSectionDescription'
),
data: {
value: settings.data.notifications.newSongNotif,
onToggle: () => {
updateSettings({
notifications: {
newSongNotif: !settings.data.notifications.newSongNotif,
},
});
},
},
},
]}
/>
);
};
export default NotificationsSettings;

View File

@@ -7,7 +7,7 @@ import { useSelector } from '../../state/Store';
import { updateSettings } from '../../state/SettingsSlice';
import ElementList from '../../components/GtkUI/ElementList';
import LocalSettings from '../../models/LocalSettings';
import { Brush2, Colorfilter, LanguageSquare, Rank, Sound } from 'iconsax-react-native';
import { Brush2, LanguageSquare, Rank } from 'iconsax-react-native';
const PreferencesSettings = () => {
const dispatch = useDispatch();
@@ -84,59 +84,6 @@ const PreferencesSettings = () => {
},
]}
/>
<ElementList
elements={[
{
icon: Colorfilter,
type: 'toggle',
title: translate('SettingsPreferencesTabColorblindModeSectionTitle'),
description: translate(
'SettingsPreferencesTabColorblindModeSectionDescription'
),
data: {
value: settings.colorBlind,
onToggle: () => {
dispatch(updateSettings({ colorBlind: !settings.colorBlind }));
},
},
},
]}
/>
<ElementList
elements={[
{
icon: Sound,
type: 'range',
title: translate('SettingsPreferencesTabMicVolumeSectionTitle'),
description: translate('SettingsPreferencesTabMicVolumeSectionDescription'),
data: {
value: settings.micVolume,
min: 0,
max: 1000,
step: 10,
onChange: (value) => {
dispatch(updateSettings({ micVolume: 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" },
],
},
},*/
]}
/>
</Column>
);
};

View File

@@ -1,69 +0,0 @@
import API from '../../API';
import React from 'react';
import { LoadingView } from '../../components/Loading';
import ElementList from '../../components/GtkUI/ElementList';
import { translate } from '../../i18n/i18n';
import { useQuery } from '../../Queries';
import { Designtools, Magicpen, Star1 } from 'iconsax-react-native';
// Too painful to infer the settings-only, typed navigator. Gave up
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PremiumSettings = () => {
const userQuery = useQuery(API.getUserInfo);
if (!userQuery.data || userQuery.isLoading) {
return <LoadingView />;
}
const user = userQuery.data;
return (
<ElementList
style={{ width: '100%' }}
elements={[
{
icon: Star1,
type: 'text',
title: translate('settingsPremiumTabPremiumAccountSectionTitle'),
description: translate('settingsPremiumTabPremiumAccountSectionDescription'),
data: {
text: translate(user.premium ? 'yes' : 'no'),
},
},
{
icon: Magicpen,
type: 'toggle',
title: translate('settingsPremiumTabPianoMagiqueSectionTitle'),
description: translate('settingsPremiumTabPianoMagiqueSectionDescription'),
helperText: translate('settingsPremiumTabPianoMagiqueSectionHelper'),
disabled: true,
data: {
value: false,
onToggle: () => {},
},
},
{
icon: Designtools,
type: 'dropdown',
title: translate('settingsPremiumTabThemePianoSectionTitle'),
description: translate('settingsPremiumTabThemePianoSectionDescription'),
disabled: true,
data: {
value: 'default',
onSelect: () => {},
options: [
{
label: 'Default',
value: 'default',
},
{
label: 'Catpuccino',
value: 'catpuccino',
},
],
},
},
]}
/>
);
};
export default PremiumSettings;

View File

@@ -10,7 +10,8 @@ import { Google, PasswordCheck, SmsEdit, UserSquare, Verify } from 'iconsax-reac
import ChangeEmailForm from '../../components/forms/changeEmailForm';
import ChangePasswordForm from '../../components/forms/changePasswordForm';
import LogoutButtonCC from '../../components/UI/LogoutButtonCC';
import { ScrollView } from 'react-native';
import { Platform, ScrollView } from 'react-native';
import APKDownloadButton from '../../components/APKDownloadButton';
const handleChangeEmail = async (newEmail: string): Promise<string> => {
await API.updateUserEmail(newEmail);
@@ -162,6 +163,7 @@ const ProfileSettings = () => {
},
]}
/>
{Platform.OS === 'web' && <APKDownloadButton />}
<LogoutButtonCC isGuest={user.isGuest} buttonType={'filled'} />
</Column>
</ScrollView>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Center, Text, useBreakpointValue, useTheme } from 'native-base';
import { Text, useBreakpointValue, useTheme } from 'native-base';
import ProfileSettings from './SettingsProfile';
import NotificationsSettings from './NotificationsSettings';
import PrivacySettings from './PrivacySettings';
import PreferencesSettings from './PreferencesSettings';
import { useWindowDimensions } from 'react-native';
@@ -13,54 +12,28 @@ import {
Route,
SceneRendererProps,
} from 'react-native-tab-view';
import {
HeartEdit,
Star1,
UserEdit,
Notification,
SecurityUser,
Music,
FolderCross,
} from 'iconsax-react-native';
import { HeartEdit, UserEdit, SecurityUser, FolderCross } from 'iconsax-react-native';
import { Scene } from 'react-native-tab-view/lib/typescript/src/types';
import PremiumSettings from './SettingsPremium';
import { RouteProps } from '../../Navigation';
import ScaffoldCC from '../../components/UI/ScaffoldCC';
import { translate } from '../../i18n/i18n';
export const PianoSettings = () => {
return (
<Center style={{ flex: 1 }}>
<Text>Global settings for the virtual piano</Text>
</Center>
);
};
const renderScene = SceneMap({
profile: ProfileSettings,
premium: PremiumSettings,
preferences: PreferencesSettings,
notifications: NotificationsSettings,
privacy: PrivacySettings,
piano: PianoSettings,
});
const getTabData = (key: string) => {
switch (key) {
case 'profile':
return { index: 0, icon: UserEdit };
case 'premium':
return { index: 1, icon: Star1 };
case 'preferences':
return { index: 2, icon: HeartEdit };
case 'notifications':
return { index: 3, icon: Notification };
return { index: 1, icon: HeartEdit };
case 'privacy':
return { index: 4, icon: SecurityUser };
case 'piano':
return { index: 5, icon: Music };
return { index: 2, icon: SecurityUser };
default:
return { index: 6, icon: FolderCross };
return { index: 3, icon: FolderCross };
}
};
@@ -71,11 +44,8 @@ const SettingsTab = (props: RouteProps<{}>) => {
const { colors } = useTheme();
const routes = [
{ key: 'profile', title: 'settingsTabProfile' },
{ key: 'premium', title: 'settingsTabPremium' },
{ key: 'preferences', title: 'settingsTabPreferences' },
{ key: 'notifications', title: 'settingsTabNotifications' },
{ key: 'privacy', title: 'settingsTabPrivacy' },
{ key: 'piano', title: 'settingsTabPiano' },
];
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isSmallScreen = screenSize === 'small';
@@ -113,11 +83,8 @@ const SettingsTab = (props: RouteProps<{}>) => {
{translate(
route.title as
| 'settingsTabProfile'
| 'settingsTabPremium'
| 'settingsTabPreferences'
| 'settingsTabNotifications'
| 'settingsTabPrivacy'
| 'settingsTabPiano'
)}
</Text>
)

View File

@@ -3169,6 +3169,13 @@
"@types/qs" "*"
"@types/serve-static" "*"
"@types/fbjs@^3.0.10":
version "3.0.10"
resolved "https://registry.yarnpkg.com/@types/fbjs/-/fbjs-3.0.10.tgz#e837db996533e28e862d8fd09b3e1c44d7f0e252"
integrity sha512-becmqsrRvB0qwgEYy96i9cN48w8YwOeaMhpyT/sbkSuWX27LDXPESBrgSFKkgcIdk39jqcPlLdLljT2y2OcyTg==
dependencies:
"@types/jsdom" "*"
"@types/glob@^7.1.1":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
@@ -3228,6 +3235,15 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jsdom@*":
version "21.1.6"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.6.tgz#bcbc7b245787ea863f3da1ef19aa1dcfb9271a1b"
integrity sha512-/7kkMsC+/kMs7gAYmmBR9P0vGTnOoLhQhyhQJSlXGI5bzTHp6xdo0TtKWQAsz6pmSAeVqKSbqeyP6hytqr9FDw==
dependencies:
"@types/node" "*"
"@types/tough-cookie" "*"
parse5 "^7.0.0"
"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
@@ -3342,6 +3358,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
"@types/tough-cookie@*":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304"
integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==
"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
@@ -5365,7 +5386,7 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
entities@^4.2.0:
entities@^4.2.0, entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
@@ -6070,7 +6091,7 @@ fbjs-css-vars@^1.0.0:
resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8"
integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==
fbjs@^3.0.0, fbjs@^3.0.4:
fbjs@^3.0.0, fbjs@^3.0.4, fbjs@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.5.tgz#aa0edb7d5caa6340011790bd9249dbef8a81128d"
integrity sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==
@@ -9198,6 +9219,13 @@ parse-png@^2.1.0:
dependencies:
pngjs "^3.3.0"
parse5@^7.0.0:
version "7.1.2"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
dependencies:
entities "^4.4.0"
parseurl@~1.3.2, parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"