Integrate Food Search

Passio provide searchForFood API to get FoodSearchResult

  /**
   * Search the local database of foods with a given search term.
   * @param searchQuery - The search term to match against food item names.
   * @returns A `Promise` resolving to an array of food item names.
   */
  searchForFood(searchQuery: string): Promise<FoodSearchResult[]>

Example

import { useState, useCallback, useEffect } from 'react'
import {
  PassioSDK,
  type FoodSearchResult,
} from '@passiolife/nutritionai-react-native-sdk-v3'
import { useDebounce } from '../utils/common'
import type { Props } from './FoodSearch'

const useFoodSearch = ({ onFoodDetail }: Props) => {
  // State variables
  const [searchQuery, setSearchQuery] = useState<string>('')
  const [loading, setLoading] = useState<boolean>(false)
  const [foodResults, setFoodResults] = useState<FoodSearchResult[] | null>()
  const [alternatives, setAlternative] = useState<string[] | null>()
  const debouncedSearchTerm: string = useDebounce<string>(searchQuery, 500)

  // Clears search results and resets state
  const cleanSearch = useCallback(() => {
    setSearchQuery('')
    setFoodResults([])
    setLoading(false)
  }, [])

  // Calls the search API based on the input value
  const callSearchApi = useCallback(
    async (query: string) => {
      // Check if the query is not empty
      if (query.length > 0) {
        // Set loading state to indicate the start of the search
        setLoading(true)

        try {
          // Fetch food results from the PassioSDK based on the query
          const searchFoods = await PassioSDK.searchForFood(query)
          setFoodResults(searchFoods?.results)
          setAlternative(searchFoods?.alternatives)
        } catch (error) {
          // Handle errors, e.g., network issues or API failures
          setFoodResults([])
        } finally {
          // Reset loading state to indicate the end of the search
          setLoading(false)
        }
      } else {
        // If the query is empty, reset the search state
        cleanSearch()
      }
    },
    [cleanSearch]
  )

  // Initiates a new search with the provided query
  const onSearchFood = useCallback(
    async (q: string) => {
      if (q.length > 0) {
        setSearchQuery(q)
        setAlternative([])
        setFoodResults([])
      } else {
        cleanSearch()
      }
    },
    [cleanSearch]
  )

  // Effect for handling debounced search term changes
  useEffect(() => {
    if (debouncedSearchTerm.length > 0) {
      callSearchApi(debouncedSearchTerm)
    } else {
      cleanSearch()
    }
  }, [callSearchApi, debouncedSearchTerm, cleanSearch])

  const onSearchResultItemPress = useCallback(
    async (foodSearchResult: FoodSearchResult) => {
      // Achieved Result through `fetchSearchResult`
      const result = await PassioSDK.fetchSearchResult(foodSearchResult)
      if (result) {
        onFoodDetail(result)
      }
    },
    [onFoodDetail]
  )

  return {
    alternatives,
    cleanSearch,
    foodResults,
    loading,
    onSearchFood,
    onSearchResultItemPress,
    searchQuery,
  }
}

export default useFoodSearch
import React from 'react'
import {
  ActivityIndicator,
  FlatList,
  Pressable,
  SafeAreaView,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  Image,
  View,
} from 'react-native'
import {
  PassioIconView,
  IconSize,
  FoodSearchResult,
  PassioFoodItem,
} from '@passiolife/nutritionai-react-native-sdk-v3'
import useFoodSearch from './useSearch'

export interface Props {
  onClose: () => void
  onFoodDetail: (passioFoodItem: PassioFoodItem) => void
}

