1. Instalamos stacknavigator
  2. 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
  1. Instalamos reactnativepaper . Para este caso no vamos a Instalar los iconos.

  2. 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;
  1. Reiniciamos el emulador.

  2. Testamos un componente. Por ejemplo un button en HomeScreen

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.

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 con react-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 reciba children 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 de react-native-paper.
  • NavigationContainer: Proporciona el tema a todos los componentes de @react-navigation/native.
  • ThemeContext.Provider: Proporciona el contexto del tema (isDark y theme) 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

  1. npm i axios
  2. En config/api creamos pokeApi.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,
  },
});

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;
};