First commit
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 A–Z' },
|
||||||
|
{ key: 'name_desc', label: 'Name Z–A' },
|
||||||
|
{ 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' },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Generated
+13548
File diff suppressed because it is too large
Load Diff
@@ -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.
Binary file not shown.
Vendored
+24407
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||||
@@ -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
Binary file not shown.
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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('*');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {}
|
||||||
@@ -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> {}
|
||||||
@@ -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);
|
||||||
@@ -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('*');
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user