First commit

This commit is contained in:
2026-06-01 23:16:10 +02:00
commit 1ea182f68d
56 changed files with 42848 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
{
"permissions": {
"allow": [
"Read(//usr/local/bin/**)",
"Read(//home/aimen/.local/**)",
"Read(//home/aimen/.nvm/**)",
"Read(//opt/**)",
"Read(//home/aimen/**)",
"Bash(find /usr /home/aimen /opt -name \"node\" -type f 2>/dev/null | head -5 *)",
"Read(//usr/**)",
"Bash(sudo apt install -y nodejs npm)",
"Bash(npx tsc *)",
"Bash(npm install *)",
"Bash(node -e \"const p = require\\('/home/aimen/Entwicklung/HouseOrg/node_modules/openai/package.json'\\); console.log\\(JSON.stringify\\(p.exports?.['./resources/chat/completions/completions'] ?? p.exports?.['./resources/chat/completions'] ?? 'no-entry', null, 2\\)\\)\")",
"Bash(node -e \"const p = require\\('/home/aimen/Entwicklung/HouseOrg/node_modules/openai/package.json'\\); console.log\\(p.main, p.module, JSON.stringify\\(Object.keys\\(p.exports ?? {}\\).slice\\(0,5\\)\\)\\)\")",
"Bash(node -e \"const fs = require\\('/home/aimen/Entwicklung/HouseOrg/node_modules/expo-file-system/build/index'\\); console.log\\(Object.keys\\(fs\\).filter\\(k => k.includes\\('Encod'\\) || k.includes\\('encod'\\)\\)\\)\")",
"Bash(npx expo *)",
"Bash(python3 -c ' *)",
"Bash(python3 -c \"from PIL import Image; print\\('pillow ok'\\)\")",
"Bash(pip3 install *)",
"Bash(python3)",
"Bash(npm uninstall *)",
"Bash(curl -s https://api.github.com/repos/pocketbase/pocketbase/releases/latest)",
"Bash(./pocketbase serve *)",
"Bash(curl -s http://localhost:8090/api/health)",
"Bash(python3 -m json.tool)",
"Bash(pkill -f \"pocketbase serve\")",
"Read(//tmp/**)",
"Bash(curl -s http://localhost:8090/api/collections)",
"Bash(./pocketbase superuser *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8081)",
"Bash(curl -s -o /dev/null -w \"HTTP Status: %{http_code}\" http://localhost:8083)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8083/)",
"Bash(curl -s http://localhost:8083/)",
"Bash(pkill -f \"expo start\")",
"Bash(npx expo start --lan --port 8083 > /tmp/expo_log.txt 2>&1 & *)",
"Bash(curl -s http://localhost:8083/status)",
"Bash(curl -s \"http://localhost:8083/__metro/health\")",
"Bash(node -e \"const p=require\\('./package.json'\\); ['@react-native-async-storage/async-storage','@react-native-community/netinfo','react-native-get-random-values'].forEach\\(k=>console.log\\(k+':', p.dependencies[k]\\)\\)\")",
"Bash(curl -s --max-time 3 \"http://192.168.178.36:8090/api/health\")",
"Bash(node -e \"console.log\\(require\\('./node_modules/zustand/package.json'\\).version\\)\")",
"Bash(node -e ' *)",
"Bash(lsof -ti:8083)",
"Bash(lsof -ti:8090)"
],
"additionalDirectories": [
"/tmp"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
# OpenAI API Key (für GPT-4o-mini Vision Erkennung)
EXPO_PUBLIC_OPENAI_API_KEY=sk-...
# Firebase Web App Konfiguration
# Zu finden in: Firebase Console → Projekteinstellungen → Allgemein → Deine Apps → Web-App
EXPO_PUBLIC_FIREBASE_API_KEY=AIza...
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=dein-projekt.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID=dein-projekt-id
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=dein-projekt.appspot.com
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
EXPO_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
+20
View File
@@ -0,0 +1,20 @@
node_modules/
.expo/
dist/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
.env
.env.local
google-services.json
GoogleService-Info.plist
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+95
View File
@@ -0,0 +1,95 @@
# HouseOrg
Gemeinsame Haushalts-App mit KI-Artikelerkennung, geteiltem Inventar und automatischer Einkaufsliste.
## Setup
### 1. Node.js installieren (Ubuntu)
```bash
sudo apt install -y nodejs npm
```
### 2. Abhängigkeiten installieren
```bash
npm install
```
### 3. Firebase einrichten
1. [Firebase Console](https://console.firebase.google.com) öffnen
2. Neues Projekt erstellen: `HouseOrg`
3. **Firestore Database** aktivieren (Production mode)
4. **Firebase Storage** aktivieren
5. **Authentication** → Anonymous aktivieren
6. **Cloud Messaging** ist standardmäßig aktiv
7. Android App registrieren (`de.houseorg.app`) → `google-services.json` herunterladen → in Projektwurzel legen
8. iOS App registrieren (`de.houseorg.app`) → `GoogleService-Info.plist` herunterladen → in Projektwurzel legen
### 4. Firestore Security Rules
```firestore
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /households/{householdId} {
allow read: if true;
allow write: if true;
match /{subcollection}/{docId} {
allow read, write: if true;
}
}
}
}
```
> Hinweis: Für Produktion auf authentifizierte Zugriffe einschränken.
### 5. OpenAI API Key
```bash
cp .env.example .env
# EXPO_PUBLIC_OPENAI_API_KEY=sk-... eintragen
```
### 6. App starten
```bash
npx expo start
```
## Architektur
- **Framework**: React Native + Expo (TypeScript)
- **Navigation**: Expo Router (file-based)
- **Backend**: Firebase (Firestore, Storage, FCM, Anonymous Auth)
- **KI-Erkennung**: OpenAI GPT-4o-mini Vision
- **Barcode**: Open Food Facts API
- **State**: Zustand
## Kosten (geschätzt)
| Dienst | Kosten |
|--------|--------|
| Firebase (Free Tier) | $0/Monat |
| OpenAI GPT-4o-mini | ~$0.10/Monat |
| Apple Developer | $99/Jahr (nur für App Store) |
| Google Play | $25 einmalig |
## Projektstruktur
```
app/
_layout.tsx # Root Layout + App-Init
onboarding.tsx # Haushalt erstellen / beitreten
join.tsx # Deeplink-Handler für Einladungen
(tabs)/
index.tsx # Inventar
shopping.tsx # Einkaufsliste
settings.tsx # Einstellungen
modals/
add-item.tsx # Artikel hinzufügen (Kamera + Barcode)
item-detail.tsx # Artikel-Detail & Bearbeiten
src/
types/ # TypeScript-Typen
constants/ # Farben, Labels, Konstanten
services/ # Firebase, AI, Barcode, Notifications
hooks/ # Zustand Store + Realtime-Sync
components/ # UI-Komponenten
utils/ # Hilfsfunktionen
```
+82
View File
@@ -0,0 +1,82 @@
{
"expo": {
"name": "HouseOrg",
"slug": "houseorg",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#2D6A4F"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "de.houseorg.app",
"infoPlist": {
"NSCameraUsageDescription": "HouseOrg nutzt die Kamera, um Haushaltartikel zu fotografieren und automatisch zu erkennen.",
"NSPhotoLibraryUsageDescription": "HouseOrg benötigt Zugriff auf deine Fotos, um Artikelbilder zu speichern.",
"NSUserNotificationsUsageDescription": "HouseOrg sendet Benachrichtigungen, wenn Artikel nachgekauft werden müssen oder das MHD abläuft."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#2D6A4F"
},
"package": "de.houseorg.app",
"permissions": [
"CAMERA",
"READ_MEDIA_IMAGES",
"RECEIVE_BOOT_COMPLETED",
"POST_NOTIFICATIONS",
"VIBRATE"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png",
"name": "HouseOrg",
"shortName": "HouseOrg",
"description": "Gemeinsam den Haushalt organisieren",
"themeColor": "#2D6A4F",
"backgroundColor": "#FFFFFF",
"preferRelatedApplications": false
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-camera",
{
"cameraPermission": "HouseOrg nutzt die Kamera, um Artikel zu fotografieren."
}
],
[
"expo-notifications",
{
"color": "#2D6A4F",
"sounds": []
}
]
],
"experiments": {
"typedRoutes": true
},
"scheme": "houseorg",
"extra": {
"eas": {
"projectId": "DEINE-EAS-PROJECT-ID"
}
},
"updates": {
"url": "https://u.expo.dev/DEINE-EAS-PROJECT-ID"
},
"runtimeVersion": {
"policy": "appVersion"
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import { Tabs, Redirect } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { StyleSheet } from 'react-native';
import { COLORS } from '../../src/constants';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
export default function TabLayout() {
const household = useHouseholdStore((s) => s.household);
const isInitialized = useHouseholdStore((s) => s.isInitialized);
const shoppingList = useHouseholdStore((s) => s.shoppingList);
const unChecked = shoppingList.filter((e) => !e.isChecked).length;
if (isInitialized && !household) {
return <Redirect href="/onboarding" />;
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: COLORS.primary,
tabBarInactiveTintColor: COLORS.textSecondary,
tabBarStyle: styles.tabBar,
tabBarLabelStyle: styles.tabLabel,
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Inventar',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="kitchen" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="shopping"
options={{
title: 'Einkauf',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="shopping-cart" size={size} color={color} />
),
tabBarBadge: unChecked > 0 ? unChecked : undefined,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Einstellungen',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="settings" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: COLORS.white,
borderTopColor: COLORS.border,
borderTopWidth: StyleSheet.hairlineWidth,
elevation: 0,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: -2 },
},
tabLabel: {
fontSize: 11,
fontWeight: '600',
},
header: {
backgroundColor: COLORS.white,
},
headerTitle: {
fontWeight: '700',
fontSize: 17,
color: COLORS.text,
},
});
+484
View File
@@ -0,0 +1,484 @@
import React, { useState, useMemo } from 'react';
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
StyleSheet,
Modal,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { ItemCard } from '../../src/components/inventory/ItemCard';
import { updateItemQuantity } from '../../src/services/items';
import { COLORS, CATEGORY_LABELS, BASE_CATEGORIES } from '../../src/constants';
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
import { isExpiringSoon, isExpired } from '../../src/utils';
import Toast from 'react-native-toast-message';
type SortKey = 'name_asc' | 'name_desc' | 'qty_asc' | 'qty_desc' | 'expiry_asc' | 'added_desc';
type MhdFilter = 'all' | 'expiring' | 'expired';
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'name_asc', label: 'Name AZ' },
{ key: 'name_desc', label: 'Name ZA' },
{ key: 'qty_asc', label: 'Menge ↑' },
{ key: 'qty_desc', label: 'Menge ↓' },
{ key: 'expiry_asc', label: 'MHD ↑' },
{ key: 'added_desc', label: 'Neueste' },
];
const MHD_OPTIONS: { key: MhdFilter; label: string }[] = [
{ key: 'all', label: 'Alle' },
{ key: 'expiring', label: 'Bald ablaufend' },
{ key: 'expired', label: 'Abgelaufen' },
];
export default function InventoryScreen() {
const items = useHouseholdStore((s) => s.items);
const household = useHouseholdStore((s) => s.household);
const deviceId = useHouseholdStore((s) => s.deviceId);
const [search, setSearch] = useState('');
const [filterVisible, setFilterVisible] = useState(false);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedLocations, setSelectedLocations] = useState<string[]>([]);
const [mhdFilter, setMhdFilter] = useState<MhdFilter>('all');
const [sortKey, setSortKey] = useState<SortKey>('name_asc');
const { options: categoryOptions } = useCustomOptions('houseorg_custom_categories', [...BASE_CATEGORIES]);
const availableLocations = useMemo(
() => Array.from(new Set(items.map((i) => i.storageLocation).filter(Boolean))).sort() as string[],
[items]
);
const activeFilterCount =
selectedCategories.length +
selectedLocations.length +
(mhdFilter !== 'all' ? 1 : 0) +
(sortKey !== 'name_asc' ? 1 : 0);
const filtered = useMemo(() => {
const term = search.toLowerCase();
let result = items.filter((item) => {
if (term && !item.name.toLowerCase().includes(term)) return false;
if (selectedCategories.length > 0 && !selectedCategories.includes(item.category)) return false;
if (selectedLocations.length > 0 && !selectedLocations.includes(item.storageLocation)) return false;
if (mhdFilter === 'expiring' && !isExpiringSoon(item.expiryDate)) return false;
if (mhdFilter === 'expired' && !isExpired(item.expiryDate)) return false;
return true;
});
return [...result].sort((a, b) => {
switch (sortKey) {
case 'name_asc': return a.name.localeCompare(b.name, 'de');
case 'name_desc': return b.name.localeCompare(a.name, 'de');
case 'qty_asc': return a.quantity - b.quantity;
case 'qty_desc': return b.quantity - a.quantity;
case 'expiry_asc':
if (!a.expiryDate && !b.expiryDate) return 0;
if (!a.expiryDate) return 1;
if (!b.expiryDate) return -1;
return a.expiryDate.getTime() - b.expiryDate.getTime();
case 'added_desc': return b.createdAt.getTime() - a.createdAt.getTime();
default: return 0;
}
});
}, [items, search, selectedCategories, selectedLocations, mhdFilter, sortKey]);
const toggleCategory = (cat: string) =>
setSelectedCategories((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
const toggleLocation = (loc: string) =>
setSelectedLocations((prev) =>
prev.includes(loc) ? prev.filter((l) => l !== loc) : [...prev, loc]
);
const resetFilters = () => {
setSelectedCategories([]);
setSelectedLocations([]);
setMhdFilter('all');
setSortKey('name_asc');
};
const handleQuantityChange = async (itemId: string, qty: number) => {
if (!household) return;
try {
await updateItemQuantity(household.id, itemId, qty);
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
}
};
const activeSummary = [
selectedCategories.length > 0 &&
`${selectedCategories.length} Kategorie${selectedCategories.length > 1 ? 'n' : ''}`,
selectedLocations.length > 0 &&
`${selectedLocations.length} Ort${selectedLocations.length > 1 ? 'e' : ''}`,
mhdFilter !== 'all' && (mhdFilter === 'expiring' ? 'Bald ablaufend' : 'Abgelaufen'),
sortKey !== 'name_asc' && SORT_OPTIONS.find((s) => s.key === sortKey)?.label,
]
.filter(Boolean)
.join(' · ');
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.searchRow}>
<View style={styles.searchBox}>
<MaterialIcons name="search" size={18} color={COLORS.textSecondary} />
<TextInput
style={styles.searchInput}
placeholder="Suchen…"
placeholderTextColor={COLORS.textSecondary}
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
<TouchableOpacity
style={[styles.filterBtn, activeFilterCount > 0 && styles.filterBtnActive]}
onPress={() => setFilterVisible(true)}
accessibilityLabel="Filter"
>
<MaterialIcons
name="tune"
size={20}
color={activeFilterCount > 0 ? COLORS.white : COLORS.text}
/>
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.addBtn}
onPress={() =>
router.push({
pathname: '/modals/add-item',
params: { householdId: household?.id ?? '', deviceId: deviceId ?? '' },
})
}
accessibilityLabel="Artikel hinzufügen"
>
<MaterialIcons name="add" size={24} color={COLORS.white} />
</TouchableOpacity>
</View>
{activeFilterCount > 0 && (
<View style={styles.activeFilterBar}>
<Text style={styles.activeFilterText} numberOfLines={1}>
{activeSummary}
</Text>
<TouchableOpacity onPress={resetFilters} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.resetText}>Zurücksetzen</Text>
</TouchableOpacity>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ItemCard
item={item}
onPress={() =>
router.push({ pathname: '/modals/item-detail', params: { itemId: item.id } })
}
onQuantityChange={(qty) => handleQuantityChange(item.id, qty)}
/>
)}
contentContainerStyle={[styles.list, filtered.length === 0 && styles.listEmpty]}
ListEmptyComponent={
<View style={styles.empty}>
<View style={styles.emptyIconWrap}>
<MaterialIcons
name={search || activeFilterCount > 0 ? 'search-off' : 'kitchen'}
size={36}
color={COLORS.primaryLight}
/>
</View>
<Text style={styles.emptyTitle}>
{search || activeFilterCount > 0 ? 'Keine Treffer' : 'Noch leer'}
</Text>
<Text style={styles.emptyText}>
{search || activeFilterCount > 0
? 'Filter oder Suche anpassen.'
: 'Tippe auf + und fotografiere deinen ersten Artikel.'}
</Text>
</View>
}
/>
<Modal visible={filterVisible} transparent animationType="slide">
<View style={styles.modalContainer}>
<TouchableOpacity style={styles.modalOverlay} onPress={() => setFilterVisible(false)} />
<View style={styles.filterSheet}>
<View style={styles.filterHandle} />
<View style={styles.filterHeader}>
<Text style={styles.filterTitle}>Filter & Sortierung</Text>
<TouchableOpacity onPress={resetFilters}>
<Text style={styles.resetText}>Zurücksetzen</Text>
</TouchableOpacity>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.filterScrollContent}
>
<FilterSection title="Sortierung">
<View style={styles.chipWrap}>
{SORT_OPTIONS.map(({ key, label }) => (
<TouchableOpacity
key={key}
style={[styles.chip, sortKey === key && styles.chipActive]}
onPress={() => setSortKey(key)}
>
<Text style={[styles.chipText, sortKey === key && styles.chipTextActive]}>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
<FilterSection title="Kategorie">
<View style={styles.chipWrap}>
{categoryOptions.map((cat) => (
<TouchableOpacity
key={cat}
style={[styles.chip, selectedCategories.includes(cat) && styles.chipActive]}
onPress={() => toggleCategory(cat)}
>
<Text
style={[
styles.chipText,
selectedCategories.includes(cat) && styles.chipTextActive,
]}
>
{CATEGORY_LABELS[cat] ?? cat}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
{availableLocations.length > 0 && (
<FilterSection title="Lagerort">
<View style={styles.chipWrap}>
{availableLocations.map((loc) => (
<TouchableOpacity
key={loc}
style={[styles.chip, selectedLocations.includes(loc) && styles.chipActive]}
onPress={() => toggleLocation(loc)}
>
<Text
style={[
styles.chipText,
selectedLocations.includes(loc) && styles.chipTextActive,
]}
>
{loc}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
)}
<FilterSection title="MHD">
<View style={styles.chipWrap}>
{MHD_OPTIONS.map(({ key, label }) => (
<TouchableOpacity
key={key}
style={[styles.chip, mhdFilter === key && styles.chipActive]}
onPress={() => setMhdFilter(key)}
>
<Text style={[styles.chipText, mhdFilter === key && styles.chipTextActive]}>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
</ScrollView>
<TouchableOpacity style={styles.applyBtn} onPress={() => setFilterVisible(false)}>
<Text style={styles.applyBtnText}>
{filtered.length === 0
? 'Keine Treffer'
: `${filtered.length} Artikel anzeigen`}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
function FilterSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>{title}</Text>
{children}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.surface },
searchRow: {
flexDirection: 'row',
gap: 10,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
},
searchBox: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderRadius: 12,
paddingHorizontal: 12,
gap: 8,
height: 46,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
searchInput: { flex: 1, fontSize: 15, color: COLORS.text },
filterBtn: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.white,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
filterBtnActive: { backgroundColor: COLORS.primary },
filterBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: COLORS.danger,
justifyContent: 'center',
alignItems: 'center',
},
filterBadgeText: { fontSize: 10, fontWeight: '700', color: COLORS.white },
addBtn: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
shadowColor: COLORS.primary,
shadowOpacity: 0.35,
shadowRadius: 8,
shadowOffset: { width: 0, height: 3 },
elevation: 4,
},
activeFilterBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingBottom: 6,
gap: 8,
},
activeFilterText: { fontSize: 12, color: COLORS.textSecondary, flex: 1 },
resetText: { fontSize: 12, color: COLORS.primary, fontWeight: '600' },
list: { paddingVertical: 8, paddingBottom: 32 },
listEmpty: { flex: 1 },
empty: { flex: 1, paddingTop: 80, alignItems: 'center', gap: 10, paddingHorizontal: 32 },
emptyIconWrap: {
width: 72,
height: 72,
borderRadius: 24,
backgroundColor: '#E8F5E9',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 4,
},
emptyTitle: { fontSize: 17, fontWeight: '600', color: COLORS.text },
emptyText: { color: COLORS.textSecondary, textAlign: 'center', fontSize: 14, lineHeight: 20 },
// Modal
modalContainer: { flex: 1, justifyContent: 'flex-end' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' },
filterSheet: {
backgroundColor: COLORS.white,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '78%',
paddingTop: 12,
},
filterHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.border,
alignSelf: 'center',
marginBottom: 16,
},
filterHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
marginBottom: 4,
},
filterTitle: { fontSize: 17, fontWeight: '700', color: COLORS.text },
filterScrollContent: { paddingHorizontal: 20, paddingBottom: 8 },
filterSection: { marginTop: 22 },
filterSectionTitle: {
fontSize: 11,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.7,
marginBottom: 10,
},
chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: COLORS.surface,
borderWidth: 1,
borderColor: COLORS.border,
},
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
chipText: { fontSize: 13, color: COLORS.text, fontWeight: '500' },
chipTextActive: { color: COLORS.white, fontWeight: '600' },
applyBtn: {
margin: 16,
marginTop: 12,
backgroundColor: COLORS.primary,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
shadowColor: COLORS.primary,
shadowOpacity: 0.28,
shadowRadius: 8,
shadowOffset: { width: 0, height: 3 },
elevation: 4,
},
applyBtnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
});
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
Switch,
StyleSheet,
Alert,
Share,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { kv } from '../../src/lib/kv';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { regenerateInviteToken, buildInviteLink } from '../../src/services/household';
import { updateFcmToken, requestNotificationPermission } from '../../src/services/notifications';
import { COLORS } from '../../src/constants';
import Toast from 'react-native-toast-message';
const NOTIF_KEY = 'houseorg_notifications_enabled';
export default function SettingsScreen() {
const household = useHouseholdStore((s) => s.household);
const setHousehold = useHouseholdStore((s) => s.setHousehold);
const [notificationsEnabled, setNotificationsEnabled] = useState<boolean | null>(null);
useEffect(() => {
kv.getItem(NOTIF_KEY).then((val) => {
setNotificationsEnabled(val !== 'false');
});
}, []);
const handleShareInvite = async () => {
if (!household) return;
const link = buildInviteLink(household.id, household.inviteToken);
const text = `Tritt unserem Haushalt bei: ${link}`;
if (typeof navigator !== 'undefined' && (navigator as any).share) {
await (navigator as any).share({ text, title: 'HouseOrg Einladung' });
} else if (typeof navigator !== 'undefined' && (navigator as any).clipboard) {
await (navigator as any).clipboard.writeText(text);
Toast.show({ type: 'success', text1: 'Link kopiert' });
} else {
await Share.share({ message: text, title: 'HouseOrg Einladung' });
}
};
const handleRegenerateLink = async () => {
if (!household) return;
Alert.alert(
'Link erneuern',
'Der alte Link wird ungültig. Alle Mitglieder können sich damit nicht mehr neu anmelden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Erneuern',
style: 'destructive',
onPress: async () => {
try {
const newToken = await regenerateInviteToken(household.id);
setHousehold({ ...household, inviteToken: newToken });
Toast.show({ type: 'success', text1: 'Neuer Einladungslink erstellt' });
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Erneuern' });
}
},
},
]
);
};
const handleNotificationToggle = async (enabled: boolean) => {
if (!household) return;
if (enabled) {
const granted = await requestNotificationPermission();
if (!granted) {
Toast.show({ type: 'error', text1: 'Benachrichtigungen nicht erlaubt' });
return;
}
}
setNotificationsEnabled(enabled);
await kv.setItem(NOTIF_KEY, String(enabled));
await updateFcmToken(household.id, enabled);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Haushalt</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: '#E8F5E9' }]}>
<MaterialIcons name="home" size={18} color={COLORS.primary} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Name</Text>
<Text style={styles.rowValue}>{household?.name}</Text>
</View>
</View>
<View style={styles.divider} />
<TouchableOpacity style={styles.row} onPress={handleShareInvite}>
<View style={[styles.iconWrap, { backgroundColor: '#E3F2FD' }]}>
<MaterialIcons name="share" size={18} color="#1E88E5" />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Einladungslink teilen</Text>
<Text style={styles.rowHint}>Mitglieder einladen</Text>
</View>
<MaterialIcons name="chevron-right" size={20} color={COLORS.textSecondary} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.row} onPress={handleRegenerateLink}>
<View style={[styles.iconWrap, { backgroundColor: '#FFF3E0' }]}>
<MaterialIcons name="refresh" size={18} color={COLORS.warning} />
</View>
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, { color: COLORS.warning }]}>
Einladungslink erneuern
</Text>
<Text style={styles.rowHint}>Alter Link wird ungültig</Text>
</View>
</TouchableOpacity>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: '#F3E5F5' }]}>
<MaterialIcons name="notifications" size={18} color="#8E24AA" />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Push-Benachrichtigungen</Text>
<Text style={styles.rowHint}>Einkaufsliste & MHD-Warnungen</Text>
</View>
<Switch
value={notificationsEnabled ?? false}
disabled={notificationsEnabled === null}
onValueChange={handleNotificationToggle}
trackColor={{ false: COLORS.border, true: COLORS.primaryLight }}
thumbColor={notificationsEnabled ? COLORS.primary : COLORS.white}
/>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>App</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: COLORS.surface }]}>
<MaterialIcons name="info-outline" size={18} color={COLORS.textSecondary} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Version</Text>
<Text style={styles.rowValue}>1.0.0</Text>
</View>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.surface },
section: { marginTop: 28, paddingHorizontal: 16 },
sectionTitle: {
fontSize: 12,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: 10,
},
card: {
backgroundColor: COLORS.white,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 1,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 12,
},
iconWrap: {
width: 34,
height: 34,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
rowContent: { flex: 1 },
rowLabel: { fontSize: 15, fontWeight: '500', color: COLORS.text },
rowValue: { fontSize: 13, color: COLORS.textSecondary, marginTop: 2 },
rowHint: { fontSize: 12, color: COLORS.textSecondary, marginTop: 1 },
divider: { height: StyleSheet.hairlineWidth, backgroundColor: COLORS.border, marginLeft: 60 },
});
+384
View File
@@ -0,0 +1,384 @@
import React, { useState, useMemo } from 'react';
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
StyleSheet,
Modal,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { ShoppingItem } from '../../src/components/shopping/ShoppingItem';
import {
addToShoppingList,
checkOffItem,
removeShoppingEntry,
clearCheckedItems,
} from '../../src/services/shopping';
import { COLORS } from '../../src/constants';
import { ShoppingListEntry } from '../../src/types';
import Toast from 'react-native-toast-message';
type ListRow =
| { type: 'section'; id: string; title: string; count: number; clearable?: boolean }
| { type: 'item'; id: string; entry: ShoppingListEntry };
export default function ShoppingScreen() {
const shoppingList = useHouseholdStore((s) => s.shoppingList);
const household = useHouseholdStore((s) => s.household);
const deviceId = useHouseholdStore((s) => s.deviceId);
const [manualItem, setManualItem] = useState('');
const [search, setSearch] = useState('');
const [checkoffEntry, setCheckoffEntry] = useState<ShoppingListEntry | null>(null);
const [quantityBought, setQuantityBought] = useState('1');
const listData = useMemo<ListRow[]>(() => {
const term = search.toLowerCase().trim();
const matches = (e: ShoppingListEntry) => !term || e.name.toLowerCase().includes(term);
const pending = shoppingList.filter((e) => !e.isChecked && matches(e));
const checked = shoppingList.filter((e) => e.isChecked && matches(e));
const rows: ListRow[] = [];
if (pending.length > 0) {
rows.push({ type: 'section', id: 'sec-pending', title: 'Zu kaufen', count: pending.length });
pending.forEach((e) => rows.push({ type: 'item', id: e.id, entry: e }));
}
if (checked.length > 0) {
rows.push({ type: 'section', id: 'sec-checked', title: 'Im Wagen', count: checked.length, clearable: true });
checked.forEach((e) => rows.push({ type: 'item', id: e.id, entry: e }));
}
return rows;
}, [shoppingList, search]);
const handleAddManual = async () => {
if (!household || !manualItem.trim()) return;
await addToShoppingList(household.id, {
itemId: null,
name: manualItem.trim(),
suggestedQuantity: 1,
unit: 'Stück',
autoAdded: false,
});
setManualItem('');
};
const handleCheckOff = (entry: ShoppingListEntry) => {
setCheckoffEntry(entry);
setQuantityBought(String(entry.suggestedQuantity));
};
const confirmCheckOff = async () => {
if (!household || !checkoffEntry || !deviceId) return;
const qty = parseFloat(quantityBought.replace(',', '.'));
if (isNaN(qty) || qty < 0) {
Toast.show({ type: 'error', text1: 'Ungültige Menge' });
return;
}
try {
await checkOffItem(household.id, checkoffEntry.id, deviceId, qty);
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Abhaken' });
}
setCheckoffEntry(null);
};
const handleRemove = async (entryId: string) => {
if (!household) return;
await removeShoppingEntry(household.id, entryId);
};
const handleClearChecked = async () => {
if (!household) return;
try {
await clearCheckedItems(household.id);
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Löschen' });
}
};
const renderItem = ({ item }: { item: ListRow }) => {
if (item.type === 'section') {
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{item.title}</Text>
<View style={styles.sectionBadge}>
<Text style={styles.sectionBadgeText}>{item.count}</Text>
</View>
{item.clearable && (
<TouchableOpacity onPress={handleClearChecked} style={styles.clearBtn}>
<Text style={styles.clearBtnText}>Alle löschen</Text>
</TouchableOpacity>
)}
</View>
);
}
return (
<ShoppingItem
entry={item.entry}
onCheckOff={() => handleCheckOff(item.entry)}
onRemove={() => handleRemove(item.entry.id)}
/>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.addRow}>
<View style={styles.addInputWrapper}>
<MaterialIcons name="add-shopping-cart" size={18} color={COLORS.textSecondary} />
<TextInput
style={styles.addInput}
placeholder="Artikel hinzufügen…"
placeholderTextColor={COLORS.textSecondary}
value={manualItem}
onChangeText={setManualItem}
returnKeyType="done"
onSubmitEditing={handleAddManual}
/>
</View>
<TouchableOpacity
style={[styles.addBtn, !manualItem.trim() && styles.addBtnDisabled]}
onPress={handleAddManual}
disabled={!manualItem.trim()}
>
<MaterialIcons name="add" size={22} color={COLORS.white} />
</TouchableOpacity>
</View>
{shoppingList.length > 0 && (
<View style={styles.searchRow}>
<MaterialIcons name="search" size={18} color={COLORS.textSecondary} />
<TextInput
style={styles.searchInput}
placeholder="Suchen…"
placeholderTextColor={COLORS.textSecondary}
value={search}
onChangeText={setSearch}
returnKeyType="search"
clearButtonMode="while-editing"
/>
{search.length > 0 && Platform.OS === 'android' && (
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="close" size={16} color={COLORS.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
<FlatList
data={listData}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={[styles.list, listData.length === 0 && styles.listEmpty]}
ListEmptyComponent={
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<MaterialIcons
name={search ? 'search-off' : 'shopping-cart'}
size={32}
color={COLORS.primaryLight}
/>
</View>
<Text style={styles.emptyTitle}>
{search ? 'Keine Treffer' : 'Alles erledigt!'}
</Text>
<Text style={styles.emptyText}>
{search
? `Kein Artikel mit „${search}" gefunden.`
: 'Die Einkaufsliste ist leer.'}
</Text>
</View>
}
/>
<Modal visible={!!checkoffEntry} transparent animationType="slide">
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.dialog}>
<View style={styles.dialogHandle} />
<Text style={styles.dialogTitle}>Wie viele wurden gekauft?</Text>
<Text style={styles.dialogItem}>{checkoffEntry?.name}</Text>
<TextInput
style={styles.dialogInput}
value={quantityBought}
onChangeText={setQuantityBought}
keyboardType="numeric"
selectTextOnFocus
autoFocus
/>
<Text style={styles.dialogUnit}>{checkoffEntry?.unit}</Text>
<View style={styles.dialogActions}>
<TouchableOpacity
style={styles.dialogBtnCancel}
onPress={() => setCheckoffEntry(null)}
>
<Text style={styles.dialogBtnCancelText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.dialogBtnConfirm} onPress={confirmCheckOff}>
<Text style={styles.dialogBtnConfirmText}>Bestätigen</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.surface },
addRow: {
flexDirection: 'row',
gap: 10,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: COLORS.white,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: COLORS.border,
},
addInputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.surface,
borderRadius: 12,
paddingHorizontal: 12,
gap: 8,
height: 46,
},
addInput: { flex: 1, fontSize: 15, color: COLORS.text },
addBtn: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
addBtnDisabled: { opacity: 0.4 },
searchRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderRadius: 12,
marginHorizontal: 16,
marginTop: 10,
marginBottom: 2,
paddingHorizontal: 12,
height: 42,
gap: 8,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
searchInput: { flex: 1, fontSize: 15, color: COLORS.text },
list: { paddingVertical: 8, paddingBottom: 32 },
listEmpty: { flex: 1 },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 6,
},
sectionTitle: {
fontSize: 12,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.6,
},
sectionBadge: {
backgroundColor: COLORS.border,
borderRadius: 10,
paddingHorizontal: 7,
paddingVertical: 1,
},
sectionBadgeText: { fontSize: 12, fontWeight: '600', color: COLORS.textSecondary },
clearBtn: { marginLeft: 'auto' },
clearBtnText: { fontSize: 12, fontWeight: '600', color: COLORS.danger },
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 48,
gap: 10,
},
emptyIcon: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: '#E8F5E9',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
emptyTitle: { fontSize: 17, fontWeight: '600', color: COLORS.text },
emptyText: { fontSize: 14, color: COLORS.textSecondary, textAlign: 'center' },
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.45)',
justifyContent: 'flex-end',
},
dialog: {
backgroundColor: COLORS.white,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
paddingBottom: 40,
alignItems: 'center',
gap: 10,
},
dialogHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.border,
marginBottom: 6,
},
dialogTitle: { fontSize: 18, fontWeight: '700', color: COLORS.text, textAlign: 'center' },
dialogItem: { fontSize: 15, color: COLORS.textSecondary },
dialogInput: {
borderWidth: 2,
borderColor: COLORS.primary,
borderRadius: 14,
paddingHorizontal: 24,
paddingVertical: 12,
fontSize: 34,
fontWeight: '700',
width: 140,
textAlign: 'center',
color: COLORS.text,
marginTop: 4,
},
dialogUnit: { fontSize: 14, color: COLORS.textSecondary, marginTop: -2 },
dialogActions: { flexDirection: 'row', gap: 12, marginTop: 8, width: '100%' },
dialogBtnCancel: {
flex: 1,
backgroundColor: COLORS.surface,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: COLORS.border,
},
dialogBtnConfirm: {
flex: 1,
backgroundColor: COLORS.primary,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
},
dialogBtnCancelText: { color: COLORS.text, fontWeight: '600', fontSize: 15 },
dialogBtnConfirmText: { color: COLORS.white, fontWeight: '600', fontSize: 15 },
});
+22
View File
@@ -0,0 +1,22 @@
import { ScrollViewStyleReset } from 'expo-router/html';
import React from 'react';
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#2D6A4F" />
<meta name="description" content="Gemeinsam den Haushalt organisieren" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="HouseOrg" />
<link rel="apple-touch-icon" href="/assets/icon.png" />
<ScrollViewStyleReset />
</head>
<body>{children}</body>
</html>
);
}
+135
View File
@@ -0,0 +1,135 @@
import 'react-native-get-random-values';
import { useEffect, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import { useHouseholdStore, useRealtimeSync } from '../src/hooks/useHousehold';
import { useNetworkSync } from '../src/hooks/useNetworkSync';
import { getOrCreateDeviceId, getStoredHouseholdId, getHousehold, registerMember } from '../src/services/household';
import { getFcmToken } from '../src/services/notifications';
import { COLORS } from '../src/constants';
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
),
]);
}
function AppInit() {
const setHousehold = useHouseholdStore((s) => s.setHousehold);
const setDeviceId = useHouseholdStore((s) => s.setDeviceId);
const setInitialized = useHouseholdStore((s) => s.setInitialized);
const [hydrated, setHydrated] = useState(() => useHouseholdStore.persist.hasHydrated());
useRealtimeSync();
useNetworkSync();
// Step 1: wait for Zustand AsyncStorage hydration
useEffect(() => {
if (hydrated) return;
console.log('[AppInit] waiting for store hydration...');
const unsub = useHouseholdStore.persist.onFinishHydration(() => {
console.log('[AppInit] store hydrated');
setHydrated(true);
});
return unsub;
}, [hydrated]);
// Step 2: run init logic after hydration is confirmed
useEffect(() => {
if (!hydrated) return;
console.log('[AppInit] init start');
(async () => {
try {
const deviceId = await getOrCreateDeviceId();
console.log('[AppInit] deviceId:', deviceId.slice(0, 8));
setDeviceId(deviceId);
const alreadyHasHousehold = useHouseholdStore.getState().household != null;
console.log('[AppInit] alreadyHasHousehold:', alreadyHasHousehold);
if (!alreadyHasHousehold) {
const householdId = await getStoredHouseholdId();
console.log('[AppInit] storedHouseholdId:', householdId);
if (householdId) {
try {
console.log('[AppInit] fetching household from PocketBase...');
const household = await withTimeout(getHousehold(householdId), 5000);
if (household) {
console.log('[AppInit] household loaded:', household.name);
setHousehold(household);
}
} catch (err) {
console.log('[AppInit] getHousehold failed (offline/timeout):', String(err));
}
}
}
} catch (e) {
console.error('[AppInit] unexpected error:', e);
} finally {
console.log('[AppInit] setInitialized()');
setInitialized();
const h = useHouseholdStore.getState().household;
if (h) {
getFcmToken()
.catch(() => null)
.then((token) => registerMember(h.id, token).catch(() => {}));
}
}
})();
}, [hydrated]);
return null;
}
export default function RootLayout() {
const isInitialized = useHouseholdStore((s) => s.isInitialized);
const setInitialized = useHouseholdStore((s) => s.setInitialized);
// Absolute safety net: force init after 8s regardless of what happened above
useEffect(() => {
const timer = setTimeout(() => {
if (!useHouseholdStore.getState().isInitialized) {
console.warn('[RootLayout] safety timeout fired — forcing init');
setInitialized();
}
}, 8000);
return () => clearTimeout(timer);
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppInit />
{!isInitialized ? (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.white }}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
) : (
<>
<Stack>
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modals/add-item"
options={{ presentation: 'modal', title: 'Artikel hinzufügen' }}
/>
<Stack.Screen
name="modals/item-detail"
options={{ presentation: 'modal', title: 'Artikel' }}
/>
</Stack>
<Toast />
</>
)}
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { View, ActivityIndicator, Alert } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { joinHousehold, getHousehold, registerMember } from '../src/services/household';
import { useHouseholdStore } from '../src/hooks/useHousehold';
import { getFcmToken } from '../src/services/notifications';
import { COLORS } from '../src/constants';
export default function JoinScreen() {
const { householdId, token } = useLocalSearchParams<{ householdId: string; token: string }>();
const setHousehold = useHouseholdStore((s) => s.setHousehold);
useEffect(() => {
(async () => {
if (!householdId || !token) {
Alert.alert('Ungültiger Link');
router.replace('/onboarding');
return;
}
const success = await joinHousehold(householdId, token);
if (!success) {
Alert.alert('Ungültiger oder abgelaufener Einladungslink.');
router.replace('/onboarding');
return;
}
const household = await getHousehold(householdId);
if (!household) {
Alert.alert('Haushalt nicht gefunden.');
router.replace('/onboarding');
return;
}
const fcmToken = await getFcmToken();
await registerMember(householdId, fcmToken);
setHousehold(household);
router.replace('/(tabs)');
})();
}, []);
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.white }}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
);
}
+472
View File
@@ -0,0 +1,472 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
ActivityIndicator,
Alert,
Image,
Platform,
} from 'react-native';
import { DatePickerModal } from '../../src/components/ui/DatePicker';
import * as ImagePicker from 'expo-image-picker';
import { CameraView, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';
import { router, useLocalSearchParams } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { createItem } from '../../src/services/items';
import { recognizeItemFromPhoto } from '../../src/services/ai';
import { lookupBarcode } from '../../src/services/barcode';
import { getStoredHouseholdId, getOrCreateDeviceId } from '../../src/services/household';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { COLORS, CATEGORY_LABELS, BASE_CATEGORIES, STORAGE_LOCATIONS } from '../../src/constants';
import { ChipSelectInput } from '../../src/components/ui/ChipSelectInput';
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
import { ItemCategory } from '../../src/types';
type Step = 'camera' | 'form';
export default function AddItemModal() {
const { householdId: paramHouseholdId, deviceId: paramDeviceId } = useLocalSearchParams<{ householdId: string; deviceId: string }>();
const householdFromStore = useHouseholdStore((s) => s.household);
const deviceIdFromStore = useHouseholdStore((s) => s.deviceId);
const [householdId, setHouseholdId] = useState(paramHouseholdId || householdFromStore?.id || '');
const [deviceId, setDeviceId] = useState(paramDeviceId || deviceIdFromStore || '');
useEffect(() => {
const resolve = async () => {
if (!householdId) {
const stored = await getStoredHouseholdId();
if (stored) setHouseholdId(stored);
}
if (!deviceId) {
const stored = await getOrCreateDeviceId();
if (stored) setDeviceId(stored);
}
};
resolve();
}, []);
const [permission, requestPermission] = useCameraPermissions();
const [step, setStep] = useState<Step>('camera');
const [mode, setMode] = useState<'photo' | 'barcode'>('photo');
const [recognizing, setRecognizing] = useState(false);
const recognizingRef = useRef(false);
const [photoUri, setPhotoUri] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const cameraRef = useRef<CameraView>(null);
// Form fields
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<ItemCategory>('food');
const [quantity, setQuantity] = useState('1');
const [unit, setUnit] = useState<string>('Stück');
const [minStock, setMinStock] = useState('0');
const [storageLocation, setStorageLocation] = useState('');
const [shoppingLocation, setShoppingLocation] = useState('');
const [price, setPrice] = useState('');
const [barcode, setBarcode] = useState<string | null>(null);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const { options: locationOptions, customOptions: customLocations, addOption: addLocation, removeOption: removeLocation } = useCustomOptions(
'houseorg_custom_locations',
STORAGE_LOCATIONS
);
const { options: categoryOptions, customOptions: customCategories, addOption: addCategory, removeOption: removeCategory } = useCustomOptions(
'houseorg_custom_categories',
[...BASE_CATEGORIES]
);
const takePhoto = async () => {
if (!cameraRef.current) return;
setRecognizing(true);
try {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8, base64: false });
if (!photo) return;
setPhotoUri(photo.uri);
const result = await recognizeItemFromPhoto(photo.uri);
setName(result.name);
setDescription(result.description);
setCategory(result.category);
setStep('form');
} catch {
Alert.alert('KI-Erkennung fehlgeschlagen', 'Bitte manuell ausfüllen.');
setStep('form');
} finally {
setRecognizing(false);
}
};
const onBarcodeScanned = async (result: BarcodeScanningResult) => {
if (recognizingRef.current) return;
recognizingRef.current = true;
setRecognizing(true);
setBarcode(result.data);
try {
const product = await lookupBarcode(result.data);
if (product) {
setName(product.name);
setDescription(product.description);
setCategory(product.category);
if (product.imageUrl) setPhotoUri(product.imageUrl);
} else {
Alert.alert('Barcode nicht gefunden', 'Bitte manuell ausfüllen.');
}
} catch (e: any) {
Alert.alert('Barcode-Abfrage fehlgeschlagen', e?.message ?? 'Netzwerkfehler');
} finally {
recognizingRef.current = false;
setRecognizing(false);
setStep('form');
}
};
const pickImage = () => {
Alert.alert('Foto hinzufügen', '', [
{
text: 'Kamera',
onPress: async () => {
const result = await ImagePicker.launchCameraAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
if (!result.canceled) setPhotoUri(result.assets[0].uri);
},
},
{
text: 'Galerie',
onPress: async () => {
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
if (!result.canceled) setPhotoUri(result.assets[0].uri);
},
},
{ text: 'Abbrechen', style: 'cancel' },
]);
};
const handleSave = async () => {
if (!householdId) {
Alert.alert('Fehler', 'Kein Haushalt gefunden. Bitte App neu starten.');
return;
}
if (!deviceId) {
Alert.alert('Fehler', 'Geräte-ID fehlt. Bitte App neu starten.');
return;
}
if (!name.trim()) {
Alert.alert('Bitte gib einen Namen ein.');
return;
}
setSaving(true);
try {
console.log('Saving item to household:', householdId);
await createItem(householdId, {
name: name.trim(),
description: description.trim(),
category,
quantity: parseFloat(quantity) || 0,
unit,
minStockThreshold: parseFloat(minStock) || 0,
photoUrl: null,
storageLocation: storageLocation.trim(),
shoppingLocation: shoppingLocation.trim(),
price: price ? parseFloat(price) : null,
expiryDate,
barcode,
addedByDevice: deviceId,
photoUri: photoUri && !photoUri.startsWith('http') ? photoUri : undefined,
});
console.log('Item saved successfully');
router.back();
} catch (e: any) {
console.error('createItem error:', e);
Alert.alert('Speichern fehlgeschlagen', e?.message ?? String(e));
} finally {
setSaving(false);
}
};
if (!permission) return null;
if (!permission.granted) {
return (
<View style={styles.permissionContainer}>
<MaterialIcons name="camera-alt" size={64} color={COLORS.primaryLight} />
<Text style={styles.permissionText}>Kamera-Zugriff benötigt</Text>
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Zugriff erlauben</Text>
</TouchableOpacity>
</View>
);
}
if (step === 'camera') {
return (
<View style={styles.cameraContainer}>
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFillObject}
barcodeScannerSettings={mode === 'barcode' ? { barcodeTypes: ['ean13', 'ean8', 'qr', 'upc_e', 'upc_a', 'code128'] } : undefined}
onBarcodeScanned={mode === 'barcode' ? onBarcodeScanned : undefined}
/>
{recognizing && (
<View style={styles.overlay}>
<ActivityIndicator size="large" color={COLORS.white} />
<Text style={styles.overlayText}>
{mode === 'photo' ? 'Artikel wird erkannt…' : 'Barcode wird gesucht…'}
</Text>
</View>
)}
<View style={styles.cameraTop}>
<TouchableOpacity onPress={() => router.back()} style={styles.cameraTopBtn}>
<MaterialIcons name="close" size={24} color={COLORS.white} />
</TouchableOpacity>
{Platform.OS !== 'web' && (
<View style={styles.modeToggle}>
<TouchableOpacity
style={[styles.modeBtn, mode === 'photo' && styles.modeBtnActive]}
onPress={() => setMode('photo')}
>
<Text style={styles.modeBtnText}>Foto</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeBtn, mode === 'barcode' && styles.modeBtnActive]}
onPress={() => setMode('barcode')}
>
<Text style={styles.modeBtnText}>Barcode</Text>
</TouchableOpacity>
</View>
)}
<TouchableOpacity
onPress={() => { setStep('form'); }}
style={styles.cameraTopBtn}
>
<Text style={{ color: COLORS.white, fontSize: 13 }}>Manuell</Text>
</TouchableOpacity>
</View>
{mode === 'photo' && !recognizing && (
<View style={styles.cameraBottom}>
<TouchableOpacity style={styles.shutter} onPress={takePhoto}>
<View style={styles.shutterInner} />
</TouchableOpacity>
</View>
)}
{mode === 'barcode' && (
<View style={styles.barcodeHint}>
<Text style={styles.barcodeHintText}>Barcode in den Rahmen halten</Text>
</View>
)}
</View>
);
}
return (
<ScrollView style={styles.form} contentContainerStyle={styles.formContent} keyboardShouldPersistTaps="handled">
{/* Foto-Sektion */}
<TouchableOpacity onPress={pickImage} style={styles.photoSection} activeOpacity={0.8}>
{photoUri ? (
<>
<Image source={{ uri: photoUri }} style={styles.preview} />
<View style={styles.photoOverlay}>
<MaterialIcons name="photo-camera" size={18} color={COLORS.white} />
<Text style={styles.photoOverlayText}>Foto ändern</Text>
</View>
</>
) : (
<View style={styles.photoPlaceholder}>
<MaterialIcons name="add-a-photo" size={32} color={COLORS.primaryLight} />
<Text style={styles.photoPlaceholderText}>Foto hinzufügen</Text>
</View>
)}
</TouchableOpacity>
<Field label="Name *">
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="z. B. Haferflocken" />
</Field>
<Field label="Beschreibung">
<TextInput style={[styles.input, styles.inputMulti]} value={description} onChangeText={setDescription} placeholder="Kurze Beschreibung" multiline />
</Field>
<Field label="Kategorie">
<ChipSelectInput
value={category}
onSelect={setCategory}
options={categoryOptions}
onAddOption={addCategory}
onRemoveOption={removeCategory}
removableOptions={customCategories}
getLabel={(cat) => CATEGORY_LABELS[cat] ?? cat}
addPlaceholder="Neue Kategorie…"
/>
</Field>
<View style={styles.row}>
<Field label="Menge" style={{ flex: 1 }}>
<TextInput style={styles.input} value={quantity} onChangeText={setQuantity} keyboardType="numeric" />
</Field>
<Field label="Einheit" style={{ flex: 1 }}>
<TextInput style={styles.input} value={unit} onChangeText={setUnit} placeholder="Stück" />
</Field>
</View>
<Field label="Mindestbestand (Warnschwelle)">
<TextInput style={styles.input} value={minStock} onChangeText={setMinStock} keyboardType="numeric" placeholder="0" />
</Field>
<Field label="Mindesthaltbarkeitsdatum">
<TouchableOpacity
style={[styles.input, styles.dateInput]}
onPress={() => setShowDatePicker(true)}
activeOpacity={0.7}
>
<MaterialIcons name="event" size={18} color={expiryDate ? COLORS.text : COLORS.textSecondary} />
<Text style={[styles.dateText, !expiryDate && styles.datePlaceholder]}>
{expiryDate ? format(expiryDate, 'dd. MMMM yyyy', { locale: de }) : 'Kein MHD'}
</Text>
{expiryDate && (
<TouchableOpacity onPress={() => setExpiryDate(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="close" size={16} color={COLORS.textSecondary} />
</TouchableOpacity>
)}
</TouchableOpacity>
{showDatePicker && (
<DatePickerModal
value={expiryDate ?? new Date()}
minimumDate={new Date()}
onChange={setExpiryDate}
onDismiss={() => setShowDatePicker(false)}
/>
)}
</Field>
<Field label="Lagerort">
<ChipSelectInput
value={storageLocation}
onSelect={setStorageLocation}
options={locationOptions}
onAddOption={addLocation}
onRemoveOption={removeLocation}
removableOptions={customLocations}
addPlaceholder="Neuer Lagerort…"
/>
</Field>
<Field label="Einkaufsort">
<TextInput style={styles.input} value={shoppingLocation} onChangeText={setShoppingLocation} placeholder="z. B. REWE" />
</Field>
<Field label="Preis (€)">
<TextInput style={styles.input} value={price} onChangeText={setPrice} keyboardType="numeric" placeholder="0.00" />
</Field>
<TouchableOpacity
style={[styles.btn, saving && styles.btnDisabled]}
onPress={handleSave}
disabled={saving}
>
{saving ? (
<ActivityIndicator color={COLORS.white} />
) : (
<Text style={styles.btnText}>Artikel speichern</Text>
)}
</TouchableOpacity>
</ScrollView>
);
}
function Field({ label, children, style }: { label: string; children: React.ReactNode; style?: object }) {
return (
<View style={[{ marginBottom: 16 }, style]}>
<Text style={styles.label}>{label}</Text>
{children}
</View>
);
}
const styles = StyleSheet.create({
permissionContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 },
permissionText: { fontSize: 18, fontWeight: '600', color: COLORS.text, textAlign: 'center' },
cameraContainer: { flex: 1, backgroundColor: '#000' },
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', gap: 16 },
overlayText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
cameraTop: { position: 'absolute', top: 48, left: 0, right: 0, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16 },
cameraTopBtn: { padding: 8 },
modeToggle: { flex: 1, flexDirection: 'row', justifyContent: 'center', gap: 8 },
modeBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)' },
modeBtnActive: { backgroundColor: COLORS.white },
modeBtnText: { color: COLORS.white, fontWeight: '600', fontSize: 14 },
cameraBottom: { position: 'absolute', bottom: 48, left: 0, right: 0, alignItems: 'center' },
shutter: { width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(255,255,255,0.3)', justifyContent: 'center', alignItems: 'center', borderWidth: 3, borderColor: COLORS.white },
shutterInner: { width: 56, height: 56, borderRadius: 28, backgroundColor: COLORS.white },
barcodeHint: { position: 'absolute', bottom: 80, left: 0, right: 0, alignItems: 'center' },
barcodeHintText: { color: COLORS.white, fontSize: 14, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8 },
form: { flex: 1, backgroundColor: COLORS.white },
formContent: { padding: 24, paddingBottom: 48 },
photoSection: { marginBottom: 20, borderRadius: 12, overflow: 'hidden' },
preview: { width: '100%', height: 180, borderRadius: 12, resizeMode: 'cover' },
photoOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
},
photoOverlayText: { color: COLORS.white, fontSize: 13, fontWeight: '600' },
photoPlaceholder: {
height: 120,
borderRadius: 12,
borderWidth: 1.5,
borderColor: COLORS.border,
borderStyle: 'dashed',
backgroundColor: COLORS.surface,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
photoPlaceholderText: { fontSize: 14, color: COLORS.primaryLight, fontWeight: '500' },
label: { fontSize: 13, fontWeight: '600', color: COLORS.textSecondary, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.3 },
input: { borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 12, padding: 13, fontSize: 16, backgroundColor: COLORS.surface, color: COLORS.text },
inputMulti: { height: 80, textAlignVertical: 'top' },
dateInput: { flexDirection: 'row', alignItems: 'center', gap: 10 },
dateText: { flex: 1, fontSize: 16, color: COLORS.text },
datePlaceholder: { color: COLORS.textSecondary },
dateConfirm: { alignSelf: 'flex-end', marginTop: 8, paddingHorizontal: 16, paddingVertical: 8, backgroundColor: COLORS.primary, borderRadius: 8 },
dateConfirmText: { color: COLORS.white, fontWeight: '600', fontSize: 14 },
segmented: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
segBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, borderColor: COLORS.border },
segBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
segBtnText: { fontSize: 13, color: COLORS.textSecondary, fontWeight: '500' },
segBtnTextActive: { color: COLORS.white },
row: { flexDirection: 'row', gap: 12 },
btn: {
backgroundColor: COLORS.primary,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
marginTop: 8,
shadowColor: COLORS.primary,
shadowOpacity: 0.28,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
btnDisabled: { opacity: 0.6, shadowOpacity: 0 },
btnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
});
+340
View File
@@ -0,0 +1,340 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
TouchableOpacity,
Image,
ActivityIndicator,
StyleSheet,
Alert,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { DatePickerModal } from '../../src/components/ui/DatePicker';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { updateItem, deleteItem, updateItemQuantity } from '../../src/services/items';
import { QuantityControl } from '../../src/components/ui/QuantityControl';
import { COLORS, CATEGORY_LABELS, STORAGE_LOCATIONS } from '../../src/constants';
import { ChipSelectInput } from '../../src/components/ui/ChipSelectInput';
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
import { formatDate, isExpired, isExpiringSoon } from '../../src/utils';
import Toast from 'react-native-toast-message';
export default function ItemDetailModal() {
const { itemId } = useLocalSearchParams<{ itemId: string }>();
const household = useHouseholdStore((s) => s.household);
const items = useHouseholdStore((s) => s.items);
const item = items.find((i) => i.id === itemId);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [newPhotoUri, setNewPhotoUri] = useState<string | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [name, setName] = useState(item?.name ?? '');
const [description, setDescription] = useState(item?.description ?? '');
const [storageLocation, setStorageLocation] = useState(item?.storageLocation ?? '');
const [shoppingLocation, setShoppingLocation] = useState(item?.shoppingLocation ?? '');
const [price, setPrice] = useState(item?.price != null ? String(item.price) : '');
const [minStock, setMinStock] = useState(String(item?.minStockThreshold ?? 0));
const [expiryDate, setExpiryDate] = useState<Date | null>(item?.expiryDate ?? null);
const { options: locationOptions, customOptions: customLocations, addOption: addLocation, removeOption: removeLocation } = useCustomOptions(
'houseorg_custom_locations',
STORAGE_LOCATIONS
);
if (!item || !household) {
return (
<View style={styles.center}>
<Text>Artikel nicht gefunden.</Text>
</View>
);
}
const pickPhoto = () => {
Alert.alert('Foto', '', [
{
text: 'Kamera',
onPress: async () => {
const result = await ImagePicker.launchCameraAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
},
},
{
text: 'Galerie',
onPress: async () => {
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
},
},
{ text: 'Abbrechen', style: 'cancel' },
]);
};
const handleSave = async () => {
setSaving(true);
try {
await updateItem(household.id, item.id, {
name: name.trim(),
description: description.trim(),
storageLocation: storageLocation.trim(),
shoppingLocation: shoppingLocation.trim(),
price: price ? parseFloat(price) : null,
minStockThreshold: parseFloat(minStock) || 0,
expiryDate,
...(newPhotoUri ? { photoUri: newPhotoUri } : {}),
});
setNewPhotoUri(null);
setEditing(false);
Toast.show({ type: 'success', text1: 'Gespeichert' });
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
} finally {
setSaving(false);
}
};
const handleDelete = () => {
Alert.alert('Artikel löschen', `Möchtest du "${item.name}" wirklich löschen?`, [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await deleteItem(household.id, item.id);
router.back();
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Löschen' });
}
},
},
]);
};
const handleQuantityChange = async (qty: number) => {
try {
await updateItemQuantity(household.id, item.id, qty);
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
}
};
const expired = isExpired(item.expiryDate);
const expiringSoon = isExpiringSoon(item.expiryDate);
return (
<>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{editing ? (
<TouchableOpacity style={styles.photoEditContainer} onPress={pickPhoto} activeOpacity={0.85}>
{(newPhotoUri ?? item.photoUrl) ? (
<>
<Image source={{ uri: newPhotoUri ?? item.photoUrl! }} style={styles.photo} />
<View style={styles.photoEditOverlay}>
<MaterialIcons name="edit" size={18} color={COLORS.white} />
<Text style={styles.photoEditText}>Foto ändern</Text>
</View>
</>
) : (
<View style={styles.photoPlaceholder}>
<MaterialIcons name="add-a-photo" size={32} color={COLORS.primary} />
<Text style={styles.photoPlaceholderText}>Foto hinzufügen</Text>
</View>
)}
</TouchableOpacity>
) : (
item.photoUrl && <Image source={{ uri: item.photoUrl }} style={styles.photo} />
)}
<View style={styles.headerRow}>
<View style={styles.headerInfo}>
{editing ? (
<TextInput style={styles.titleInput} value={name} onChangeText={setName} />
) : (
<Text style={styles.title}>{item.name}</Text>
)}
<Text style={styles.category}>{CATEGORY_LABELS[item.category]}</Text>
</View>
<TouchableOpacity onPress={() => (editing ? handleSave() : setEditing(true))} disabled={saving}>
{saving ? (
<ActivityIndicator size="small" color={COLORS.primary} />
) : (
<MaterialIcons
name={editing ? 'check' : 'edit'}
size={24}
color={editing ? COLORS.primary : COLORS.textSecondary}
/>
)}
</TouchableOpacity>
</View>
<View style={styles.quantitySection}>
<Text style={styles.sectionLabel}>Bestand</Text>
<QuantityControl
quantity={item.quantity}
unit={item.unit}
minStockThreshold={item.minStockThreshold}
onChangeQuantity={handleQuantityChange}
/>
{item.onShoppingList && (
<Text style={styles.onListHint}>Auf der Einkaufsliste</Text>
)}
</View>
{(expiringSoon || expired) && (
<View style={[styles.mhdAlert, expired ? styles.mhdAlertExpired : styles.mhdAlertWarn]}>
<MaterialIcons
name={expired ? 'error' : 'warning'}
size={18}
color={expired ? COLORS.danger : COLORS.warning}
/>
<Text style={styles.mhdAlertText}>
{expired ? 'Abgelaufen!' : `MHD läuft in ≤ 3 Tagen ab`}
</Text>
</View>
)}
<View style={styles.detailsCard}>
<DetailRow label="Beschreibung" value={editing ? undefined : item.description}>
{editing && (
<TextInput
style={styles.editInput}
value={description}
onChangeText={setDescription}
multiline
/>
)}
</DetailRow>
<DetailRow label="Lagerort" value={editing ? undefined : item.storageLocation || '—'}>
{editing && (
<ChipSelectInput
value={storageLocation}
onSelect={setStorageLocation}
options={locationOptions}
onAddOption={addLocation}
onRemoveOption={removeLocation}
removableOptions={customLocations}
addPlaceholder="Neuer Lagerort…"
/>
)}
</DetailRow>
<DetailRow label="Einkaufsort" value={editing ? undefined : item.shoppingLocation || '—'}>
{editing && (
<TextInput style={styles.editInput} value={shoppingLocation} onChangeText={setShoppingLocation} />
)}
</DetailRow>
<DetailRow label="Mindestbestand" value={editing ? undefined : `${item.minStockThreshold} ${item.unit}`}>
{editing && (
<TextInput style={styles.editInput} value={minStock} onChangeText={setMinStock} keyboardType="numeric" />
)}
</DetailRow>
<DetailRow label="Preis" value={editing ? undefined : item.price != null ? `${item.price.toFixed(2)}` : '—'}>
{editing && (
<TextInput style={styles.editInput} value={price} onChangeText={setPrice} keyboardType="numeric" />
)}
</DetailRow>
<DetailRow label="MHD" value={editing ? undefined : formatDate(item.expiryDate)}>
{editing && (
<TouchableOpacity onPress={() => setShowDatePicker(true)} style={styles.datePicker}>
<Text style={styles.datePickerText}>{formatDate(expiryDate) || 'Datum wählen'}</Text>
<MaterialIcons name="calendar-today" size={18} color={COLORS.primary} />
</TouchableOpacity>
)}
</DetailRow>
{showDatePicker && (
<DatePickerModal
value={expiryDate ?? new Date()}
minimumDate={new Date()}
onChange={setExpiryDate}
onDismiss={() => setShowDatePicker(false)}
/>
)}
<DetailRow label="Barcode" value={item.barcode || '—'} last />
</View>
<TouchableOpacity style={styles.deleteBtn} onPress={handleDelete}>
<MaterialIcons name="delete" size={18} color={COLORS.danger} />
<Text style={styles.deleteBtnText}>Artikel löschen</Text>
</TouchableOpacity>
</ScrollView>
<Toast />
</>
);
}
function DetailRow({
label,
value,
children,
last,
}: {
label: string;
value?: string;
children?: React.ReactNode;
last?: boolean;
}) {
return (
<View style={[styles.detailRow, !last && styles.detailRowBorder]}>
<Text style={styles.detailLabel}>{label}</Text>
{children ?? <Text style={styles.detailValue}>{value}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.white },
content: { paddingBottom: 48 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
photo: { width: '100%', height: 220, resizeMode: 'cover' },
photoEditContainer: { width: '100%', height: 220 },
photoEditOverlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
backgroundColor: 'rgba(0,0,0,0.45)',
paddingVertical: 10,
},
photoEditText: { color: COLORS.white, fontSize: 14, fontWeight: '600' },
photoPlaceholder: {
width: '100%',
height: 220,
backgroundColor: COLORS.surface,
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
photoPlaceholderText: { color: COLORS.primary, fontSize: 14, fontWeight: '600' },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', padding: 20, gap: 12 },
headerInfo: { flex: 1 },
title: { fontSize: 24, fontWeight: '700', color: COLORS.text },
titleInput: { fontSize: 24, fontWeight: '700', color: COLORS.text, borderBottomWidth: 2, borderBottomColor: COLORS.primary },
category: { fontSize: 14, color: COLORS.textSecondary, marginTop: 4 },
quantitySection: { paddingHorizontal: 20, paddingBottom: 16, gap: 8 },
sectionLabel: { fontSize: 12, fontWeight: '600', color: COLORS.textSecondary, textTransform: 'uppercase', letterSpacing: 0.4 },
onListHint: { fontSize: 12, color: COLORS.warning, fontWeight: '500' },
mhdAlert: { flexDirection: 'row', alignItems: 'center', gap: 8, marginHorizontal: 20, marginBottom: 12, padding: 12, borderRadius: 10 },
mhdAlertWarn: { backgroundColor: '#FFF3CD' },
mhdAlertExpired: { backgroundColor: '#F8D7DA' },
mhdAlertText: { fontSize: 14, fontWeight: '500', color: COLORS.text },
detailsCard: { marginHorizontal: 16, borderRadius: 14, overflow: 'hidden', borderWidth: 1, borderColor: COLORS.border },
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, gap: 12 },
detailRowBorder: { borderBottomWidth: 1, borderBottomColor: COLORS.border },
detailLabel: { fontSize: 14, color: COLORS.textSecondary, fontWeight: '500', minWidth: 100 },
detailValue: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right' },
editInput: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right', borderBottomWidth: 1, borderBottomColor: COLORS.primary },
datePicker: { flexDirection: 'row', alignItems: 'center', gap: 6 },
datePickerText: { fontSize: 14, color: COLORS.primary },
deleteBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 32, marginHorizontal: 16, padding: 14, borderRadius: 12, borderWidth: 1.5, borderColor: COLORS.danger },
deleteBtnText: { color: COLORS.danger, fontSize: 15, fontWeight: '600' },
});
+311
View File
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import { router } from 'expo-router';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import * as Device from 'expo-device';
import { useHouseholdStore } from '../src/hooks/useHousehold';
import { createHousehold, joinHousehold, getHousehold, registerMember } from '../src/services/household';
import { getFcmToken } from '../src/services/notifications';
import { COLORS } from '../src/constants';
export default function OnboardingScreen() {
const setHousehold = useHouseholdStore((s) => s.setHousehold);
const [householdName, setHouseholdName] = useState('');
const [deviceName, setDeviceName] = useState(Device.deviceName ?? '');
const [inviteLink, setInviteLink] = useState('');
const [tab, setTab] = useState<'create' | 'join'>('create');
const [loading, setLoading] = useState(false);
const handleCreate = async () => {
if (!householdName.trim()) {
Alert.alert('Pflichtfeld', 'Bitte gib einen Haushaltsnamen ein.');
return;
}
setLoading(true);
try {
const household = await createHousehold(householdName.trim());
setHousehold(household);
router.replace('/');
const fcmToken = await getFcmToken().catch(() => null);
await registerMember(household.id, fcmToken, deviceName).catch((e) =>
console.warn('registerMember failed (non-critical):', e)
);
} catch (e: any) {
Alert.alert('Fehler', 'Haushalt konnte nicht erstellt werden. Bitte Internetverbindung prüfen.');
} finally {
setLoading(false);
}
};
const handleJoin = async () => {
const raw = inviteLink.trim();
const qIndex = raw.indexOf('?');
if (!raw || qIndex === -1) {
Alert.alert('Ungültiger Link', 'Füge den vollständigen Einladungslink ein.');
return;
}
const params = new URLSearchParams(raw.slice(qIndex + 1));
const householdId = params.get('householdId') ?? '';
const token = params.get('token') ?? '';
if (!householdId || !token) {
Alert.alert('Ungültiger Link', 'Der Link enthält keine gültigen Zugangsdaten.');
return;
}
setLoading(true);
try {
const success = await joinHousehold(householdId, token);
if (!success) {
Alert.alert('Abgelaufener Link', 'Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen neuen Link anfordern.');
return;
}
const household = await getHousehold(householdId);
if (!household) {
Alert.alert('Fehler', 'Haushalt nicht gefunden. Bitte erneut versuchen.');
return;
}
const fcmToken = await getFcmToken().catch(() => null);
await registerMember(householdId, fcmToken, deviceName);
setHousehold(household);
router.replace('/');
} catch {
Alert.alert('Verbindungsfehler', 'Bitte Internetverbindung prüfen und erneut versuchen.');
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
bounces={false}
showsVerticalScrollIndicator={false}
>
<View style={styles.hero}>
<View style={styles.heroIcon}>
<MaterialIcons name="home" size={42} color={COLORS.white} />
</View>
<Text style={styles.title}>HouseOrg</Text>
<Text style={styles.subtitle}>Gemeinsam organisiert</Text>
</View>
<View style={styles.card}>
<View style={styles.tabs}>
<TouchableOpacity
style={[styles.tab, tab === 'create' && styles.tabActive]}
onPress={() => setTab('create')}
>
<Text style={[styles.tabText, tab === 'create' && styles.tabTextActive]}>
Neu erstellen
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, tab === 'join' && styles.tabActive]}
onPress={() => setTab('join')}
>
<Text style={[styles.tabText, tab === 'join' && styles.tabTextActive]}>
Beitreten
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
{tab === 'create' ? (
<>
<Text style={styles.label}>Haushaltsname</Text>
<TextInput
style={styles.input}
placeholder="z. B. Familie Müller"
placeholderTextColor={COLORS.textSecondary}
value={householdName}
onChangeText={setHouseholdName}
returnKeyType="next"
autoFocus
/>
<Text style={styles.label}>Mein Gerätename</Text>
<TextInput
style={styles.input}
placeholder="z. B. Aiméns iPhone"
placeholderTextColor={COLORS.textSecondary}
value={deviceName}
onChangeText={setDeviceName}
returnKeyType="done"
onSubmitEditing={handleCreate}
/>
<TouchableOpacity
style={[styles.btn, loading && styles.btnDisabled]}
onPress={handleCreate}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={COLORS.white} />
) : (
<>
<MaterialIcons name="home" size={18} color={COLORS.white} />
<Text style={styles.btnText}>Haushalt erstellen</Text>
</>
)}
</TouchableOpacity>
</>
) : (
<>
<Text style={styles.label}>Einladungslink</Text>
<TextInput
style={[styles.input, styles.inputMultiline]}
placeholder="houseorg://join?householdId=…"
placeholderTextColor={COLORS.textSecondary}
value={inviteLink}
onChangeText={setInviteLink}
multiline
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Mein Gerätename</Text>
<TextInput
style={styles.input}
placeholder="z. B. Aiméns iPhone"
placeholderTextColor={COLORS.textSecondary}
value={deviceName}
onChangeText={setDeviceName}
returnKeyType="done"
onSubmitEditing={handleJoin}
/>
<TouchableOpacity
style={[styles.btn, loading && styles.btnDisabled]}
onPress={handleJoin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={COLORS.white} />
) : (
<>
<MaterialIcons name="group-add" size={18} color={COLORS.white} />
<Text style={styles.btnText}>Beitreten</Text>
</>
)}
</TouchableOpacity>
</>
)}
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: COLORS.primary },
keyboardView: { flex: 1 },
scroll: { flexGrow: 1 },
hero: {
alignItems: 'center',
paddingTop: 56,
paddingBottom: 52,
paddingHorizontal: 24,
backgroundColor: COLORS.primary,
},
heroIcon: {
width: 84,
height: 84,
borderRadius: 26,
backgroundColor: 'rgba(255,255,255,0.18)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 36,
fontWeight: '700',
color: COLORS.white,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 17,
color: 'rgba(255,255,255,0.72)',
marginTop: 6,
},
card: {
flex: 1,
backgroundColor: COLORS.surface,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
padding: 24,
paddingTop: 28,
},
tabs: {
flexDirection: 'row',
backgroundColor: COLORS.white,
borderRadius: 14,
padding: 4,
marginBottom: 26,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
tab: {
flex: 1,
paddingVertical: 10,
borderRadius: 10,
alignItems: 'center',
},
tabActive: { backgroundColor: COLORS.primary },
tabText: { fontSize: 14, color: COLORS.textSecondary, fontWeight: '500' },
tabTextActive: { color: COLORS.white, fontWeight: '600' },
form: { gap: 12 },
label: {
fontSize: 12,
fontWeight: '600',
color: COLORS.text,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
input: {
borderWidth: 1.5,
borderColor: COLORS.border,
borderRadius: 12,
padding: 14,
fontSize: 16,
backgroundColor: COLORS.white,
color: COLORS.text,
},
inputMultiline: { height: 100, textAlignVertical: 'top' },
btn: {
backgroundColor: COLORS.primary,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginTop: 4,
shadowColor: COLORS.primary,
shadowOpacity: 0.28,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
btnDisabled: { opacity: 0.6, shadowOpacity: 0 },
btnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
+26
View File
@@ -0,0 +1,26 @@
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "deine-apple-id@example.com",
"ascAppId": "DEINE-APP-STORE-CONNECT-ID"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Redirect openai .mjs ESM imports to .js CJS to avoid TDZ circular dependency errors on web
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (
context.originModulePath.includes('node_modules/openai') &&
moduleName.endsWith('.mjs')
) {
return context.resolveRequest(context, moduleName.replace(/\.mjs$/, '.js'), platform);
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = config;
+13548
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
{
"name": "houseorg",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"lint": "eslint ."
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "^11.4.1",
"@tanstack/react-query": "^5.0.0",
"date-fns": "^4.0.0",
"expo": "~54.0.0",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-device": "~8.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"openai": "^4.0.0",
"pocketbase": "^0.26.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "^1.11.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-toast-message": "^2.2.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "^0.5.1",
"uuid": "^11.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@expo/ngrok": "^4.1.3",
"@types/react": "~19.1.0",
"@types/uuid": "^10.0.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~9.2.0",
"typescript": "~5.8.3"
}
}
Binary file not shown.
BIN
View File
Binary file not shown.
+24407
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
// Auto-manage shopping list when item quantity changes relative to min_stock_threshold
onRecordAfterUpdateSuccess((e) => {
const item = e.record;
const qty = item.getFloat('quantity');
const minStock = item.getFloat('min_stock_threshold');
if (!minStock || minStock === 0) return;
const householdId = item.getString('household');
const app = e.app;
const existing = app.findRecordsByFilter(
'shopping_list',
`item_id = "${item.id}" && is_checked = false`,
'',
1,
0
);
if (qty <= minStock && existing.length === 0) {
const col = app.findCollectionByNameOrId('shopping_list');
const entry = new Record(col);
entry.set('household', householdId);
entry.set('item_id', item.id);
entry.set('name', item.getString('name'));
entry.set('suggested_quantity', minStock - qty + 1);
entry.set('unit', item.getString('unit'));
entry.set('is_checked', false);
entry.set('auto_added', true);
app.save(entry);
if (!item.getBool('on_shopping_list')) {
item.set('on_shopping_list', true);
app.save(item);
}
} else if (qty > minStock && existing.length > 0) {
app.delete(existing[0]);
if (item.getBool('on_shopping_list')) {
item.set('on_shopping_list', false);
app.save(item);
}
}
}, 'items');
+106
View File
@@ -0,0 +1,106 @@
migrate((app) => {
function collectionExists(name) {
try { app.findCollectionByNameOrId(name); return true; } catch { return false; }
}
// ── households ──────────────────────────────────────────────────────────────
if (!collectionExists('households')) {
const col = new Collection({
name: 'households',
type: 'base',
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'invite_token', type: 'text', required: true },
],
});
app.save(col);
}
const householdsId = app.findCollectionByNameOrId('households').id;
// ── items ────────────────────────────────────────────────────────────────────
if (!collectionExists('items')) {
const col = new Collection({
name: 'items',
type: 'base',
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
fields: [
{ name: 'household', type: 'relation', required: true, collectionId: householdsId, cascadeDelete: true, maxSelect: 1 },
{ name: 'name', type: 'text', required: true },
{ name: 'description', type: 'text' },
{ name: 'category', type: 'text' },
{ name: 'quantity', type: 'number' },
{ name: 'unit', type: 'text' },
{ name: 'min_stock_threshold', type: 'number' },
{ name: 'on_shopping_list', type: 'bool' },
{ name: 'photo', type: 'file', maxSelect: 1, maxSize: 5242880 },
{ name: 'storage_location', type: 'text' },
{ name: 'shopping_location', type: 'text' },
{ name: 'price', type: 'number' },
{ name: 'expiry_date', type: 'date' },
{ name: 'barcode', type: 'text' },
{ name: 'added_by_device', type: 'text' },
],
});
app.save(col);
}
// ── shopping_list ────────────────────────────────────────────────────────────
if (!collectionExists('shopping_list')) {
const col = new Collection({
name: 'shopping_list',
type: 'base',
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
fields: [
{ name: 'household', type: 'relation', required: true, collectionId: householdsId, cascadeDelete: true, maxSelect: 1 },
{ name: 'item_id', type: 'text' },
{ name: 'name', type: 'text', required: true },
{ name: 'suggested_quantity', type: 'number' },
{ name: 'unit', type: 'text' },
{ name: 'is_checked', type: 'bool' },
{ name: 'checked_by_device', type: 'text' },
{ name: 'auto_added', type: 'bool' },
],
});
app.save(col);
}
// ── members ──────────────────────────────────────────────────────────────────
if (!collectionExists('members')) {
const col = new Collection({
name: 'members',
type: 'base',
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
fields: [
{ name: 'household', type: 'relation', required: true, collectionId: householdsId, cascadeDelete: true, maxSelect: 1 },
{ name: 'device_id', type: 'text', required: true },
{ name: 'device_name', type: 'text' },
{ name: 'fcm_token', type: 'text' },
{ name: 'notifications_enabled', type: 'bool' },
],
});
app.save(col);
}
}, (app) => {
// rollback
for (const name of ['members', 'shopping_list', 'items', 'households']) {
try { app.delete(app.findCollectionByNameOrId(name)); } catch {}
}
});
Executable
BIN
View File
Binary file not shown.
+141
View File
@@ -0,0 +1,141 @@
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { HouseholdItem } from '../../types';
import { COLORS, CATEGORY_ICONS, CATEGORY_LABELS, CATEGORY_COLORS, CATEGORY_ICON_COLORS } from '../../constants';
import { isExpiringSoon, isExpired, formatDate } from '../../utils';
import { QuantityControl } from '../ui/QuantityControl';
interface Props {
item: HouseholdItem;
onPress: () => void;
onQuantityChange: (qty: number) => void;
}
export function ItemCard({ item, onPress, onQuantityChange }: Props) {
const expired = isExpired(item.expiryDate);
const expiringSoon = isExpiringSoon(item.expiryDate);
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.75}>
<View
style={[
styles.photoContainer,
!item.photoUrl && { backgroundColor: CATEGORY_COLORS[item.category] ?? COLORS.surface },
]}
>
{item.photoUrl ? (
<Image source={{ uri: item.photoUrl }} style={styles.photo} />
) : (
<MaterialIcons
name={CATEGORY_ICONS[item.category] as any}
size={28}
color={CATEGORY_ICON_COLORS[item.category] ?? COLORS.primaryLight}
/>
)}
</View>
<View style={styles.content}>
<View style={styles.titleRow}>
<Text style={styles.name} numberOfLines={1}>
{item.name}
</Text>
{item.onShoppingList && (
<View style={styles.cartBadge}>
<MaterialIcons name="shopping-cart" size={11} color={COLORS.warning} />
</View>
)}
</View>
<Text style={styles.meta} numberOfLines={1}>
{CATEGORY_LABELS[item.category]}
{item.storageLocation ? ` · ${item.storageLocation}` : ''}
</Text>
{(expiringSoon || expired) && item.expiryDate && (
<View style={[styles.mhdBadge, expired ? styles.mhdExpired : styles.mhdWarn]}>
<MaterialIcons
name={expired ? 'error-outline' : 'schedule'}
size={11}
color={expired ? '#B71C1C' : '#E65100'}
/>
<Text style={[styles.mhdText, expired ? styles.mhdTextExpired : styles.mhdTextWarn]}>
{expired ? 'Abgelaufen' : `MHD ${formatDate(item.expiryDate)}`}
</Text>
</View>
)}
<QuantityControl
quantity={item.quantity}
unit={item.unit}
minStockThreshold={item.minStockThreshold}
onChangeQuantity={onQuantityChange}
compact
/>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: COLORS.white,
borderRadius: 16,
marginHorizontal: 16,
marginVertical: 5,
padding: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.07,
shadowRadius: 8,
elevation: 2,
gap: 12,
alignItems: 'center',
},
photoContainer: {
width: 72,
height: 72,
borderRadius: 12,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: COLORS.surface,
},
photo: { width: '100%', height: '100%', resizeMode: 'cover' },
content: { flex: 1, gap: 4 },
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
name: {
fontSize: 16,
fontWeight: '600',
color: COLORS.text,
flex: 1,
},
cartBadge: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#FFF8E1',
justifyContent: 'center',
alignItems: 'center',
},
meta: { fontSize: 12, color: COLORS.textSecondary },
mhdBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
borderRadius: 6,
paddingHorizontal: 7,
paddingVertical: 3,
alignSelf: 'flex-start',
},
mhdWarn: { backgroundColor: '#FFF3E0' },
mhdExpired: { backgroundColor: '#FFEBEE' },
mhdText: { fontSize: 11, fontWeight: '600' },
mhdTextWarn: { color: '#E65100' },
mhdTextExpired: { color: '#B71C1C' },
});
+108
View File
@@ -0,0 +1,108 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { MaterialIcons } from '@expo/vector-icons';
import { ShoppingListEntry } from '../../types';
import { COLORS } from '../../constants';
interface Props {
entry: ShoppingListEntry;
onCheckOff: () => void;
onRemove: () => void;
}
export function ShoppingItem({ entry, onCheckOff, onRemove }: Props) {
const renderRightActions = () => (
<TouchableOpacity style={styles.deleteAction} onPress={onRemove}>
<MaterialIcons name="delete-outline" size={22} color={COLORS.white} />
</TouchableOpacity>
);
return (
<Swipeable renderRightActions={renderRightActions} overshootRight={false} friction={2}>
<View style={[styles.card, entry.isChecked && styles.cardChecked]}>
<TouchableOpacity
style={[styles.checkbox, entry.isChecked && styles.checkboxChecked]}
onPress={onCheckOff}
disabled={entry.isChecked}
accessibilityLabel={`${entry.name} abhaken`}
>
{entry.isChecked && <MaterialIcons name="check" size={15} color={COLORS.white} />}
</TouchableOpacity>
<View style={styles.info}>
<Text style={[styles.name, entry.isChecked && styles.nameChecked]} numberOfLines={1}>
{entry.name}
</Text>
<View style={styles.metaRow}>
<Text style={styles.quantity}>
{entry.suggestedQuantity} {entry.unit}
</Text>
{entry.autoAdded && (
<View style={styles.autoBadge}>
<Text style={styles.autoBadgeText}>Auto</Text>
</View>
)}
</View>
</View>
</View>
</Swipeable>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderRadius: 14,
marginHorizontal: 16,
marginVertical: 4,
padding: 14,
gap: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 1,
},
cardChecked: { opacity: 0.5 },
checkbox: {
width: 26,
height: 26,
borderRadius: 13,
borderWidth: 2,
borderColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
checkboxChecked: {
backgroundColor: COLORS.primary,
borderColor: COLORS.primary,
},
info: { flex: 1 },
name: { fontSize: 15, fontWeight: '500', color: COLORS.text },
nameChecked: { textDecorationLine: 'line-through', color: COLORS.textSecondary },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 },
quantity: { fontSize: 12, color: COLORS.textSecondary },
autoBadge: {
backgroundColor: COLORS.surface,
borderRadius: 4,
paddingHorizontal: 5,
paddingVertical: 1,
},
autoBadgeText: {
fontSize: 10,
color: COLORS.textSecondary,
fontWeight: '600',
letterSpacing: 0.3,
},
deleteAction: {
backgroundColor: COLORS.danger,
justifyContent: 'center',
alignItems: 'center',
width: 72,
borderRadius: 14,
marginVertical: 4,
marginRight: 16,
},
});
+211
View File
@@ -0,0 +1,211 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { COLORS } from '../../constants';
interface Props {
value: string;
onSelect: (value: string) => void;
options: string[];
onAddOption: (value: string) => void;
onRemoveOption?: (value: string) => void;
removableOptions?: string[];
getLabel?: (value: string) => string;
addPlaceholder?: string;
}
export function ChipSelectInput({
value,
onSelect,
options,
onAddOption,
onRemoveOption,
removableOptions = [],
getLabel,
addPlaceholder = 'Neu hinzufügen…',
}: Props) {
const [adding, setAdding] = useState(false);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<TextInput>(null);
const confirm = () => {
const trimmed = inputValue.trim();
if (trimmed) {
onAddOption(trimmed);
onSelect(trimmed);
}
setInputValue('');
setAdding(false);
};
const cancel = () => {
setInputValue('');
setAdding(false);
};
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{options.map((opt) => {
const isActive = value === opt;
const isRemovable = removableOptions.includes(opt) && !!onRemoveOption;
return (
<TouchableOpacity
key={opt}
style={[styles.chip, isActive && styles.chipActive, isRemovable && styles.chipRemovable]}
onPress={() => onSelect(opt)}
>
<Text style={[styles.chipText, isActive && styles.chipTextActive]}>
{getLabel ? getLabel(opt) : opt}
</Text>
{isRemovable && (
<TouchableOpacity
onPress={() => onRemoveOption(opt)}
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
style={styles.removeBtn}
>
<MaterialIcons
name="close"
size={12}
color={isActive ? COLORS.white : COLORS.textSecondary}
/>
</TouchableOpacity>
)}
</TouchableOpacity>
);
})}
{!adding && (
<TouchableOpacity
style={styles.addChip}
onPress={() => {
setAdding(true);
setTimeout(() => inputRef.current?.focus(), 50);
}}
>
<MaterialIcons name="add" size={16} color={COLORS.primary} />
<Text style={styles.addChipText}>Neu</Text>
</TouchableOpacity>
)}
</ScrollView>
{adding && (
<View style={styles.inputRow}>
<TextInput
ref={inputRef}
style={styles.input}
value={inputValue}
onChangeText={setInputValue}
placeholder={addPlaceholder}
placeholderTextColor={COLORS.textSecondary}
returnKeyType="done"
onSubmitEditing={confirm}
/>
<TouchableOpacity style={styles.confirmBtn} onPress={confirm}>
<MaterialIcons name="check" size={18} color={COLORS.white} />
</TouchableOpacity>
<TouchableOpacity style={styles.cancelBtn} onPress={cancel}>
<MaterialIcons name="close" size={18} color={COLORS.textSecondary} />
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 8,
paddingVertical: 2,
},
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1.5,
borderColor: COLORS.border,
backgroundColor: COLORS.surface,
},
chipActive: {
backgroundColor: COLORS.primary,
borderColor: COLORS.primary,
},
chipText: {
fontSize: 13,
color: COLORS.textSecondary,
fontWeight: '500',
},
chipTextActive: {
color: COLORS.white,
fontWeight: '600',
},
chipRemovable: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
removeBtn: {
marginLeft: 2,
},
addChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1.5,
borderColor: COLORS.primary,
borderStyle: 'dashed',
},
addChipText: {
fontSize: 13,
color: COLORS.primary,
fontWeight: '500',
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
},
input: {
flex: 1,
borderWidth: 1.5,
borderColor: COLORS.primary,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 9,
fontSize: 15,
color: COLORS.text,
backgroundColor: COLORS.surface,
},
confirmBtn: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
cancelBtn: {
width: 36,
height: 36,
borderRadius: 10,
borderWidth: 1.5,
borderColor: COLORS.border,
justifyContent: 'center',
alignItems: 'center',
},
});
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { Platform, TouchableOpacity, Text, StyleSheet } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { COLORS } from '../../constants';
type Props = {
value: Date;
minimumDate?: Date;
onChange: (date: Date) => void;
onDismiss: () => void;
};
export function DatePickerModal({ value, minimumDate, onChange, onDismiss }: Props) {
return (
<>
<DateTimePicker
value={value}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
minimumDate={minimumDate}
onChange={(event, date) => {
if (Platform.OS === 'android') {
onDismiss();
if (event.type !== 'dismissed' && date) onChange(date);
} else {
if (date) onChange(date);
}
}}
/>
{Platform.OS === 'ios' && (
<TouchableOpacity style={styles.confirm} onPress={onDismiss}>
<Text style={styles.confirmText}>Fertig</Text>
</TouchableOpacity>
)}
</>
);
}
const styles = StyleSheet.create({
confirm: {
alignSelf: 'flex-end',
marginTop: 8,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: COLORS.primary,
borderRadius: 8,
},
confirmText: { color: '#fff', fontWeight: '600', fontSize: 14 },
});
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
// TypeScript type source — overridden at runtime by DatePicker.native.tsx / DatePicker.web.tsx
type Props = {
value: Date;
minimumDate?: Date;
onChange: (date: Date) => void;
onDismiss: () => void;
};
export function DatePickerModal(_props: Props): React.ReactElement | null {
return null;
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
type Props = {
value: Date;
minimumDate?: Date;
onChange: (date: Date) => void;
onDismiss: () => void;
};
const toDateStr = (d: Date) => d.toISOString().split('T')[0];
export function DatePickerModal({ value, minimumDate, onChange, onDismiss }: Props) {
return (
<input
type="date"
value={toDateStr(value)}
min={minimumDate ? toDateStr(minimumDate) : undefined}
style={{
display: 'block',
marginTop: 8,
padding: 8,
fontSize: 16,
borderRadius: 8,
border: '1.5px solid #ccc',
width: '100%',
boxSizing: 'border-box',
}}
onChange={(e) => {
if (e.target.value) {
const [y, m, d] = e.target.value.split('-').map(Number);
onChange(new Date(y, m - 1, d));
onDismiss();
}
}}
/>
);
}
+108
View File
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
import { COLORS } from '../../constants';
interface Props {
quantity: number;
unit: string;
minStockThreshold: number;
onChangeQuantity: (qty: number) => void;
compact?: boolean;
}
export function QuantityControl({
quantity,
unit,
minStockThreshold,
onChangeQuantity,
compact,
}: Props) {
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(String(quantity));
const decrease = () => onChangeQuantity(Math.max(0, quantity - 1));
const increase = () => onChangeQuantity(quantity + 1);
const commitEdit = () => {
const parsed = parseFloat(inputValue.replace(',', '.'));
if (!isNaN(parsed) && parsed >= 0) {
onChangeQuantity(Math.round(parsed * 100) / 100);
}
setEditing(false);
};
const isLow = quantity <= minStockThreshold;
const btnSize = compact ? 28 : 40;
const btnRadius = compact ? 8 : 20;
const fontSize = compact ? 14 : 18;
return (
<View style={styles.row}>
<TouchableOpacity
style={[styles.btn, { width: btnSize, height: btnSize, borderRadius: btnRadius }]}
onPress={decrease}
accessibilityLabel="Menge reduzieren"
>
<Text style={[styles.btnText, compact && styles.btnTextCompact]}></Text>
</TouchableOpacity>
{editing ? (
<TextInput
style={[styles.input, { fontSize }]}
value={inputValue}
keyboardType="numeric"
onChangeText={setInputValue}
onBlur={commitEdit}
onSubmitEditing={commitEdit}
autoFocus
/>
) : (
<TouchableOpacity
onPress={() => {
setInputValue(String(quantity));
setEditing(true);
}}
>
<Text style={[styles.quantity, { fontSize }, isLow && styles.quantityLow]}>
{quantity} {unit}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.btn, { width: btnSize, height: btnSize, borderRadius: btnRadius }]}
onPress={increase}
accessibilityLabel="Menge erhöhen"
>
<Text style={[styles.btnText, compact && styles.btnTextCompact]}>+</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
btn: {
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
btnText: { color: COLORS.white, fontSize: 20, fontWeight: '500', lineHeight: 24 },
btnTextCompact: { fontSize: 16, lineHeight: 20 },
quantity: {
fontWeight: '600',
color: COLORS.text,
minWidth: 60,
textAlign: 'center',
},
quantityLow: { color: COLORS.danger },
input: {
fontWeight: '600',
color: COLORS.text,
borderBottomWidth: 2,
borderBottomColor: COLORS.primary,
minWidth: 60,
textAlign: 'center',
padding: 2,
},
});
+74
View File
@@ -0,0 +1,74 @@
export const COLORS = {
primary: '#2D6A4F',
primaryLight: '#52B788',
primaryDark: '#1B4332',
secondary: '#F4A261',
danger: '#E63946',
warning: '#F59E0B',
surface: '#F2F2F7',
surfaceDark: '#1C1C1E',
text: '#1C1C1E',
textSecondary: '#8E8E93',
border: '#E5E5EA',
white: '#FFFFFF',
black: '#000000',
} as const;
export const BASE_CATEGORIES = ['food', 'cleaning', 'hygiene', 'other'] as const;
export const CATEGORY_LABELS: Record<string, string> = {
food: 'Lebensmittel',
cleaning: 'Reinigung',
hygiene: 'Hygiene',
other: 'Sonstiges',
};
export const CATEGORY_ICONS: Record<string, string> = {
food: 'restaurant',
cleaning: 'eco',
hygiene: 'soap',
other: 'category',
};
export const CATEGORY_COLORS: Record<string, string> = {
food: '#E8F5E9',
cleaning: '#E3F2FD',
hygiene: '#FFF3E0',
other: '#F3E5F5',
};
export const CATEGORY_ICON_COLORS: Record<string, string> = {
food: '#43A047',
cleaning: '#1E88E5',
hygiene: '#FB8C00',
other: '#8E24AA',
};
export const UNITS = [
'Stück',
'kg',
'g',
'L',
'ml',
'Packung',
'Dose',
'Flasche',
'Beutel',
'Tube',
'Paar',
];
export const STORAGE_LOCATIONS = [
'Kühlschrank',
'Gefrierschrank',
'Vorratskammer',
'Keller',
'Schrank',
'Badezimmer',
'Sonstiges',
];
export const MHD_WARNING_DAYS = 3;
export const DEEPLINK_SCHEME = 'houseorg';
export const INVITE_PATH = 'join';
+40
View File
@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
import { kv } from '../lib/kv';
export function useCustomOptions(key: string, defaults: string[]) {
const [custom, setCustom] = useState<string[]>([]);
useEffect(() => {
kv.getItem(key).then((json) => {
if (json) {
try { setCustom(JSON.parse(json)); } catch {}
}
});
}, [key]);
const addOption = useCallback(async (value: string) => {
const trimmed = value.trim();
if (!trimmed || defaults.includes(trimmed)) return;
setCustom((prev) => {
if (prev.includes(trimmed)) return prev;
const next = [...prev, trimmed];
kv.setItem(key, JSON.stringify(next));
return next;
});
}, [key, defaults]);
const removeOption = useCallback((value: string) => {
setCustom((prev) => {
const next = prev.filter((o) => o !== value);
kv.setItem(key, JSON.stringify(next));
return next;
});
}, [key]);
return {
options: [...defaults, ...custom],
customOptions: custom,
addOption,
removeOption,
};
}
+74
View File
@@ -0,0 +1,74 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Household, HouseholdItem, ShoppingListEntry } from '../types';
import { subscribeToItems } from '../services/items';
import { subscribeToShoppingList } from '../services/shopping';
import { scheduleExpiryCheck } from '../services/notifications';
import { useEffect } from 'react';
interface HouseholdStore {
household: Household | null;
deviceId: string | null;
items: HouseholdItem[];
shoppingList: ShoppingListEntry[];
isInitialized: boolean;
setHousehold: (h: Household | null) => void;
setDeviceId: (id: string) => void;
setItems: (items: HouseholdItem[]) => void;
setShoppingList: (entries: ShoppingListEntry[]) => void;
setInitialized: () => void;
}
export const useHouseholdStore = create<HouseholdStore>()(
persist(
(set) => ({
household: null,
deviceId: null,
items: [],
shoppingList: [],
isInitialized: false,
setHousehold: (h) => set({ household: h }),
setDeviceId: (id) => set({ deviceId: id }),
setItems: (items) => set({ items }),
setShoppingList: (shoppingList) => set({ shoppingList }),
setInitialized: () => set({ isInitialized: true }),
}),
{
name: 'houseorg-store',
storage: createJSONStorage(() => {
// AsyncStorage uses window.localStorage on web — not available during SSR (Node.js)
if (typeof window === 'undefined') {
return { getItem: () => null, setItem: () => {}, removeItem: () => {} };
}
return AsyncStorage;
}),
partialize: (s) => ({
household: s.household,
deviceId: s.deviceId,
items: s.items,
shoppingList: s.shoppingList,
// isInitialized intentionally excluded — runtime-only flag
}),
}
)
);
export function useRealtimeSync() {
const household = useHouseholdStore((s) => s.household);
const setItems = useHouseholdStore((s) => s.setItems);
const setShoppingList = useHouseholdStore((s) => s.setShoppingList);
useEffect(() => {
if (!household) return;
const unsubItems = subscribeToItems(household.id, (items) => {
setItems(items);
scheduleExpiryCheck(items).catch(() => {});
});
const unsubList = subscribeToShoppingList(household.id, setShoppingList);
return () => {
unsubItems();
unsubList();
};
}, [household?.id]);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { syncQueue, PendingOp } from '../lib/syncQueue';
import { createItem, updateItem, deleteItem, updateItemQuantity } from '../services/items';
import {
addToShoppingList,
checkOffItem,
removeShoppingEntry,
clearCheckedItems,
} from '../services/shopping';
async function executeOp(op: PendingOp): Promise<void> {
const p = op.payload as any;
switch (op.type) {
case 'createItem':
await createItem(p.householdId, p.input);
break;
case 'updateItem':
await updateItem(p.householdId, p.itemId, p.changes);
break;
case 'deleteItem':
await deleteItem(p.householdId, p.itemId);
break;
case 'updateItemQuantity':
await updateItemQuantity(p.householdId, p.itemId, p.qty);
break;
case 'addToShoppingList':
await addToShoppingList(p.householdId, p.entry);
break;
case 'checkOffItem':
await checkOffItem(p.householdId, p.entryId, p.deviceId, p.quantityBought);
break;
case 'removeShoppingEntry':
await removeShoppingEntry(p.householdId, p.entryId);
break;
case 'clearCheckedItems':
await clearCheckedItems(p.householdId);
break;
}
}
async function replayQueue(): Promise<void> {
const ops = await syncQueue.getAll();
for (const op of ops) {
try {
await executeOp(op);
await syncQueue.remove(op.id);
} catch {
break; // stop until next reconnect
}
}
}
export function useNetworkSync() {
useEffect(() => {
if (Platform.OS === 'web') {
window.addEventListener('online', replayQueue);
return () => window.removeEventListener('online', replayQueue);
}
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) replayQueue();
});
return unsubscribe;
}, []);
}
+18
View File
@@ -0,0 +1,18 @@
import { Platform } from 'react-native';
async function getItem(key: string): Promise<string | null> {
if (Platform.OS === 'web') return localStorage.getItem(key);
const { getItemAsync } = await import('expo-secure-store');
return getItemAsync(key);
}
async function setItem(key: string, value: string): Promise<void> {
if (Platform.OS === 'web') {
localStorage.setItem(key, value);
return;
}
const { setItemAsync } = await import('expo-secure-store');
return setItemAsync(key, value);
}
export const kv = { getItem, setItem };
+63
View File
@@ -0,0 +1,63 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { v4 as uuidv4 } from 'uuid';
const QUEUE_KEY = 'houseorg_sync_queue';
export type PendingOpType =
| 'createItem'
| 'updateItem'
| 'deleteItem'
| 'updateItemQuantity'
| 'addToShoppingList'
| 'checkOffItem'
| 'removeShoppingEntry'
| 'clearCheckedItems';
export type PendingOp = {
id: string;
ts: number;
type: PendingOpType;
payload: Record<string, unknown>;
};
async function readQueue(): Promise<PendingOp[]> {
try {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
async function writeQueue(ops: PendingOp[]): Promise<void> {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(ops));
}
export const syncQueue = {
async add(op: Omit<PendingOp, 'id' | 'ts'>): Promise<void> {
const ops = await readQueue();
ops.push({ ...op, id: uuidv4(), ts: Date.now() });
await writeQueue(ops);
},
async getAll(): Promise<PendingOp[]> {
const ops = await readQueue();
return ops.sort((a, b) => a.ts - b.ts);
},
async remove(id: string): Promise<void> {
const ops = await readQueue();
await writeQueue(ops.filter((op) => op.id !== id));
},
};
export function isOfflineError(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e);
return (
msg.includes('Failed to fetch') ||
msg.includes('Network request failed') ||
msg.includes('NetworkError') ||
msg.includes('network') ||
(e as any)?.status === 0
);
}
+81
View File
@@ -0,0 +1,81 @@
import OpenAI from 'openai';
import { Platform } from 'react-native';
import { AiRecognitionResult, ItemCategory } from '../types';
const client = new OpenAI({
apiKey: process.env.EXPO_PUBLIC_OPENAI_API_KEY ?? '',
dangerouslyAllowBrowser: true,
});
const PROMPT = `Du bist ein Assistent für eine Haushalts-App. Analysiere das Foto und erkenne den abgebildeten Artikel.
Antworte NUR mit gültigem JSON in diesem Format:
{
"name": "Artikelname (kurz, auf Deutsch)",
"description": "Kurze Beschreibung in einem Satz auf Deutsch",
"category": "food | cleaning | hygiene | other"
}
Kategorien:
- food: Lebensmittel, Getränke
- cleaning: Reinigungsmittel, Putzmittel
- hygiene: Körperpflege, Hygieneartikel
- other: Alles andere
Falls du den Artikel nicht erkennen kannst, nutze category "other" und name "Unbekannter Artikel".`;
async function toBase64(uri: string): Promise<string> {
if (Platform.OS === 'web') {
const res = await fetch(uri);
const blob = await res.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
const { readAsStringAsync } = await import('expo-file-system');
return readAsStringAsync(uri, { encoding: 'base64' });
}
export async function recognizeItemFromPhoto(photoUri: string): Promise<AiRecognitionResult> {
const base64 = await toBase64(photoUri);
const response = await client.chat.completions.create({
model: 'gpt-4o-mini',
max_tokens: 200,
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: {
url: `data:image/jpeg;base64,${base64}`,
detail: 'low',
},
},
{ type: 'text', text: PROMPT },
],
},
],
});
const text = response.choices[0]?.message?.content ?? '';
try {
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('No JSON in response');
const parsed = JSON.parse(jsonMatch[0]);
return {
name: String(parsed.name ?? 'Unbekannter Artikel'),
description: String(parsed.description ?? ''),
category: (['food', 'cleaning', 'hygiene', 'other'].includes(parsed.category)
? parsed.category
: 'other') as ItemCategory,
};
} catch {
return { name: 'Unbekannter Artikel', description: '', category: 'other' };
}
}
+42
View File
@@ -0,0 +1,42 @@
import { BarcodeProduct, ItemCategory } from '../types';
const OPEN_FOOD_FACTS_URL = 'https://world.openfoodfacts.org/api/v0/product';
function mapOpenFoodFactsCategory(categories: string): ItemCategory {
const lower = categories.toLowerCase();
if (lower.includes('beverage') || lower.includes('food') || lower.includes('snack')) {
return 'food';
}
return 'food'; // Open Food Facts is food-focused; default to food
}
export async function lookupBarcode(barcode: string): Promise<BarcodeProduct | null> {
const url = `${OPEN_FOOD_FACTS_URL}/${barcode}.json`;
const res = await fetch(url, {
headers: { 'User-Agent': 'HouseOrg/1.0 (contact@houseorg.de)' },
});
if (!res.ok) throw new Error(`Server-Fehler: ${res.status}`);
try {
const data = await res.json();
if (data.status !== 1 || !data.product) return null;
const p = data.product;
const name: string =
p.product_name_de || p.product_name || p.generic_name || 'Unbekanntes Produkt';
const description: string =
p.generic_name_de || p.generic_name || p.ingredients_text_de || '';
const imageUrl: string | undefined = p.image_front_small_url || p.image_url;
const categories: string = p.categories ?? '';
return {
name,
description: description.slice(0, 150),
category: mapOpenFoodFactsCategory(categories),
barcode,
imageUrl,
};
} catch {
return null;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { v4 as uuidv4 } from 'uuid';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { pb } from './pocketbase';
import { kv } from '../lib/kv';
import { Household, HouseholdMember } from '../types';
const NOTIF_KEY = 'houseorg_notifications_enabled';
const HOUSEHOLD_ID_KEY = 'household_id';
const DEVICE_ID_KEY = 'device_id';
function mapHousehold(record: { id: string; name: string; invite_token: string; created: string }): Household {
return {
id: record.id,
name: record.name,
inviteToken: record.invite_token,
createdAt: new Date(record.created),
};
}
export async function getOrCreateDeviceId(): Promise<string> {
let deviceId = await kv.getItem(DEVICE_ID_KEY);
if (!deviceId) {
deviceId = uuidv4();
await kv.setItem(DEVICE_ID_KEY, deviceId);
}
return deviceId;
}
export async function getStoredHouseholdId(): Promise<string | null> {
return kv.getItem(HOUSEHOLD_ID_KEY);
}
export async function createHousehold(name: string): Promise<Household> {
const record = await pb.collection('households').create({
name,
invite_token: uuidv4(),
});
const household = mapHousehold(record as any);
await kv.setItem(HOUSEHOLD_ID_KEY, household.id);
return household;
}
export async function joinHousehold(householdId: string, inviteToken: string): Promise<boolean> {
try {
const record = await pb.collection('households').getOne(householdId);
if ((record as any).invite_token !== inviteToken) return false;
await kv.setItem(HOUSEHOLD_ID_KEY, householdId);
return true;
} catch {
return false;
}
}
export async function registerMember(
householdId: string,
fcmToken: string | null,
displayName?: string
): Promise<void> {
const deviceId = await getOrCreateDeviceId();
const deviceName = displayName?.trim() || Device.deviceName || 'Unbekanntes Gerät';
const storedNotif = await kv.getItem(NOTIF_KEY);
const notificationsEnabled = storedNotif !== 'false';
const member: Omit<HouseholdMember, 'joinedAt'> & { household: string; device_id: string; device_name: string; fcm_token: string | null; notifications_enabled: boolean } = {
household: householdId,
device_id: deviceId,
device_name: deviceName,
deviceId,
deviceName,
fcmToken: notificationsEnabled ? fcmToken : null,
fcm_token: notificationsEnabled ? fcmToken : null,
notificationsEnabled,
notifications_enabled: notificationsEnabled,
};
const existing = await pb.collection('members').getFullList({
filter: `device_id = "${deviceId}" && household = "${householdId}"`,
});
if (existing.length > 0) {
await pb.collection('members').update(existing[0].id, member);
} else {
await pb.collection('members').create(member);
}
}
export async function getHousehold(id: string): Promise<Household | null> {
try {
const record = await pb.collection('households').getOne(id);
return mapHousehold(record as any);
} catch (e: any) {
if (e?.status === 404) return null;
throw e;
}
}
export async function regenerateInviteToken(householdId: string): Promise<string> {
const newToken = uuidv4();
await pb.collection('households').update(householdId, { invite_token: newToken });
return newToken;
}
export function buildInviteLink(householdId: string, token: string): string {
const base = process.env.EXPO_PUBLIC_POCKETBASE_URL ?? '';
if (Platform.OS === 'web' && base) {
const origin = base.replace(':8090', '').replace(/\/$/, '');
return `${origin}/join?householdId=${householdId}&token=${token}`;
}
return `houseorg://join?householdId=${householdId}&token=${token}`;
}
+215
View File
@@ -0,0 +1,215 @@
import { Platform } from 'react-native';
import { RecordModel } from 'pocketbase';
import { pb } from './pocketbase';
import { syncQueue, isOfflineError } from '../lib/syncQueue';
import { getPhotoUrl, buildPhotoFormData, compressPhoto } from './storage';
import { HouseholdItem } from '../types';
type CreateItemInput = Omit<HouseholdItem, 'id' | 'createdAt' | 'updatedAt' | 'onShoppingList'> & {
photoUri?: string;
};
function mapItemRecord(r: RecordModel): HouseholdItem {
return {
id: r.id,
name: r.name,
description: r.description ?? '',
category: r.category,
quantity: r.quantity ?? 0,
unit: r.unit,
minStockThreshold: r.min_stock_threshold ?? 0,
onShoppingList: r.on_shopping_list ?? false,
photoUrl: getPhotoUrl(r),
storageLocation: r.storage_location ?? '',
shoppingLocation: r.shopping_location ?? '',
price: r.price ?? null,
expiryDate: r.expiry_date ? new Date(r.expiry_date) : null,
barcode: r.barcode ?? null,
addedByDevice: r.added_by_device ?? '',
createdAt: new Date(r.created),
updatedAt: new Date(r.updated),
};
}
function getStore() {
// Lazy import to avoid circular dependency
const { useHouseholdStore } = require('../hooks/useHousehold');
return useHouseholdStore.getState();
}
export async function createItem(householdId: string, input: CreateItemInput): Promise<HouseholdItem> {
const { photoUri, ...itemData } = input;
const buildData = () => ({
household: householdId,
name: itemData.name,
description: itemData.description,
category: itemData.category,
quantity: itemData.quantity,
unit: itemData.unit,
min_stock_threshold: itemData.minStockThreshold,
on_shopping_list: false,
storage_location: itemData.storageLocation,
shopping_location: itemData.shoppingLocation,
price: itemData.price ?? null,
expiry_date: itemData.expiryDate?.toISOString() ?? null,
barcode: itemData.barcode ?? null,
added_by_device: itemData.addedByDevice,
});
try {
let record: RecordModel;
if (photoUri) {
const compressed = await compressPhoto(photoUri);
const fd = await buildPhotoFormData('photo', compressed);
const plain = buildData();
Object.entries(plain).forEach(([k, v]) => {
if (v != null) fd.append(k, String(v));
});
record = await pb.collection('items').create(fd);
} else {
record = await pb.collection('items').create(buildData());
}
return mapItemRecord(record);
} catch (e) {
if (isOfflineError(e)) {
const optimistic: HouseholdItem = {
...itemData,
id: 'offline_' + Date.now(),
onShoppingList: false,
photoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const store = getStore();
store.setItems([...store.items, optimistic]);
await syncQueue.add({
type: 'createItem',
payload: { householdId, input: { ...itemData } },
});
return optimistic;
}
throw e;
}
}
export async function updateItemQuantity(
householdId: string,
itemId: string,
newQuantity: number
): Promise<void> {
const store = getStore();
store.setItems(
store.items.map((i: HouseholdItem) => (i.id === itemId ? { ...i, quantity: newQuantity } : i))
);
try {
// Server-hook handles shopping list auto-add/remove
await pb.collection('items').update(itemId, { quantity: newQuantity });
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'updateItemQuantity', payload: { householdId, itemId, qty: newQuantity } });
} else throw e;
}
}
export async function updateItem(
householdId: string,
itemId: string,
changes: Partial<HouseholdItem> & { photoUri?: string }
): Promise<void> {
const { photoUri, photoUrl: _photoUrl, ...rest } = changes as any;
const plain: Record<string, unknown> = {
...(rest.name !== undefined && { name: rest.name }),
...(rest.description !== undefined && { description: rest.description }),
...(rest.storageLocation !== undefined && { storage_location: rest.storageLocation }),
...(rest.shoppingLocation !== undefined && { shopping_location: rest.shoppingLocation }),
...(rest.price !== undefined && { price: rest.price }),
...(rest.minStockThreshold !== undefined && { min_stock_threshold: rest.minStockThreshold }),
...(rest.expiryDate !== undefined && { expiry_date: rest.expiryDate?.toISOString() ?? null }),
...(rest.barcode !== undefined && { barcode: rest.barcode }),
};
const store = getStore();
store.setItems(
store.items.map((i: HouseholdItem) =>
i.id === itemId ? { ...i, ...changes, updatedAt: new Date() } : i
)
);
try {
if (photoUri) {
const compressed = await compressPhoto(photoUri);
const fd = await buildPhotoFormData('photo', compressed);
Object.entries(plain).forEach(([k, v]) => {
if (v != null) fd.append(k, String(v));
});
await pb.collection('items').update(itemId, fd);
} else {
await pb.collection('items').update(itemId, plain);
}
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'updateItem', payload: { householdId, itemId, changes: plain } });
} else throw e;
}
}
export async function deleteItem(householdId: string, itemId: string): Promise<void> {
const store = getStore();
store.setItems(store.items.filter((i: HouseholdItem) => i.id !== itemId));
try {
const orphans = await pb.collection('shopping_list').getFullList({
filter: `item_id = "${itemId}"`,
});
await Promise.all(orphans.map((e) => pb.collection('shopping_list').delete(e.id)));
await pb.collection('items').delete(itemId);
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'deleteItem', payload: { householdId, itemId } });
} else throw e;
}
}
export function subscribeToItems(
householdId: string,
onChange: (items: HouseholdItem[]) => void
): () => void {
let current: HouseholdItem[] = [];
let cancelled = false;
async function connect() {
if (cancelled) return;
try {
const records = await pb.collection('items').getFullList({
filter: `household = "${householdId}"`,
sort: 'name',
});
current = records.map(mapItemRecord);
onChange(current);
await pb.collection('items').subscribe('*', (e) => {
if ((e.record as any).household !== householdId) return;
if (e.action === 'create') {
current = [...current, mapItemRecord(e.record)].sort((a, b) =>
a.name.localeCompare(b.name, 'de')
);
} else if (e.action === 'update') {
current = current.map((i) => (i.id === e.record.id ? mapItemRecord(e.record) : i));
} else if (e.action === 'delete') {
current = current.filter((i) => i.id !== e.record.id);
}
onChange(current);
});
} catch {
if (!cancelled) setTimeout(connect, 5000);
}
}
connect();
return () => {
cancelled = true;
pb.collection('items').unsubscribe('*');
};
}
+108
View File
@@ -0,0 +1,108 @@
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { pb } from './pocketbase';
import { getOrCreateDeviceId } from './household';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function requestNotificationPermission(): Promise<boolean> {
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
export async function getFcmToken(): Promise<string | null> {
try {
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
(Constants as any).easConfig?.projectId;
const token = await Notifications.getExpoPushTokenAsync(
projectId ? { projectId } : {}
);
return token.data;
} catch {
return null;
}
}
export async function updateFcmToken(householdId: string, enabled: boolean): Promise<void> {
const deviceId = await getOrCreateDeviceId();
const token = enabled ? await getFcmToken() : null;
const existing = await pb.collection('members').getFullList({
filter: `device_id = "${deviceId}" && household = "${householdId}"`,
});
if (existing.length === 0) return;
await pb.collection('members').update(existing[0].id, {
fcm_token: token,
notifications_enabled: enabled,
});
}
export async function sendShoppingListPush(
householdId: string,
itemName: string
): Promise<void> {
try {
const deviceId = await getOrCreateDeviceId();
const members = await pb.collection('members').getFullList({
filter: `household = "${householdId}" && notifications_enabled = true && fcm_token != ""`,
});
const tokens = members
.filter((m) => m.device_id !== deviceId)
.map((m) => m.fcm_token as string)
.filter((t) => t?.startsWith('ExponentPushToken'));
if (tokens.length === 0) return;
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(
tokens.map((to) => ({
to,
title: 'Einkaufsliste',
body: `${itemName} wurde hinzugefügt`,
sound: 'default',
}))
),
});
} catch (e) {
console.warn('sendShoppingListPush failed:', e);
}
}
export async function scheduleExpiryCheck(
items: Array<{ name: string; expiryDate: Date | null }>
) {
await Notifications.cancelAllScheduledNotificationsAsync();
const now = new Date();
const threeDays = 3 * 24 * 60 * 60 * 1000;
const expiringSoon = items.filter(
(item) =>
item.expiryDate &&
item.expiryDate.getTime() - now.getTime() <= threeDays &&
item.expiryDate.getTime() > now.getTime()
);
for (const item of expiringSoon) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'MHD läuft ab',
body: `${item.name} läuft in ≤ 3 Tagen ab!`,
data: {},
},
trigger: null,
});
}
}
+6
View File
@@ -0,0 +1,6 @@
// TypeScript type source — overridden at runtime by notifications.native.ts / notifications.web.ts
export async function requestNotificationPermission(): Promise<boolean> { return false; }
export async function getFcmToken(): Promise<string | null> { return null; }
export async function updateFcmToken(_householdId: string, _enabled: boolean): Promise<void> {}
export async function sendShoppingListPush(_householdId: string, _itemName: string): Promise<void> {}
export async function scheduleExpiryCheck(_items: Array<{ name: string; expiryDate: Date | null }>): Promise<void> {}
+5
View File
@@ -0,0 +1,5 @@
export async function requestNotificationPermission(): Promise<boolean> { return false; }
export async function getFcmToken(): Promise<string | null> { return null; }
export async function updateFcmToken(_householdId: string, _enabled: boolean): Promise<void> {}
export async function sendShoppingListPush(_householdId: string, _itemName: string): Promise<void> {}
export async function scheduleExpiryCheck(_items: Array<{ name: string; expiryDate: Date | null }>): Promise<void> {}
+8
View File
@@ -0,0 +1,8 @@
import PocketBase from 'pocketbase';
export const pb = new PocketBase(
process.env.EXPO_PUBLIC_POCKETBASE_URL ?? 'http://localhost:8090'
);
// Prevent AbortError on fast navigations in React Native
pb.autoCancellation(false);
+164
View File
@@ -0,0 +1,164 @@
import { RecordModel } from 'pocketbase';
import { pb } from './pocketbase';
import { syncQueue, isOfflineError } from '../lib/syncQueue';
import { ShoppingListEntry } from '../types';
import { updateItemQuantity } from './items';
import { sendShoppingListPush } from './notifications';
function mapShoppingRecord(r: RecordModel): ShoppingListEntry {
return {
id: r.id,
itemId: r.item_id || null,
name: r.name,
suggestedQuantity: r.suggested_quantity ?? 1,
unit: r.unit ?? '',
isChecked: r.is_checked ?? false,
checkedByDevice: r.checked_by_device || null,
autoAdded: r.auto_added ?? false,
createdAt: new Date(r.created),
};
}
function getStore() {
const { useHouseholdStore } = require('../hooks/useHousehold');
return useHouseholdStore.getState();
}
export async function addToShoppingList(
householdId: string,
entry: Omit<ShoppingListEntry, 'id' | 'createdAt' | 'isChecked' | 'checkedByDevice'>
): Promise<void> {
const store = getStore();
const optimistic: ShoppingListEntry = {
...entry,
id: 'offline_' + Date.now(),
isChecked: false,
checkedByDevice: null,
createdAt: new Date(),
};
store.setShoppingList([...store.shoppingList, optimistic]);
try {
await pb.collection('shopping_list').create({
household: householdId,
item_id: entry.itemId ?? null,
name: entry.name,
suggested_quantity: entry.suggestedQuantity,
unit: entry.unit,
is_checked: false,
auto_added: entry.autoAdded,
});
sendShoppingListPush(householdId, entry.name);
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'addToShoppingList', payload: { householdId, entry } });
} else throw e;
}
}
export async function checkOffItem(
householdId: string,
entryId: string,
deviceId: string,
quantityBought: number
): Promise<void> {
const store = getStore();
const entry = store.shoppingList.find((e: ShoppingListEntry) => e.id === entryId);
if (!entry) return;
store.setShoppingList(
store.shoppingList.map((e: ShoppingListEntry) =>
e.id === entryId ? { ...e, isChecked: true, checkedByDevice: deviceId } : e
)
);
try {
// Mark is_checked FIRST so server hook (which filters is_checked=false) won't delete the entry
await pb.collection('shopping_list').update(entryId, {
is_checked: true,
checked_by_device: deviceId,
});
// Then update quantity — server hook finds no is_checked=false entries to delete
if (entry.itemId) {
const itemRecord = await pb.collection('items').getOne(entry.itemId);
const currentQty = (itemRecord.quantity as number) ?? 0;
await updateItemQuantity(householdId, entry.itemId, currentQty + quantityBought);
}
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({
type: 'checkOffItem',
payload: { householdId, entryId, deviceId, quantityBought },
});
} else throw e;
}
}
export async function clearCheckedItems(householdId: string): Promise<void> {
const store = getStore();
store.setShoppingList(store.shoppingList.filter((e: ShoppingListEntry) => !e.isChecked));
try {
const checked = await pb.collection('shopping_list').getFullList({
filter: `household = "${householdId}" && is_checked = true`,
});
await Promise.all(checked.map((e) => pb.collection('shopping_list').delete(e.id)));
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'clearCheckedItems', payload: { householdId } });
} else throw e;
}
}
export async function removeShoppingEntry(householdId: string, entryId: string): Promise<void> {
const store = getStore();
store.setShoppingList(store.shoppingList.filter((e: ShoppingListEntry) => e.id !== entryId));
try {
await pb.collection('shopping_list').delete(entryId);
} catch (e) {
if (isOfflineError(e)) {
await syncQueue.add({ type: 'removeShoppingEntry', payload: { householdId, entryId } });
} else throw e;
}
}
export function subscribeToShoppingList(
householdId: string,
onChange: (entries: ShoppingListEntry[]) => void
): () => void {
let current: ShoppingListEntry[] = [];
let cancelled = false;
async function connect() {
if (cancelled) return;
try {
const records = await pb.collection('shopping_list').getFullList({
filter: `household = "${householdId}"`,
sort: 'created',
});
current = records.map(mapShoppingRecord);
onChange(current);
await pb.collection('shopping_list').subscribe('*', (e) => {
if ((e.record as any).household !== householdId) return;
if (e.action === 'create') {
current = [...current, mapShoppingRecord(e.record)];
} else if (e.action === 'update') {
current = current.map((i) => (i.id === e.record.id ? mapShoppingRecord(e.record) : i));
} else if (e.action === 'delete') {
current = current.filter((i) => i.id !== e.record.id);
}
onChange(current);
});
} catch {
if (!cancelled) setTimeout(connect, 5000);
}
}
connect();
return () => {
cancelled = true;
pb.collection('shopping_list').unsubscribe('*');
};
}
+34
View File
@@ -0,0 +1,34 @@
import { Platform } from 'react-native';
import { RecordModel } from 'pocketbase';
import { pb } from './pocketbase';
export function getPhotoUrl(record: RecordModel): string | null {
if (!record.photo) return null;
return pb.files.getURL(record, record.photo as string);
}
export async function buildPhotoFormData(
key: string,
uri: string,
formData?: FormData
): Promise<FormData> {
const fd = formData ?? new FormData();
if (Platform.OS === 'web') {
const res = await fetch(uri);
const blob = await res.blob();
fd.append(key, blob, 'photo.jpg');
} else {
fd.append(key, { uri, name: 'photo.jpg', type: 'image/jpeg' } as any);
}
return fd;
}
export async function compressPhoto(uri: string): Promise<string> {
if (Platform.OS === 'web') return uri;
const { manipulateAsync, SaveFormat } = await import('expo-image-manipulator');
const result = await manipulateAsync(uri, [{ resize: { width: 800 } }], {
compress: 0.7,
format: SaveFormat.JPEG,
});
return result.uri;
}
+75
View File
@@ -0,0 +1,75 @@
export type ItemCategory = string;
export type ItemUnit =
| 'Stück'
| 'kg'
| 'g'
| 'L'
| 'ml'
| 'Packung'
| 'Dose'
| 'Flasche'
| 'Beutel'
| 'Tube'
| 'Paar';
export interface HouseholdItem {
id: string;
name: string;
description: string;
category: ItemCategory;
quantity: number;
unit: ItemUnit | string;
minStockThreshold: number;
onShoppingList: boolean;
photoUrl: string | null;
storageLocation: string;
shoppingLocation: string;
price: number | null;
expiryDate: Date | null;
barcode: string | null;
addedByDevice: string;
updatedAt: Date;
createdAt: Date;
}
export interface ShoppingListEntry {
id: string;
itemId: string | null;
name: string;
suggestedQuantity: number;
unit: string;
isChecked: boolean;
checkedByDevice: string | null;
autoAdded: boolean;
createdAt: Date;
}
export interface HouseholdMember {
deviceId: string;
deviceName: string;
fcmToken: string | null;
notificationsEnabled: boolean;
joinedAt: Date;
}
export interface Household {
id: string;
name: string;
inviteToken: string;
createdAt: Date;
}
export interface AiRecognitionResult {
name: string;
description: string;
category: ItemCategory;
}
export interface BarcodeProduct {
name: string;
description: string;
category: ItemCategory;
barcode: string;
imageUrl?: string;
}
+27
View File
@@ -0,0 +1,27 @@
import { differenceInDays, format, isBefore } from 'date-fns';
import { de } from 'date-fns/locale';
import { MHD_WARNING_DAYS } from '../constants';
export function formatDate(date: Date | null): string {
if (!date) return '—';
return format(date, 'dd.MM.yyyy', { locale: de });
}
export function isExpiringSoon(expiryDate: Date | null): boolean {
if (!expiryDate) return false;
const days = differenceInDays(expiryDate, new Date());
return days >= 0 && days <= MHD_WARNING_DAYS;
}
export function isExpired(expiryDate: Date | null): boolean {
if (!expiryDate) return false;
return isBefore(expiryDate, new Date());
}
export function formatQuantity(quantity: number, unit: string): string {
return `${quantity} ${unit}`;
}
export function buildDeepLink(householdId: string, token: string): string {
return `houseorg://join?householdId=${encodeURIComponent(householdId)}&token=${encodeURIComponent(token)}`;
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}