Feature/adc/#192 barre de recherche (#201)

* context and react query add to searchView

* handle empty result + back

* #192 - New card components and history fetch + dummy suggestions fetch

* respoonsive design + filters map

* artist details view + translations + SongData mapping fix + items limitation

* history search back and front + cards + fix

* fixed useless history entries

* clean code

* clean code

* fix pr: SearchHistory new type related fixes

* simplified SearchResultComponent (useEffect removed, condition simplified to trigger different 'modes'

* search re-do onPress history cards + scoreView obj map

* clean code API.ts

* fix pr + search history behavior

* added utility function to get song suggestions with artists and fixed error types along the way

* fix in songrow the title didn't shrinked when not enough space on screen

* removed redirect callback from ArtistCard to ArtistResults

* moved the callback from genre card grid to searchresult and implemented history for songs

* SearchBar is now updating input search following stringQuery

* added scroll view to have the complete background

* Added the route props for query in Searchview

* fixed robot test

---------

Co-authored-by: Clément Le Bihan <clement.lebihan773@gmail.com>
This commit is contained in:
Amaury
2023-05-26 10:50:25 +02:00
committed by GitHub
parent 5baf9309c6
commit 97bf7bdac8
26 changed files with 902 additions and 442 deletions

View File

@@ -1,14 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber } from "class-validator";
export class SearchHistoryDto {
@ApiProperty()
@IsNumber()
userID: number;
@ApiProperty()
query: string;
@ApiProperty()
type: "song" | "artist" | "album";
type: "song" | "artist" | "album" | "genre";
}

View File

@@ -15,6 +15,7 @@ import { SearchHistory, SongHistory } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { HistoryService } from './history.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
@Controller('history')
@ApiTags('history')
@@ -50,4 +51,15 @@ export class HistoryController {
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
return this.historyService.createSongHistoryRecord(record);
}
@Post("search")
@HttpCode(201)
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({description: "Invalid token"})
async createSearchHistory(
@Request() req: any,
@Body() record: SearchHistoryDto
): Promise<void> {
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
}
}

View File

@@ -72,11 +72,10 @@ export class HistoryService {
};
}
async createSearchHistoryRecord({
userID,
query,
type,
}: SearchHistoryDto): Promise<SearchHistory> {
async createSearchHistoryRecord(
userID: number,
{ query, type }: SearchHistoryDto
): Promise<SearchHistory> {
return this.prisma.searchHistory.create({
data: {
query,

View File

@@ -13,7 +13,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { Song } from '@prisma/client';
import { Artist, Genre, Song } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SearchSongDto } from './dto/search-song.dto';
import { SearchService } from './search.service';
@@ -23,117 +23,39 @@ import { SearchService } from './search.service';
export class SearchController {
constructor(private readonly searchService: SearchService) { }
@ApiOperation({
summary: 'Get a song details by song name',
description: 'Get a song details by song name',
})
@Get('song/:name')
@Get('songs/:query')
@UseGuards(JwtAuthGuard)
async findByName(@Request() req: any, @Param('name') name: string): Promise<Song | null> {
const ret = await this.searchService.songByTitle({ name }, req.user?.id);
if (!ret) throw new NotFoundException();
return ret;
}
@ApiOperation({
summary: 'Get songs details by advanced filter',
description: 'Get songs details by advanced filter',
})
@Post('song/advanced')
@HttpCode(200) // change from '201 created' to '200 OK' http default response code
async findAdvanced(
@Body() searchSongDto: SearchSongDto,
): Promise<Song[] | null> {
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
try {
const ret = await this.searchService.findAdvanced({
albumId: searchSongDto.album ? +searchSongDto.album : undefined,
artistId: searchSongDto.artist ? +searchSongDto.artist : undefined,
genreId: searchSongDto.genre ? +searchSongDto.genre : undefined,
});
const ret = await this.searchService.songByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
console.log(error);
throw new BadRequestException(null, error?.toString());
throw new InternalServerErrorException();
}
}
@ApiOperation({
summary: 'Get songs details by artist',
description: 'Get songs details by artist',
})
@Get('song/artist/:artistId')
async findByArtist(
@Param('artistId', ParseIntPipe) artistId: number,
): Promise<Song[] | null> {
const ret = await this.searchService.songsByArtist(artistId);
if (!ret.length) throw new NotFoundException();
else return ret;
}
@ApiOperation({
summary: 'Get songs details by genre',
description: 'Get songs details by genre',
})
@Get('song/genre/:genreId')
async findByGenre(
@Param('genreId', ParseIntPipe) genreId: number,
): Promise<Song[] | null> {
const ret = await this.searchService.songsByGenre(genreId);
if (!ret.length) throw new NotFoundException();
else return ret;
}
@ApiOperation({
summary: 'Get songs details by album',
description: 'Get songs details by album',
})
@Get('song/album/:albumId')
async findByAlbum(
@Param('albumId', ParseIntPipe) albumId: number,
): Promise<Song[] | null> {
const ret = await this.searchService.songsByAlbum(albumId);
if (ret.length) throw new NotFoundException();
else return ret;
}
@ApiOperation({
summary: 'Guess elements details by keyword',
description: 'Guess elements details by keyword',
})
@Get('guess/:type/:word')
@ApiParam({
name: 'word',
type: 'string',
required: true,
example: 'Yoko Shimomura',
})
@ApiParam({ name: 'type', type: 'string', required: true, example: 'artist' })
@Get('genres/:query')
@UseGuards(JwtAuthGuard)
async guess(
@Request() req: any,
@Param() params: { type: string; word: string },
): Promise<any[] | null> {
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
try {
let ret: any[];
switch (params.type) {
case 'artist':
ret = await this.searchService.guessArtist(params.word, req.user?.id);
break;
case 'album':
ret = await this.searchService.guessAlbum(params.word, req.user?.id);
break;
case 'song':
ret = await this.searchService.guessSong(params.word, req.user?.id);
break;
default:
throw new BadRequestException();
}
const ret = await this.searchService.genreByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
console.log(error);
throw new InternalServerErrorException(null, error?.toString());
throw new InternalServerErrorException();
}
}
}
@Get('artists/:query')
@UseGuards(JwtAuthGuard)
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
try {
const ret = await this.searchService.artistByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
throw new InternalServerErrorException();
}
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Album, Artist, Prisma, Song } from '@prisma/client';
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
import { HistoryService } from 'src/history/history.service';
import { PrismaService } from 'src/prisma/prisma.service';
@@ -7,100 +7,27 @@ import { PrismaService } from 'src/prisma/prisma.service';
export class SearchService {
constructor(private prisma: PrismaService, private history: HistoryService) { }
async songByTitle(
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
userID: number
): Promise<Song | null> {
if (songWhereUniqueInput.name)
await this.history.createSearchHistoryRecord({ query: songWhereUniqueInput.name, userID, type: "song" });
return this.prisma.song.findUnique({
where: songWhereUniqueInput,
});
}
async songsByArtist(artistId: number): Promise<Song[]> {
async songByGuess(query: string, userID: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
artistId: artistId,
name: { contains: query, mode: 'insensitive' },
},
orderBy: [],
});
}
async songsByGenre(genreId: number): Promise<Song[]> {
return this.prisma.song.findMany({
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
return this.prisma.genre.findMany({
where: {
genreId: genreId,
name: { contains: query, mode: 'insensitive' },
},
});
}
async songsByAlbum(albumId: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
albumId: albumId,
},
});
}
async artistByName(artistName: string): Promise<Artist | null> {
return this.prisma.artist.findUnique({
where: {
name: artistName,
},
});
}
async guessSong(word: string, userID: number): Promise<Song[]> {
await this.history.createSearchHistoryRecord({ query: word, type: "song", userID });
return this.prisma.song.findMany({
where: {
name: { contains: word },
},
});
}
async guessArtist(word: string, userID: number): Promise<Artist[]> {
await this.history.createSearchHistoryRecord({ query: word, type: "artist", userID });
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
return this.prisma.artist.findMany({
where: {
name: { contains: word },
name: { contains: query, mode: 'insensitive' },
},
});
}
async guessAlbum(word: string, userID: number): Promise<Album[]> {
await this.history.createSearchHistoryRecord({ query: word, type: "album", userID });
return this.prisma.album.findMany({
where: {
name: { contains: word },
},
});
}
async findAdvanced(params: {
albumId?: number;
genreId?: number;
artistId?: number;
orderBy?: Prisma.SongOrderByWithRelationInput;
}): Promise<Song[]> {
const {
albumId: albumId,
genreId: genreId,
artistId: artistId,
orderBy: orderBy,
} = params;
return this.prisma.song.findMany({
where: {
OR: [
{
albumId: { equals: albumId },
genreId: { equals: genreId },
artistId: { equals: artistId },
},
],
},
orderBy,
});
}
}

