diff --git a/example/ios/NutritionUxExample/Info.plist b/example/ios/NutritionUxExample/Info.plist index 299cc37..ab3ae38 100644 --- a/example/ios/NutritionUxExample/Info.plist +++ b/example/ios/NutritionUxExample/Info.plist @@ -39,6 +39,8 @@ NSMicrophoneUsageDescription Description of why you require the use of the microphone + NSPhotoLibraryUsageDescription + The photo library is used for food detection on a photo NSSpeechRecognitionUsageDescription Description of why you require the use of the speech recognition UILaunchStoryboardName diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b680def..1a23475 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -894,6 +894,10 @@ PODS: - React-debug - react-native-blob-util (0.19.6): - React-Core + - react-native-image-picker (7.1.2): + - glog + - RCT-Folly (= 2022.05.16.00) + - React-Core - react-native-nutrition-ux (3.1.0-alpha-1): - React-Core - react-native-pdf (6.7.4): @@ -1104,6 +1108,12 @@ PODS: - RNSVG (15.1.0): - React-Core - SocketRocket (0.6.1) + - VisionCamera (4.3.2): + - VisionCamera/Core (= 4.3.2) + - VisionCamera/React (= 4.3.2) + - VisionCamera/Core (4.3.2) + - VisionCamera/React (4.3.2): + - React-Core - Yoga (1.14.0) DEPENDENCIES: @@ -1141,6 +1151,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - react-native-blob-util (from `../node_modules/react-native-blob-util`) + - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-nutrition-ux (from `../..`) - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) @@ -1176,6 +1187,7 @@ DEPENDENCIES: - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) + - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -1248,6 +1260,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" react-native-blob-util: :path: "../node_modules/react-native-blob-util" + react-native-image-picker: + :path: "../node_modules/react-native-image-picker" react-native-nutrition-ux: :path: "../.." react-native-pdf: @@ -1318,6 +1332,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-screens" RNSVG: :path: "../node_modules/react-native-svg" + VisionCamera: + :path: "../node_modules/react-native-vision-camera" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -1355,6 +1371,7 @@ SPEC CHECKSUMS: React-logger: 66b168e2b2bee57bd8ce9e69f739d805732a5570 React-Mapbuffer: 9ee041e1d7be96da6d76a251f92e72b711c651d6 react-native-blob-util: d8fa1a7f726867907a8e43163fdd8b441d4489ea + react-native-image-picker: 994a97b28e7f2c2197e21801569bb19f6e494e38 react-native-nutrition-ux: ee2cfaa810f02017b6df580b19f586aabc5e9bc6 react-native-pdf: 79aa75e39a80c1d45ffe58aa500f3cf08f267a2e react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89 @@ -1391,6 +1408,7 @@ SPEC CHECKSUMS: RNScreens: 77fc79e66b726ee45b091486b348418ee1d792ab RNSVG: 50cf2c7018e57cf5d3522d98d0a3a4dd6bf9d093 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 + VisionCamera: 2c4cb89c573c5d54d1191e433bd224998d3b14b7 Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 PODFILE CHECKSUM: a2947df3ae3fe759cfd0e850554bc385583bc722 diff --git a/example/package.json b/example/package.json index 14281d1..b72a777 100644 --- a/example/package.json +++ b/example/package.json @@ -37,6 +37,7 @@ "react-native-blob-util": "^0.19.6", "react-native-dotenv": "^3.4.11", "react-native-gesture-handler": "^2.16.0", + "react-native-image-picker": "^7.1.2", "react-native-linear-gradient": "^2.8.3", "react-native-modal-datetime-picker": "^17.1.0", "react-native-pdf": "^6.7.4", @@ -45,6 +46,7 @@ "react-native-screens": "^3.30.1", "react-native-sqlite-storage": "^6.0.1", "react-native-svg": "^15.1.0", + "react-native-vision-camera": "^4.3.2", "victory-native": "^37.0.2" }, "resolutions": { diff --git a/example/yarn.lock b/example/yarn.lock index 2a31cfa..1141e39 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -5250,6 +5250,11 @@ react-native-gesture-handler@^2.16.0: lodash "^4.17.21" prop-types "^15.7.2" +react-native-image-picker@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-7.1.2.tgz#383849d1953caf4578874a1f5e5dd11c737bd5cd" + integrity sha512-b5y5nP60RIPxlAXlptn2QwlIuZWCUDWa/YPUVjgHc0Ih60mRiOg1PSzf0IjHSLeOZShCpirpvSPGnDExIpTRUg== + react-native-linear-gradient@^2.8.3: version "2.8.3" resolved "https://registry.yarnpkg.com/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz#9a116649f86d74747304ee13db325e20b21e564f" @@ -5310,6 +5315,11 @@ react-native-svg@^15.1.0: css-select "^5.1.0" css-tree "^1.1.3" +react-native-vision-camera@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/react-native-vision-camera/-/react-native-vision-camera-4.3.2.tgz#4acf80b62328275a69b22cd142f71a4e4aa2c12e" + integrity sha512-zrMWS+I5kIV9UShryRBOjV0PfOvKIH1LlvnQKw8n4D2NOuT6d3dTZ1KtwmktorwrPxRPf3FRktSn2Gv6F1kmWQ== + react-native@0.73.2: version "0.73.2" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.2.tgz#74ee163c8189660d41d1da6560411da7ce41a608" diff --git a/package.json b/package.json index 6e8cd62..47d4e44 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "react-error-boundary": "^4.0.12", "react-native-blob-util": "^0.19.6", "react-native-gesture-handler": "^2.16.0", + "react-native-image-picker": "^7.1.2", "react-native-linear-gradient": "^2.8.3", "react-native-modal": "^13.0.1", "react-native-modal-datetime-picker": "^17.1.0", @@ -103,6 +104,7 @@ "react-native-swipe-gestures": "^1.0.5", "react-native-toast-message": "^2.2.0", "react-native-uuid": "^2.0.1", + "react-native-vision-camera": "^4.3.2", "use-async-resource": "^2.2.2", "victory-native": "^37.0.2" }, diff --git a/react-native.config.js b/react-native.config.js index 8330630..7a825d0 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -16,5 +16,7 @@ module.exports = { '@notifee/react-native': {}, '@react-native-async-storage/async-storage': {}, 'react-native-screens': {}, + 'react-native-vision-camera': {}, + 'react-native-image-picker': {}, }, }; diff --git a/src/assets/icons/capture@4x.png b/src/assets/icons/capture@4x.png new file mode 100644 index 0000000..2f27feb Binary files /dev/null and b/src/assets/icons/capture@4x.png differ diff --git a/src/assets/icons/close.png b/src/assets/icons/close.png new file mode 100644 index 0000000..2410c34 Binary files /dev/null and b/src/assets/icons/close.png differ diff --git a/src/assets/index.ts b/src/assets/index.ts index 381aa64..1eda080 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -4,6 +4,7 @@ export const ic_left_white = require('./chev_left_white.png'); export const ic_right_white = require('./chev_right_white.png'); export const chev_down = require('./chev_down.png'); export const ICONS = { + close: require('./icons/close.png'), back: require('./icons/back.png'), menu: require('./icons/menu.png'), bottomDiary: require('./icons/bottom_diary.png'), @@ -74,6 +75,7 @@ export const ICONS = { editGreyIc: require('./icons/edit_grey_ic.png'), RecordingStop: require('./icons/stop_white.png'), Mic: require('./icons/mic_blue.png'), + CaptureIcon: require('./icons/capture.png'), }; export const onBoardingAssets = { onBoardingStep1: require('./images/onboarding_01.png'), diff --git a/src/components/button/BasicButton.tsx b/src/components/button/BasicButton.tsx index 668782d..528c04d 100644 --- a/src/components/button/BasicButton.tsx +++ b/src/components/button/BasicButton.tsx @@ -24,6 +24,7 @@ interface Props { boarderColor?: string; testId?: string; rightIcon?: JSX.Element; + disabled?: boolean; } export const BasicButton: React.FC = (props) => { @@ -39,6 +40,7 @@ export const BasicButton: React.FC = (props) => { boarderColor = brandingContext.primaryColor, testId, rightIcon, + disabled = false, } = props; const styles = basicButtonStyle(brandingContext); @@ -86,6 +88,7 @@ export const BasicButton: React.FC = (props) => { return ( void; onFavorite: () => void; onVoiceLogging: () => void; + onTakePicture: () => void; + onTakeCamera: () => void; } +type Type = 'All' | 'UseImage'; + export const LogOptions = ({ onFavorite, onFoodScanner, onTextSearch, onVoiceLogging, + onTakePicture, + onTakeCamera, }: Props) => { const branding = useBranding(); const styles = logOptionsStyle(branding); + const [type, setType] = useState('All'); const renderItem = (icon: number, title: string, onPress: () => void) => { return ( @@ -34,10 +41,32 @@ export const LogOptions = ({ return ( - {renderItem(ICONS.logOptionFavorite, 'Favorites', onFavorite)} - {renderItem(ICONS.Mic, 'Voice Logging', onVoiceLogging)} - {renderItem(ICONS.logOptionSearch, 'Text Search', onTextSearch)} - {renderItem(ICONS.logOptionFoodScanner, 'Food Scanner', onFoodScanner)} + {type === 'All' ? ( + <> + {renderItem(ICONS.logOptionFavorite, 'Favorites', onFavorite)} + {renderItem(ICONS.Mic, 'Voice Logging', onVoiceLogging)} + {renderItem(ICONS.logOptionSearch, 'Text Search', onTextSearch)} + {renderItem( + ICONS.logOptionFoodScanner, + 'Food Scanner', + onFoodScanner + )} + {renderItem(ICONS.logOptionFoodScanner, 'Use Image', () => { + setType('UseImage'); + })} + + ) : ( + <> + <> + {renderItem(ICONS.logOptionSearch, 'Take Photos', onTakeCamera)} + {renderItem( + ICONS.logOptionFoodScanner, + 'Select Photos', + onTakePicture + )} + + + )} ); }; diff --git a/src/components/svgs/scan.tsx b/src/components/svgs/scan.tsx index ed135a8..ebbc7ea 100644 --- a/src/components/svgs/scan.tsx +++ b/src/components/svgs/scan.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { Dimensions } from 'react-native'; import { Path, Svg } from 'react-native-svg'; -const ScanSVG = () => { +const ScanSVG = ({ margin = 20 }: { margin?: number }) => { return ( void; onFavorite: () => void; onVoiceLogging: () => void; + onTakePicture: () => void; + onTakeCamera: () => void; } export const renderTabBarIcons = ( diff --git a/src/navigaitons/HomeBottomNavigations.tsx b/src/navigaitons/HomeBottomNavigations.tsx index 1b4cbe2..a527f27 100644 --- a/src/navigaitons/HomeBottomNavigations.tsx +++ b/src/navigaitons/HomeBottomNavigations.tsx @@ -94,6 +94,14 @@ export const HomeBottomNavigation = React.memo(() => { const mealLogDateRef = useRef(new Date()); + const onTakePicture = () => { + navigation.navigate('TakePictureScreen', { + logToDate: mealLogDateRef.current, + logToMeal: undefined, + type: 'picture', + }); + }; + const renderTabs = (props: BottomTabBarProps) => { return ( @@ -123,6 +131,14 @@ export const HomeBottomNavigation = React.memo(() => { logToMeal: undefined, }); }} + onTakeCamera={() => { + navigation.navigate('TakePictureScreen', { + logToDate: mealLogDateRef.current, + logToMeal: undefined, + type: 'camera', + }); + }} + onTakePicture={onTakePicture} {...props} items={menu} /> diff --git a/src/navigaitons/Nutrition-Navigator.tsx b/src/navigaitons/Nutrition-Navigator.tsx index 3738176..5598e45 100644 --- a/src/navigaitons/Nutrition-Navigator.tsx +++ b/src/navigaitons/Nutrition-Navigator.tsx @@ -16,6 +16,7 @@ import { RecipeEditorScreen, IngredientQuickScanScreen, VoiceLoggingScreen, + TakePictureScreen, } from '../screens'; import { DashboardScreenRoute, @@ -38,6 +39,7 @@ import { WeightEntryRoute, SettingScreenRoute, NutritionInformationScreenRoute, + TakePictureScreenRoute, } from './Route'; import MyPlanScreen from '../screens/myPlans/MyPlanScreen'; import { HomeBottomNavigation } from './HomeBottomNavigations'; @@ -180,6 +182,11 @@ export const NutritionNavigator = () => { name={ROUTES.VoiceLoggingScreen} component={VoiceLoggingScreen} /> + { floatingRef.current?.onClose(); props.onVoiceLogging(); }} + onTakePicture={async () => { + floatingRef.current?.onClose(); + props.onTakePicture(); + }} + onTakeCamera={() => { + floatingRef.current?.onClose(); + props.onTakeCamera(); + }} /> } /> diff --git a/src/navigaitons/params/NutritionNavigatorParam.ts b/src/navigaitons/params/NutritionNavigatorParam.ts index f9b351b..9a6818d 100644 --- a/src/navigaitons/params/NutritionNavigatorParam.ts +++ b/src/navigaitons/params/NutritionNavigatorParam.ts @@ -12,7 +12,10 @@ import type { import type { IngredientQuickScanScreenProps } from '../../screens/recipeEditor/RecipesScan/IngredientQuickScanScreen/IngredientQuickScanScreen'; import type { Nutrient, Water, Weight } from '../../models'; import type { FavoritesScreenProps } from '../../screens/myFavoritess'; -import type { VoiceLoggingScreenProps } from '../../screens/voiceLogging'; +import type { + TakePictureScreenProps, + VoiceLoggingScreenProps, +} from '../../screens/voiceLogging'; export type Module = | 'QuickScan' @@ -73,4 +76,5 @@ export type ParamList = { SettingScreen: SettingScreenProps; NutritionInformationScreen: NutritionScreenProps; VoiceLoggingScreen: VoiceLoggingScreenProps; + TakePictureScreen: TakePictureScreenProps; }; diff --git a/src/screens/index.tsx b/src/screens/index.tsx index d0756f8..4f54a25 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -18,3 +18,4 @@ export * from './water'; export * from './weight'; export * from './nutritionInformation'; export * from './voiceLogging'; +export * from './takePicture'; diff --git a/src/screens/takePicture/TakePictureScreen.tsx b/src/screens/takePicture/TakePictureScreen.tsx new file mode 100644 index 0000000..13c84af --- /dev/null +++ b/src/screens/takePicture/TakePictureScreen.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { takePictureStyle } from './takePicture.styles'; +import { useTakePicture } from './useTakePicture'; +import { useBranding } from '../../contexts'; +import { ActivityIndicator, Text, View } from 'react-native'; +import BottomSheet from '@gorhom/bottom-sheet'; +import { PictureLoggingResult } from './result/PictureLoggingResult'; +import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; +import { useSharedValue } from 'react-native-reanimated'; +import { TakePicture } from './views/TakePicture'; +import { SelectPhotos } from './views/SelectPhotos'; + +export const TakePictureScreen = gestureHandlerRootHOC(() => { + const { + type, + recognizePictureRemote, + snapPoints, + bottomSheetModalRef, + selectPhotoRef, + onLogSelectPress, + passioAdvisorFoodInfo, + onRetakePress, + isFetchingResponse, + takePictureRef, + onCancelPress, + isPreparingLog, + } = useTakePicture(); + + const animatedIndex = useSharedValue(0); + const branding = useBranding(); + const styles = takePictureStyle(branding); + return ( + <> + {isFetchingResponse && ( + + + + Generating results... + + + )} + {type === 'camera' ? ( + + ) : ( + + )} + + + + + ); +}); diff --git a/src/screens/takePicture/index.tsx b/src/screens/takePicture/index.tsx new file mode 100644 index 0000000..113a407 --- /dev/null +++ b/src/screens/takePicture/index.tsx @@ -0,0 +1 @@ +export * from './TakePictureScreen'; diff --git a/src/screens/takePicture/result/PictureLoggingResult.tsx b/src/screens/takePicture/result/PictureLoggingResult.tsx new file mode 100644 index 0000000..78af45c --- /dev/null +++ b/src/screens/takePicture/result/PictureLoggingResult.tsx @@ -0,0 +1,237 @@ +import React, { useState } from 'react'; +import { + type StyleProp, + StyleSheet, + View, + type ViewStyle, + TouchableOpacity, + Image, +} from 'react-native'; +import { COLORS } from '../../../constants'; +import { Text } from '../../../components/texts/Text'; +import type { PassioAdvisorFoodInfo } from '@passiolife/nutritionai-react-native-sdk-v3'; +import { PictureLoggingResultItemView } from './PictureLoggingResultItemView'; +import { BasicButton } from '../../../components'; +import { FlatList } from 'react-native-gesture-handler'; +import { ICONS } from '../../../assets'; + +interface Props { + style?: StyleProp; + passioAdvisorFoodInfoResult: Array; + onRetake: () => void; + type: 'camera' | 'picture'; + onLogSelect: (selected: PassioAdvisorFoodInfo[]) => void; + onCancel: () => void; + isPreparingLog: boolean; +} + +interface Selection extends PassioAdvisorFoodInfo { + index: number; +} + +export const PictureLoggingResult = ({ + style, + passioAdvisorFoodInfoResult, + type, + isPreparingLog, + onRetake, + onLogSelect, + onCancel, +}: Props) => { + const [selected, setSelected] = useState([]); + + const onFoodSelect = (result: Selection) => { + const find = selected?.find((item) => item.index === result?.index); + if (find) { + setSelected((item) => item?.filter((i) => i.index !== result?.index)); + } else { + setSelected((item) => [...(item ?? []), result]); + } + }; + + const onClearPress = () => { + setSelected([]); + }; + + const renderNoDataFound = () => { + return ( + + + {'No Result Found'} + + ); + }; + + return ( + + + + + {selected && selected.length > 0 ? 'Clear' : ''} + + + + {passioAdvisorFoodInfoResult.length > 0 && ( + + {passioAdvisorFoodInfoResult.length === 0 + ? 'No Result found' + : 'Your Results'} + + )} + + {passioAdvisorFoodInfoResult.length > 0 && ( + + Select the foods you would like to log + + )} + { + const foodDataInfo = item.foodDataInfo; + const isSelected = + selected?.find((it) => it?.index === index) !== undefined; + + const npCalories = + item?.foodDataInfo?.nutritionPreview?.calories ?? 0; + const npWeightQuantity = + item?.foodDataInfo?.nutritionPreview?.weightQuantity ?? 0; + const ratio = npCalories / npWeightQuantity; + const advisorInfoWeightGram = item?.weightGrams ?? 0; + const calories = ratio * advisorInfoWeightGram; + + return ( + { + onFoodSelect({ ...item, index: index }); + }} + isSelected={isSelected} + /> + ); + }} + /> + {passioAdvisorFoodInfoResult.length > 0 ? ( + + { + if (type === 'camera') { + onRetake(); + } else { + onCancel(); + } + }} + style={styles.buttonTryAgain} + text={type === 'camera' ? 'Retake' : 'Cancel'} + /> + { + onLogSelect(selected ?? []); + }} + style={styles.buttonLogSelected} + isLoading={isPreparingLog} + enable={selected && selected.length > 0} + text="Log Selected" + /> + + ) : ( + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + itemsContainer: { + backgroundColor: 'white', + flex: 1, + }, + footer: {}, + noDataFound: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: 80, + alignContent: 'center', + }, + list: { + marginHorizontal: 16, + marginBottom: 20, + marginTop: 16, + flex: 1, + }, + quickSuggestionTextStyle: { + alignSelf: 'center', + paddingHorizontal: 16, + }, + noQuickSuggestionTitle: { + paddingHorizontal: 16, + marginBottom: 5, + alignSelf: 'center', + marginTop: 4, + }, + noQuickSuggestionDescriptions: { + fontSize: 15, + alignSelf: 'center', + textAlign: 'justify', + fontWeight: '400', + paddingHorizontal: 32, + color: COLORS.grey7, + }, + buttonContainer: { + flexDirection: 'row', + marginBottom: 40, + }, + buttonTryAgain: { flex: 1, marginStart: 16, marginEnd: 8 }, + buttonLogSelected: { flex: 1, marginEnd: 16, marginStart: 8 }, + clearBtnView: { + alignItems: 'flex-end', + paddingHorizontal: 16, + }, + clearBtn: { + paddingHorizontal: 4, + paddingVertical: 2, + }, + clearBtnText: { + textDecorationLine: 'underline', + color: '#4F46E5', + }, + contentView: { + alignItems: 'center', + marginVertical: 20, + }, + contentText: { + color: '#4F46E5', + }, + micIcon: { + height: 20, + width: 20, + }, +}); diff --git a/src/screens/takePicture/result/PictureLoggingResultItemView.tsx b/src/screens/takePicture/result/PictureLoggingResultItemView.tsx new file mode 100644 index 0000000..1b6ef1b --- /dev/null +++ b/src/screens/takePicture/result/PictureLoggingResultItemView.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import { PassioIDEntityType } from '@passiolife/nutritionai-react-native-sdk-v3'; +import { PassioFoodIcon } from '../../../components/passio/PassioFoodIcon'; +import { Text } from '../../../components'; + +interface Props { + imageName?: string; + foodName: string; + bottom: string; + onFoodLogEditor?: () => void; + onFoodLogSelect: () => void; + isSelected: boolean; +} + +export const PictureLoggingResultItemView = (props: Props) => { + const { foodName, imageName, onFoodLogSelect, isSelected, bottom } = props; + return ( + + + + + + + {foodName} + + + {bottom} + + + + + + + ); +}; +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(238, 242, 255, 1)', + flex: 1, + marginVertical: 4, + marginHorizontal: 2, + paddingVertical: 8, + borderRadius: 8, + shadowColor: '#00000029', + shadowOpacity: 1, + shadowOffset: { + width: 1.0, + height: 1.0, + }, + shadowRadius: 0.5, + elevation: 1, + }, + imageContainer: { + width: 42, + marginLeft: 8, + borderRadius: 32, + overflow: 'hidden', + alignSelf: 'center', + }, + + image: { + width: 42, + aspectRatio: 1, + }, + addIcon: { + width: 24, + height: 24, + borderRadius: 100, + borderWidth: 1, + borderColor: '#D1D5DB', + backgroundColor: '#ffffff', + marginRight: 8, + }, + text: { + textTransform: 'capitalize', + marginStart: 16, + marginVertical: 2, + marginRight: 10, + }, + selectedAddIcon: { + borderWidth: 1, + borderColor: '#4F46E5', + backgroundColor: '#4F46E5', + }, + secondaryText: {}, +}); diff --git a/src/screens/takePicture/takePicture.styles.ts b/src/screens/takePicture/takePicture.styles.ts new file mode 100644 index 0000000..8c8a47a --- /dev/null +++ b/src/screens/takePicture/takePicture.styles.ts @@ -0,0 +1,27 @@ +import { StyleSheet } from 'react-native'; +import type { Branding } from '../../contexts'; + +export const takePictureStyle = ({}: Branding) => + StyleSheet.create({ + bottomSheetChildrenContainer: { + shadowColor: '#00000029', + shadowOffset: { + width: 0, + height: 0, + }, + shadowRadius: 10, + shadowOpacity: 1.0, + elevation: 10, + flex: 1, + }, + generatingResultLoading: { + justifyContent: 'center', + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + alignItems: 'center', + zIndex: 10, + }, + }); diff --git a/src/screens/takePicture/useTakePicture.ts b/src/screens/takePicture/useTakePicture.ts new file mode 100644 index 0000000..9d59a81 --- /dev/null +++ b/src/screens/takePicture/useTakePicture.ts @@ -0,0 +1,126 @@ +import type { ParamList } from '../../navigaitons'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type BottomSheet from '@gorhom/bottom-sheet'; +import { createFoodLogUsingFoodDataInfo } from '../../utils'; +import { + PassioSDK, + type PassioAdvisorFoodInfo, +} from '@passiolife/nutritionai-react-native-sdk-v3'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { useServices } from '../../contexts'; +import type { TakePictureRef } from './views/TakePicture'; +import type { SelectPhotosRef } from './views/SelectPhotos'; + +export type TakePictureScreenProps = StackNavigationProp< + ParamList, + 'TakePictureScreen' +>; + +export const PHOTO_LIMIT = 7; + +export function useTakePicture() { + const navigation = useNavigation(); + const services = useServices(); + const takePictureRef = useRef(null); + const selectPhotoRef = useRef(null); + + const route = useRoute>(); + + const bottomSheetModalRef = useRef(null); + const snapPoints = useMemo(() => ['30%', '70%'], []); + const [isFetchingResponse, setFetchResponse] = useState(false); + const [isPreparingLog, setPreparingLog] = useState(false); + const [passioAdvisorFoodInfo, setPassioAdvisorFoodInfo] = useState< + PassioAdvisorFoodInfo[] | null + >(null); + + const recognizePictureRemote = useCallback(async (imgs: string[]) => { + setFetchResponse(true); + try { + setPassioAdvisorFoodInfo(null); + + let foodInfoArray: Array = []; + + const data = imgs.map(async (item) => { + const val = await PassioSDK.recognizeImageRemote( + item.replace('file://', '') ?? '' + ); + foodInfoArray?.push(val); + }); + + await Promise.all(data); + let foodInfoArrayFlat = foodInfoArray.flat(); + if (foodInfoArrayFlat && foodInfoArrayFlat?.length > 0) { + setFetchResponse(false); + bottomSheetModalRef.current?.expand(); + setPassioAdvisorFoodInfo(foodInfoArrayFlat as PassioAdvisorFoodInfo[]); + } else { + bottomSheetModalRef.current?.expand(); + } + } catch (error) { + } finally { + setFetchResponse(false); + } + }, []); + + const onLogSelectPress = useCallback( + async (selected: PassioAdvisorFoodInfo[]) => { + setPreparingLog(true); + const foodLogs = await createFoodLogUsingFoodDataInfo( + selected, + route.params.logToDate, + route.params.logToMeal + ); + + for (const item of foodLogs) { + await services.dataService.saveFoodLog({ + ...item, + }); + } + setPreparingLog(false); + navigation.pop(1); + navigation.navigate('BottomNavigation', { + screen: 'MealLogScreen', + }); + }, + + [ + navigation, + route.params.logToDate, + route.params.logToMeal, + services.dataService, + ] + ); + + const onRetakePress = useCallback(() => { + if (route.params.type === 'camera') { + bottomSheetModalRef.current?.close(); + setPassioAdvisorFoodInfo([]); + takePictureRef.current?.onRetake(); + } else { + bottomSheetModalRef.current?.close(); + setPassioAdvisorFoodInfo([]); + selectPhotoRef.current?.onRetake(); + } + }, [route.params.type]); + + const onCancelPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + return { + recognizePictureRemote, + snapPoints, + bottomSheetModalRef, + selectPhotoRef, + onLogSelectPress, + passioAdvisorFoodInfo, + onRetakePress, + onCancelPress, + isPreparingLog, + isFetchingResponse, + type: route.params.type ?? 'camera', + takePictureRef, + }; +} diff --git a/src/screens/takePicture/views/SelectPhotos.tsx b/src/screens/takePicture/views/SelectPhotos.tsx new file mode 100644 index 0000000..c1fc4f5 --- /dev/null +++ b/src/screens/takePicture/views/SelectPhotos.tsx @@ -0,0 +1,115 @@ +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { Dimensions, FlatList, Image, Platform, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { launchImageLibrary } from 'react-native-image-picker'; +import { PHOTO_LIMIT, type TakePictureScreenProps } from '../useTakePicture'; + +const width = Dimensions.get('screen').width; + +interface Props { + recognizePictureRemote: (images: string[]) => void; +} + +export interface SelectPhotosRef { + onRetake: () => void; +} + +export const SelectPhotos = React.forwardRef( + ({ recognizePictureRemote }: Props, ref: React.Ref) => { + const [images, setImages] = useState([]); + const navigation = useNavigation(); + const isFirstTime = useRef(true); + + const onTakeImages = useCallback(async () => { + try { + const { assets } = await launchImageLibrary({ + selectionLimit: PHOTO_LIMIT, + mediaType: 'photo', + }); + const galleryImages = assets?.map( + (i) => i.uri?.replace('file://', '') ?? '' + ); + + if (galleryImages && galleryImages.length > 0) { + setImages(galleryImages); + recognizePictureRemote(galleryImages); + } else { + navigation.goBack(); + } + return galleryImages; + } catch (e) { + return []; + } + }, [navigation, recognizePictureRemote]); + + useImperativeHandle( + ref, + () => ({ + onRetake: () => { + setImages([]); + onTakeImages(); + }, + }), + [onTakeImages] + ); + + useEffect(() => { + async function init() { + setTimeout(async () => { + try { + await onTakeImages(); + } catch { + navigation.goBack(); + } + }, 300); + } + + if (isFirstTime.current) { + init(); + isFirstTime.current = false; + } + }, [navigation, onTakeImages, recognizePictureRemote]); + + return ( + + + { + return ( + + ); + }} + numColumns={3} + /> + + + ); + } +); diff --git a/src/screens/takePicture/views/TakePicture.tsx b/src/screens/takePicture/views/TakePicture.tsx new file mode 100644 index 0000000..0776e7d --- /dev/null +++ b/src/screens/takePicture/views/TakePicture.tsx @@ -0,0 +1,293 @@ +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import type { TakePictureScreenProps } from '../useTakePicture'; +import { useBranding } from '../../../contexts'; +import { + Camera, + CameraCaptureError, + useCameraDevice, + useCameraPermission, +} from 'react-native-vision-camera'; +import { + Dimensions, + FlatList, + Image, + Platform, + TouchableOpacity, + View, +} from 'react-native'; +import { BasicButton } from '../../../components'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { scaleHeight } from '../../../utils'; +import { ICONS } from '../../../assets'; +import Animated, { + SharedValue, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useNavigation } from '@react-navigation/native'; +import ScanSVG from '../../../components/svgs/scan'; + +interface Props { + recognizePictureRemote: (images: string[]) => void; + animatedIndex: SharedValue; +} + +export interface TakePictureRef { + onRetake: () => void; +} + +const RenderItem = ({ + item, + onDelete, +}: { + item: string; + onDelete: (item: string) => void; +}) => { + const [isSelect, setSelect] = useState(false); + + return ( + { + setSelect(!isSelect); + }} + > + + + {isSelect && ( + { + onDelete(item); + }} + style={{ + position: 'absolute', + right: 0, + height: 18, + width: 18, + overflow: 'hidden', + }} + > + + + )} + + ); +}; + +export const TakePicture = React.forwardRef( + ( + { recognizePictureRemote, animatedIndex }: Props, + ref: React.Ref + ) => { + const [images, setImages] = useState([]); + const camera = useRef(null); + const navigation = useNavigation(); + const flatListRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + onRetake: () => { + setImages([]); + }, + }), + [] + ); + + const onCancelPress = () => { + navigation.goBack(); + }; + + const captureImage = useCallback(async () => { + camera.current + ?.takePhoto({ enableShutterSound: true }) + .then((value) => { + let path = + Platform.OS === 'android' ? `file://${value.path}` : value.path; + setImages([...images, path]); + }) + .catch((_val: CameraCaptureError) => {}); + }, [images]); + + const { hasPermission, requestPermission } = useCameraPermission(); + const device = useCameraDevice('back'); + const branding = useBranding(); + useEffect(() => { + requestPermission(); + }, [hasPermission, requestPermission]); + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + animatedIndex.value, + [-1, 0, 1], + [46, -0, -240] + ), + }, + ], + }; + }); + + if (!hasPermission) { + return <>; + } + + if (!device) { + return <>; + } + + return ( + + + + + + + + + { + return ( + { + setImages((i) => i.filter((o) => o !== path)); + }} + /> + ); + }} + /> + + + + + = 7} + > + = 7 ? '#DCDCDC' : 'white'} + resizeMethod="resize" + resizeMode="contain" + style={{ height: 78, width: 78 }} + /> + + recognizePictureRemote(images)} + text="Next" + boarderColor={branding.primaryColor} + style={{ + maxHeight: 50, + flex: 1, + marginEnd: 16, + marginStart: 16, + opacity: images.length > 0 ? 1 : 0.5, + }} + /> + + + ); + } +); diff --git a/src/screens/voiceLogging/VoiceLoggingScreen.tsx b/src/screens/voiceLogging/VoiceLoggingScreen.tsx index 88e89f6..9802383 100644 --- a/src/screens/voiceLogging/VoiceLoggingScreen.tsx +++ b/src/screens/voiceLogging/VoiceLoggingScreen.tsx @@ -18,6 +18,12 @@ export interface VoiceLoggingScreenProps { logToMeal?: MealLabel | undefined; onSaveData?: (item: PassioFoodItem) => void; } +export interface TakePictureScreenProps { + logToDate?: Date | undefined; + logToMeal?: MealLabel | undefined; + type: 'picture' | 'camera'; + images?: string[]; +} export const VoiceLoggingScreen = gestureHandlerRootHOC(() => { const { diff --git a/src/screens/voiceLogging/useVoiceLoggingScreen.ts b/src/screens/voiceLogging/useVoiceLoggingScreen.ts index 5dfc72d..6112fa4 100644 --- a/src/screens/voiceLogging/useVoiceLoggingScreen.ts +++ b/src/screens/voiceLogging/useVoiceLoggingScreen.ts @@ -8,14 +8,18 @@ import { PassioSpeechRecognitionModel, } from '@passiolife/nutritionai-react-native-sdk-v3'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ShowToast, getLogToDate, mealLabelByDate } from '../../utils'; +import { + ShowToast, + createFoodLogUsingPortionSize, + getLogToDate, + mealLabelByDate, +} from '../../utils'; import { useNavigation, useRoute, type RouteProp, } from '@react-navigation/native'; import type { ParamList } from '../../navigaitons'; -import { convertPassioFoodItemToFoodLog } from '../../utils/V3Utils'; import { useServices } from '../../contexts'; import type { StackNavigationProp } from '@react-navigation/stack'; import type BottomSheet from '@gorhom/bottom-sheet'; @@ -102,10 +106,12 @@ export function useVoiceLogging() { item.advisorInfo.foodDataInfo ); if (foodItem) { - const foodLog = convertPassioFoodItemToFoodLog( + let foodLog = createFoodLogUsingPortionSize( foodItem, logToDate, - meal + meal, + item.advisorInfo.weightGrams, + item.advisorInfo.portionSize ); await services.dataService.saveFoodLog({ ...foodLog, diff --git a/src/utils/V3Utils.tsx b/src/utils/V3Utils.tsx index 2d7dd88..1a76cbc 100644 --- a/src/utils/V3Utils.tsx +++ b/src/utils/V3Utils.tsx @@ -165,8 +165,25 @@ export const round2Digit = (value: number) => { return value.toFixed(2); }; +export const updateServing = ( + foodLog: FoodLog, + { mass, unit }: ServingUnit +) => { + const { computedWeight, foodItems } = foodLog; + const servingWeight = + computedWeight?.value ?? foodItems[0]?.computedWeight.value; + const defaultWeight = servingWeight ?? 0; + const newQuantity = Number(defaultWeight / mass); + foodLog.selectedQuantity = Number( + newQuantity < 10 ? newQuantity.toFixed(2) : Math.round(newQuantity) + ); + foodLog.selectedUnit = unit; + return foodLog; +}; + export const updateQuantityOfFoodLog = (foodLog: FoodLog, qty: number) => { const copyOfFoodLog = { ...foodLog }; + if (qty > 0) { const oldQuantity = copyOfFoodLog.selectedQuantity; const oldWeight = diff --git a/src/utils/index.ts b/src/utils/index.ts index 48485bd..c246510 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -18,6 +18,7 @@ export * from './PassioUtils'; export * from './QuickResultUtils'; export * from './ScaningUtils'; export * from './quickSuggestionUtils'; +export * from './passioFoodDataInfoUtils'; export { isToday, currentTimeStamp, diff --git a/src/utils/passioFoodDataInfoUtils.ts b/src/utils/passioFoodDataInfoUtils.ts new file mode 100644 index 0000000..b86a543 --- /dev/null +++ b/src/utils/passioFoodDataInfoUtils.ts @@ -0,0 +1,72 @@ +import { + PassioAdvisorFoodInfo, + PassioFoodItem, + PassioSDK, +} from '@passiolife/nutritionai-react-native-sdk-v3'; +import type { FoodLog, MealLabel } from '..'; +import { getLogToDate, mealLabelByDate } from './ScaningUtils'; +import { + convertPassioFoodItemToFoodLog, + updateQuantityOfFoodLog, +} from './V3Utils'; + +export const createFoodLogUsingFoodDataInfo = async ( + foods: PassioAdvisorFoodInfo[], + date?: Date, + mealLabel?: MealLabel +) => { + const logToDate = getLogToDate(date, mealLabel); + const meal = date === undefined ? mealLabelByDate(logToDate) : mealLabel; + const foodLogs: FoodLog[] = []; + + for (const item of foods) { + if (item && item.foodDataInfo) { + const foodItem = await PassioSDK.fetchFoodItemForDataInfo( + item.foodDataInfo + ); + if (foodItem) { + const foodLog = convertPassioFoodItemToFoodLog( + foodItem, + logToDate, + meal + ); + foodLogs.push(foodLog); + } + } + } + + return foodLogs; +}; + +export const createFoodLogUsingPortionSize = ( + foodItem: PassioFoodItem, + logToDate: Date, + meal: MealLabel, + weightGram: number, + portionSize: string +) => { + const [qty, unit] = portionSize.split(' '); + + const isNotIncludedUnit = + foodItem?.amount.servingUnits?.filter( + (i) => i.unitName?.toString() === unit?.toString() + ).length === 0; + + let foodLog = convertPassioFoodItemToFoodLog(foodItem, logToDate, meal); + + if (isNotIncludedUnit) { + const { computedWeight, foodItems } = foodLog; + const servingWeight = + computedWeight?.value ?? foodItems[0]?.computedWeight.value; + const defaultWeight = servingWeight ?? 0; + const newQuantity = Number(defaultWeight / Number(qty)); + foodLog.selectedQuantity = Number( + newQuantity < 10 ? newQuantity.toFixed(2) : Math.round(newQuantity) + ); + foodLog.selectedUnit = 'gram'; + foodLog = updateQuantityOfFoodLog(foodLog, weightGram); + return foodLog; + } else { + return foodLog; + } +}; diff --git a/yarn.lock b/yarn.lock index d8137d4..5069928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8336,6 +8336,11 @@ react-native-gesture-handler@^2.16.0: lodash "^4.17.21" prop-types "^15.7.2" +react-native-image-picker@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-7.1.2.tgz#383849d1953caf4578874a1f5e5dd11c737bd5cd" + integrity sha512-b5y5nP60RIPxlAXlptn2QwlIuZWCUDWa/YPUVjgHc0Ih60mRiOg1PSzf0IjHSLeOZShCpirpvSPGnDExIpTRUg== + react-native-linear-gradient@^2.8.3: version "2.8.3" resolved "https://registry.yarnpkg.com/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz#9a116649f86d74747304ee13db325e20b21e564f" @@ -8424,6 +8429,11 @@ react-native-uuid@^2.0.1: resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.2.tgz#3da192e342ef35ee95a7def676ab41c1256dfd66" integrity sha512-5ypj/hV58P+6VREdjkW0EudSibsH3WdqDERoHKnD9syFWjF+NfRWWrJb2sa3LIwI5zpzMvUiabs+DX40WHpEMw== +react-native-vision-camera@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/react-native-vision-camera/-/react-native-vision-camera-4.3.2.tgz#4acf80b62328275a69b22cd142f71a4e4aa2c12e" + integrity sha512-zrMWS+I5kIV9UShryRBOjV0PfOvKIH1LlvnQKw8n4D2NOuT6d3dTZ1KtwmktorwrPxRPf3FRktSn2Gv6F1kmWQ== + react-native@0.73.2: version "0.73.2" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.2.tgz#74ee163c8189660d41d1da6560411da7ce41a608"