// FoodSearchScreen component
export const FoodSearchView = (props: Props) => {
  // Get styles object from the searchStyle function
  const styles = searchStyle()

  // Destructure values from the custom hook
  const {
    searchQuery,
    onSearchFood,
    foodResults,
    loading,
    alternatives,
    onSearchResultItemPress,
  } = useFoodSearch(props)

  // Function to render each item in the FlatList
  const renderSearchItem = ({ item }: { item: FoodSearchResult }) => {
    return (
      <TouchableOpacity
        style={styles.itemContainer}
        onPress={() => onSearchResultItemPress(item)}
      >
        <View style={styles.itemIconContainer}>
          <PassioIconView
            style={styles.itemIcon}
            config={{
              passioID: item.iconID,
              iconSize: IconSize.PX360,
            }}
          />
        </View>
        <View>
          <Text style={styles.itemFoodName}>{item.foodName}</Text>
          <Text style={styles.itemBrandName}>
            {item.nutritionPreview?.calories + ' calories, ' + item.brandName ??
              ''}
          </Text>
        </View>
      </TouchableOpacity>
    )
  }

  const renderAlternativeItem = ({ item }: { item: string }) => {
    return (
      <Pressable
        style={styles.alternativeContainer}
        onPress={() => onSearchFood(item)}
      >
        <Text style={styles.itemAlternativeName}>{item}</Text>
      </Pressable>
    )
  }

  // Display loading indicator when results are empty and loading is true
  const renderLoading = () => {
    return <>{loading ? <ActivityIndicator /> : null}</>
  }

  // Render the component
  return (
    <SafeAreaView style={styles.body}>
      <View style={styles.closeButton}>
        <TouchableOpacity onPress={props.onClose}>
          <Image
            style={styles.closeText}
            source={require('../assets/back.png')}
          />
        </TouchableOpacity>
      </View>
      {/* Search input */}
      <TextInput
        value={searchQuery}
        style={styles.textInput}
        placeholder={'Enter Food'}
        placeholderTextColor={'gray'}
        onChangeText={onSearchFood}
      />

      <FlatList
        data={foodResults}
        contentContainerStyle={styles.list}
        renderItem={renderSearchItem}
        ListEmptyComponent={renderLoading}
        ListHeaderComponent={() => (
          <FlatList
            data={alternatives}
            contentContainerStyle={styles.itemAlternativeContainer}
            horizontal
            showsHorizontalScrollIndicator={false}
            renderItem={renderAlternativeItem}
            keyExtractor={(item, index) => item.toString() + index}
          />
        )}
        keyExtractor={(item, index) => item.iconID.toString() + index}
      />
    </SafeAreaView>
  )
}

// Styles for the component
const searchStyle = () =>
  StyleSheet.create({
    closeButton: {},
    list: {
      marginTop: 16,
    },
    closeText: {
      margin: 16,
      height: 24,
      width: 24,
    },
    itemContainer: {
      padding: 12,
      flex: 1,
      marginVertical: 4,
      marginHorizontal: 16,
      backgroundColor: 'white',
      flexDirection: 'row',
      borderRadius: 24,
    },
    itemFoodName: {
      flex: 1,
      textTransform: 'capitalize',
      marginHorizontal: 8,
      fontSize: 16,
    },
    itemBrandName: {
      flex: 1,
      textTransform: 'capitalize',
      marginHorizontal: 8,
      fontSize: 12,
    },

    itemAlternativeContainer: {
      overflow: 'hidden',
    },
    alternativeContainer: {
      marginStart: 16,
      alignItems: 'center',
      overflow: 'hidden',
      alignSelf: 'center',
      backgroundColor: 'rgba(238, 242, 255, 1)',
      shadowColor: '#000',
      shadowOffset: { width: 0, height: 0.1 },
      shadowOpacity: 0.5,
      shadowRadius: 0.5,
      marginVertical: 2,
      marginBottom: 14,
      elevation: 5,
      borderRadius: 24,
    },
    itemAlternativeName: {
      textTransform: 'capitalize',
      paddingVertical: 8,
      paddingHorizontal: 16,
    },
    itemIconContainer: {
      height: 46,
      width: 46,
      borderRadius: 30,
      overflow: 'hidden',
    },
    itemIcon: {
      height: 46,
      width: 46,
    },
    textInput: {
      backgroundColor: 'white',
      paddingHorizontal: 16,
      padding: 12,
      color: 'black',
      fontWeight: '500',
      fontSize: 16,
      marginHorizontal: 16,
    },
    body: {
      backgroundColor: 'rgba(242, 245, 251, 1)',
      flex: 1,
    },
  })

Last updated