View File

@@ -83,14 +83,17 @@ Create and get a search history record
Output
Integer response status 404
POST /history/search
... { "query": "tata", "type": "song" }
&{res}= GET /history/search
Output
Integer response status 200
Array response body
String $[0].type "song"
String $[0].query "tata"
String $[1].type "song"
String $[1].query "toto"
${len}= Get Length ${res.body}
Should Be Equal As Integers ${len} 1
[Teardown] DELETE /users/${userID}

View File

@@ -1,7 +1,9 @@
import Artist from "./models/Artist";
import Album from "./models/Album";
import AuthToken from "./models/AuthToken";
import Chapter from "./models/Chapter";
import Lesson from "./models/Lesson";
import Genre from "./models/Genre";
import LessonHistory from "./models/LessonHistory";
import Song from "./models/Song";
import SongHistory from "./models/SongHistory";
@@ -10,7 +12,7 @@ import Constants from "expo-constants";
import store from "./state/Store";
import { Platform } from "react-native";
import { en } from "./i18n/Translations";
import { QueryClient } from "react-query";
import { useQuery, QueryClient } from "react-query";
import UserSettings from "./models/UserSettings";
import { PartialDeep } from "type-fest";
import SearchHistory from "./models/SearchHistory";
@@ -343,7 +345,51 @@ export default class API {
*/
public static async searchSongs(query: string): Promise<Song[]> {
return API.fetch({
route: `/search/guess/song/${query}`,
route: `/search/songs/${query}`,
});
}
/**
* Search artists by name
* @param query the string used to find the artists
*/
public static async searchArtists(query?: string): Promise<Artist[]> {
return API.fetch({
route: `/search/artists/${query}`,
});
}
/**
* Search Album by name
* @param query the string used to find the album
*/
public static async searchAlbum(query?: string): Promise<Album[]> {
return [
{
id: 1,
name: "Super Trooper",
},
{
id: 2,
name: "Kingdom Heart 365/2 OST",
},
{
id: 3,
name: "The Legend Of Zelda Ocarina Of Time OST",
},
{
id: 4,
name: "Random Access Memories",
},
] as Album[];
}
/**
* Retrieve music genres
*/
public static async searchGenres(query?: string): Promise<Genre[]> {
return API.fetch({
route: `/search/genres/${query}`,
});
}
@@ -363,30 +409,55 @@ export default class API {
/**
* Retrieve the authenticated user's search history
* @param lessonId the id to find the lesson
* @param skip number of entries skipped before returning
* @param take how much do we take to return
* @returns Returns an array of history entries (temporary type any)
*/
public static async getSearchHistory(): Promise<SearchHistory[]> {
const tmp = await this.fetch({
route: "/history/search",
});
public static async getSearchHistory(skip?: number, take?: number): Promise<SearchHistory[]> {
return (await API.fetch({
route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`,
method: "GET",
})).map((e: any) => {
return {
id: e.id,
query: e.query,
type: e.type,
userId: e.userId,
timestamp: new Date(e.searchDate),
} as SearchHistory
})
}
return tmp.map((value: any) => ({
query: value.query,
userID: value.userId,
id: value.id,
}));
/**
* Posts a new entry in the user's search history
* @param query is the query itself
* @param type the type of object searched
* @param timestamp the date it's been issued
* @returns nothing
*/
public static async createSearchHistoryEntry(query: string, type: string, timestamp: number): Promise<void> {
return await API.fetch({
route: `/history/search`,
method: "POST",
body: {
query: query,
type: type
},
})
}
/**
* Retrieve the authenticated user's recommendations
* @returns an array of songs
*/
public static async getUserRecommendations(): Promise<Song[]> {
public static async getSongSuggestions(): Promise<Song[]> {
const queryClient = new QueryClient();
return await queryClient.fetchQuery(["API", "allsongs"], API.getAllSongs);
}
/**
* Retrieve the authenticated user's play history
* * @returns an array of songs
*/
public static async getUserPlayHistory(): Promise<SongHistory[]> {
return this.fetch({

View File

@@ -18,6 +18,7 @@ import ScoreView from './views/ScoreView';
import { LoadingView } from './components/Loading';
import ProfileView from './views/ProfileView';
import useColorScheme from './hooks/colorScheme';
import ArtistDetailsView from './views/ArtistDetailsView';
import { Button, Center, VStack } from 'native-base';
import { unsetAccessToken } from './state/UserSlice';
import TextButton from './components/TextButton';
@@ -28,6 +29,7 @@ const protectedRoutes = () => ({
Play: { component: PlayView, options: { title: translate('play') } },
Settings: { component: SetttingsNavigator, options: { title: 'Settings' } },
Song: { component: SongLobbyView, options: { title: translate('play') } },
Artist: { component: ArtistDetailsView, options: { title: translate('artistFilter') } },
Score: { component: ScoreView, options: { title: translate('score'), headerLeft: null } },
Search: { component: SearchView, options: { title: translate('search') } },
User: { component: ProfileView, options: { title: translate('user') } },

View File

@@ -0,0 +1,43 @@
import React from "react";
import Card, { CardBorderRadius } from './Card';
import { VStack, Text, Image } from 'native-base';
type ArtistCardProps = {
image: string;
name: string;
id: number;
onPress: () => void;
}
const ArtistCard = (props: ArtistCardProps) => {
const { image, name, id } = props;
return (
<Card
shadow={3}
onPress={props.onPress}
>
<VStack m={1.5} space={3}>
<Image
style={{ zIndex: 0, aspectRatio: 1, borderRadius: CardBorderRadius }}
source={{ uri: image }}
alt={name}
/>
<VStack>
<Text isTruncated bold fontSize="md" noOfLines={2} height={50}>
{name}
</Text>
</VStack>
</VStack>
</Card>
);
}
ArtistCard.defaultProps = {
image: 'https://picsum.photos/200',
name: 'Artist',
id: 0,
onPress: () => { }
}
export default ArtistCard;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { FlatGrid } from 'react-native-super-grid';
import { Heading, VStack } from 'native-base';
type CardGridCustomProps<T> = {
content: T[];
heading?: JSX.Element;
maxItemsPerRow?: number;
style?: Parameters<typeof FlatGrid>[0]['additionalRowStyle'];
cardComponent: React.ComponentType<T>;
};
const CardGridCustom = <T extends Record<string, any>>(props: CardGridCustomProps<T>) => {
const { content, heading, maxItemsPerRow, style, cardComponent: CardComponent } = props;
return (
<VStack space={5}>
{heading && <Heading>{heading}</Heading>}
<FlatGrid
maxItemsPerRow={maxItemsPerRow}
additionalRowStyle={style ?? { justifyContent: 'flex-start' }}
data={content}
renderItem={({ item }) => <CardComponent {...item} />}
spacing={10}
/>
</VStack>
);
};
export default CardGridCustom;

View File

@@ -0,0 +1,51 @@
import React from "react";
import Card from './Card';
import { VStack, Text, Box, Icon } from 'native-base';
import { useTheme } from "native-base";
import { Ionicons } from "@expo/vector-icons";
import API from "../API";
type GenreCardProps = {
icon: string;
name: string;
onPress: () => void;
}
const GenreCard = (props: GenreCardProps) => {
const { icon, name } = props;
const theme = useTheme();
return (
<Card
shadow={3}
onPress={props.onPress}
>
<VStack m={1.5} space={3} alignItems="center">
<Box
bg={theme.colors.primary[400]}
w={20}
h={20}
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon size={"md"} as={Ionicons} name={icon} />
</Box>
<VStack>
<Text isTruncated bold fontSize="md" noOfLines={2} height={50}>
{name}
</Text>
</VStack>
</VStack>
</Card>
);
}
GenreCard.defaultProps = {
icon: 'https://picsum.photos/200',
name: 'Genre',
onPress: () => { }
}
export default GenreCard;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { VStack, Text } from 'native-base';
import Card from './Card';
type SearchHistoryCardProps = {
query: string;
type: string;
timestamp?: string;
};
const SearchHistoryCard = (props: SearchHistoryCardProps & { onPress: (query: string) => void }) => {
const { query, type, timestamp, onPress } = props;
const handlePress = () => {
if (onPress) {
onPress(query);
}
};
return (
<Card shadow={2} onPress={handlePress} >
<VStack m={1.5} space={3}>
<Text fontSize="lg" fontWeight="bold">
{query ?? "query"}
</Text>
<Text fontSize="lg" fontWeight="semibold">
{type ?? "type"}
</Text>
<Text color="gray.500">{timestamp ?? "timestamp"}</Text>
</VStack>
</Card>
);
};
export default SearchHistoryCard;

View File

@@ -1,149 +1,119 @@
import {
Input,
Column,
Row,
Text,
Pressable,
HStack,
VStack,
Image,
Icon,
Square,
} from "native-base";
Input,
Button,
Flex} from "native-base";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
import useColorScheme from "../hooks/colorScheme";
import { MaterialIcons } from "@expo/vector-icons";
import { translate } from "../i18n/i18n";
import { SearchContext } from "../views/SearchView";
import { debounce } from 'lodash';
export enum SuggestionType {
TEXT,
ILLUSTRATED,
}
export type Filter = "artist" | "song" | "genre" | "all";
export type SuggestionList = {
type: SuggestionType;
data: SuggestionProps | IllustratedSuggestionProps;
}[];
type SearchBarProps = {
onChangeText?: any;
};
export interface SearchBarProps {
onTextChange: (text: string) => void;
onTextSubmit: (text: string) => void;
suggestions: SuggestionList;
}
export interface IllustratedSuggestionProps {
text: string;
subtext: string;
imageSrc: string;
onPress: () => void;
}
type FilterButton = {
name: string;
callback: () => void;
id: Filter;
};
export interface SuggestionProps {
text: string;
onPress: () => void;
}
const SearchBar = (props: SearchBarProps) => {
const {filter, updateFilter} = React.useContext(SearchContext);
const {stringQuery, updateStringQuery} = React.useContext(SearchContext);
const [barText, updateBarText] = React.useState(stringQuery);
// debounce function
const debounce = (func: any, delay: number) => {
let inDebounce: any;
return function (this: any) {
const context = this;
const args = arguments;
clearTimeout(inDebounce);
inDebounce = setTimeout(() => func.apply(context, args), delay);
const debouncedUpdateStringQuery = debounce(updateStringQuery, 500);
// there's a bug due to recursive feedback that erase the text as soon as you type this is a temporary "fix"
// will probably be fixed by removing the React.useContext
// React.useEffect(() => {
// updateBarText(stringQuery);
// }, [stringQuery]);
const handleClearQuery = () => {
updateStringQuery('');
updateBarText('');
};
};
const IllustratedSuggestion = ({
text,
subtext,
imageSrc,
onPress,
}: IllustratedSuggestionProps) => {
const colorScheme = useColorScheme();
const handleChangeText = (text: string) => {
debouncedUpdateStringQuery(text);
updateBarText(text);
}
const filters: FilterButton[] = [
{
name: translate('allFilter'),
callback: () => updateFilter('all'),
id: 'all'
},
{
name: translate('artistFilter'),
callback: () => updateFilter('artist'),
id: 'artist',
},
{
name: translate('songsFilter'),
callback: () => updateFilter('song'),
id: 'song',
},
{
name: translate('genreFilter'),
callback: () => updateFilter('genre'),
id: 'genre',
},
];
return (
<Pressable
onPress={onPress}
margin={2}
padding={2}
>{({ isHovered, isPressed }) => (
<HStack alignItems="center" space={4}
bg={colorScheme == 'dark'
? (isHovered || isPressed) ? 'gray.800' : undefined
: (isHovered || isPressed) ? 'primary.100' : undefined
}
>
<Square size={"sm"}>
<Image
source={{ uri: imageSrc }}
alt="Alternate Text"
size="xs"
rounded="lg"
/>
</Square>
<VStack alignItems="flex-start">
<Text fontSize="md">{text}</Text>
<Text fontSize="sm" color="gray.500">
{subtext}
</Text>
</VStack>
</HStack>
)}</Pressable>
);
};
const TextSuggestion = ({ text, onPress }: SuggestionProps) => {
const colorScheme = useColorScheme();
return (
<Pressable
onPress={onPress}
margin={2}
padding={2}
>{({ isHovered, isPressed }) => (
<Row alignItems="center" space={4}
bg={colorScheme == 'dark'
? (isHovered || isPressed) ? 'gray.800' : undefined
: (isHovered || isPressed) ? 'primary.100' : undefined
}
>
<Square size={"sm"}>
<Icon size={"md"} as={Ionicons} name="search" />
</Square>
<Text fontSize="md">{text}</Text>
</Row>
)}</Pressable>
);
};
// render the suggestions based on the type
const SuggestionRenderer = (suggestions: SuggestionList) => {
const suggestionRenderers = {
[SuggestionType.TEXT]: TextSuggestion,
[SuggestionType.ILLUSTRATED]: IllustratedSuggestion,
};
return suggestions.map((suggestion, index) => {
const SuggestionComponent = suggestionRenderers[suggestion.type];
return <SuggestionComponent {...suggestion.data} key={index} />;
});
};
const SearchBar = ({
onTextChange,
onTextSubmit,
suggestions,
}: SearchBarProps) => {
const debouncedOnTextChange = React.useRef(
debounce((t: string) => onTextChange(t), 70)
).current;
return (
<>
<Flex m={3} flexDirection={["column", "row"]}>
<Input
placeholder="Search"
type="text"
onChangeText={debouncedOnTextChange}
onSubmitEditing={(event) => onTextSubmit(event.nativeEvent.text)}
onChangeText={(text) => handleChangeText(text)}
variant={"rounded"}
value={barText}
rounded={"full"}
placeholder={translate('search')}
width={['100%', '50%']} //responsive array syntax with native-base
py={2}
px={2}
fontSize={'12'}
InputLeftElement={
<Icon
m={[1, 2]}
ml={[2, 3]}
size={['4', '6']}
color="gray.400"
as={<MaterialIcons name="search" />}
/>
}
InputRightElement={<Icon
m={[1, 2]}
mr={[2, 3]}
size={['4', '6']}
color="gray.400"
onPress={handleClearQuery}
as={<MaterialIcons name="close" />}
/>}
/>
<Column>{SuggestionRenderer(suggestions)}</Column>
</>
<Flex flexDirection={'row'} >
{filters.map((btn) => (
<Button
key={btn.name}
rounded={'full'}
onPress={btn.callback}
mx={[2, 5]}
my={[1, 0]}
minW={[30, 20]}
variant={filter === btn.id ? 'solid' : 'outline'}>
{btn.name}
</Button>
))}
</Flex>
</Flex>
);
};
}
export default SearchBar;

View File

@@ -1,41 +0,0 @@
import React from "react";
import SearchBar, { IllustratedSuggestionProps } from "../components/SearchBar";
import { SuggestionList, SuggestionType } from "../components/SearchBar";
interface SearchBarSuggestionsProps {
onTextSubmit: (text: string) => void;
suggestions: SuggestionList;
}
// do a function that takes in a string and returns a list of filtered suggestions
const filterSuggestions = (text: string, suggestions: SuggestionList) => {
return suggestions.filter((suggestion) => {
switch (suggestion.type) {
case SuggestionType.TEXT:
return suggestion.data.text.toLowerCase().includes(text.toLowerCase());
case SuggestionType.ILLUSTRATED:
return (
suggestion.data.text.toLowerCase().includes(text.toLowerCase()) ||
(suggestion.data as IllustratedSuggestionProps).subtext.toLowerCase().includes(text.toLowerCase())
);
}
});
};
const SearchBarSuggestions = ({
onTextSubmit,
suggestions,
}: SearchBarSuggestionsProps) => {
const [searchText, setSearchText] = React.useState("");
return (
<SearchBar
onTextChange={(t) => setSearchText(t)}
onTextSubmit={onTextSubmit}
suggestions={
searchText === "" ? [] : filterSuggestions(searchText, suggestions)
}
/>
);
};
export default SearchBarSuggestions;

View File

@@ -0,0 +1,273 @@
import React, { useContext, useEffect, useState } from "react";
import {
HStack,
VStack,
Heading,
Text,
Pressable,
Box,
Card,
Image,
Flex,
useBreakpointValue,
Column,
ScrollView} from "native-base";
import { SafeAreaView, useColorScheme } from "react-native";
import { RootState, useSelector } from '../state/Store';
import { SearchContext } from "../views/SearchView";
import { useQuery } from "react-query";
import { translate } from "../i18n/i18n";
import API from "../API";
import LoadingComponent from "./Loading";
import ArtistCard from "./ArtistCard";
import GenreCard from "./GenreCard";
import SongCard from "./SongCard";
import CardGridCustom from "./CardGridCustom";
import TextButton from "./TextButton";
import SearchHistoryCard from "./HistoryCard";
import Song, { SongWithArtist } from "../models/Song";
import { getSongWArtistSuggestions } from "./utils/api";
import { useNavigation } from "../Navigation";
const swaToSongCardProps = (song: SongWithArtist) => ({
songId: song.id,
name: song.name,
artistName: song.artist.name,
cover: song.cover ?? 'https://picsum.photos/200',
});
const RowCustom = (props: Parameters<typeof Box>[0] & { onPress?: () => void }) => {
const settings = useSelector((state: RootState) => state.settings.local);
const systemColorMode = useColorScheme();
const colorScheme = settings.colorScheme;
return <Pressable onPress={props.onPress}>
{({ isHovered, isPressed }) => (
<Box {...props}
py={3}
my={1}
bg={(colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark'
? (isHovered || isPressed) ? 'gray.800' : undefined
: (isHovered || isPressed) ? 'coolGray.200' : undefined
}
>
{ props.children }
</Box>
)}
</Pressable>
}
type SongRowProps = {
song: Song | SongWithArtist; // TODO: remove Song
onPress: () => void;
};
const SongRow = ({ song, onPress }: SongRowProps) => {
return (
<RowCustom width={"100%"}>
<HStack px={2} space={5} justifyContent={"space-between"} >
<Image
flexShrink={0}
flexGrow={0}
pl={10}
style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }}
source={{ uri: song.cover ?? 'https://picsum.photos/200' }}
alt={song.name}
/>
<HStack style={{display: 'flex', flexShrink: 1, flexGrow: 1, alignItems: 'center', justifyContent: "flex-start"}} space={6}>
<Text style={{
flexShrink: 1,
}} isTruncated pl={10} maxW={"100%"} bold fontSize='md'>{song.name}</Text>
<Text style={{
flexShrink: 0,
}} fontSize={"sm"}>{song.artistId ?? 'artist'}</Text>
</HStack>
<TextButton
flexShrink={0}
flexGrow={0}
translate={{ translationKey: 'playBtn' }}
colorScheme='primary' variant={"outline"} size='sm'
onPress={onPress}
/>
</HStack>
</RowCustom>
);
}
SongRow.defaultProps = {
onPress: () => {},
};
const HomeSearchComponent = () => {
const {stringQuery, updateStringQuery} = React.useContext(SearchContext);
const {isLoading: isLoadingHistory, data: historyData = []} = useQuery(
'history',
() => API.getSearchHistory(0, 12),
{ enabled: true },
);
const {isLoading: isLoadingSuggestions, data: suggestionsData = []} = useQuery(
'suggestions',
() => getSongWArtistSuggestions(),
{ enabled: true },
);
return (
<VStack mt="5" style={{overflow: 'hidden'}}>
<Card shadow={3} mb={5}>
<Heading margin={5}>{translate('lastSearched')}</Heading>
{ isLoadingHistory ? <LoadingComponent/> : <CardGridCustom content={historyData.map((h) => {
return {
...h,
timestamp: h.timestamp.toLocaleString(),
onPress: () => {updateStringQuery(h.query)}
}
})} cardComponent={SearchHistoryCard}/> }
</Card>
<Card shadow={3} mt={5} mb={5}>
<Heading margin={5}>{translate('songsToGetBetter')}</Heading>
{ isLoadingSuggestions ? <LoadingComponent/> : <CardGridCustom content={suggestionsData.map(swaToSongCardProps)} cardComponent={SongCard}/> }
</Card>
</VStack>
);
}
const SongsSearchComponent = (props: any) => {
const {songData} = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<ScrollView>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('songsFilter')}
</Text>
<Box>
{songData?.length ? (
songData.slice(0, props.maxRows).map((comp, index) => (
<SongRow
key={index}
song={comp}
onPress={() => {
API.createSearchHistoryEntry(comp.name, "song", Date.now());
navigation.navigate('Song', { songId: comp.id });
}}
/>
))
) : (
<Text>{translate('errNoResults')}</Text>
)}
</Box>
</ScrollView>
);
}
const ArtistSearchComponent = (props: any) => {
const {artistData} = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('artistFilter')}
</Text>
{ artistData?.length
? <CardGridCustom content={artistData.slice(0, props?.maxItems ?? artistData.length).map((a) => (
{
image: a.picture ?? 'https://picsum.photos/200',
name: a.name,
id: a.id,
onPress: () => {
API.createSearchHistoryEntry(a.name, "artist", Date.now());
navigation.navigate('Artist', { artistId: a.id })
}
}
))} cardComponent={ArtistCard} />
: <Text>{translate('errNoResults')}</Text> }
</Box>
);
}
const GenreSearchComponent = (props: any) => {
const {genreData} = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('genreFilter')}
</Text>
{ genreData?.length
? <CardGridCustom content={genreData.slice(0, props?.maxItems ?? genreData.length).map((g) => (
{
icon: 'musical-note-sharp',
name: g.name,
onPress: () => {
API.createSearchHistoryEntry(g.name, "genre", Date.now());
navigation.navigate('Home');
}
}
))} cardComponent={GenreCard}/>
: <Text>{translate('errNoResults')}</Text> }
</Box>
);
}
const AllComponent = () => {
const screenSize = useBreakpointValue({ base: "small", md: "big" });
const isMobileView = screenSize == "small";
return (
<SafeAreaView>
<Flex flexWrap="wrap" direction={isMobileView ? 'column' : 'row'} justifyContent={['flex-start']} mt={4}>
<Column w={isMobileView ? '100%' : '50%'}>
<Box minH={isMobileView ? 100 : 200}>
<ArtistSearchComponent maxItems={6}/>
</Box>
<Box minH={isMobileView ? 100 : 200}>
<GenreSearchComponent maxItems={6}/>
</Box>
</Column>
<Box w={isMobileView ? '100%' : '50%'}>
<SongsSearchComponent maxRows={9}/>
</Box>
</Flex>
</SafeAreaView>
);
}
const FilterSwitch = () => {
const { filter } = React.useContext(SearchContext);
const [currentFilter, setCurrentFilter] = React.useState(filter);
React.useEffect(() => {
setCurrentFilter(filter);
}, [filter]);
switch (currentFilter) {
case "all":
return <AllComponent />;
case "song":
return <SongsSearchComponent />;
case "artist":
return <ArtistSearchComponent />;
case "genre":
return <GenreSearchComponent />;
default:
return <Text>Something very bad happened: {currentFilter}</Text>;
}
};
export const SearchResultComponent = (props: any) => {
const [searchString, setSearchString] = useState<string>("");
const { stringQuery, updateStringQuery } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim();
return shouldOutput ? (
<Box p={5}>
<FilterSwitch />
</Box>
) : (
<HomeSearchComponent />
);
};

View File

@@ -1,16 +1,16 @@
import React from "react";
import Card, { CardBorderRadius } from './Card';
import { VStack, Text, Image, Pressable } from 'native-base';
import { VStack, Text, Image } from 'native-base';
import { useNavigation } from "../Navigation";
type SongCardProps = {
albumCover: string;
songTitle: string;
cover: string;
name: string;
artistName: string;
songId: number
}
const SongCard = (props: SongCardProps) => {
const { albumCover, songTitle, artistName, songId } = props;
const { cover, name, artistName, songId } = props;
const navigation = useNavigation();
return (
<Card
@@ -20,12 +20,12 @@ const SongCard = (props: SongCardProps) => {
<VStack m={1.5} space={3}>
<Image
style={{ zIndex: 0, aspectRatio: 1, borderRadius: CardBorderRadius}}
source={{ uri: albumCover }}
alt={[props.songTitle, props.artistName].join('-')}
source={{ uri: cover }}
alt={[props.name, props.artistName].join('-')}
/>
<VStack>
<Text isTruncated bold fontSize='md' noOfLines={2} height={50}>
{songTitle}
{name}
</Text>
<Text isTruncated >
{artistName}

View File

@@ -0,0 +1,15 @@
import API from "../../API";
import Song, { SongWithArtist } from "../../models/Song";
export const getSongWArtistSuggestions = async () => {
const nextStepQuery = await API.getSongSuggestions();
const songWartist = await Promise.all(
nextStepQuery.map(async (song) => {
if (!song.artistId) throw new Error("Song has no artistId");
const artist = await API.getArtist(song.artistId);
return { ...song, artist } as SongWithArtist;
})
);
return songWartist;
};

View File

@@ -34,6 +34,12 @@ export const en = {
levelProgress: 'good notes',
score: 'Score',
//search
allFilter: 'All',
artistFilter: 'Artists',
songsFilter: 'Songs',
genreFilter: 'Genres',
// profile page
user: 'Profile',
medals: 'Medals',
@@ -97,6 +103,7 @@ export const en = {
unknownError: 'Unknown error',
errAlrdExst: 'Already exist',
errIncrrct: 'Incorrect Credentials',
errNoResults: 'No Results Found',
userProfileFetchError: 'An error occured while fetching your profile',
tryAgain: 'Try Again',
@@ -202,6 +209,12 @@ export const fr: typeof en = {
longestCombo: 'Combo le plus long : ',
favoriteGenre: 'Genre favori : ',
//search
allFilter: 'Tout',
artistFilter: 'Artistes',
songsFilter: 'Morceaux',
genreFilter: 'Genres',
// Difficulty settings
diffBtn: 'Difficulté',
easy: 'Débutant',
@@ -273,6 +286,7 @@ export const fr: typeof en = {
errAlrdExst: "Utilisateur existe déjà",
unknownError: 'Erreur inconnue',
errIncrrct: 'Identifiant incorrect',
errNoResults: 'Aucun resultat',
userProfileFetchError: 'Une erreur est survenue lors de la récupération du profil',
tryAgain: 'Réessayer',
@@ -383,6 +397,12 @@ export const sp: typeof en = {
longestCombo: 'combo más largo : ',
favoriteGenre: 'genero favorito : ',
//search
allFilter: 'Todos',
artistFilter: 'Artistas',
songsFilter: 'canciones',
genreFilter: 'géneros',
// Difficulty settings
diffBtn: 'Dificultad',
easy: 'Principiante',
@@ -435,6 +455,8 @@ export const sp: typeof en = {
unknownError: 'Error desconocido',
errAlrdExst: "Ya existe",
errIncrrct: "credenciales incorrectas",
errNoResults: 'No se han encontrado resultados',
userProfileFetchError: 'Ocurrió un error al obtener su perfil',
tryAgain: 'intentar otra vez',

7
front/models/Album.ts Normal file
View File

@@ -0,0 +1,7 @@
import Model from "./Model";
interface Album extends Model {
name: string;
}
export default Album;

View File

@@ -2,6 +2,7 @@ import Model from "./Model";
interface Artist extends Model {
name: string;
picture?: string;
}
export default Artist;

View File

@@ -1,7 +1,10 @@
interface SearchHistory {
query: string;
userID: number;
id: number;
import Model from "./Model";
interface SearchHistory extends Model {
query: string;
type: "song" | "artist" | "album" | "genre";
userId: number;
timestamp: Date;
}
export default SearchHistory;

View File

@@ -1,13 +1,19 @@
import Model from "./Model";
import SongDetails from "./SongDetails";
import Artist from "./Artist";
interface Song extends Model {
name: string
artistId: number | null
albumId: number | null
id: number;
name: string;
artistId: number;
albumId: number | null;
genreId: number | null;
cover: string;
details: SongDetails;
}
export interface SongWithArtist extends Song {
artist: Artist;
}
export default Song;

View File

@@ -0,0 +1,42 @@
import { VStack, Text, Image, Heading, IconButton, Icon, Container } from 'native-base';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native';
import { useQuery } from 'react-query';
import LoadingComponent from '../components/Loading';
import API from '../API';
const handleFavorite = () => {
};
const ArtistDetailsView = ({ artistId }: any) => {
const { isLoading, data: artistData, error } = useQuery(['artist', artistId], () => API.getArtist(artistId));
if (isLoading) {
return <LoadingComponent />;
}
return (
<SafeAreaView>
<Container m={3}>
<Image
source={{ uri: 'https://picsum.photos/200' }}
alt={artistData?.name}
size={20}
borderRadius="full"
/>
<VStack space={3}>
<Heading>{artistData?.name}</Heading>
<IconButton
icon={<Icon as={Ionicons} name="heart" size={6} color="red.500" />}
onPress={() => handleFavorite()}
variant="unstyled"
_pressed={{ opacity: 0.6 }}
/>
</VStack>
</Container>
</SafeAreaView>
);
};
export default ArtistDetailsView;

View File

@@ -1,6 +1,8 @@
import React from "react";
import { useQueries, useQuery } from "react-query";
import API from "../API";
import LoadingComponent from "../components/Loading";
import CardGridCustom from "../components/CardGridCustom";
import { LoadingView } from "../components/Loading";
import {
Center,
@@ -16,7 +18,7 @@ import {
Column,
Button,
Text,
useTheme
useTheme
} from "native-base";
import { useNavigation } from "../Navigation";
@@ -25,6 +27,7 @@ import CompetenciesTable from "../components/CompetenciesTable";
import ProgressBar from "../components/ProgressBar";
import Translate from "../components/Translate";
import TextButton from "../components/TextButton";
import SearchHistoryCard from "../components/HistoryCard";
import Song from "../models/Song";
import { FontAwesome5 } from "@expo/vector-icons";
@@ -34,9 +37,9 @@ const HomeView = () => {
const screenSize = useBreakpointValue({ base: 'small', md: "big"});
const userQuery = useQuery(['user'], () => API.getUserInfo());
const playHistoryQuery = useQuery(['history', 'play'], () => API.getUserPlayHistory());
const searchHistoryQuery = useQuery(['history', 'search'], () => API.getSearchHistory());
const searchHistoryQuery = useQuery(['history', 'search'], () => API.getSearchHistory(0, 10));
const skillsQuery = useQuery(['skills'], () => API.getUserSkills());
const nextStepQuery = useQuery(['user', 'recommendations'], () => API.getUserRecommendations());
const nextStepQuery = useQuery(['user', 'recommendations'], () => API.getSongSuggestions());
const songHistory = useQueries(
playHistoryQuery.data?.map(({ songID }) => ({
queryKey: ['song', songID],
@@ -48,7 +51,7 @@ const HomeView = () => {
.concat(nextStepQuery.data ?? [])
.filter((s): s is Song => s !== undefined))
.map((song) => (
{ queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.id) }
{ queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.artistId) }
))
);
@@ -77,8 +80,8 @@ const HomeView = () => {
heading={<Translate translationKey='goNextStep'/>}
songs={nextStepQuery.data?.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId))
.map((song) => ({
albumCover: song.cover,
songTitle: song.name,
cover: song.cover,
name: song.name,
songId: song.id,
artistName: artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)!.data!.name
})) ?? []
@@ -100,9 +103,9 @@ const HomeView = () => {
.filter((song, i, array) => array.map((s) => s.id).findIndex((id) => id == song.id) == i)
.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId))
.map((song) => ({
albumCover: song.cover,
songTitle: song.name,
songId: song.id,
cover: song.cover,
name: song.name,
id: song.id,
artistName: artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)!.data!.name
})) ?? []
}
@@ -135,7 +138,7 @@ const HomeView = () => {
searchHistoryQuery.data?.length === 0 && <Translate translationKey='noRecentSearches'/>
}
{
[...(new Set(searchHistoryQuery.data.map((x) => x.query)))].reverse().slice(0, 5).map((query) => (
[...(new Set(searchHistoryQuery.data.map((x) => x.query)))].slice(0, 5).map((query) => (
<Button
leftIcon={
<FontAwesome5 name="search" size={16} />

View File

@@ -5,6 +5,9 @@ import { RouteProps, useNavigation } from "../Navigation";
import { CardBorderRadius } from "../components/Card";
import TextButton from "../components/TextButton";
import API from '../API';
import LoadingComponent from "../components/Loading";
import CardGridCustom from "../components/CardGridCustom";
import SongCard from "../components/SongCard";
import { useQueries, useQuery } from "react-query";
import { LoadingView } from "../components/Loading";
@@ -28,7 +31,7 @@ const ScoreView = ({ songId, overallScore, score }: RouteProps<ScoreViewProps>)
{ enabled: songQuery.data != undefined }
);
// const perfoamnceRecommandationsQuery = useQuery(['song', props.songId, 'score', 'latest', 'recommendations'], () => API.getLastSongPerformanceScore(props.songId));
const recommendations = useQuery(['song', 'recommendations'], () => API.getUserRecommendations());
const recommendations = useQuery(['song', 'recommendations'], () => API.getSongSuggestions());
const artistRecommendations = useQueries(recommendations.data
?.filter(({ artistId }) => artistId !== null)
.map((song) => ({
@@ -75,17 +78,18 @@ const ScoreView = ({ songId, overallScore, score }: RouteProps<ScoreViewProps>)
</Column>
</Card>
</Row>
<SongCardGrid
<CardGridCustom
style={{ justifyContent: "space-evenly" }}
content={recommendations.data.map((i) => ({
cover: i.cover,
name: i.name ,
artistName: artistRecommendations.find(({ data }) => data?.id == i.artistId)?.data?.name ?? "",
id: i.id
}))}
cardComponent={SongCard}
heading={<Text fontSize='sm'>
<Translate translationKey="songsToGetBetter"/>
</Text>}
songs={recommendations.data.map((i) => ({
albumCover: i.cover,
songTitle: i.name ,
artistName: artistRecommendations.find(({ data }) => data?.id == i.artistId)?.data?.name ?? "",
songId: i.id
}))}
/>
<Row space={3} style={{ width: '100%', justifyContent: 'center' }}>
<TextButton colorScheme='gray'

View File

@@ -1,38 +1,102 @@
import React, { useState } from "react";
import { Box } from "native-base";
import { useNavigation } from "../Navigation";
import SearchBarSuggestions from "../components/SearchBarSuggestions";
import { useQueries, useQuery } from "react-query";
import { SuggestionType } from "../components/SearchBar";
import SearchBar from "../components/SearchBar";
import Artist from "../models/Artist";
import Song from "../models/Song";
import Genre from "../models/Genre";
import API from "../API";
import { useQuery } from "react-query";
import { SearchResultComponent } from "../components/SearchResult";
import { SafeAreaView } from "react-native";
import { Filter } from "../components/SearchBar";
import { ScrollView } from "native-base";
import { RouteProps } from "../Navigation";
const SearchView = () => {
const [query, setQuery] = useState<string>();
const navigation = useNavigation();
const searchQuery = useQuery(
['search', query],
() => API.searchSongs(query!),
{ enabled: query != undefined }
interface SearchContextType {
filter: "artist" | "song" | "genre" | "all";
updateFilter: (newData: "artist" | "song" | "genre" | "all") => void;
stringQuery: string;
updateStringQuery: (newData: string) => void;
songData: Song[];
artistData: Artist[];
genreData: Genre[];
isLoadingSong: boolean;
isLoadingArtist: boolean;
isLoadingGenre: boolean;
}
export const SearchContext = React.createContext<SearchContextType>({
filter: "all",
updateFilter: () => {},
stringQuery: "",
updateStringQuery: () => {},
songData: [],
artistData: [],
genreData: [],
isLoadingSong: false,
isLoadingArtist: false,
isLoadingGenre: false,
});
type SearchViewProps = {
query?: string;
};
const SearchView = (props: RouteProps<SearchViewProps>) => {
let isRequestSucceeded = false;
const [filter, setFilter] = useState<Filter>("all");
const [stringQuery, setStringQuery] = useState<string>(props.query || "");
const { isLoading: isLoadingSong, data: songData = [] } = useQuery(
["song", stringQuery],
() => API.searchSongs(stringQuery),
{ enabled: !!stringQuery }
);
const artistsQueries = useQueries(searchQuery.data?.map((song) => (
{ queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.id) }
)) ??[]);
const { isLoading: isLoadingArtist, data: artistData = [] } = useQuery(
["artist", stringQuery],
() => API.searchArtists(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingGenre, data: genreData = [] } = useQuery(
["genre", stringQuery],
() => API.searchGenres(stringQuery),
{ enabled: !!stringQuery }
);
const updateFilter = (newData: Filter) => {
// called when the filter is changed
setFilter(newData);
};
const updateStringQuery = (newData: string) => {
// called when the stringQuery is updated
setStringQuery(newData);
isRequestSucceeded = false;
};
return (
<Box style={{ padding: 10 }}>
<SearchBarSuggestions
onTextSubmit={setQuery}
suggestions={searchQuery.data?.map((searchResult) => ({
type: SuggestionType.ILLUSTRATED,
data: {
text: searchResult.name,
subtext: artistsQueries.find((artistQuery) => artistQuery.data?.id == searchResult.artistId)?.data?.name ?? "",
imageSrc: searchResult.cover,
onPress: () => navigation.navigate("Song", { songId: searchResult.id })
}
})) ?? []}
/>
</Box>
<ScrollView>
<SafeAreaView>
<SearchContext.Provider
value={{
filter,
stringQuery,
songData,
artistData,
genreData,
isLoadingSong,
isLoadingArtist,
isLoadingGenre,
updateFilter,
updateStringQuery,
}}
>
<SearchBar />
<SearchResultComponent />
</SearchContext.Provider>
</SafeAreaView>
</ScrollView>
);
};