diff --git a/App.js b/App.js index bcf57f1..e8c3519 100644 --- a/App.js +++ b/App.js @@ -33,32 +33,31 @@ export default function App() { const [manualPrices, setManualPrices] = useState({}); const [recentProducts, setRecentProducts] = useState([]); const [isHydrated, setIsHydrated] = useState(false); + const [storageError, setStorageError] = useState(''); useEffect(() => { let isMounted = true; async function hydrate() { - const [ - storedPreferences, - storedShoppingList, - storedProductCache, - storedManualPrices, - storedRecentProducts - ] = await Promise.all([ - storageService.loadPreferences(defaultPreferences), - storageService.loadShoppingList(initialShoppingList), - storageService.loadProductCache(), - storageService.loadManualPrices(), - storageService.loadRecentProducts() - ]); + try { + const [storedPreferences, storedShoppingList, storedProductCache, storedManualPrices, storedRecentProducts] = await Promise.all([ + storageService.loadPreferences(defaultPreferences), + storageService.loadShoppingList(initialShoppingList), + storageService.loadProductCache(), + storageService.loadManualPrices(), + storageService.loadRecentProducts() + ]); - if (isMounted) { - setPreferences(storedPreferences); - setShoppingList(storedShoppingList); - setProductCache(storedProductCache); - setManualPrices(storedManualPrices); - setRecentProducts(storedRecentProducts); - setIsHydrated(true); + if (isMounted) { + setPreferences({ ...defaultPreferences, ...storedPreferences }); + setShoppingList({ ...initialShoppingList, ...storedShoppingList, items: storedShoppingList?.items || [] }); + setProductCache(storedProductCache || {}); + setManualPrices(storedManualPrices || {}); + setRecentProducts(Array.isArray(storedRecentProducts) ? storedRecentProducts : []); + setIsHydrated(true); + } + } catch (error) { + if (isMounted) setStorageError('Deine lokalen Daten konnten nicht geladen werden.'); } } @@ -68,6 +67,27 @@ export default function App() { }; }, []); + function retryHydration() { + setStorageError(''); + setIsHydrated(false); + // Reloading the app is not necessary for the normal path, but this keeps + // the retry action explicit if a storage adapter fails temporarily. + Promise.all([ + storageService.loadPreferences(defaultPreferences), + storageService.loadShoppingList(initialShoppingList), + storageService.loadProductCache(), + storageService.loadManualPrices(), + storageService.loadRecentProducts() + ]).then(([nextPreferences, nextList, nextCache, nextPrices, nextRecent]) => { + setPreferences({ ...defaultPreferences, ...nextPreferences }); + setShoppingList({ ...initialShoppingList, ...nextList, items: nextList?.items || [] }); + setProductCache(nextCache || {}); + setManualPrices(nextPrices || {}); + setRecentProducts(Array.isArray(nextRecent) ? nextRecent : []); + setIsHydrated(true); + }).catch(() => setStorageError('Bitte starte Shoppy erneut, um die lokalen Daten zu laden.')); + } + useEffect(() => { if (isHydrated) storageService.savePreferences(preferences); }, [isHydrated, preferences]); @@ -132,11 +152,13 @@ export default function App() { } return ( - setActiveTab('scan')} - preferences={preferences} - recentProducts={recentProducts} + setActiveTab('list')} + onStartScan={() => setActiveTab('scan')} + preferences={preferences} + recentProducts={recentProducts} + shoppingList={shoppingList} /> ); }, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]); @@ -356,7 +378,8 @@ export default function App() { if (!isHydrated) { return ( - Shoppy wird geladen... + {storageError || 'Shoppy wird geladen...'} + {storageError ? Erneut versuchen : null} ); } @@ -443,5 +466,11 @@ const styles = StyleSheet.create({ color: colors.muted, fontSize: 16, fontWeight: '700' + }, + retryText: { + color: colors.primary, + fontSize: 15, + fontWeight: '800', + marginTop: 16 } }); diff --git a/README.md b/README.md index 621ab9c..53da090 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,25 @@ Die App nutzt für Barcodes `https://world.openfoodfacts.org/api/v2/product/{bar ## Runbook +### Automatischer Android-Build mit Gitea Actions + +Der Workflow in `.gitea/workflows/android-release.yml` startet bei jedem Push +in ein Gitea-Repository. Er installiert die Abhängigkeiten mit `npm ci`, +erzeugt das nicht versionierte Android-Projekt per Expo und kompiliert mit +Gradle eine Release-APK. Die APK ist im jeweiligen Gitea-Actions-Lauf als +Artefakt `shoppy-android-release-` herunterladbar. + +Voraussetzungen: Gitea Actions ist für das Repository aktiviert und ein +Linux-Runner mit dem Label `ubuntu-latest` ist registriert. Der Runner benötigt +Netzwerkzugang für npm, Gradle und das Android SDK sowie ausreichend Speicher +für einen Android-Build. `origin` zeigt auf das Gitea-Repository; der Workflow +startet, sobald die Änderungen dorthin gepusht werden. + +Die APK wird derzeit im Release-Modus mit dem von Expo erzeugten Debug-Schlüssel +signiert. Sie eignet sich zum Testen, aber nicht als Store-Release. Für eine +Veröffentlichung muss ein eigener Release-Schlüssel sicher in Gitea hinterlegt +und die Android-Signierung entsprechend konfiguriert werden. + Projekt installieren: ```bash @@ -113,5 +132,5 @@ Für den Barcode-Scan muss die Kamera-Berechtigung erlaubt werden. Auf Web oder Repository-Link für den Kursleiter: ```text -https://github.com/Fretuks/Shoppy +https://git.bachmannj.ch/Fretux/Shoppy ``` diff --git a/src/components/form/ManualSearchInput.js b/src/components/form/ManualSearchInput.js index 48d7e68..0b494d2 100644 --- a/src/components/form/ManualSearchInput.js +++ b/src/components/form/ManualSearchInput.js @@ -3,20 +3,21 @@ import { StyleSheet, TextInput, View } from 'react-native'; import PrimaryButton from '../PrimaryButton'; import { colors } from '../../utils/constants'; -export default function ManualSearchInput({ value, onChangeText, onSubmit, placeholder = 'Produktname oder Barcode' }) { +export default function ManualSearchInput({ value, onChangeText, onSubmit, disabled = false, placeholder = 'Produktname oder Barcode' }) { return ( - + ); } diff --git a/src/components/layout/AppHeader.js b/src/components/layout/AppHeader.js index 072a753..9e83d38 100644 --- a/src/components/layout/AppHeader.js +++ b/src/components/layout/AppHeader.js @@ -7,7 +7,7 @@ export default function AppHeader({ title, subtitle, onBack, rightAction }) { {onBack ? ( - + ‹ ) : null} diff --git a/src/components/layout/BottomTabBar.js b/src/components/layout/BottomTabBar.js index f2629a0..fd1b32b 100644 --- a/src/components/layout/BottomTabBar.js +++ b/src/components/layout/BottomTabBar.js @@ -16,6 +16,7 @@ export default function BottomTabBar({ activeTab, onChange }) { const isActive = activeTab === tab.id; return ( onChange(tab.id)} diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 5685108..558357d 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -1,4 +1,4 @@ -import { StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import PrimaryButton from '../components/PrimaryButton'; import AppHeader from '../components/layout/AppHeader'; @@ -8,7 +8,7 @@ import ProductCard from '../components/product/ProductCard'; import { calculateProductRating } from '../services/ratingService'; import { colors } from '../utils/constants'; -export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) { +export default function HomeScreen({ preferences, recentProducts, shoppingList, onStartScan, onOpenProduct, onOpenList }) { const frequentProducts = [...recentProducts] .filter((product) => (product.scanCount || 0) > 1) .sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)) @@ -28,6 +28,14 @@ export default function HomeScreen({ preferences, recentProducts, onStartScan, o Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal. + {shoppingList?.items?.length > 0 ? ( + + Einkaufsliste + + {shoppingList.items.filter((item) => !item.checked).length} offene Produkte + + + ) : null} {frequentProducts.length > 0 ? ( @@ -119,5 +127,20 @@ const styles = StyleSheet.create({ fontSize: 14, lineHeight: 20, marginTop: 4 + }, + listShortcut: { + backgroundColor: '#eaf4ef', + borderRadius: 8, + gap: 2, + padding: 12 + }, + listShortcutTitle: { + color: colors.primaryDark, + fontSize: 15, + fontWeight: '800' + }, + listShortcutCopy: { + color: colors.muted, + fontSize: 13 } }); diff --git a/src/screens/ScanScreen.js b/src/screens/ScanScreen.js index e1cf6ce..acfa82b 100644 --- a/src/screens/ScanScreen.js +++ b/src/screens/ScanScreen.js @@ -30,9 +30,15 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o setIsScanning(false); setIsLoadingProduct(true); - const result = await onScanBarcode(event.data).finally(() => { + let result; + + try { + result = await onScanBarcode(event.data); + } catch (error) { + result = { status: 'error', errorMessage: 'Das Produkt konnte gerade nicht geladen werden.' }; + } finally { setIsLoadingProduct(false); - }); + } if (result?.status !== 'found') { setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.'); @@ -41,10 +47,15 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o } async function handleManualSearch() { + if (!manualSearchQuery.trim() || isSearching) return; + setIsSearching(true); - const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled); - setManualResults(results); - setIsSearching(false); + try { + const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled); + setManualResults(results); + } finally { + setIsSearching(false); + } } function retryScan() { @@ -98,6 +109,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o {isSearching ? Suche läuft... : null} diff --git a/src/screens/ShoppingListScreen.js b/src/screens/ShoppingListScreen.js index 2d32028..94efefc 100644 --- a/src/screens/ShoppingListScreen.js +++ b/src/screens/ShoppingListScreen.js @@ -33,7 +33,7 @@ export default function ShoppingListScreen({ {shoppingList.title} - Produkte abhaken, löschen oder nach dem Einkauf abschließen. + Produkte abhaken, löschen oder nach dem Einkauf abschliessen. @@ -95,7 +95,7 @@ export default function ShoppingListScreen({ )} {shoppingList.items.length > 0 ? ( - + ) : null} ); diff --git a/src/services/storageService.js b/src/services/storageService.js index c223187..27fdf1d 100644 --- a/src/services/storageService.js +++ b/src/services/storageService.js @@ -9,12 +9,24 @@ const keys = { }; async function readJson(key, fallbackValue) { - const rawValue = await AsyncStorage.getItem(key); - return rawValue ? JSON.parse(rawValue) : fallbackValue; + try { + const rawValue = await AsyncStorage.getItem(key); + return rawValue ? JSON.parse(rawValue) : fallbackValue; + } catch (error) { + // A damaged value should not prevent the app from starting. The next save + // will replace it with a valid value. + console.warn(`Shoppy konnte ${key} nicht laden.`, error); + return fallbackValue; + } } async function writeJson(key, value) { - await AsyncStorage.setItem(key, JSON.stringify(value)); + try { + await AsyncStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + // Storage is best-effort; the in-memory state remains usable. + console.warn(`Shoppy konnte ${key} nicht speichern.`, error); + } } export const storageService = {