Handle error states and accessibility improvements
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user