diff --git a/ecoscan_app/app/(tabs)/(scan)/index.tsx b/ecoscan_app/app/(tabs)/(scan)/index.tsx index 6051d027..84b372b0 100644 --- a/ecoscan_app/app/(tabs)/(scan)/index.tsx +++ b/ecoscan_app/app/(tabs)/(scan)/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { KeyboardAvoidingView, Platform, @@ -7,7 +7,7 @@ import { TextInput, View, } from "react-native"; -import { ActivityIndicator, Button, Snackbar, Text } from "react-native-paper"; +import { ActivityIndicator, Button, Text } from "react-native-paper"; import BarcodeScanner from "@/components/BarcodeScanner"; import { PageContainer } from "@/components/PageContainer"; @@ -18,25 +18,18 @@ import { useError } from "@/context/ErrorContext"; export default function Scan() { const [barcode, setBarcode] = useState(""); - const [error, setError] = useState(); - const [snackbarVisible, setSnackbarVisible] = useState(false); + const { setError } = useError(); const router = useRouter(); const { loading, analyzeProduct, cancelAnalysis } = useAnalyzeProduct(); - const { consumeError } = useError(); const [scanned, setScanned] = useState(false); const scrollViewRef = useRef(null); - const showError = (message: string) => { - setError(message); - setSnackbarVisible(true); - }; - const onScanned = async (code: string) => { scrollViewRef.current?.scrollTo({ y: 0, animated: true }); setScanned(true); const trimmed = code.trim(); if (!trimmed) { - showError("Barcode darf nicht leer sein."); + setError("Barcode darf nicht leer sein."); return; } try { @@ -47,22 +40,15 @@ export default function Scan() { params: { id: trimmed }, }); } else { - showError("Produkt konnte nicht analysiert werden."); + setError("Produkt konnte nicht analysiert werden."); } } catch (err) { const msg = err instanceof Error ? err.message : "Analyse fehlgeschlagen."; - showError(msg); + setError(msg); } }; - useEffect(() => { - const errorMsg = consumeError(); - if (errorMsg) { - showError(errorMsg); - } - }, [consumeError]); - return ( )} - - setSnackbarVisible(false)} - duration={4000} - style={{ backgroundColor: theme.colors.error }} - > - {error} - diff --git a/ecoscan_app/app/_layout.tsx b/ecoscan_app/app/_layout.tsx index 0099e7dc..b460ae51 100644 --- a/ecoscan_app/app/_layout.tsx +++ b/ecoscan_app/app/_layout.tsx @@ -4,11 +4,15 @@ import { useRouter, useSegments, } from "expo-router"; -import { PaperProvider } from "react-native-paper"; +import { PaperProvider, Snackbar } from "react-native-paper"; import { theme } from "@/theme"; import { AuthProvider, useAuth } from "@/context/AuthContext"; import { useEffect } from "react"; -import { ErrorProvider } from "@/context/ErrorContext"; +import { + useSnackbar, + getSnackbarStyles, + SnackbarProvider, +} from "@/context/SnackbarContext"; import { NotificationProvider } from "@/context/NotificationProvider"; import { ProductProvider } from "@/context/ProductContext"; @@ -17,6 +21,7 @@ function RootLayoutNav() { const segments = useSegments(); const router = useRouter(); const navigationState = useRootNavigationState(); + const { currentSnackbar, dismissSnackbar } = useSnackbar(); useEffect(() => { if (!navigationState?.key || isLoading) return; @@ -44,23 +49,38 @@ function RootLayoutNav() { ); - return isAuthenticated && !isLoading ? ( - {stack} - ) : ( - stack + return ( + <> + {isAuthenticated && !isLoading ? ( + {stack} + ) : ( + stack + )} + + {currentSnackbar?.message || ""} + + ); } export default function RootLayout() { return ( - + - + ); } diff --git a/ecoscan_app/components/product/BoughtButton.tsx b/ecoscan_app/components/product/BoughtButton.tsx index d929a397..680a4291 100644 --- a/ecoscan_app/components/product/BoughtButton.tsx +++ b/ecoscan_app/components/product/BoughtButton.tsx @@ -1,9 +1,9 @@ -import { Button, Snackbar, Portal } from "react-native-paper"; +import { Button } from "react-native-paper"; import { StyleSheet } from "react-native"; import { Product } from "@/types/product"; import { useSaveProduct } from "@/hooks/useSaveProduct"; -import { useState, useEffect } from "react"; -import { theme } from "@/theme"; +import { useEffect } from "react"; +import { useSnackbar } from "@/context/SnackbarContext"; export interface BoughtButtonProps { product?: Product; @@ -12,13 +12,15 @@ export interface BoughtButtonProps { export default function BoughtButton({ product }: BoughtButtonProps) { const { saveProduct, loading, error, success, saved, resetSaved } = useSaveProduct(); - const [visible, setVisible] = useState(false); + const { showSuccess, showError } = useSnackbar(); useEffect(() => { - if (success || error) { - setVisible(true); + if (success) { + showSuccess("Produkt erfolgreich gekauft!"); + } else if (error) { + showError("Fehler beim Speichern des Produkts."); } - }, [success, error]); + }, [success, error, showSuccess, showError]); useEffect(() => { resetSaved(); @@ -43,19 +45,6 @@ export default function BoughtButton({ product }: BoughtButtonProps) { > Gekauft - - - setVisible(false)} - duration={2000} - style={{ - backgroundColor: error ? theme.colors.error : theme.colors.primary, - }} - > - {error ? `${error}` : "Erfolgreich gespeichert"} - - ); } diff --git a/ecoscan_app/context/ErrorContext.tsx b/ecoscan_app/context/ErrorContext.tsx index cf05e3a3..17bbef7d 100644 --- a/ecoscan_app/context/ErrorContext.tsx +++ b/ecoscan_app/context/ErrorContext.tsx @@ -4,37 +4,19 @@ import { useCallback, useContext, useRef, + useEffect, + useState, } from "react"; +import { useSnackbar } from "@/context/SnackbarContext"; +export function useError() { + const { showError } = useSnackbar(); -type ErrorContextType = { - setError: (msg: string) => void; - consumeError: () => string | undefined; -}; - -const ErrorContext = createContext(undefined); - -export function ErrorProvider({ children }: { children: ReactNode }) { - const errorRef = useRef(undefined); - - const setError = useCallback((msg: string) => { - errorRef.current = msg; - }, []); - - const consumeError = useCallback(() => { - const msg = errorRef.current; - errorRef.current = undefined; - return msg; - }, []); - - return ( - - {children} - + const setError = useCallback( + (msg: string, duration?: number) => { + showError(msg, duration ?? 5000); + }, + [showError], ); -} -export function useError() { - const ctx = useContext(ErrorContext); - if (!ctx) throw new Error("useError must be used within ErrorProvider"); - return ctx; + return { setError }; } diff --git a/ecoscan_app/context/SnackbarContext.tsx b/ecoscan_app/context/SnackbarContext.tsx new file mode 100644 index 00000000..835242c7 --- /dev/null +++ b/ecoscan_app/context/SnackbarContext.tsx @@ -0,0 +1,147 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, + useMemo, +} from "react"; + +import { theme } from "@/theme"; + +export type SnackbarType = "error" | "success" | "info" | "warning"; + +type Snackbar = { + id: string; + message: string; + type: SnackbarType; + duration?: number; +}; + +type SnackbarContextType = { + showError: (msg: string, duration?: number) => void; + showSuccess: (msg: string, duration?: number) => void; + showInfo: (msg: string, duration?: number) => void; + showWarning: (msg: string, duration?: number) => void; + currentSnackbar: Snackbar | undefined; + dismissSnackbar: () => void; +}; + +const SnackbarContext = createContext( + undefined, +); + +export const getSnackbarStyles = (type: SnackbarType) => { + switch (type) { + case "error": + return { + backgroundColor: theme.colors.error, + }; + case "success": + return { + backgroundColor: theme.colors.primary, + }; + case "warning": + return { + backgroundColor: theme.colors.warning, + }; + case "info": + return { + backgroundColor: theme.colors.surface, + }; + default: + return { + backgroundColor: theme.colors.surface, + }; + } +}; + +export function SnackbarProvider({ children }: { children: ReactNode }) { + const [snackbarQueue, setSnackbarQueue] = useState([]); + const [currentSnackbar, setCurrentSnackbar] = useState< + Snackbar | undefined + >(); + + const addSnackbar = useCallback( + (message: string, type: SnackbarType, duration?: number) => { + const id = Date.now().toString(); + setSnackbarQueue((prev) => [ + ...prev, + { id, message, type, duration: duration ?? 4000 }, + ]); + }, + [], + ); + + const showError = useCallback( + (msg: string, duration?: number) => { + addSnackbar(msg, "error", duration ?? 5000); + }, + [addSnackbar], + ); + + const showSuccess = useCallback( + (msg: string, duration?: number) => { + addSnackbar(msg, "success", duration ?? 3000); + }, + [addSnackbar], + ); + + const showInfo = useCallback( + (msg: string, duration?: number) => { + addSnackbar(msg, "info", duration ?? 3000); + }, + [addSnackbar], + ); + + const showWarning = useCallback( + (msg: string, duration?: number) => { + addSnackbar(msg, "warning", duration ?? 4000); + }, + [addSnackbar], + ); + + const dismissSnackbar = useCallback(() => { + setCurrentSnackbar(undefined); + }, []); + + useEffect(() => { + if (!currentSnackbar && snackbarQueue.length > 0) { + const [next, ...rest] = snackbarQueue; + setCurrentSnackbar(next); + setSnackbarQueue(rest); + } + }, [currentSnackbar, snackbarQueue]); + + const contextValue = useMemo( + () => ({ + showError, + showSuccess, + showInfo, + showWarning, + currentSnackbar, + dismissSnackbar, + }), + [ + showError, + showSuccess, + showInfo, + showWarning, + currentSnackbar, + dismissSnackbar, + ], + ); + + return ( + + {children} + + ); +} + +export function useSnackbar() { + const ctx = useContext(SnackbarContext); + if (!ctx) throw new Error("useSnackbar must be used within SnackbarProvider"); + return ctx; +} diff --git a/ecoscan_app/hooks/useAnalyzeProduct.ts b/ecoscan_app/hooks/useAnalyzeProduct.ts index 52558505..17f7ebb5 100644 --- a/ecoscan_app/hooks/useAnalyzeProduct.ts +++ b/ecoscan_app/hooks/useAnalyzeProduct.ts @@ -14,7 +14,6 @@ type UseAnalyzeProductResult = { export function useAnalyzeProduct(): UseAnalyzeProductResult { const api = useApiClient(); const { setProduct } = useProduct(); - const { setError } = useError(); const [loading, setLoading] = useState(false); const { startStream, closeStream } = useSseClient( "product-analysis-evaluation", @@ -33,12 +32,11 @@ export function useAnalyzeProduct(): UseAnalyzeProductResult { err instanceof Error && err.name === "AbortError" ? "Analyse abgebrochen" : "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut."; - setError(errorMsg); - console.error("SSE stream error:", err); + console.warn("[useAnalyzeProduct] SSE stream error:", err); completionRef.current?.reject(new Error(errorMsg)); completionRef.current = null; }, - [setError], + [closeStream], ); const handleStreamSuccess = useCallback( @@ -49,7 +47,7 @@ export function useAnalyzeProduct(): UseAnalyzeProductResult { completionRef.current?.resolve(true); completionRef.current = null; }, - [setProduct], + [setProduct, closeStream], ); const cancelAnalysis = useCallback(() => { @@ -76,7 +74,6 @@ export function useAnalyzeProduct(): UseAnalyzeProductResult { setProduct(productData); return true; } - setError("Produkt konnte nicht geladen werden."); return false; } return new Promise((resolve, reject) => { @@ -89,22 +86,11 @@ export function useAnalyzeProduct(): UseAnalyzeProductResult { }); } catch (err) { setLoading(false); - const errorMsg = - err instanceof Error - ? err.message - : "Produkt konnte nicht analysiert werden."; - setError(errorMsg); + console.warn("[useAnalyzeProduct] analyzeProduct failed:", err); throw err; } }, - [ - api, - setProduct, - setError, - startStream, - handleStreamSuccess, - handleStreamError, - ], + [api, setProduct, startStream, handleStreamSuccess, handleStreamError], ); useEffect(() => {