From 80337813fc103a8731625a99c1f3198ef9a7bd70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Struck?= <98230431+michalstruck@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:56:06 +0100 Subject: [PATCH] barcode reader (#66) * native/feat: barcode reader initial component, install and app config, new assets * native/barcode-scanner: feat: ui improvement, double tap handling, permission message reword * native/barcode-scanner: feat: code scanner tracers * native/barcode-scanner: feat: barcode scanner button and icon and modal * barcode-scanner/feat: migration, generated types * barcode-scanner/admin/feat: add barcodes from the panel, genericUpdate erro handling fix * barcode-scanner/supabase/feat: new trigger, check barcode uniqueness on insert * barcode-scanner/native/feat: useListBarcodes, barcode_modal params * barcode-scanner/native/refactor: remove console logs * barcode-scanner/native/fix: gen-types * barcode-scanner/native/fix: gen-types * barcode-scanner/native/fix: global caching/stale times * barcode-scanner/supabase/fix: migration * barcode-scanner/native/feat: working barcode scanner, error handling * barcode-scanner/native/fix: navigate back when barcode is not recognized, improve tracers * barcode-scanner/native/feat: better error display * barcode-scanner/native/fix: typography align --------- Co-authored-by: Michal Struck --- admin/package.json | 2 +- admin/src/lib/database.types.ts | 18 ++ admin/src/lib/genericUpdate.ts | 9 +- admin/src/routes/products/[id]/+page.svelte | 44 +++- native/app.json | 10 +- native/app/(tabs)/[inventory]/index.tsx | 26 ++- native/app/(tabs)/list.tsx | 2 +- native/app/_layout.tsx | 18 +- native/app/barcode_modal.tsx | 72 ++++++ native/app/index.tsx | 9 +- native/assets/images/camera-switch.png | Bin 0 -> 2394 bytes native/assets/images/scan-barcode.png | Bin 0 -> 814 bytes .../BarcodeScanner/BarcodeOutline.tsx | 110 +++++++++ .../_ReanimatedCameraTracers.tsx | 218 ++++++++++++++++++ native/components/BarcodeScanner/common.ts | 18 ++ native/components/BarcodeScanner/index.tsx | 189 +++++++++++++++ native/components/Icon.tsx | 8 + native/components/Typography.tsx | 14 +- native/db/hooks/useListBarcodes.ts | 51 ++++ native/db/supabase.ts | 3 +- native/db/types/generated.ts | 73 ++++-- native/package-lock.json | 12 + native/package.json | 5 +- .../migrations/20231119131615_barcodes.sql | 23 ++ 24 files changed, 875 insertions(+), 59 deletions(-) create mode 100644 native/app/barcode_modal.tsx create mode 100644 native/assets/images/camera-switch.png create mode 100644 native/assets/images/scan-barcode.png create mode 100644 native/components/BarcodeScanner/BarcodeOutline.tsx create mode 100644 native/components/BarcodeScanner/_ReanimatedCameraTracers.tsx create mode 100644 native/components/BarcodeScanner/common.ts create mode 100644 native/components/BarcodeScanner/index.tsx create mode 100644 native/db/hooks/useListBarcodes.ts create mode 100644 supabase/migrations/20231119131615_barcodes.sql diff --git a/admin/package.json b/admin/package.json index c0aa104e..3cb1891b 100644 --- a/admin/package.json +++ b/admin/package.json @@ -9,7 +9,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write .", - "gen-types": "supabase gen types typescript --local > ./src/lib/database.types.ts" + "gen-types": "npx supabase gen types typescript --local > ./src/lib/database.types.ts" }, "devDependencies": { "@fontsource/fira-mono": "^4.5.10", diff --git a/admin/src/lib/database.types.ts b/admin/src/lib/database.types.ts index ede4560c..32b744aa 100644 --- a/admin/src/lib/database.types.ts +++ b/admin/src/lib/database.types.ts @@ -79,6 +79,7 @@ export interface Database { }; product: { Row: { + barcodes: string[] | null; company_id: number | null; created_at: string; id: number; @@ -87,6 +88,7 @@ export interface Database { unit: string; }; Insert: { + barcodes?: string[] | null; company_id?: number | null; created_at?: string; id?: number; @@ -95,6 +97,7 @@ export interface Database { unit?: string; }; Update: { + barcodes?: string[] | null; company_id?: number | null; created_at?: string; id?: number; @@ -148,6 +151,21 @@ export interface Database { } ]; }; + test_tenant: { + Row: { + details: string | null; + id: number; + }; + Insert: { + details?: string | null; + id?: number; + }; + Update: { + details?: string | null; + id?: number; + }; + Relationships: []; + }; worker: { Row: { company_id: number | null; diff --git a/admin/src/lib/genericUpdate.ts b/admin/src/lib/genericUpdate.ts index 3b781697..cd431432 100644 --- a/admin/src/lib/genericUpdate.ts +++ b/admin/src/lib/genericUpdate.ts @@ -1,9 +1,9 @@ import { goto } from "$app/navigation"; -import type { PostgrestBuilder } from "@supabase/postgrest-js"; +import type { PostgrestError, PostgrestBuilder } from "@supabase/postgrest-js"; export const genericUpdate = async ( builder: PostgrestBuilder, - onSuccess: string, + onSuccess?: string, setLoading?: (x: boolean) => void ) => { try { @@ -11,11 +11,10 @@ export const genericUpdate = async ( setLoading && setLoading(true); let { error } = await builder; if (error) throw error; - goto(onSuccess); + if (onSuccess) goto(onSuccess); } catch (error) { - if (error instanceof Error) alert(error.message); + if (error) alert((error as PostgrestError).message); } finally { setLoading && setLoading(false); - // loading = false; } }; diff --git a/admin/src/routes/products/[id]/+page.svelte b/admin/src/routes/products/[id]/+page.svelte index 8016b692..1ae95c49 100644 --- a/admin/src/routes/products/[id]/+page.svelte +++ b/admin/src/routes/products/[id]/+page.svelte @@ -7,6 +7,7 @@ import { genericUpdate } from "$lib/genericUpdate"; import ScreenCard from "$lib/ScreenCard.svelte"; import { Label, Span, Input, Button } from "flowbite-svelte"; + import { CloseCircleSolid } from "flowbite-svelte-icons"; let loading = false; let product: Tables<"product"> | null = null; @@ -17,12 +18,21 @@ name = product.name; unit = product.unit; steps = product.steps; + barcodes = product.barcodes ?? []; }) ); let name: string | undefined = undefined; let unit: string | undefined = undefined; let steps: number[] = []; + let barcodes: string[] = []; + let newBarcode: string | null = null; + + const addBarcode = () => { + if (!newBarcode) return; + barcodes = [...barcodes, newBarcode]; + newBarcode = null; + }; const update = () => genericUpdate( @@ -32,15 +42,16 @@ name, unit, steps, + barcodes: barcodes?.concat(newBarcode ?? []), }) .eq("id", id), - "/products", + undefined, (x) => (loading = x) ); {#if product} - +
+
+ Kody +
+
+
+ + +
+ {#each barcodes as _barcode, i} + + (barcodes = barcodes.filter((_, j) => i !== j))} + /> + + {/each} +
+
+
{loading ? "Saving ..." : "Aktualizuj produkt"}
diff --git a/native/app.json b/native/app.json index a584d52b..c5477c5f 100644 --- a/native/app.json +++ b/native/app.json @@ -31,7 +31,15 @@ "bundler": "metro", "favicon": "./assets/images/favicon.png" }, - "plugins": ["expo-router"], + "plugins": [ + "expo-router", + [ + "expo-camera", + { + "cameraPermission": "Pozwól $(PRODUCT_NAME) na dostęp do kamery." + } + ] + ], "experiments": { "typedRoutes": true }, diff --git a/native/app/(tabs)/[inventory]/index.tsx b/native/app/(tabs)/[inventory]/index.tsx index 66f17d83..49b8ea10 100644 --- a/native/app/(tabs)/[inventory]/index.tsx +++ b/native/app/(tabs)/[inventory]/index.tsx @@ -1,8 +1,10 @@ import React from "react"; import { ScrollView, StyleSheet, View } from "react-native"; -import { useLocalSearchParams } from "expo-router"; +import { Link, useLocalSearchParams } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; +import { Button } from "../../../components/Button"; +import { ScanBarcodeIcon } from "../../../components/Icon"; import { InventoryListCard } from "../../../components/InventoryListCard"; import { Typography } from "../../../components/Typography"; import { useListRecords } from "../../../db"; @@ -27,6 +29,24 @@ export default function InventoryIdIndex() { + + + {recordList.map(({ name, quantity, unit, id }) => ( // TODO - think of a clever way to check if these are not null, and let TS know justifyContent: "center", alignItems: "center", }, - listContainer: { - paddingHorizontal: theme.spacing * 4, - }, + listContainer: { paddingHorizontal: theme.spacing * 4 }, scroll: { width: "100%", height: "100%", diff --git a/native/app/(tabs)/list.tsx b/native/app/(tabs)/list.tsx index 625b56b6..dfb5837a 100644 --- a/native/app/(tabs)/list.tsx +++ b/native/app/(tabs)/list.tsx @@ -69,7 +69,7 @@ const ListIndex = () => { () => groupDaysByMonth(groupByDay(inventoryList)), [inventoryList] ); - console.log(inventoryList?.map((inv) => inv.date)); + if (!inventoryList || !months) return null; return ( diff --git a/native/app/_layout.tsx b/native/app/_layout.tsx index 1c01ed68..b4a62e07 100644 --- a/native/app/_layout.tsx +++ b/native/app/_layout.tsx @@ -44,7 +44,7 @@ const ONE_SECOND = 1000; const queryClient = new QueryClient({ defaultOptions: { mutations: { - cacheTime: Infinity, + cacheTime: ONE_SECOND * 60 * 5, retry: 100, retryDelay: (attemptIndex) => Math.min(ONE_SECOND * 2 ** attemptIndex, 30 * ONE_SECOND), @@ -56,9 +56,8 @@ const queryClient = new QueryClient({ retry: 100, retryDelay: (attemptIndex) => Math.min(ONE_SECOND * 2 ** attemptIndex, 30 * ONE_SECOND), - cacheTime: Infinity, - staleTime: 60 * 60 * ONE_SECOND, - networkMode: "offlineFirst", + cacheTime: ONE_SECOND * 60 * 5, + staleTime: ONE_SECOND * 60, }, }, }); @@ -111,7 +110,6 @@ export default function Root() { onlineManager.setEventListener((setOnline) => { return NetInfo.addEventListener((state) => { setOnline(!!state.isConnected); - console.warn(onlineManager.isOnline()); }); }); @@ -167,6 +165,7 @@ export default function Root() { header: (p) =>
, }} > + + {/* maybe a bottomsheet? */} + (); + + const [permission, requestPermission] = Camera.useCameraPermissions(); + + if (!permission) { + return ( + + + + ); + } + + if (!permission?.granted) { + // Camera permissions are not granted yet + return ( + + + Aby skorzystać ze skanera kodów, pozwól aplikacji na dostęp do kamery. + + + + ); + } + + return ( + + + + ); +} + +const useStyles = createStyles((theme) => + StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + backgroundColor: theme.colors.lightBlue, + height: "100%", + }, + }) +); diff --git a/native/app/index.tsx b/native/app/index.tsx index 81929c8f..0901c519 100644 --- a/native/app/index.tsx +++ b/native/app/index.tsx @@ -1,13 +1,6 @@ import React from "react"; import { View } from "react-native"; -import { Typography } from "../components/Typography"; - -// TODO Landing page export default function Landing() { - return ( - - test5 - - ); + return ; } diff --git a/native/assets/images/camera-switch.png b/native/assets/images/camera-switch.png new file mode 100644 index 0000000000000000000000000000000000000000..3823edd00a0512b79d74c75e310129597c964835 GIT binary patch literal 2394 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 V0$Tf>fQg=g!PC{xWt~$(6985}Obh@3 literal 0 HcmV?d00001 diff --git a/native/components/BarcodeScanner/BarcodeOutline.tsx b/native/components/BarcodeScanner/BarcodeOutline.tsx new file mode 100644 index 00000000..582c50e0 --- /dev/null +++ b/native/components/BarcodeScanner/BarcodeOutline.tsx @@ -0,0 +1,110 @@ +import { MutableRefObject } from "react"; +import { Animated, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { createStyles } from "../../theme/useStyles"; +import { useDimensions } from "../../utils/useDimensions"; +import { TRACER_SIZE, interpolatableX, interpolatableY } from "./common"; + +interface BarcodeOutlineProps { + animatedTLCornerX: MutableRefObject; + animatedTLCornerY: MutableRefObject; + animatedBLCornerX: MutableRefObject; + animatedBLCornerY: MutableRefObject; + animatedBRCornerX: MutableRefObject; + animatedBRCornerY: MutableRefObject; + animatedTRCornerX: MutableRefObject; + animatedTRCornerY: MutableRefObject; +} +export const BarcodeOutline = ({ + animatedTLCornerX, + animatedTLCornerY, + animatedBLCornerX, + animatedBLCornerY, + animatedBRCornerX, + animatedBRCornerY, + animatedTRCornerX, + animatedTRCornerY, +}: BarcodeOutlineProps) => { + const { width, height } = useDimensions(); + const { top: topInset } = useSafeAreaInsets(); + + const styles = useStyles(); + const interpolateX = interpolatableX(height, topInset); + const interpolateY = interpolatableY(width); + + return ( + + {/* top left */} + + {/* bottom left */} + + {/* bottom right */} + + {/* top right */} + + + ); +}; +const useStyles = createStyles((theme) => + StyleSheet.create({ + outlineContainer: { + position: "absolute", + }, + outlinePoint: { + position: "absolute", + width: TRACER_SIZE, + height: TRACER_SIZE, + backgroundColor: "rgba(0, 0, 0,1)", + justifyContent: "center", + borderRadius: theme.borderRadiusFull, + }, + }) +); diff --git a/native/components/BarcodeScanner/_ReanimatedCameraTracers.tsx b/native/components/BarcodeScanner/_ReanimatedCameraTracers.tsx new file mode 100644 index 00000000..e7a098c6 --- /dev/null +++ b/native/components/BarcodeScanner/_ReanimatedCameraTracers.tsx @@ -0,0 +1,218 @@ +import { BarCodeScanningResult, Camera, CameraType, Point } from "expo-camera"; +import { useState } from "react"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; +import { TapGestureHandler } from "react-native-gesture-handler"; +import Animated, { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { createStyles } from "../../theme/useStyles"; +import { useDimensions } from "../../utils/useDimensions"; +import { Button } from "../Button"; +import { CameraSwitchIcon } from "../Icon"; +import { Typography } from "../Typography"; +// this crashes the app with no error message +const TRACER_SIZE = 10; +const TOP_BAR_HEIGHT = 56; +export default function Landing() { + const styles = useStyles(); + const [type, setType] = useState(CameraType.back); + + const [corner1, setCorner1] = useState({ x: 0, y: 0 }); + const [corner2, setCorner2] = useState({ x: 0, y: 0 }); + const [corner3, setCorner3] = useState({ x: 0, y: 0 }); + const [corner4, setCorner4] = useState({ x: 0, y: 0 }); + + const animatedCorner1X = useSharedValue(corner1.x); + const animatedCorner1Y = useSharedValue(corner1.y); + const animatedCorner2X = useSharedValue(corner2.x); + const animatedCorner2Y = useSharedValue(corner2.y); + const animatedCorner3X = useSharedValue(corner3.x); + const animatedCorner3Y = useSharedValue(corner3.y); + const animatedCorner4X = useSharedValue(corner4.x); + const animatedCorner4Y = useSharedValue(corner4.y); + + const { width, height } = useDimensions(); + const { top: topInset } = useSafeAreaInsets(); + + const interpolateXAnim = (x: Animated.SharedValue) => + interpolate( + x.value, + [0, 1], + [0, height - 2 * TRACER_SIZE - TOP_BAR_HEIGHT - topInset] + ); + + const interpolateYAnim = (y: Animated.SharedValue) => + interpolate(y.value, [0, 1], [width - TRACER_SIZE, 0]); + + const corner1Styles = useAnimatedStyle(() => ({ + top: interpolateXAnim(animatedCorner1X) - TRACER_SIZE, + left: interpolateYAnim(animatedCorner1Y), + })); + const corner2Styles = useAnimatedStyle(() => ({ + top: interpolateXAnim(animatedCorner2X) + TRACER_SIZE, + left: interpolateYAnim(animatedCorner2Y), + })); + const corner3Styles = useAnimatedStyle(() => ({ + top: interpolateXAnim(animatedCorner3X) + TRACER_SIZE, + left: interpolateYAnim(animatedCorner3Y), + })); + const corner4Styles = useAnimatedStyle(() => ({ + top: interpolateXAnim(animatedCorner4X) - TRACER_SIZE, + left: interpolateYAnim(animatedCorner4Y), + })); + const [permission, requestPermission] = Camera.useCameraPermissions(); + + if (!permission) { + // Camera permissions are still loading + return ; + } + + if (!permission.granted) { + // Camera permissions are not granted yet + return ( + + + Aby skorzystać ze skanera kodów, pozwól aplikacji na dostęp do kamery. + + + + ); + } + const toggleCameraType = () => { + setType((current) => + current === CameraType.back ? CameraType.front : CameraType.back + ); + }; + + const setCorners = (corners: Point[]) => { + animatedCorner1X.value = withTiming(corners[0].x); + animatedCorner1Y.value = withTiming(corners[0].y); + animatedCorner2X.value = withTiming(corners[1].x); + animatedCorner2Y.value = withTiming(corners[1].y); + animatedCorner3X.value = withTiming(corners[2].x); + animatedCorner3Y.value = withTiming(corners[2].y); + animatedCorner4X.value = withTiming(corners[3].x); + animatedCorner4Y.value = withTiming(corners[3].y); + + if (corner1.x === 0 && corner1.y === 0) { + setCorner1(corners[0]); + setCorner2(corners[1]); + setCorner3(corners[2]); + setCorner4(corners[3]); + } + }; + const handleBarCodeScan = (event: BarCodeScanningResult) => { + const { cornerPoints } = event; + setCorners(cornerPoints); + console.log( + cornerPoints, + "interpolated", + corner1, + corner2, + corner3, + corner4 + ); + }; + + const RenderOutline = () => ( + + {/* top left */} + + {/* bottom left */} + + {/* bottom right */} + + {/* top right */} + + + ); + return ( + + + + {} + + + + + + + + + ); +} + +const useStyles = createStyles((theme) => + StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + backgroundColor: theme.colors.lightBlue, + height: "100%", + }, + camera: { + flex: 1, + }, + buttonContainer: { + flexDirection: "row", + backgroundColor: "transparent", + marginTop: 32, + marginRight: 16, + justifyContent: "flex-end", + alignItems: "flex-start", + }, + button: { + width: 64, + height: 64, + borderRadius: theme.borderRadiusFull, + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", + }, + text: { + fontSize: 24, + fontWeight: "bold", + color: "white", + }, + outlineContainer: { + position: "absolute", + }, + outlinePoint: { + position: "absolute", + width: TRACER_SIZE, + height: TRACER_SIZE, + backgroundColor: "rgba(0, 0, 0,1)", + justifyContent: "center", + borderRadius: theme.borderRadiusFull, + }, + }) +); diff --git a/native/components/BarcodeScanner/common.ts b/native/components/BarcodeScanner/common.ts new file mode 100644 index 00000000..eb96efba --- /dev/null +++ b/native/components/BarcodeScanner/common.ts @@ -0,0 +1,18 @@ +import { Animated } from "react-native"; + +export const TRACER_SIZE = 10; +export const TOP_BAR_HEIGHT = 56; + +export const interpolatableX = + (height: number, topInset: number) => (x: Animated.Value) => { + return x.interpolate({ + inputRange: [0, 1], + outputRange: [0, height - TOP_BAR_HEIGHT - topInset], + }); + }; + +export const interpolatableY = (width: number) => (y: Animated.Value) => + y.interpolate({ + inputRange: [0, 1], + outputRange: [width, 0], + }); diff --git a/native/components/BarcodeScanner/index.tsx b/native/components/BarcodeScanner/index.tsx new file mode 100644 index 00000000..f145db86 --- /dev/null +++ b/native/components/BarcodeScanner/index.tsx @@ -0,0 +1,189 @@ +import { BarCodeScanningResult, Camera, CameraType, Point } from "expo-camera"; +import { useRouter } from "expo-router"; +import React, { useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Animated, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { useListBarcodes } from "../../db/hooks/useListBarcodes"; +import { createStyles } from "../../theme/useStyles"; +import { CameraSwitchIcon } from "../Icon"; +import { Typography } from "../Typography"; +import { BarcodeOutline } from "./BarcodeOutline"; + +const setCornerXY = + (animatedX: Animated.Value, animatedY: Animated.Value) => (value: Point) => + Animated.parallel([ + Animated.timing(animatedX, { + toValue: value.x, + duration: 50, + useNativeDriver: true, + }), + Animated.timing(animatedY, { + toValue: value.y, + duration: 50, + useNativeDriver: true, + }), + ]).start(); + +export const BarcodeScanner = ({ inventoryId }: { inventoryId: number }) => { + const styles = useStyles(); + + const [type, setType] = useState(CameraType.back); + const animatedTLCornerX = useRef(new Animated.Value(0)); + const animatedTLCornerY = useRef(new Animated.Value(0)); + const animatedBLCornerX = useRef(new Animated.Value(0)); + const animatedBLCornerY = useRef(new Animated.Value(0)); + const animatedBRCornerX = useRef(new Animated.Value(0)); + const animatedBRCornerY = useRef(new Animated.Value(0)); + const animatedTRCornerX = useRef(new Animated.Value(0)); + const animatedTRCornerY = useRef(new Animated.Value(0)); + const [alertShown, setAlertShown] = useState(false); + + const router = useRouter(); + const { data: barcodeList, isLoading } = useListBarcodes({ inventoryId }); + + const toggleCameraType = () => { + setType((current) => + current === CameraType.back ? CameraType.front : CameraType.back + ); + }; + const doubleTap = Gesture.Tap().numberOfTaps(2).onStart(toggleCameraType); + + const setTLCornerAnimation = setCornerXY( + animatedTLCornerX.current, + animatedTLCornerY.current + ); + const setBLCornerAnimation = setCornerXY( + animatedBLCornerX.current, + animatedBLCornerY.current + ); + const setBRCornerAnimation = setCornerXY( + animatedBRCornerX.current, + animatedBRCornerY.current + ); + const setTRCornerAnimation = setCornerXY( + animatedTRCornerX.current, + animatedTRCornerY.current + ); + + const setCorners = (corners: Point[]) => { + setTLCornerAnimation(corners[0]); + setBLCornerAnimation(corners[1]); + setBRCornerAnimation(corners[2]); + setTRCornerAnimation(corners[3]); + }; + + if (isLoading && !barcodeList) { + return ( + + + + ); + } + + if (!isLoading && !barcodeList) { + return ( + + + Nie znaleziono kodów kreskowych dla tej inwentaryzacji + + + ); + } + + const handleBarCodeScan = (event: BarCodeScanningResult) => { + const { cornerPoints, data } = event; + setCorners(cornerPoints); + const mappedBarcode = barcodeList?.[data]; + if (!mappedBarcode) { + !alertShown && + Alert.alert("Nie znaleziono kodu kreskowego", "", [ + { + text: "Cofnij", + onPress: () => { + router.back(); + }, + }, + ]); + setAlertShown(true); + return; + } + router.push(`/(tabs)/${inventoryId}/${mappedBarcode}`); + }; + + return ( + + + + + + + + + + + ); +}; + +const useStyles = createStyles((theme) => + StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + backgroundColor: theme.colors.lightBlue, + height: "100%", + }, + camera: { + flex: 1, + }, + buttonContainer: { + flexDirection: "row", + backgroundColor: "transparent", + marginTop: 32, + marginRight: 16, + justifyContent: "flex-end", + alignItems: "flex-start", + }, + button: { + width: 64, + height: 64, + borderRadius: theme.borderRadiusFull, + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", + }, + text: { + fontSize: 24, + fontWeight: "bold", + color: "white", + }, + paddingH: { + paddingHorizontal: theme.spacing, + }, + }) +); diff --git a/native/components/Icon.tsx b/native/components/Icon.tsx index 2e1b3396..a5352875 100644 --- a/native/components/Icon.tsx +++ b/native/components/Icon.tsx @@ -125,3 +125,11 @@ export const ListIcon = (props: IconProps) => const inventoryIconSrc = require("../assets/images/inventory.png"); export const InventoryIcon = (props: IconProps) => createIcon({ source: inventoryIconSrc, props }); + +const cameraSwitchIconSrc = require("../assets/images/camera-switch.png"); +export const CameraSwitchIcon = (props: IconProps) => + createIcon({ source: cameraSwitchIconSrc, props }); + +export const ScanBarcodeIconSrc = require("../assets/images/scan-barcode.png"); +export const ScanBarcodeIcon = (props: IconProps) => + createIcon({ source: ScanBarcodeIconSrc, props }); diff --git a/native/components/Typography.tsx b/native/components/Typography.tsx index 340c1724..5f52064d 100644 --- a/native/components/Typography.tsx +++ b/native/components/Typography.tsx @@ -11,8 +11,6 @@ import { import { MainTheme, ThemeColors } from "../theme"; import { createStyles } from "../theme/useStyles"; -type Align = Capitalize["align"]>; - export type TypographyProps = { children: React.ReactNode; color?: ThemeColors; @@ -45,7 +43,7 @@ export const Typography = ({ style={[ color && { color: theme.colors[color] }, styles[variant], - align && styles[`align${align.toUpperCase() as Align}`], + align && styles[`align${align}`], underline && styles.underline, opacity && styles.opacity, style, @@ -60,19 +58,19 @@ export const Typography = ({ const useStyles = createStyles((theme) => StyleSheet.create({ - alignLeft: { + alignleft: { textAlign: "left", }, - alignCenter: { + aligncenter: { textAlign: "center", }, - alignRight: { + alignright: { textAlign: "right", }, - alignAuto: { + alignauto: { textAlign: "auto", }, - alignJustify: { + alignjustify: { textAlign: "justify", }, xs: { ...theme.text.xs }, diff --git a/native/db/hooks/useListBarcodes.ts b/native/db/hooks/useListBarcodes.ts new file mode 100644 index 00000000..30a90b02 --- /dev/null +++ b/native/db/hooks/useListBarcodes.ts @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; + +import isEmpty from "lodash/isEmpty"; +import { supabase } from "../supabase"; +import { Product, Record } from "../types"; + +type ListBarcodePostgrestRes = + | { + barcodes: Product["barcodes"]; + record_view: [{ id: Record["id"] } | null]; + }[] + | null; + +type ListBarcodeReturn = { + [barcode: Product["barcodes"][number]]: Record["id"]; +}; + +const listBarcodes = async (inventory_id: number) => { + const res = await supabase + .from("product") + .select("barcodes, record_view(id)") + .eq("record_view.inventory_id", inventory_id); + + const data = res.data as ListBarcodePostgrestRes; + if (!data) return null; + + const reducedData = data.reduce((result, item) => { + const barcodes = item.barcodes; + const recordId = item.record_view?.[0]?.id; + if (!recordId) return result; + barcodes.forEach((barcode) => { + result[barcode] = recordId; + }); + return result; + }, {} as ListBarcodeReturn); + + if (isEmpty(reducedData)) return null; + + return { + ...res, + data: reducedData, + }; +}; + +export const useListBarcodes = ({ inventoryId }: { inventoryId: number }) => { + const query = useQuery( + ["listBarcodes", inventoryId], + async () => await listBarcodes(inventoryId) + ); + return { ...query, data: query.data?.data as ListBarcodeReturn | null }; +}; diff --git a/native/db/supabase.ts b/native/db/supabase.ts index 525e89e5..81c66271 100644 --- a/native/db/supabase.ts +++ b/native/db/supabase.ts @@ -9,7 +9,8 @@ if (Platform.OS !== "web") { } // import { SUPABASE_URL, SUPABASE_ANON_KEY } from "@env"; - +// router +// const localUrl = "http://192.168.0.104:54321"; const localUrl = "http://172.20.10.14:54321"; const localAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"; diff --git a/native/db/types/generated.ts b/native/db/types/generated.ts index e070bb3a..cb27810b 100644 --- a/native/db/types/generated.ts +++ b/native/db/types/generated.ts @@ -3,7 +3,7 @@ export type Json = | number | boolean | null - | { [key: string]: Json } + | { [key: string]: Json | undefined } | Json[]; export interface Database { @@ -85,6 +85,7 @@ export interface Database { }; product: { Row: { + barcodes: string[]; company_id: number | null; created_at: string; id: number; @@ -93,6 +94,7 @@ export interface Database { unit: string; }; Insert: { + barcodes?: string[]; company_id?: number | null; created_at?: string; id?: number; @@ -101,6 +103,7 @@ export interface Database { unit?: string; }; Update: { + barcodes?: string[]; company_id?: number | null; created_at?: string; id?: number; @@ -154,10 +157,26 @@ export interface Database { } ]; }; + test_tenant: { + Row: { + details: string | null; + id: number; + }; + Insert: { + details?: string | null; + id?: number; + }; + Update: { + details?: string | null; + id?: number; + }; + Relationships: []; + }; worker: { Row: { company_id: number | null; created_at: string; + email: string; id: string; is_admin: boolean; name: string | null; @@ -165,6 +184,7 @@ export interface Database { Insert: { company_id?: number | null; created_at?: string; + email?: string; id: string; is_admin?: boolean; name?: string | null; @@ -172,6 +192,7 @@ export interface Database { Update: { company_id?: number | null; created_at?: string; + email?: string; id?: string; is_admin?: boolean; name?: string | null; @@ -237,6 +258,30 @@ export interface Database { } ]; }; + worker_for_current_user: { + Row: { + company_id: number | null; + created_at: string | null; + email: string | null; + id: string | null; + is_admin: boolean | null; + name: string | null; + }; + Relationships: [ + { + foreignKeyName: "worker_company_id_fkey"; + columns: ["company_id"]; + referencedRelation: "company"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "worker_id_fkey"; + columns: ["id"]; + referencedRelation: "users"; + referencedColumns: ["id"]; + } + ]; + }; }; Functions: { add_next_product_record: { @@ -252,6 +297,13 @@ export interface Database { quantity: number; }; }; + assign_new_worker_to_company: { + Args: { + new_company_id: number; + worker_email: string; + }; + Returns: string; + }; get_previous_inventory: { Args: { inventory_id: number; @@ -264,19 +316,6 @@ export interface Database { name: string; }[]; }; - get_previous_product_record: { - Args: { - current_inventory_id: number; - current_product_id: number; - }; - Returns: { - created_at: string; - id: number; - inventory_id: number; - product_id: number; - quantity: number; - }; - }; get_previous_product_record_quantity: { Args: { current_inventory_id: number; @@ -414,12 +453,6 @@ export interface Database { columns: ["bucket_id"]; referencedRelation: "buckets"; referencedColumns: ["id"]; - }, - { - foreignKeyName: "objects_owner_fkey"; - columns: ["owner"]; - referencedRelation: "users"; - referencedColumns: ["id"]; } ]; }; diff --git a/native/package-lock.json b/native/package-lock.json index f6995f85..c54564ff 100644 --- a/native/package-lock.json +++ b/native/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/react-query-persist-client": "4.18.0", "date-fns": "^2.30.0", "expo": "~49.0.6", + "expo-camera": "~13.4.4", "expo-font": "~11.4.0", "expo-linking": "~5.0.2", "expo-router": "2.0.0", @@ -10555,6 +10556,17 @@ "url-parse": "^1.5.9" } }, + "node_modules/expo-camera": { + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-13.4.4.tgz", + "integrity": "sha512-7k54APbpSulUDR2CrD5SrmKjCdfdg4tqKRpbBOKc2J2MIBHhunExU77435JDYSejHRY5bfRHZsEp3yKwR862uw==", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "14.4.2", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-14.4.2.tgz", diff --git a/native/package.json b/native/package.json index b272c1e0..d95a5713 100644 --- a/native/package.json +++ b/native/package.json @@ -11,7 +11,7 @@ "lint": "eslint --fix --cache", "typecheck": "tsc --noEmit", "format": "prettier --write .", - "gen-types": "supabase gen types typescript --project-id 'xysdrumggpxdpxoysaib' --schema public > db/types/generated.ts" + "gen-types": "npx supabase gen types typescript --local > db/types/generated.ts" }, "jest": { "preset": "jest-expo" @@ -49,7 +49,8 @@ "react-native-screens": "~3.22.0", "react-native-url-polyfill": "^1.3.0", "react-native-web": "~0.19.6", - "@expo/config-plugins": "~7.2.2" + "@expo/config-plugins": "~7.2.2", + "expo-camera": "~13.4.4" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/supabase/migrations/20231119131615_barcodes.sql b/supabase/migrations/20231119131615_barcodes.sql new file mode 100644 index 00000000..050b6b25 --- /dev/null +++ b/supabase/migrations/20231119131615_barcodes.sql @@ -0,0 +1,23 @@ +ALTER TABLE "public"."product" ADD COLUMN "barcodes" text[] DEFAULT '{}'::text[] NOT NULL; + +CREATE OR REPLACE FUNCTION check_unique_barcodes() +RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "public"."product" p + WHERE p.company_id = NEW.company_id + AND ( + (ARRAY(SELECT unnest(NEW.barcodes)) && ARRAY(SELECT unnest(p.barcodes))) OR + (NEW.barcodes IS NULL AND p.barcodes IS NULL) + ) + ) THEN + RAISE EXCEPTION 'Barcode must be unique within the same company_id'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prevent_duplicate_barcodes +BEFORE INSERT OR UPDATE ON "public"."product" +FOR EACH ROW EXECUTE FUNCTION check_unique_barcodes(); \ No newline at end of file