Handle error states and accessibility improvements

This commit is contained in:
2026-10-16 15:09:20 +02:00
parent fbd57d664f
commit 9b80580f9c
9 changed files with 139 additions and 42 deletions
+55 -26
View File
@@ -33,32 +33,31 @@ export default function App() {
const [manualPrices, setManualPrices] = useState({}); const [manualPrices, setManualPrices] = useState({});
const [recentProducts, setRecentProducts] = useState([]); const [recentProducts, setRecentProducts] = useState([]);
const [isHydrated, setIsHydrated] = useState(false); const [isHydrated, setIsHydrated] = useState(false);
const [storageError, setStorageError] = useState('');
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
async function hydrate() { async function hydrate() {
const [ try {
storedPreferences, const [storedPreferences, storedShoppingList, storedProductCache, storedManualPrices, storedRecentProducts] = await Promise.all([
storedShoppingList, storageService.loadPreferences(defaultPreferences),
storedProductCache, storageService.loadShoppingList(initialShoppingList),
storedManualPrices, storageService.loadProductCache(),
storedRecentProducts storageService.loadManualPrices(),
] = await Promise.all([ storageService.loadRecentProducts()
storageService.loadPreferences(defaultPreferences), ]);
storageService.loadShoppingList(initialShoppingList),
storageService.loadProductCache(),
storageService.loadManualPrices(),
storageService.loadRecentProducts()
]);
if (isMounted) { if (isMounted) {
setPreferences(storedPreferences); setPreferences({ ...defaultPreferences, ...storedPreferences });
setShoppingList(storedShoppingList); setShoppingList({ ...initialShoppingList, ...storedShoppingList, items: storedShoppingList?.items || [] });
setProductCache(storedProductCache); setProductCache(storedProductCache || {});
setManualPrices(storedManualPrices); setManualPrices(storedManualPrices || {});
setRecentProducts(storedRecentProducts); setRecentProducts(Array.isArray(storedRecentProducts) ? storedRecentProducts : []);
setIsHydrated(true); 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(() => { useEffect(() => {
if (isHydrated) storageService.savePreferences(preferences); if (isHydrated) storageService.savePreferences(preferences);
}, [isHydrated, preferences]); }, [isHydrated, preferences]);
@@ -132,11 +152,13 @@ export default function App() {
} }
return ( return (
<HomeScreen <HomeScreen
onOpenProduct={openProduct} onOpenProduct={openProduct}
onStartScan={() => setActiveTab('scan')} onOpenList={() => setActiveTab('list')}
preferences={preferences} onStartScan={() => setActiveTab('scan')}
recentProducts={recentProducts} preferences={preferences}
recentProducts={recentProducts}
shoppingList={shoppingList}
/> />
); );
}, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]); }, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]);
@@ -356,7 +378,8 @@ export default function App() {
if (!isHydrated) { if (!isHydrated) {
return ( return (
<View style={styles.loading}> <View style={styles.loading}>
<Text style={styles.loadingText}>Shoppy wird geladen...</Text> <Text style={styles.loadingText}>{storageError || 'Shoppy wird geladen...'}</Text>
{storageError ? <Text onPress={retryHydration} style={styles.retryText}>Erneut versuchen</Text> : null}
</View> </View>
); );
} }
@@ -443,5 +466,11 @@ const styles = StyleSheet.create({
color: colors.muted, color: colors.muted,
fontSize: 16, fontSize: 16,
fontWeight: '700' fontWeight: '700'
},
retryText: {
color: colors.primary,
fontSize: 15,
fontWeight: '800',
marginTop: 16
} }
}); });
+20 -1
View File
@@ -86,6 +86,25 @@ Die App nutzt für Barcodes `https://world.openfoodfacts.org/api/v2/product/{bar
## Runbook ## 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-<Laufnummer>` 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: Projekt installieren:
```bash ```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: Repository-Link für den Kursleiter:
```text ```text
https://github.com/Fretuks/Shoppy https://git.bachmannj.ch/Fretux/Shoppy
``` ```
+3 -2
View File
@@ -3,20 +3,21 @@ import { StyleSheet, TextInput, View } from 'react-native';
import PrimaryButton from '../PrimaryButton'; import PrimaryButton from '../PrimaryButton';
import { colors } from '../../utils/constants'; 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 ( return (
<View style={styles.row}> <View style={styles.row}>
<TextInput <TextInput
autoCapitalize="none" autoCapitalize="none"
onChangeText={onChangeText} onChangeText={onChangeText}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
editable={!disabled}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={colors.muted} placeholderTextColor={colors.muted}
returnKeyType="search" returnKeyType="search"
style={styles.input} style={styles.input}
value={value} value={value}
/> />
<PrimaryButton label="Suchen" onPress={onSubmit} /> <PrimaryButton disabled={disabled} label="Suchen" onPress={onSubmit} />
</View> </View>
); );
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export default function AppHeader({ title, subtitle, onBack, rightAction }) {
<View style={styles.header}> <View style={styles.header}>
<View style={styles.row}> <View style={styles.row}>
{onBack ? ( {onBack ? (
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}> <Pressable accessibilityLabel="Zurück" accessibilityRole="button" onPress={onBack} style={styles.backButton}>
<Text style={styles.backText}>‹</Text> <Text style={styles.backText}>‹</Text>
</Pressable> </Pressable>
) : null} ) : null}
+1
View File
@@ -16,6 +16,7 @@ export default function BottomTabBar({ activeTab, onChange }) {
const isActive = activeTab === tab.id; const isActive = activeTab === tab.id;
return ( return (
<Pressable <Pressable
accessibilityLabel={`${tab.label} öffnen`}
accessibilityRole="button" accessibilityRole="button"
key={tab.id} key={tab.id}
onPress={() => onChange(tab.id)} onPress={() => onChange(tab.id)}
+25 -2
View File
@@ -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 PrimaryButton from '../components/PrimaryButton';
import AppHeader from '../components/layout/AppHeader'; import AppHeader from '../components/layout/AppHeader';
@@ -8,7 +8,7 @@ import ProductCard from '../components/product/ProductCard';
import { calculateProductRating } from '../services/ratingService'; import { calculateProductRating } from '../services/ratingService';
import { colors } from '../utils/constants'; 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] const frequentProducts = [...recentProducts]
.filter((product) => (product.scanCount || 0) > 1) .filter((product) => (product.scanCount || 0) > 1)
.sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)) .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. Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
</Text> </Text>
<PrimaryButton label="Produkt scannen" onPress={onStartScan} /> <PrimaryButton label="Produkt scannen" onPress={onStartScan} />
{shoppingList?.items?.length > 0 ? (
<Pressable accessibilityRole="button" onPress={onOpenList} style={styles.listShortcut}>
<Text style={styles.listShortcutTitle}>Einkaufsliste</Text>
<Text style={styles.listShortcutCopy}>
{shoppingList.items.filter((item) => !item.checked).length} offene Produkte
</Text>
</Pressable>
) : null}
</View> </View>
{frequentProducts.length > 0 ? ( {frequentProducts.length > 0 ? (
@@ -119,5 +127,20 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 20,
marginTop: 4 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
} }
}); });
+17 -5
View File
@@ -30,9 +30,15 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
setIsScanning(false); setIsScanning(false);
setIsLoadingProduct(true); 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); setIsLoadingProduct(false);
}); }
if (result?.status !== 'found') { if (result?.status !== 'found') {
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.'); setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
@@ -41,10 +47,15 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
} }
async function handleManualSearch() { async function handleManualSearch() {
if (!manualSearchQuery.trim() || isSearching) return;
setIsSearching(true); setIsSearching(true);
const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled); try {
setManualResults(results); const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled);
setIsSearching(false); setManualResults(results);
} finally {
setIsSearching(false);
}
} }
function retryScan() { function retryScan() {
@@ -98,6 +109,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
<ManualSearchInput <ManualSearchInput
onChangeText={setManualSearchQuery} onChangeText={setManualSearchQuery}
onSubmit={handleManualSearch} onSubmit={handleManualSearch}
disabled={isSearching}
value={manualSearchQuery} value={manualSearchQuery}
/> />
{isSearching ? <Text style={styles.note}>Suche läuft...</Text> : null} {isSearching ? <Text style={styles.note}>Suche läuft...</Text> : null}
+2 -2
View File
@@ -33,7 +33,7 @@ export default function ShoppingListScreen({
<View style={styles.summary}> <View style={styles.summary}>
<Text style={styles.summaryTitle}>{shoppingList.title}</Text> <Text style={styles.summaryTitle}>{shoppingList.title}</Text>
<Text style={styles.summaryCopy}> <Text style={styles.summaryCopy}>
Produkte abhaken, löschen oder nach dem Einkauf abschließen. Produkte abhaken, löschen oder nach dem Einkauf abschliessen.
</Text> </Text>
<View style={styles.budgetBox}> <View style={styles.budgetBox}>
<View style={styles.budgetHeader}> <View style={styles.budgetHeader}>
@@ -95,7 +95,7 @@ export default function ShoppingListScreen({
)} )}
{shoppingList.items.length > 0 ? ( {shoppingList.items.length > 0 ? (
<SecondaryButton label="Einkauf abschließen" onPress={onCompleteList} /> <SecondaryButton label="Einkauf abschliessen" onPress={onCompleteList} />
) : null} ) : null}
</ScreenContainer> </ScreenContainer>
); );
+15 -3
View File
@@ -9,12 +9,24 @@ const keys = {
}; };
async function readJson(key, fallbackValue) { async function readJson(key, fallbackValue) {
const rawValue = await AsyncStorage.getItem(key); try {
return rawValue ? JSON.parse(rawValue) : fallbackValue; 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) { 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 = { export const storageService = {