- Instalamos stacknavigator
- Estuctura de carpetas
- android
- ios
- src
- actions
- pokemons
- assets
- pokeball-dark.png
- pokeball-light.png
- config
- api
- helpers
- theme
- domain (logica de negocio)
- entities
- infrastructure (intermediario entre domain y presentation)
- interfaces
- readme.md
- mappers
- presentation (tsx | react | react-native)
- components
- context
- hooks
- navigation
- Navigator.tsx
- screens
- home
- HomeScreen.tsx
- pokemon
- search
- App.tsx
- __tests__
- Gemfile
- README.md
- app.json
- babel.config.js
- gesture-handler.native.js
- index.js
- jest.config.js
- metro.config.js
- package-lock.json
- package.json
- tsconfig.json
-
Instalamos reactnativepaper . Para este caso no vamos a Instalar los iconos.
-
Envolvemos toda la appe
paperProvider
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { StackNav } from './presentation/navigation/Navigator';
import { PaperProvider } from 'react-native-paper';
const App = () => {
return (
<PaperProvider>
<NavigationContainer>
<StackNav />
</NavigationContainer>
</PaperProvider>
);
};
export default App;
-
Reiniciamos el emulador.
-
Testamos un componente. Por ejemplo un
button
enHomeScreen
import { View } from 'react-native';
import React from 'react';
import { Button } from 'react-native-paper';
const HomeScreen = () => {
return (
<View>
<Button icon="camera" mode="contained" onPress={() => console.log('Pressed')}>
Press me
</Button>
</View>
);
};
export default HomeScreen;
<Button icon="camera" mode="contained" onPress={() => console.log('Pressed')}>
Press me
</Button>
Tema dark y light
Nos basaremos en Material Design 3_
documentacion oficial
Cambiamos las versiones en package.json
a estas :
"@react-navigation/native": "^6.1.18",
"@react-navigation/stack": "^6.4.1",
En context creamos ThemeContext.tsx
import React from 'react';
import { PropsWithChildren, createContext } from 'react';
import {
NavigationContainer,
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
} from '@react-navigation/native';
import { PaperProvider, adaptNavigationTheme } from 'react-native-paper';
import { useColorScheme } from 'react-native';
const { LightTheme, DarkTheme } = adaptNavigationTheme({
reactNavigationLight: NavigationDefaultTheme,
reactNavigationDark: NavigationDarkTheme,
});
export const ThemeContext = createContext({
isDark: false,
theme: LightTheme,
});
export const ThemeContextProvider = ({ children }: PropsWithChildren) => {
const colorsScheme = useColorScheme();
const isDark = colorsScheme === 'dark';
const theme = isDark ? DarkTheme : LightTheme;
//Importamos aca PaperProvider y NavigationContainer porque ambnos
//esperan la prop `theme`
return (
<PaperProvider theme={theme}>
<NavigationContainer theme={theme} >
<ThemeContext.Provider value={{ isDark, theme }}>
{children}
</ThemeContext.Provider>
</NavigationContainer>
</PaperProvider>
);
};
1. Importaciones iniciales
import React from 'react';
import { PropsWithChildren, createContext } from 'react';
import {
NavigationContainer,
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
} from '@react-navigation/native';
import { PaperProvider, adaptNavigationTheme } from 'react-native-paper';
import { useColorScheme } from 'react-native';
Explicación:
- Se importan las herramientas necesarias para:
- Crear un contexto (con
createContext
). - Usar temas claros y oscuros para
react-native-paper
y@react-navigation/native
. - Detectar la preferencia del sistema operativo del usuario (tema claro u oscuro) con
useColorScheme
.
- Crear un contexto (con
2. Adaptar temas de navegación y Paper
const { LightTheme, DarkTheme } = adaptNavigationTheme({
reactNavigationLight: NavigationDefaultTheme,
reactNavigationDark: NavigationDarkTheme,
});
Explicación:
adaptNavigationTheme
convierte los temas de@react-navigation/native
en temas compatibles conreact-native-paper
. Esto es necesario porque ambas bibliotecas manejan temas pero de forma ligeramente distinta.- Aquí se definen dos temas:
LightTheme
: El tema claro.DarkTheme
: El tema oscuro.
3. Crear un contexto para manejar el tema
export const ThemeContext = createContext({
isDark: false,
theme: LightTheme,
});
Explicación:
createContext
: Crea un contexto que compartirá el estado del tema con otros componentes en la aplicación.- El contexto inicializa con:
isDark
: Indica si el tema es oscuro (falso por defecto).theme
: El tema inicial (claro).
4. Definir el ThemeContextProvider
export const ThemeContextProvider = ({ children }: PropsWithChildren) => {
PropsWithChildren
: Es un tipo especial en TypeScript que permite que el componente recibachildren
como una propiedad (los elementos que se renderizan dentro de este componente).
5. Detectar el esquema de color del sistema operativo
const colorsScheme = useColorScheme();
const isDark = colorsScheme === 'dark';
const theme = isDark ? DarkTheme : LightTheme;
Explicación:
useColorScheme
detecta si el sistema operativo del usuario está configurado en modo oscuro o claro.isDark
será verdadero si el tema del sistema es oscuro.theme
asigna el tema correspondiente (oscuro o claro).
6. Proveer el tema a los componentes hijos
return (
<PaperProvider theme={theme}>
<NavigationContainer theme={theme} >
<ThemeContext.Provider value={{ isDark, theme }}>
{children}
</ThemeContext.Provider>
</NavigationContainer>
</PaperProvider>
);
Explicación:
PaperProvider
: Proporciona el tema (claro u oscuro) a todos los componentes dereact-native-paper
.NavigationContainer
: Proporciona el tema a todos los componentes de@react-navigation/native
.ThemeContext.Provider
: Proporciona el contexto del tema (isDark
ytheme
) a cualquier componente dentro de este árbol.{children}
: Permite que otros componentes sean renderizados dentro de este árbol.
Ejemplo práctico
Si en otro lugar de la app quieres usar el tema, puedes consumir el contexto con:
import { useContext } from 'react';
import { ThemeContext } from './ThemeContextProvider';
const MyComponent = () => {
const { isDark, theme } = useContext(ThemeContext);
return (
<View style={{ backgroundColor: theme.colors.background }}>
<Text style={{ color: theme.colors.text }}>
El tema actual es {isDark ? 'Oscuro' : 'Claro'}
</Text>
</View>
);
};
App.tsx
queda asi despues de mover todo al contexto :
import React from 'react';
import { StackNav } from './presentation/navigation/Navigator';
import { ThemeContextProvider } from './presentation/context/ThemeContext';
const App = () => {
return (
<ThemeContextProvider>
<StackNav />
</ThemeContextProvider>
);
};
export default App;
Interfaces , entidades y peticion http
En entities
creamos pokemon
export interface Pokemon {
id: number;
name: string;
types: string[];
avatar: string;
sprites: string[]
//TODO:
//color:string;
}
que son las props que nos interesan.
Instalamos axios
npm i axios
- En
config/api
creamospokeApi.ts
import axios from 'axios';
export const pokeApi = axios.create({
baseURL: 'http://pokeapi.co/api/v2',
});
En src/actions/pokemons/
creamos get-pokemons.ts
import { pokeApi } from '../../config/api/pokeApi';
import { Pokemon } from '../../domain/entities/pokemon';
export const getPokemons = async (): Promise<Pokemon[]> => {
try {
const url = '/pokemon';
const { data } = await pokeApi.get(url);
console.log(data);
return [];
} catch (error) {
throw new Error('Error getting pokemons');
}
};
Ejecutamos la funcion en HomeScreen para ver si tenemos en consola la respuesta :
import { View } from 'react-native';
import React from 'react';
import { Button, Text } from 'react-native-paper';
import { getPokemons } from '../../../actions/pokemons';
const HomeScreen = () => {
getPokemons();
return (
<View>
<Text>HomeScreen</Text>
<Button icon="camera" mode="contained" onPress={() => console.log('Pressed')}>
Press me
</Button>
</View>
);
};
export default HomeScreen;
FadeIn
Creamos el componente FadeIn
el mismo que en las secciones anteriores
pero con una diferencia:
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
ImageStyle,
StyleProp,
Text,
View,
} from 'react-native';
import { useAnimation } from '../../hooks/useAnimation';
interface Props {
uri: string;
style?: StyleProp<ImageStyle>;
}
export const FadeInImage = ({ uri, style }: Props) => {
const { animatedOpacity, fadeIn } = useAnimation();
const [isLoading, setIsLoading] = useState(true);
const isDisposed = useRef(false);
useEffect(() => {
return () => {
isDisposed.current = true;
};
}, []);
const onLoadEnd = () => {
if (isDisposed.current) { return; }
fadeIn({});
setIsLoading(false);
};
return (
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
{isLoading && (
<ActivityIndicator
style={{ position: 'absolute' }}
color="grey"
size={30}
/>
)}
<Animated.Image
source={{ uri }}
onLoadEnd={onLoadEnd}
style={[style, { opacity: animatedOpacity }]}
/>
</View>
);
};
Tenemos que saber si ya desaparecio de pantalla, ya que FlatList
destruye al
componente y no podemos hacer un cambio de estado en un coponente que no existe:
const isDisposed = useRef(false);
useEffect(() => {
return () => {
isDisposed.current = true;
};
}, []);
const onLoadEnd = () => {
if (isDisposed.current) { return; }
fadeIn({});
setIsLoading(false);
};
En hooks cremos useAnimation
import { useRef } from 'react';
import { Animated, Easing } from 'react-native';
export const useAnimation = () => {
const animatedOpacity = useRef(new Animated.Value(0)).current;
const animatedTop = useRef(new Animated.Value(0)).current;
const fadeIn = ({ duration = 300, toValue = 1, callback = () => { } }) => {
Animated.timing(animatedOpacity, {
toValue: toValue,
duration: duration,
useNativeDriver: true,
}).start(callback);
};
const fadeOut = ({ duration = 300, toValue = 0, callback = () => { } }) => {
Animated.timing(animatedOpacity, {
toValue: toValue,
duration: duration,
useNativeDriver: true,
}).start(callback);
};
const startMovingTopPosition = ({
initialPosition = 0,
toValue = 0,
duration = 300,
easing = Easing.linear,
callback = () => { },
}) => {
animatedTop.setValue(initialPosition);
Animated.timing(animatedTop, {
toValue: toValue,
duration: duration,
useNativeDriver: true,
easing: easing,
}).start(callback);
};
return {
// Properties
animatedOpacity,
animatedTop,
// Methods
fadeIn,
fadeOut,
startMovingTopPosition,
};
};
y lo usamos en el componente PokemonCard
asi :
const PokemonCard = ({ pokemon }: Props) => {
return (
<Card style={[
styles.cardContainer,
]}>
<Text style={styles.name} variant="bodyLarge" lineBreakMode="middle" >
{pokemon.name}
{'\n#' + pokemon.id}
</Text>
<View style={styles.pokeballContainer}>
<Image source={require('../../../assets/pokeball-light.png')} style={styles.pokeball} />
</View>
<FadeInImage
uri={pokemon.avatar} style={styles.pokemonImage}
/>
<Text style={[styles.name, { marginTop: 35 }]}>{pokemon.types[0]}</Text>
</Card>
);
};
infinite scroll
En HomeScreen:
import { FlatList, StyleSheet, View } from 'react-native';
import React from 'react';
import { getPokemons } from '../../../actions/pokemons';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import PokeballBg from '../../components/ui/PomkeonBg';
import { Text } from 'react-native-paper';
import { globalTheme } from '../../../config/theme/global-theme';
import PokemonCard from '../../components/pokemons/PokemonCard';
const HomeScreen = () => {
//* peticion tradicional http
// const { isLoading, data: pokemons = [] } = useQuery({
// queryKey: ['pokemons'],
// queryFn: () => getPokemons(0), staleTime: 1000 * 60 * 60, //60 min
// });
const { isLoading, data, fetchNextPage } = useInfiniteQuery({
queryKey: ['pokemons', 'infinite'],
initialPageParam: 0,
queryFn: (params) => getPokemons(params.pageParam),
getNextPageParam: (lastpage, pages) => pages.length,
staleTime: 1000 * 60 * 60, //60 min
});
return (
<View style={globalTheme.globalMargin}>
<PokeballBg style={styles.imgPosition} />
<FlatList
data={data?.pages.flat() ?? []}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: 16 }}
ListHeaderComponent={() => (
<Text variant="displayMedium" >Pokedex</Text>
)}
onEndReachedThreshold={0.6}
onEndReached={() => fetchNextPage()}
renderItem={({ item }) => (
<PokemonCard pokemon={item} />
)}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
export default HomeScreen;
const styles = StyleSheet.create({
imgPosition: {
position: 'absolute',
top: -100,
right: -100,
},
});
Navegar a la pantalla del detalle
En StackNav
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import HomeScreen from '../screens/home/HomeScreen';
import PokemonScreen from '../screens/pokemon/PokemonScreen';
import SearchScreen from '../screens/search/SearchScreen';
export type RootStackParams = {
HomeScreen: undefined;
PokemonScreen: { pokemonId: number },
SearchScreen: undefined,
}
const Stack = createStackNavigator();
export const StackNav = () => {
return (
<Stack.Navigator screenOptions={{
headerShown: false,
}}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="PokemonScreen" component={PokemonScreen} />
<Stack.Screen name="SearchScreen" component={SearchScreen} />
</Stack.Navigator>
);
};
En Card.tsx
import { Image, Pressable, StyleSheet, View } from 'react-native';
import React from 'react';
import { Pokemon } from '../../../domain/entities/pokemon';
import { Card, Text } from 'react-native-paper';
import { FadeInImage } from '../ui/FadeInImage';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParams } from '../../navigation/Navigator';
interface Props {
pokemon: Pokemon;
}
const PokemonCard = ({ pokemon }: Props) => {
const navigation = useNavigation<NavigationProp<RootStackParams>>();
return (
<Pressable style={{ flex: 1 }} onPress={() => navigation.navigate('PokemonScreen', { pokemonId: pokemon.id })}>
<Card style={[
styles.cardContainer,
]}>
<Text style={styles.name} variant="bodyLarge" lineBreakMode="middle" >
{pokemon.name}
{'\n#' + pokemon.id}
</Text>
<View style={styles.pokeballContainer}>
<Image source={require('../../../assets/pokeball-light.png')} style={styles.pokeball} />
</View>
<FadeInImage
uri={pokemon.avatar} style={styles.pokemonImage}
/>
<Text style={[styles.name, { marginTop: 35 }]}>{pokemon.types[0]}</Text>
</Card>
</Pressable>
);
};
export default PokemonCard;
const styles = StyleSheet.create({
cardContainer: {
marginHorizontal: 10,
backgroundColor: 'grey',
height: 120,
flex: 0.5,
marginBottom: 25,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
name: {
color: 'white',
top: 10,
left: 10,
},
pokeball: {
width: 100,
height: 100,
right: -25,
top: -25,
opacity: 0.4,
},
pokemonImage: {
width: 120,
height: 120,
position: 'absolute',
right: -15,
top: -30,
},
pokeballContainer: {
alignItems: 'flex-end',
width: '100%',
position: 'absolute',
overflow: 'hidden',
opacity: 0.5,
},
});
En PokemonScreen.tsx
import { StyleSheet, Text, View } from 'react-native';
import React from 'react';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParams } from '../../navigation/Navigator';
import { useQuery } from '@tanstack/react-query';
import { getPokemonById } from '../../../actions/pokemons';
interface Props extends StackScreenProps<RootStackParams, 'PokemonScreen'> { }
const PokemonScreen = ({ navigation, route }: Props) => {
const { pokemonId } = route.params;
const { data: pokemon } = useQuery({
queryKey: ['pokemon', pokemonId],
queryFn: () => getPokemonById(pokemonId),
staleTime: 1000 * 60 * 60, // 1H
});
return (
<View>
<Text>{pokemon?.name}</Text>
</View>
);
};
export default PokemonScreen;
const styles = StyleSheet.create({});
En actions/pokemon/
creamos getPokemonById
import { PokemonMapper } from '../../infraestructure/mappers/pokemon.mapper';
export const getPokemonById = async (id: number): Promise<Pokemon> => {
try {
const { data } = await pokeApi.get<PokeAPIPokemon>(`/pokemon/${id}`);
const pokemon = await PokemonMapper.pokeApiPokemonToEntity(data);
return pokemon;
} catch (error) {
throw new Error(`no se encontro el pokemon por el id ${id}`);
}
};
En este punto deberiamos podoer ver el nombre del pokemon en la nueva pantalla.
Pantalla del pokemon
import { FlatList, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import React, { useContext } from 'react';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParams } from '../../navigation/Navigator';
import { useQuery } from '@tanstack/react-query';
import { getPokemonById } from '../../../actions/pokemons';
import { Formatter } from '../../../config/helpers/formatter';
import { FadeInImage } from '../../components/ui/FadeInImage';
import { Chip } from 'react-native-paper';
import { ThemeContext } from '../../context/ThemeContext';
interface Props extends StackScreenProps<RootStackParams, 'PokemonScreen'> { }
const PokemonScreen = ({ route }: Props) => {
const { pokemonId } = route.params;
const { isDark } = useContext(ThemeContext);
const { data: pokemon } = useQuery({
queryKey: ['pokemon', pokemonId],
queryFn: () => getPokemonById(pokemonId),
staleTime: 1000 * 60 * 60, // 1H
});
const pokeballImg = isDark
? require('../../../assets/pokeball-dark.png')
: require('../../../assets/pokeball-light.png');
if (!pokemon) {
return ('loading');
}
return (
<ScrollView
style={{ flex: 1 }}
bounces={false}
showsVerticalScrollIndicator={false}>
{/* Header Container */}
<View style={styles.headerContainer}>
{/* Nombre del Pokemon */}
<Text
style={{
...styles.pokemonName,
}}>
{Formatter.capitalize(pokemon.name) + '\n'}#{pokemon.id}
</Text>
{/* Pokeball */}
<Image source={pokeballImg} style={styles.pokeball} />
<FadeInImage uri={pokemon.avatar} style={styles.pokemonImage} />
</View>
{/* Types */}
<View
style={{ flexDirection: 'row', marginHorizontal: 20, marginTop: 10 }}>
{pokemon.types.map(type => (
<Chip
key={type}
mode="outlined"
selectedColor="white"
style={{ marginLeft: 10 }}>
{type}
</Chip>
))}
</View>
{/* Sprites */}
<FlatList
data={pokemon.sprites}
horizontal
keyExtractor={item => item}
showsHorizontalScrollIndicator={false}
centerContent
style={{
marginTop: 20,
height: 100,
}}
renderItem={({ item }) => (
<FadeInImage
uri={item}
style={{ width: 100, height: 100, marginHorizontal: 5 }}
/>
)}
/>
<View style={{ height: 100 }} />
</ScrollView>
);
};
export default PokemonScreen;
const styles = StyleSheet.create({
headerContainer: {
height: 370,
zIndex: 999,
alignItems: 'center',
borderBottomRightRadius: 1000,
borderBottomLeftRadius: 1000,
backgroundColor: 'rgba(0,0,0,0.2)',
},
pokemonName: {
color: 'white',
fontSize: 40,
alignSelf: 'flex-start',
left: 20,
},
pokeball: {
width: 250,
height: 250,
bottom: -20,
opacity: 0.7,
},
pokemonImage: {
width: 240,
height: 240,
position: 'absolute',
bottom: -40,
},
loadingIndicator: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
subTitle: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
marginHorizontal: 20,
marginTop: 20,
},
statsContainer: {
flexDirection: 'column',
marginHorizontal: 20,
alignItems: 'center',
},
});
El helper/formatter
export class Formatter {
static capitalize(str: string, allWords: boolean = false) {
if (allWords) {
return str.replace(/\b\w/g, (l) => l.toUpperCase());
} else {
return str.replace(/\b\w/, (l) => l.toUpperCase());
}
}
}
Actualizar cache
En HomeScreen con useQueryClient
import { FlatList, StyleSheet, View } from 'react-native';
import React from 'react';
import { getPokemons } from '../../../actions/pokemons';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import PokeballBg from '../../components/ui/PomkeonBg';
import { Text } from 'react-native-paper';
import { globalTheme } from '../../../config/theme/global-theme';
import PokemonCard from '../../components/pokemons/PokemonCard';
const HomeScreen = () => {
const queryClient = useQueryClient();
const { isLoading, data, fetchNextPage } = useInfiniteQuery({
queryKey: ['pokemons', 'infinite'],
initialPageParam: 0,
queryFn: async params => {
const pokemons = await getPokemons(params.pageParam);
pokemons.forEach(pokemon => {
queryClient.setQueryData(['pokemon', pokemon.id], pokemon);
});
return pokemons;
},
getNextPageParam: (lastpage, pages) => pages.length,
staleTime: 1000 * 60 * 60, //60 min
});
return (
<View style={globalTheme.globalMargin}>
<PokeballBg style={styles.imgPosition} />
<FlatList
data={data?.pages.flat() ?? []}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: 16 }}
ListHeaderComponent={() => (
<Text variant="displayMedium" >Pokedex</Text>
)}
onEndReachedThreshold={0.6}
onEndReached={() => fetchNextPage()}
renderItem={({ item }) => (
<PokemonCard pokemon={item} />
)}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
export default HomeScreen;
const styles = StyleSheet.create({
imgPosition: {
position: 'absolute',
top: -100,
right: -100,
},
});
Search Component
Creamos en HomeScreen
el FAB
button :
import { FlatList, StyleSheet, View } from 'react-native';
import React from 'react';
import { getPokemons } from '../../../actions/pokemons';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import PokeballBg from '../../components/ui/PomkeonBg';
import { FAB, Text, useTheme } from 'react-native-paper';
import { globalTheme } from '../../../config/theme/global-theme';
import PokemonCard from '../../components/pokemons/PokemonCard';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParams } from '../../navigation/Navigator';
interface Props extends StackScreenProps<RootStackParams, 'HomeScreen'> { }
const HomeScreen = ({ navigation }: Props) => {
const theme = useTheme();
const queryClient = useQueryClient();
const { isLoading, data, fetchNextPage } = useInfiniteQuery({
queryKey: ['pokemons', 'infinite'],
initialPageParam: 0,
queryFn: async params => {
const pokemons = await getPokemons(params.pageParam);
pokemons.forEach(pokemon => {
queryClient.setQueryData(['pokemon', pokemon.id], pokemon);
});
return pokemons;
},
getNextPageParam: (lastpage, pages) => pages.length,
staleTime: 1000 * 60 * 60, //60 min
});
return (
<View style={globalTheme.globalMargin}>
<PokeballBg style={styles.imgPosition} />
<FlatList
data={data?.pages.flat() ?? []}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: 16 }}
ListHeaderComponent={() => (
<Text variant="displayMedium" >Pokedex</Text>
)}
onEndReachedThreshold={0.6}
onEndReached={() => fetchNextPage()}
renderItem={({ item }) => (
<PokemonCard pokemon={item} />
)}
showsVerticalScrollIndicator={false}
/>
<FAB label="buscar"
onPress={() => navigation.push('SearchScreen')}
color={theme.dark ? 'white' : 'blacr'}
style={[{ backgroundColor: theme.colors.primary }, styles.fab]} />
</View>
);
};
export default HomeScreen;
const styles = StyleSheet.create({
imgPosition: {
position: 'absolute',
top: -100,
right: -100,
},
fab: {
position: 'absolute',
bottom: 20,
right: 20,
},
});
SearchScreen
import { ActivityIndicator, FlatList, TextInput, View } from 'react-native';
import React from 'react';
import { globalTheme } from '../../../config/theme/global-theme';
import { Pokemon } from '../../../domain/entities/pokemon';
import { Text } from 'react-native-paper';
import PokemonCard from '../../components/pokemons/PokemonCard';
const SearchScreen = () => {
return (
<View style={[globalTheme.globalMargin]}>
<TextInput
placeholder="Buscar Pokémon"
// mode="flat"
autoFocus
autoCorrect={false}
onChangeText={value => console.log(value)}
value=""
/>
<ActivityIndicator style={{ paddingTop: 20 }} />
<FlatList
data={[] as Pokemon[]}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: 16 }}
ListHeaderComponent={() => (
<Text variant="displayMedium" >Pokedex</Text>
)}
renderItem={({ item }) => (
<PokemonCard pokemon={item} />
)}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
export default SearchScreen;
Pre cargar la informacion para buscar por nombre
En SearchScreen
import { ActivityIndicator, FlatList, TextInput, View } from 'react-native';
import React from 'react';
import { globalTheme } from '../../../config/theme/global-theme';
import { Pokemon } from '../../../domain/entities/pokemon';
import { Text } from 'react-native-paper';
import PokemonCard from '../../components/pokemons/PokemonCard';
import { useQuery } from '@tanstack/react-query';
import { getPokemonNamesWithId } from '../../../actions/pokemons';
const SearchScreen = () => {
const { isLoading, data: pokemonNameList = [] } = useQuery({
queryKey: ['pokemons', 'all'],
queryFn: () => getPokemonNamesWithId(),
});
return (
<View style={[globalTheme.globalMargin]}>
<TextInput
placeholder="Buscar Pokémon"
// mode="flat"
autoFocus
autoCorrect={false}
onChangeText={value => console.log(value)}
value=""
/>
<ActivityIndicator style={{ paddingTop: 20 }} />
<FlatList
data={[] as Pokemon[]}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: 16 }}
ListHeaderComponent={() => (
<Text variant="displayMedium" >Pokedex</Text>
)}
renderItem={({ item }) => (
<PokemonCard pokemon={item} />
)}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
export default SearchScreen;
En Actions creamos
import { pokeApi } from '../../config/api/pokeApi';
import { PokeAPIPaginatedResponse } from '../../infraestructure/interfaces/pokeApi.interfaces';
export const getPokemonNamesWithId = async () => {
const url = 'pokemon?limit=1000';
const { data } = await pokeApi.get<PokeAPIPaginatedResponse>(url);
return data.results.map((info) => ({
id: Number(info.url.split('/'[6])),
name:info.name
}));
};
Con esto tenemos los primeros 1000 pokemon, y luego haremos la busqueda sobre el array que contiene esos 1000.
Filtrar listado por id y nombre
import PokemonCard from '../../components/pokemons/PokemonCard.tsx';
import { useDebouncedValue } from '../../hooks/useDebauncedValue.tsx';
export const SearchScreen = () => {
const { top } = useSafeAreaInsets();
const [term, setTerm] = useState('');
const debouncedValue = useDebouncedValue(term);
const { isLoading, data: pokemonNameList = [] } = useQuery({
queryKey: ['pokemons', 'all'],
queryFn: () => getPokemonNamesWithId(),
});
// Todo: aplicar debounce
const pokemonNameIdList = useMemo(() => {
// Es un número
if (!isNaN(Number(debouncedValue))) {
const pokemon = pokemonNameList.find(
pokemon => pokemon.id === Number(debouncedValue),
);
return pokemon ? [pokemon] : [];
}
if (debouncedValue.length === 0) { return []; }
if (debouncedValue.length < 3) { return []; }
return pokemonNameList.filter(pokemon =>
pokemon.name.includes(debouncedValue.toLocaleLowerCase()),
);
}, [debouncedValue, pokemonNameList]);
const { isLoading: isLoadingPokemons, data: pokemons = [] } = useQuery({
queryKey: ['pokemons', 'by', pokemonNameIdList],
queryFn: () =>
getPokemonsByIds(pokemonNameIdList.map(pokemon => pokemon.id)),
staleTime: 1000 * 60 * 5, // 5 minutos
});
if (isLoading) {
return <Text>cargando</Text>;
}
return (
<View style={[{ paddingTop: top + 10, flex: 1 }]}>
<TextInput
placeholder="Buscar Pokémon"
mode="flat"
autoFocus
autoCorrect={false}
onChangeText={setTerm}
value={term}
/>
{isLoadingPokemons && <ActivityIndicator style={{ paddingTop: 20 }} />}
{/* <Text>{ JSON.stringify(pokemonNameIdList, null, 2) }</Text> */}
<FlatList
data={pokemons}
keyExtractor={(pokemon, index) => `${pokemon.id}-${index}`}
numColumns={2}
style={{ paddingTop: top + 20 }}
renderItem={({ item }) => <PokemonCard pokemon={item} />}
showsVerticalScrollIndicator={false}
ListFooterComponent={<View style={{ height: 150 }} />}
/>
</View>
);
};
import { pokeApi } from '../../config/api/pokeApi';
import { PokeAPIPaginatedResponse } from '../../infraestructure/interfaces/pokeApi.interfaces';
export const getPokemonNamesWithId = async () => {
const url = 'pokemon?limit=1000';
const { data } = await pokeApi.get<PokeAPIPaginatedResponse>(url);
return data.results.map(info => ({
id: Number(info.url.split('/')[6]),
name: info.name,
}));
};
import { Pokemon } from '../../domain/entities/pokemon';
import { getPokemonById } from './get-pokemon-by-id.ts';
export const getPokemonsByIds = async (ids: number[]): Promise<Pokemon[]> => {
try {
const pokemonPromises: Promise<Pokemon>[] = ids.map(id => {
return getPokemonById(id);
});
return Promise.all(pokemonPromises);
} catch (error) {
throw new Error(`Error getting pokemons by ids: ${ids}`);
}
};
El hook debounce:
import { useEffect, useState } from 'react';
export const useDebouncedValue = (input: string = '', time: number = 500) => {
const [debouncedValue, setDebouncedValue] = useState(input);
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedValue(input);
}, time);
return () => {
clearTimeout(timeout);
};
}, [input]);
return debouncedValue;
};