mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 05:29:39 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -22,6 +22,7 @@ apps/nutriphi/
|
|||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
pnpm nutriphi:dev # Run all nutriphi apps
|
||||
pnpm dev:nutriphi:mobile # Start mobile app
|
||||
|
|
@ -31,6 +32,7 @@ pnpm dev:nutriphi:backend # Start backend server
|
|||
```
|
||||
|
||||
### Mobile App (nutriphi/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
|
|
@ -42,6 +44,7 @@ pnpm type-check # Run TypeScript checks
|
|||
```
|
||||
|
||||
### Backend (apps/nutriphi/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm start:dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
|
|
@ -50,6 +53,7 @@ pnpm type-check # Run TypeScript checks
|
|||
```
|
||||
|
||||
### Web App (nutriphi/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
|
|
@ -58,6 +62,7 @@ pnpm type-check # Run type checks
|
|||
```
|
||||
|
||||
### Landing Page (nutriphi/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
|
|
@ -81,28 +86,31 @@ pnpm type-check # Run Astro checks
|
|||
All endpoints (except health) require JWT authentication via `Authorization: Bearer <token>` header.
|
||||
|
||||
#### Meals API
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check (public) |
|
||||
| `/api/meals/analyze/image` | POST | Analyze food image with AI |
|
||||
| `/api/meals/analyze/text` | POST | Analyze food description |
|
||||
| `/api/meals` | GET | Get user's meals |
|
||||
| `/api/meals` | POST | Create new meal entry |
|
||||
| `/api/meals/summary` | GET | Get daily nutrition summary |
|
||||
| `/api/meals/:id` | GET | Get meal by ID |
|
||||
| `/api/meals/:id` | PUT | Update meal |
|
||||
| `/api/meals/:id` | DELETE | Delete meal |
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------------- | ------ | --------------------------- |
|
||||
| `/api/health` | GET | Health check (public) |
|
||||
| `/api/meals/analyze/image` | POST | Analyze food image with AI |
|
||||
| `/api/meals/analyze/text` | POST | Analyze food description |
|
||||
| `/api/meals` | GET | Get user's meals |
|
||||
| `/api/meals` | POST | Create new meal entry |
|
||||
| `/api/meals/summary` | GET | Get daily nutrition summary |
|
||||
| `/api/meals/:id` | GET | Get meal by ID |
|
||||
| `/api/meals/:id` | PUT | Update meal |
|
||||
| `/api/meals/:id` | DELETE | Delete meal |
|
||||
|
||||
#### Sync API
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/sync/push` | POST | Push local changes to server |
|
||||
| `/api/sync/pull` | GET | Pull changes from server |
|
||||
| `/api/sync/status` | GET | Get sync status |
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------ | ------ | ---------------------------- |
|
||||
| `/api/sync/push` | POST | Push local changes to server |
|
||||
| `/api/sync/pull` | GET | Pull changes from server |
|
||||
| `/api/sync/status` | GET | Get sync status |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://nutriphi:password@localhost:5435/nutriphi
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
|
|
@ -117,6 +125,7 @@ PORT=3002
|
|||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_MANA_MIDDLEWARE_URL=https://api.manacore.de
|
||||
EXPO_PUBLIC_MIDDLEWARE_APP_ID=nutriphi
|
||||
|
|
@ -124,6 +133,7 @@ EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
|||
```
|
||||
|
||||
#### Web (.env)
|
||||
|
||||
```
|
||||
PUBLIC_NUTRIPHI_MIDDLEWARE_URL=https://api.manacore.de
|
||||
PUBLIC_MIDDLEWARE_APP_ID=nutriphi
|
||||
|
|
@ -143,6 +153,7 @@ PUBLIC_BACKEND_URL=http://localhost:3002
|
|||
## Mobile App Architecture
|
||||
|
||||
### File Structure (apps/mobile)
|
||||
|
||||
- `app/` - Expo Router pages and layouts
|
||||
- `(tabs)/` - Tab-based navigation screens
|
||||
- `_layout.tsx` - Root layout with Stack navigation
|
||||
|
|
@ -160,15 +171,18 @@ PUBLIC_BACKEND_URL=http://localhost:3002
|
|||
- `utils/` - Utility functions
|
||||
|
||||
### Styling
|
||||
|
||||
- NativeWind (Tailwind for React Native)
|
||||
- Components use `className` prop with Tailwind utility classes
|
||||
|
||||
### State Management
|
||||
|
||||
- Zustand stores for auth, meals, app settings
|
||||
- SQLite for local offline storage
|
||||
- Cloud sync via backend API
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. User signs in via Mana Middleware
|
||||
2. Tokens stored securely in expo-secure-store
|
||||
3. JWT sent with all API requests
|
||||
|
|
@ -177,16 +191,19 @@ PUBLIC_BACKEND_URL=http://localhost:3002
|
|||
## Backend Architecture
|
||||
|
||||
### Authentication Guard
|
||||
|
||||
- `JwtAuthGuard` validates tokens against Mana Core Auth
|
||||
- `CurrentUser` decorator extracts user data from JWT
|
||||
- All protected endpoints use `@UseGuards(JwtAuthGuard)`
|
||||
|
||||
### Database
|
||||
|
||||
- PostgreSQL via Drizzle ORM (`@manacore/nutriphi-database` package)
|
||||
- Schema: `meals`, `nutrition_goals` tables
|
||||
- User isolation via `userId` field in all queries
|
||||
|
||||
### Sync Strategy
|
||||
|
||||
- **Push**: Local changes uploaded with version tracking
|
||||
- **Pull**: Server changes downloaded since last sync
|
||||
- **Conflict Resolution**: Last-write-wins with client priority
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
# DoneSteps.md - Implementierungsfortschritt Nutriphi
|
||||
|
||||
## Überblick
|
||||
|
||||
Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der Nutriphi KI-Kalorien Tracker App basierend auf dem detaillierten Plan in `Plan.md`.
|
||||
|
||||
## ✅ Phase 1: Foundations & Database (KOMPLETT)
|
||||
|
||||
### 1.1 Projekt-Setup
|
||||
|
||||
- **Expo-Projekt mit TypeScript und NativeWind** ✅
|
||||
- Bestehende Basis-Konfiguration erweitert
|
||||
- Zusätzliche Dependencies installiert: `expo-sqlite`, `expo-camera`, `expo-file-system`, `@google/generative-ai`, `react-native-uuid`
|
||||
- Ordnerstruktur nach Plan erstellt: `services/`, `types/`, `hooks/`
|
||||
|
||||
### 1.2 Erweiterte SQLite-Datenbank
|
||||
|
||||
- **Vollständige Datenbankarchitektur implementiert** ✅
|
||||
- `meals` Tabelle mit allen geplanten Feldern (35+ Spalten)
|
||||
- `food_items` Tabelle für detaillierte Lebensmittel-Analyse
|
||||
|
|
@ -20,12 +23,12 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Dual-kompatible Struktur (lokal + Cloud-ready)
|
||||
|
||||
### 1.3 Database Services & Migration System
|
||||
|
||||
- **SQLiteService.ts** ✅
|
||||
- Singleton-Pattern für Datenbankzugriff
|
||||
- Vollständige CRUD-Operationen für Meals und FoodItems
|
||||
- Erweiterte Funktionen: Suche, Statistiken, Aggregationen
|
||||
- Typsichere Implementierung mit TypeScript
|
||||
|
||||
- **MigrationService.ts** ✅
|
||||
- Robustes Schema-Update-System
|
||||
- Transaktionale Migrationen mit Rollback-Fähigkeit
|
||||
|
|
@ -33,11 +36,11 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Bereits 3 Migrationen definiert für zukünftige Updates
|
||||
|
||||
### 1.4 TypeScript-Typdefinitionen
|
||||
|
||||
- **Database.ts** ✅
|
||||
- Vollständige Interface-Definitionen für alle DB-Tabellen
|
||||
- Input/Output-Typen für API-Operationen
|
||||
- Union-Types für Enums (meal_type, analysis_status, etc.)
|
||||
|
||||
- **API.ts** ✅
|
||||
- Gemini API Response-Strukturen
|
||||
- Error-Handling-Typen
|
||||
|
|
@ -46,18 +49,19 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## ✅ Phase 2: Core UI & Camera (KOMPLETT)
|
||||
|
||||
### 2.1 UI-Komponenten-Bibliothek
|
||||
|
||||
- **Basis-UI-Komponenten** ✅
|
||||
- `Card.tsx`: Wiederverwendbare Card-Komponente mit Varianten
|
||||
- `LoadingSpinner.tsx`: Zentralisierte Loading-States
|
||||
- `FloatingActionButton.tsx`: Erweitert mit `size` und `position` Props für flexible Positionierung
|
||||
|
||||
### 2.2 MealList mit NativeWind
|
||||
|
||||
- **MealList.tsx** ✅
|
||||
- Vollständige Liste mit Pull-to-Refresh
|
||||
- Integrierte Suchfunktion
|
||||
- Überarbeiteter Empty State ohne redundanten Button
|
||||
- Error-Handling mit Retry-Mechanismus
|
||||
|
||||
- **MealItem.tsx** ✅
|
||||
- Rich Meal Cards mit Foto-Preview
|
||||
- Nutrition Summary (kompakt)
|
||||
|
|
@ -72,6 +76,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Responsive Progress-Bars
|
||||
|
||||
### 2.3 Camera & Gallery Integration
|
||||
|
||||
- **Erweiterte Photo-Services** ✅
|
||||
- `PhotoService.ts`: Foto-Management mit lokaler Speicherung
|
||||
- `useCamera.ts`: Hook erweitert um `pickImageFromGallery()` Funktion
|
||||
|
|
@ -87,12 +92,12 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Auto-Gallery-Picker für Gallery-Modus
|
||||
|
||||
### 2.4 State Management mit Zustand
|
||||
|
||||
- **MealStore.ts** ✅
|
||||
- Vollständige Meal-CRUD-Operationen
|
||||
- Optimistische Updates
|
||||
- Error-Handling mit User-Feedback
|
||||
- Search-Integration
|
||||
|
||||
- **AppStore.ts** ✅
|
||||
- Globaler App-Zustand
|
||||
- UI-State-Management (Modals, Processing)
|
||||
|
|
@ -101,11 +106,11 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- `cameraMode` State für Kamera/Galerie-Modi
|
||||
|
||||
### 2.5 App-Integration
|
||||
|
||||
- **Database-Initialisierung** ✅
|
||||
- `useDatabase.ts`: Hook für DB-Setup mit Loading-States
|
||||
- Integration in `_layout.tsx` mit User-Feedback
|
||||
- Migration-Ausführung beim App-Start
|
||||
|
||||
- **Navigation & Layout** ✅
|
||||
- Tab-Navigation mit FontAwesome Icons (cutlery, bar-chart)
|
||||
- CameraModal-Integration mit mode-based Rendering
|
||||
|
|
@ -115,6 +120,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## ✅ Phase 2.5: Gallery Integration & UX Enhancement (KOMPLETT)
|
||||
|
||||
### 2.6 Gallery-Funktionalität
|
||||
|
||||
- **expo-image-picker Integration** ✅
|
||||
- Native Galerie-Zugriff mit automatischen Permissions
|
||||
- Image-Editing mit 4:3 Aspect Ratio Cropping
|
||||
|
|
@ -122,6 +128,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Identische PhotoService-Integration wie Kamera
|
||||
|
||||
### 2.7 Streamlined User Experience
|
||||
|
||||
- **Dual Floating Action Button System** ✅
|
||||
- 📷 **Kamera-Button**: Größer (80x80px), zentriert positioniert
|
||||
- 🖼️ **Galerie-Button**: Normal (64x64px), rechts positioniert
|
||||
|
|
@ -135,6 +142,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Mode-based CameraModal (camera/gallery)
|
||||
|
||||
### 2.8 App-Konfiguration
|
||||
|
||||
- **Permissions Setup** ✅
|
||||
- Camera Permission: "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
|
||||
- Photos Permission: "Allow Nutriphi to access your photo library to select existing meal photos."
|
||||
|
|
@ -143,6 +151,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## 🔄 Aktueller Status
|
||||
|
||||
### Was funktioniert:
|
||||
|
||||
1. **Vollständige lokale Datenpersistierung** - Alle Mahlzeiten werden in SQLite gespeichert
|
||||
2. **Dual Photo Input** - Fotos können sowohl per Kamera aufgenommen als auch aus Galerie gewählt werden
|
||||
3. **Streamlined UX-Flow** - Direkter Zugang zu beiden Modi ohne Umwege
|
||||
|
|
@ -152,6 +161,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
7. **Error Handling** - Robuste Fehlerbehandlung auf allen Ebenen
|
||||
|
||||
### Bereit für nächste Phase:
|
||||
|
||||
- Datenbank und Services sind vollständig KI-Integration-ready
|
||||
- Photo-Service kann Base64-Konvertierung für Gemini API
|
||||
- Meal-Records haben alle notwendigen Felder für AI-Analyse
|
||||
|
|
@ -160,18 +170,21 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## 🎯 Technische Highlights
|
||||
|
||||
### Architektur-Qualität:
|
||||
|
||||
- **Typsicherheit**: 100% TypeScript mit strikten Typen
|
||||
- **Separation of Concerns**: Klare Trennung zwischen UI, Business Logic und Data Layer
|
||||
- **Skalierbarkeit**: Modulare Struktur für einfache Erweiterungen
|
||||
- **Performance**: Optimierte DB-Indizes und React-Patterns
|
||||
|
||||
### Code-Qualität:
|
||||
|
||||
- **Linting**: Alle ESLint-Regeln befolgt
|
||||
- **Formatting**: Konsistente Prettier-Formatierung
|
||||
- **Patterns**: React Hooks, Custom Hooks, Singleton Services
|
||||
- **Error Boundaries**: Graceful Error-Handling überall
|
||||
|
||||
### UX-Features:
|
||||
|
||||
- **Smooth Animations**: React Native Reanimated für 60fps
|
||||
- **Dual Input Methods**: Kamera + Galerie für maximale Flexibilität
|
||||
- **Intuitive UI**: Große, zentrale Kamera-Button für Hauptfunktion
|
||||
|
|
@ -182,6 +195,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## ✅ Phase 3: Gemini AI Integration (KOMPLETT)
|
||||
|
||||
### 3.1 Gemini API Service
|
||||
|
||||
- **GeminiService.ts** ✅
|
||||
- Vollständige Integration mit Google Generative AI
|
||||
- Optimierte Multi-Shot Prompts für Ernährungsanalyse
|
||||
|
|
@ -191,6 +205,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Base64-Bildkonvertierung mit FileSystem
|
||||
|
||||
### 3.2 AI-Analyse-Features
|
||||
|
||||
- **Intelligente Lebensmittel-Erkennung** ✅
|
||||
- Automatische Erkennung von Mahlzeiten und Zutaten
|
||||
- Portionsgrößen-Schätzung
|
||||
|
|
@ -200,6 +215,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Bio/Verarbeitet-Klassifizierung
|
||||
|
||||
### 3.3 Background Processing
|
||||
|
||||
- **Asynchrone Foto-Analyse** ✅
|
||||
- Foto-Upload und sofortige UI-Rückkehr
|
||||
- Background-Verarbeitung mit Gemini API
|
||||
|
|
@ -207,11 +223,11 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Status-Updates (pending → completed/failed)
|
||||
|
||||
### 3.4 UI-Integration
|
||||
|
||||
- **AnalysisStatusIndicator.tsx** ✅
|
||||
- Visueller Status-Indikator mit Loading-Animation
|
||||
- Mini und Normal Modi für verschiedene UI-Kontexte
|
||||
- Farbkodierte Status (gelb=pending, grün=completed, rot=failed)
|
||||
|
||||
- **Meal Detail Enhancements** ✅
|
||||
- Auto-Polling während Analyse läuft (alle 2 Sekunden)
|
||||
- Dynamische UI-Updates nach Analyse-Completion
|
||||
|
|
@ -221,6 +237,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## ✅ Phase 3.5: Performance & Stability (KOMPLETT)
|
||||
|
||||
### 3.5 Bug Fixes & Optimierungen
|
||||
|
||||
- **Kritische Fehler behoben** ✅
|
||||
- Text-Rendering-Error in FoodItemCard (Allergen-Array-Handling)
|
||||
- Bildpfad-Problem nach temp→permanent Konvertierung
|
||||
|
|
@ -240,6 +257,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Console-Logs für besseres Debugging
|
||||
|
||||
### 3.6 Technische Verbesserungen
|
||||
|
||||
- **Database Layer** ✅
|
||||
- SQLite-Transaktionen für Batch-Operations
|
||||
- Rollback-Fähigkeit bei Fehlern
|
||||
|
|
@ -258,6 +276,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## ✅ Phase 4: Advanced Features (KOMPLETT)
|
||||
|
||||
### 4.1 Context Menu Integration
|
||||
|
||||
- **Native Context Menu** ✅
|
||||
- Long-Press auf MealCard aktiviert natives Context Menu
|
||||
- iOS/Android native Look & Feel mit `react-native-context-menu-view`
|
||||
|
|
@ -280,6 +299,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Optimistisches UI-Update
|
||||
|
||||
### 4.2 Location Tracking
|
||||
|
||||
- **Automatische GPS-Erfassung** ✅
|
||||
- GPS-Koordinaten werden beim Foto-Capture erfasst
|
||||
- Reverse Geocoding für lesbare Adressen
|
||||
|
|
@ -299,6 +319,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Opt-in Ansatz - Privacy by Design
|
||||
|
||||
### 4.3 Enhanced Settings
|
||||
|
||||
- **Erweiterte Settings-Seite** ✅
|
||||
- Neue "Privatsphäre & Standort" Sektion
|
||||
- Location-Toggle mit Echtzeit-Updates
|
||||
|
|
@ -312,6 +333,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Batch-Update-Fähigkeit
|
||||
|
||||
### 4.4 Database Enhancements
|
||||
|
||||
- **Location-Felder in Meals** ✅
|
||||
- latitude, longitude, location_accuracy
|
||||
- Migration #4 für bestehende DBs
|
||||
|
|
@ -319,6 +341,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- Rückwärtskompatibel
|
||||
|
||||
### 4.5 Critical Bug Fixes
|
||||
|
||||
- **Location Feature Stabilität** ✅
|
||||
- App-Absturz beim Bildauswahl mit aktiviertem Standort behoben
|
||||
- getDatabase() Methode zu SQLiteService hinzugefügt
|
||||
|
|
@ -340,6 +363,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## 🎯 Aktueller Status (Stand: 17.01.2025)
|
||||
|
||||
### Vollständig implementierte Features:
|
||||
|
||||
1. **KI-gestützte Ernährungsanalyse** - Automatische Erkennung und Analyse von Mahlzeiten
|
||||
2. **Real-time Updates** - Live-Status während der Analyse mit Auto-Polling
|
||||
3. **Context Menu** - Native Long-Press Menu mit 6+ Aktionen
|
||||
|
|
@ -351,6 +375,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
9. **Intuitive Benutzeroberfläche** - Klare Status-Indikatoren und Feedback
|
||||
|
||||
### Technische Highlights:
|
||||
|
||||
- **100% TypeScript** mit strikten Typen
|
||||
- **Reaktive Updates** durch Zustand State Management
|
||||
- **Modulare Architektur** für einfache Erweiterbarkeit
|
||||
|
|
@ -360,6 +385,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
- **Native UI-Elemente** für beste User Experience
|
||||
|
||||
### App-Metriken:
|
||||
|
||||
- **Analyse-Zeit**: ~13 Sekunden pro Foto (Gemini API)
|
||||
- **Genauigkeit**: 85%+ Confidence-Score bei guten Fotos
|
||||
- **Stabilität**: Robuste Fehlerbehandlung verhindert Crashes
|
||||
|
|
@ -369,6 +395,7 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
## 📋 Nächste Schritte (Phase 4+)
|
||||
|
||||
### Geplante Features:
|
||||
|
||||
1. **Cloud-Sync** mit Supabase-Backend
|
||||
2. **Erweiterte Statistiken** und Trends
|
||||
3. **Benutzerdefinierte Ernährungsziele**
|
||||
|
|
@ -378,4 +405,4 @@ Dieses Dokument beschreibt alle erledigten Schritte bei der Implementierung der
|
|||
7. **Rezept-Erkennung** und Vorschläge
|
||||
8. **Apple Health Integration**
|
||||
|
||||
Die App ist jetzt voll funktionsfähig als KI-gestützter Kalorien-Tracker mit robuster lokaler Datenhaltung und fortschrittlicher Bildanalyse!
|
||||
Die App ist jetzt voll funktionsfähig als KI-gestützter Kalorien-Tracker mit robuster lokaler Datenhaltung und fortschrittlicher Bildanalyse!
|
||||
|
|
|
|||
|
|
@ -1,140 +1,141 @@
|
|||
|
||||
|
||||
MVP Projektplan: KI-Kalorien Tracker Nutriphi (Finaler Plan)
|
||||
|
||||
1. Projekt-Übersicht
|
||||
Ziel: React Native App mit Expo für automatische Kalorienschätzung durch Foto-Analyse Architektur: Local-First mit optionaler Cloud-Synchronisation (Supabase-ready) UI-Framework: NativeWind (Tailwind CSS für React Native) Sync-Strategy: Bidirektionale Synchronisation mit Konfliktlösung
|
||||
Ziel: React Native App mit Expo für automatische Kalorienschätzung durch Foto-Analyse Architektur: Local-First mit optionaler Cloud-Synchronisation (Supabase-ready) UI-Framework: NativeWind (Tailwind CSS für React Native) Sync-Strategy: Bidirektionale Synchronisation mit Konfliktlösung
|
||||
2. Technologie-Stack
|
||||
Frontend
|
||||
* React Native: 0.73+
|
||||
* Expo SDK: 50+
|
||||
* TypeScript: Vollständige Typisierung
|
||||
* NativeWind: 2.0+ für Styling
|
||||
* React Navigation: 6.x für Navigation
|
||||
* Zustand: Lightweight State Management
|
||||
* Expo Camera: Foto-Aufnahme
|
||||
* React Native Reanimated: Smooth Animationen
|
||||
Datenschicht
|
||||
* Expo SQLite: Primäre lokale Datenbank
|
||||
* Expo FileSystem: Lokale Foto-Speicherung
|
||||
* AsyncStorage: App-Einstellungen
|
||||
* Supabase: Cloud-Backend (optional, später)
|
||||
* Supabase Storage: Cloud-Foto-Speicherung
|
||||
KI & APIs
|
||||
* Google Gemini Vision API: Primäre Bilderkennung
|
||||
* Optimierter Prompt: Strukturierte Nährwert-Analyse
|
||||
* Fallback-System: Graceful Degradation bei API-Fehlern
|
||||
Development Tools
|
||||
* Expo Dev Tools: Development Environment
|
||||
* TypeScript: Compile-time Type Safety
|
||||
* ESLint + Prettier: Code Quality
|
||||
* Jest: Unit Testing
|
||||
Frontend
|
||||
|
||||
- React Native: 0.73+
|
||||
- Expo SDK: 50+
|
||||
- TypeScript: Vollständige Typisierung
|
||||
- NativeWind: 2.0+ für Styling
|
||||
- React Navigation: 6.x für Navigation
|
||||
- Zustand: Lightweight State Management
|
||||
- Expo Camera: Foto-Aufnahme
|
||||
- React Native Reanimated: Smooth Animationen
|
||||
Datenschicht
|
||||
- Expo SQLite: Primäre lokale Datenbank
|
||||
- Expo FileSystem: Lokale Foto-Speicherung
|
||||
- AsyncStorage: App-Einstellungen
|
||||
- Supabase: Cloud-Backend (optional, später)
|
||||
- Supabase Storage: Cloud-Foto-Speicherung
|
||||
KI & APIs
|
||||
- Google Gemini Vision API: Primäre Bilderkennung
|
||||
- Optimierter Prompt: Strukturierte Nährwert-Analyse
|
||||
- Fallback-System: Graceful Degradation bei API-Fehlern
|
||||
Development Tools
|
||||
- Expo Dev Tools: Development Environment
|
||||
- TypeScript: Compile-time Type Safety
|
||||
- ESLint + Prettier: Code Quality
|
||||
- Jest: Unit Testing
|
||||
|
||||
3. Erweiterte Datenbank-Architektur
|
||||
Haupttabelle: meals (Dual-kompatibel)
|
||||
Haupttabelle: meals (Dual-kompatibel)
|
||||
|
||||
sql
|
||||
CREATE TABLE meals (
|
||||
-- Primärschlüssel (dual-kompatibel)
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- SQLite
|
||||
cloud_id TEXT UNIQUE, -- UUID für Supabase
|
||||
|
||||
-- Sync-Metadaten
|
||||
user_id TEXT, -- NULL lokal, UUID in Cloud
|
||||
sync_status TEXT DEFAULT 'local', -- local, synced, conflict, pending
|
||||
version INTEGER DEFAULT 1, -- Für Konfliktlösung
|
||||
last_sync_at TEXT, -- ISO DateTime
|
||||
|
||||
-- Foto & Metadaten
|
||||
photo_path TEXT NOT NULL, -- Lokaler Pfad
|
||||
photo_url TEXT, -- Cloud Storage URL
|
||||
photo_size INTEGER, -- Dateigröße in Bytes
|
||||
photo_dimensions TEXT, -- JSON: {"width": 1920, "height": 1080}
|
||||
|
||||
-- Zeitstempel
|
||||
timestamp TEXT DEFAULT (datetime('now')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
-- Mahlzeit-Kontext
|
||||
meal_type TEXT, -- breakfast, lunch, dinner, snack
|
||||
location TEXT, -- Optional: GPS oder manuell
|
||||
|
||||
-- KI-Analyse Ergebnisse
|
||||
analysis_result TEXT, -- Vollständiges JSON der Gemini-Antwort
|
||||
analysis_confidence REAL, -- 0.0 - 1.0
|
||||
analysis_status TEXT DEFAULT 'pending', -- pending, completed, failed, manual
|
||||
|
||||
-- Aggregierte Nährwerte
|
||||
total_calories INTEGER,
|
||||
total_protein REAL,
|
||||
total_carbs REAL,
|
||||
total_fat REAL,
|
||||
total_fiber REAL,
|
||||
total_sugar REAL,
|
||||
|
||||
-- Gesundheitsbewertung
|
||||
health_score REAL, -- 1.0 - 10.0
|
||||
health_category TEXT, -- very_healthy, healthy, moderate, unhealthy
|
||||
|
||||
-- User-Interaktion
|
||||
user_notes TEXT,
|
||||
user_modified INTEGER DEFAULT 0, -- Boolean als Integer
|
||||
user_rating INTEGER, -- 1-5 Sterne für KI-Genauigkeit
|
||||
|
||||
-- API-Metadaten
|
||||
api_provider TEXT DEFAULT 'gemini',
|
||||
api_cost REAL, -- Kosten in Cent
|
||||
processing_time INTEGER -- Millisekunden
|
||||
-- Primärschlüssel (dual-kompatibel)
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- SQLite
|
||||
cloud_id TEXT UNIQUE, -- UUID für Supabase
|
||||
|
||||
-- Sync-Metadaten
|
||||
user_id TEXT, -- NULL lokal, UUID in Cloud
|
||||
sync_status TEXT DEFAULT 'local', -- local, synced, conflict, pending
|
||||
version INTEGER DEFAULT 1, -- Für Konfliktlösung
|
||||
last_sync_at TEXT, -- ISO DateTime
|
||||
|
||||
-- Foto & Metadaten
|
||||
photo_path TEXT NOT NULL, -- Lokaler Pfad
|
||||
photo_url TEXT, -- Cloud Storage URL
|
||||
photo_size INTEGER, -- Dateigröße in Bytes
|
||||
photo_dimensions TEXT, -- JSON: {"width": 1920, "height": 1080}
|
||||
|
||||
-- Zeitstempel
|
||||
timestamp TEXT DEFAULT (datetime('now')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
-- Mahlzeit-Kontext
|
||||
meal_type TEXT, -- breakfast, lunch, dinner, snack
|
||||
location TEXT, -- Optional: GPS oder manuell
|
||||
|
||||
-- KI-Analyse Ergebnisse
|
||||
analysis_result TEXT, -- Vollständiges JSON der Gemini-Antwort
|
||||
analysis_confidence REAL, -- 0.0 - 1.0
|
||||
analysis_status TEXT DEFAULT 'pending', -- pending, completed, failed, manual
|
||||
|
||||
-- Aggregierte Nährwerte
|
||||
total_calories INTEGER,
|
||||
total_protein REAL,
|
||||
total_carbs REAL,
|
||||
total_fat REAL,
|
||||
total_fiber REAL,
|
||||
total_sugar REAL,
|
||||
|
||||
-- Gesundheitsbewertung
|
||||
health_score REAL, -- 1.0 - 10.0
|
||||
health_category TEXT, -- very_healthy, healthy, moderate, unhealthy
|
||||
|
||||
-- User-Interaktion
|
||||
user_notes TEXT,
|
||||
user_modified INTEGER DEFAULT 0, -- Boolean als Integer
|
||||
user_rating INTEGER, -- 1-5 Sterne für KI-Genauigkeit
|
||||
|
||||
-- API-Metadaten
|
||||
api_provider TEXT DEFAULT 'gemini',
|
||||
api_cost REAL, -- Kosten in Cent
|
||||
processing_time INTEGER -- Millisekunden
|
||||
);
|
||||
Detailtabelle: food_items
|
||||
|
||||
sql
|
||||
CREATE TABLE food_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
meal_id INTEGER NOT NULL,
|
||||
|
||||
-- Sync-Metadaten
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
version INTEGER DEFAULT 1,
|
||||
|
||||
-- Lebensmittel-Details
|
||||
name TEXT NOT NULL,
|
||||
category TEXT, -- protein, vegetable, grain, fruit, dairy, fat, processed, beverage
|
||||
portion_size TEXT, -- "150g", "1 Stück", "1 Tasse"
|
||||
|
||||
-- Nährwerte pro Item
|
||||
calories INTEGER,
|
||||
protein REAL,
|
||||
carbs REAL,
|
||||
fat REAL,
|
||||
fiber REAL,
|
||||
sugar REAL,
|
||||
|
||||
-- KI-Metadaten
|
||||
confidence REAL, -- 0.0 - 1.0
|
||||
bounding_box TEXT, -- JSON: Position im Bild
|
||||
|
||||
-- Eigenschaften
|
||||
is_organic INTEGER DEFAULT 0,
|
||||
is_processed INTEGER DEFAULT 0,
|
||||
allergens TEXT, -- JSON Array
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
meal_id INTEGER NOT NULL,
|
||||
|
||||
-- Sync-Metadaten
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
version INTEGER DEFAULT 1,
|
||||
|
||||
-- Lebensmittel-Details
|
||||
name TEXT NOT NULL,
|
||||
category TEXT, -- protein, vegetable, grain, fruit, dairy, fat, processed, beverage
|
||||
portion_size TEXT, -- "150g", "1 Stück", "1 Tasse"
|
||||
|
||||
-- Nährwerte pro Item
|
||||
calories INTEGER,
|
||||
protein REAL,
|
||||
carbs REAL,
|
||||
fat REAL,
|
||||
fiber REAL,
|
||||
sugar REAL,
|
||||
|
||||
-- KI-Metadaten
|
||||
confidence REAL, -- 0.0 - 1.0
|
||||
bounding_box TEXT, -- JSON: Position im Bild
|
||||
|
||||
-- Eigenschaften
|
||||
is_organic INTEGER DEFAULT 0,
|
||||
is_processed INTEGER DEFAULT 0,
|
||||
allergens TEXT, -- JSON Array
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
|
||||
FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE
|
||||
);
|
||||
Sync-Metadaten
|
||||
|
||||
sql
|
||||
CREATE TABLE sync_metadata (
|
||||
table_name TEXT NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
cloud_id TEXT,
|
||||
last_sync_at TEXT,
|
||||
conflict_data TEXT, -- JSON für Konfliktlösung
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (table_name, record_id)
|
||||
table_name TEXT NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
cloud_id TEXT,
|
||||
last_sync_at TEXT,
|
||||
conflict_data TEXT, -- JSON für Konfliktlösung
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (table_name, record_id)
|
||||
);
|
||||
Performance-Indizes
|
||||
|
||||
|
|
@ -144,13 +145,13 @@ CREATE INDEX idx_meals_sync_status ON meals(sync_status);
|
|||
CREATE INDEX idx_meals_meal_type ON meals(meal_type);
|
||||
CREATE INDEX idx_food_items_meal ON food_items(meal_id);
|
||||
CREATE INDEX idx_food_items_category ON food_items(category);
|
||||
CREATE INDEX idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at);
|
||||
4. Optimierter Gemini Prompt
|
||||
CREATE INDEX idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at); 4. Optimierter Gemini Prompt
|
||||
Haupt-Prompt-Template:
|
||||
|
||||
Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
|
||||
|
||||
AUFGABE:
|
||||
|
||||
1. Erkenne alle sichtbaren Lebensmittel und schätze realistische Portionsgrößen
|
||||
2. Berechne Nährwerte basierend auf Standard-Portionen
|
||||
3. Bewerte die Gesundheit der gesamten Mahlzeit
|
||||
|
|
@ -158,46 +159,46 @@ AUFGABE:
|
|||
|
||||
ANTWORT-FORMAT (nur JSON, keine zusätzlichen Texte):
|
||||
{
|
||||
"meal_analysis": {
|
||||
"total_calories": <Gesamtkalorien>,
|
||||
"total_protein": <Protein in g>,
|
||||
"total_carbs": <Kohlenhydrate in g>,
|
||||
"total_fat": <Fett in g>,
|
||||
"total_fiber": <Ballaststoffe in g>,
|
||||
"total_sugar": <Zucker in g>,
|
||||
"health_score": <1.0-10.0>,
|
||||
"health_category": "healthy|moderate|unhealthy",
|
||||
"confidence": <0.0-1.0>,
|
||||
"meal_type_suggestion": "breakfast|lunch|dinner|snack"
|
||||
},
|
||||
"food_items": [
|
||||
{
|
||||
"name": "Gegrilltes Hähnchen",
|
||||
"category": "protein",
|
||||
"portion_size": "120g",
|
||||
"calories": 180,
|
||||
"protein": 27.0,
|
||||
"carbs": 0.0,
|
||||
"fat": 7.5,
|
||||
"fiber": 0.0,
|
||||
"sugar": 0.0,
|
||||
"confidence": 0.9,
|
||||
"is_organic": false,
|
||||
"is_processed": false,
|
||||
"allergens": []
|
||||
}
|
||||
],
|
||||
"analysis_notes": {
|
||||
"health_reasoning": "Ausgewogene Mahlzeit mit hochwertigem Protein und Gemüse",
|
||||
"improvement_suggestions": [
|
||||
"Mehr Vollkornprodukte hinzufügen",
|
||||
"Portion der Kohlenhydrate erhöhen"
|
||||
],
|
||||
"cooking_method": "grilled",
|
||||
"estimated_freshness": "fresh",
|
||||
"hidden_ingredients": ["Olivenöl (1 TL)", "Gewürze"],
|
||||
"portion_accuracy": "high"
|
||||
}
|
||||
"meal_analysis": {
|
||||
"total_calories": <Gesamtkalorien>,
|
||||
"total_protein": <Protein in g>,
|
||||
"total_carbs": <Kohlenhydrate in g>,
|
||||
"total_fat": <Fett in g>,
|
||||
"total_fiber": <Ballaststoffe in g>,
|
||||
"total_sugar": <Zucker in g>,
|
||||
"health_score": <1.0-10.0>,
|
||||
"health_category": "healthy|moderate|unhealthy",
|
||||
"confidence": <0.0-1.0>,
|
||||
"meal_type_suggestion": "breakfast|lunch|dinner|snack"
|
||||
},
|
||||
"food_items": [
|
||||
{
|
||||
"name": "Gegrilltes Hähnchen",
|
||||
"category": "protein",
|
||||
"portion_size": "120g",
|
||||
"calories": 180,
|
||||
"protein": 27.0,
|
||||
"carbs": 0.0,
|
||||
"fat": 7.5,
|
||||
"fiber": 0.0,
|
||||
"sugar": 0.0,
|
||||
"confidence": 0.9,
|
||||
"is_organic": false,
|
||||
"is_processed": false,
|
||||
"allergens": []
|
||||
}
|
||||
],
|
||||
"analysis_notes": {
|
||||
"health_reasoning": "Ausgewogene Mahlzeit mit hochwertigem Protein und Gemüse",
|
||||
"improvement_suggestions": [
|
||||
"Mehr Vollkornprodukte hinzufügen",
|
||||
"Portion der Kohlenhydrate erhöhen"
|
||||
],
|
||||
"cooking_method": "grilled",
|
||||
"estimated_freshness": "fresh",
|
||||
"hidden_ingredients": ["Olivenöl (1 TL)", "Gewürze"],
|
||||
"portion_accuracy": "high"
|
||||
}
|
||||
}
|
||||
|
||||
BEWERTUNGSKRITERIEN health_score:
|
||||
|
|
@ -209,6 +210,7 @@ BEWERTUNGSKRITERIEN health_score:
|
|||
1: Sehr ungesund (Fast Food, stark verarbeitet)
|
||||
|
||||
KATEGORIEN:
|
||||
|
||||
- protein: Fleisch, Fisch, Eier, Hülsenfrüchte, Nüsse
|
||||
- vegetable: Alle Gemüsesorten
|
||||
- grain: Reis, Nudeln, Brot, Getreide
|
||||
|
|
@ -219,139 +221,141 @@ KATEGORIEN:
|
|||
- beverage: Getränke
|
||||
|
||||
WICHTIG:
|
||||
|
||||
- Realistische Portionsgrößen (Deutsche Standards)
|
||||
- Kalorien auf 5er-Schritte runden
|
||||
- Bei Unsicherheit: confidence reduzieren
|
||||
- Versteckte Fette/Öle nicht vergessen
|
||||
- Mehrere gleiche Items separat listen
|
||||
Kontext-spezifische Erweiterungen:
|
||||
Kontext-spezifische Erweiterungen:
|
||||
|
||||
typescript
|
||||
const promptContexts = {
|
||||
breakfast: "KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen",
|
||||
restaurant: "KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich",
|
||||
homemade: "KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze",
|
||||
fastfood: "KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten"
|
||||
};
|
||||
5. App-Architektur
|
||||
breakfast: "KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen",
|
||||
restaurant: "KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich",
|
||||
homemade: "KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze",
|
||||
fastfood: "KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten"
|
||||
}; 5. App-Architektur
|
||||
Ordnerstruktur:
|
||||
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # Basis UI-Komponenten
|
||||
│ │ ├── Button.tsx
|
||||
│ │ ├── Card.tsx
|
||||
│ │ └── LoadingSpinner.tsx
|
||||
│ ├── meals/ # Mahlzeit-spezifische Komponenten
|
||||
│ │ ├── MealList.tsx
|
||||
│ │ ├── MealItem.tsx
|
||||
│ │ ├── MealDetail.tsx
|
||||
│ │ └── NutritionBar.tsx
|
||||
│ ├── camera/ # Kamera-Komponenten
|
||||
│ │ ├── CameraModal.tsx
|
||||
│ │ ├── PhotoPreview.tsx
|
||||
│ │ └── PhotoButton.tsx
|
||||
│ └── sync/ # Sync-UI-Komponenten
|
||||
│ ├── SyncStatus.tsx
|
||||
│ └── ConflictResolver.tsx
|
||||
│ ├── ui/ # Basis UI-Komponenten
|
||||
│ │ ├── Button.tsx
|
||||
│ │ ├── Card.tsx
|
||||
│ │ └── LoadingSpinner.tsx
|
||||
│ ├── meals/ # Mahlzeit-spezifische Komponenten
|
||||
│ │ ├── MealList.tsx
|
||||
│ │ ├── MealItem.tsx
|
||||
│ │ ├── MealDetail.tsx
|
||||
│ │ └── NutritionBar.tsx
|
||||
│ ├── camera/ # Kamera-Komponenten
|
||||
│ │ ├── CameraModal.tsx
|
||||
│ │ ├── PhotoPreview.tsx
|
||||
│ │ └── PhotoButton.tsx
|
||||
│ └── sync/ # Sync-UI-Komponenten
|
||||
│ ├── SyncStatus.tsx
|
||||
│ └── ConflictResolver.tsx
|
||||
├── services/
|
||||
│ ├── database/
|
||||
│ │ ├── SQLiteService.ts # Lokale Datenbank
|
||||
│ │ ├── SyncService.ts # Synchronisation
|
||||
│ │ └── MigrationService.ts # Schema-Updates
|
||||
│ ├── api/
|
||||
│ │ ├── GeminiService.ts # KI-Integration
|
||||
│ │ └── SupabaseService.ts # Cloud-Backend
|
||||
│ ├── storage/
|
||||
│ │ ├── PhotoService.ts # Foto-Management
|
||||
│ │ └── CacheService.ts # Lokaler Cache
|
||||
│ └── utils/
|
||||
│ ├── DateUtils.ts
|
||||
│ ├── NutritionUtils.ts
|
||||
│ └── ValidationUtils.ts
|
||||
│ ├── database/
|
||||
│ │ ├── SQLiteService.ts # Lokale Datenbank
|
||||
│ │ ├── SyncService.ts # Synchronisation
|
||||
│ │ └── MigrationService.ts # Schema-Updates
|
||||
│ ├── api/
|
||||
│ │ ├── GeminiService.ts # KI-Integration
|
||||
│ │ └── SupabaseService.ts # Cloud-Backend
|
||||
│ ├── storage/
|
||||
│ │ ├── PhotoService.ts # Foto-Management
|
||||
│ │ └── CacheService.ts # Lokaler Cache
|
||||
│ └── utils/
|
||||
│ ├── DateUtils.ts
|
||||
│ ├── NutritionUtils.ts
|
||||
│ └── ValidationUtils.ts
|
||||
├── stores/
|
||||
│ ├── MealStore.ts # Zustand für Mahlzeiten
|
||||
│ ├── SyncStore.ts # Synchronisation-Status
|
||||
│ └── AppStore.ts # Globaler App-Zustand
|
||||
│ ├── MealStore.ts # Zustand für Mahlzeiten
|
||||
│ ├── SyncStore.ts # Synchronisation-Status
|
||||
│ └── AppStore.ts # Globaler App-Zustand
|
||||
├── types/
|
||||
│ ├── Database.ts # Datenbank-Typen
|
||||
│ ├── API.ts # API-Response-Typen
|
||||
│ └── UI.ts # UI-Komponenten-Typen
|
||||
│ ├── Database.ts # Datenbank-Typen
|
||||
│ ├── API.ts # API-Response-Typen
|
||||
│ └── UI.ts # UI-Komponenten-Typen
|
||||
├── hooks/
|
||||
│ ├── useMeals.ts # Mahlzeit-Management
|
||||
│ ├── useCamera.ts # Kamera-Funktionen
|
||||
│ └── useSync.ts # Synchronisation
|
||||
│ ├── useMeals.ts # Mahlzeit-Management
|
||||
│ ├── useCamera.ts # Kamera-Funktionen
|
||||
│ └── useSync.ts # Synchronisation
|
||||
└── utils/
|
||||
├── constants.ts
|
||||
├── config.ts
|
||||
└── helpers.ts
|
||||
6. Implementierungsphasen
|
||||
├── constants.ts
|
||||
├── config.ts
|
||||
└── helpers.ts 6. Implementierungsphasen
|
||||
Phase 1: Foundations & Database
|
||||
Fokus: Solide Basis schaffen
|
||||
* Expo-Projekt mit TypeScript und NativeWind setup
|
||||
* Erweiterte SQLite-Datenbank implementieren
|
||||
* Basis-Services für Database-Operations
|
||||
* Migration-System für Schema-Updates
|
||||
* Grundlegende App-Navigation
|
||||
Phase 2: Core UI & Camera
|
||||
Fokus: Basis-Funktionalität implementieren
|
||||
* MealList mit erweiterten NativeWind-Komponenten
|
||||
* Floating Action Button mit Animationen
|
||||
* Camera-Modal mit Vorschau-Funktionalität
|
||||
* Foto-Speicherung im lokalen FileSystem
|
||||
* Basic Loading-States und Error-Handling
|
||||
Phase 3: AI Integration & Analysis
|
||||
Fokus: Gemini API vollständig integrieren
|
||||
* Optimierten Prompt-Service implementieren
|
||||
* Robuste API-Error-Handling-Strategien
|
||||
* Retry-Mechanismus mit exponential backoff
|
||||
* Offline-Queue für fehlgeschlagene Requests
|
||||
* Erweiterte Nährwert-Darstellung in UI
|
||||
Phase 4: Enhanced UX & Data Visualization
|
||||
Fokus: User Experience verbessern
|
||||
* Detailansicht für einzelne Mahlzeiten
|
||||
* Nährwert-Visualisierung (Balken, Kreise)
|
||||
* Tagesstatistiken und einfache Trends
|
||||
* User-Korrektur-Interface für KI-Ergebnisse
|
||||
* Mahlzeit-Typ-Erkennung und -Kategorisierung
|
||||
Phase 5: Sync Preparation & Cloud-Ready
|
||||
Fokus: Für Cloud-Sync vorbereiten
|
||||
* Sync-Metadaten in lokaler Datenbank
|
||||
* Data-Transformation-Layer implementieren
|
||||
* Conflict-Resolution-Algorithmus
|
||||
* Settings-Screen für Sync-Präferenzen
|
||||
* Export/Import-Funktionalität als Backup
|
||||
Phase 6: Supabase Integration (Optional)
|
||||
Fokus: Cloud-Synchronisation aktivieren
|
||||
* Supabase-Client-Setup und Authentication
|
||||
* Bidirektionale Sync-Implementation
|
||||
* Photo-Upload zu Supabase Storage
|
||||
* Real-time Conflict-Resolution
|
||||
* Multi-Device-Support
|
||||
|
||||
- Expo-Projekt mit TypeScript und NativeWind setup
|
||||
- Erweiterte SQLite-Datenbank implementieren
|
||||
- Basis-Services für Database-Operations
|
||||
- Migration-System für Schema-Updates
|
||||
- Grundlegende App-Navigation
|
||||
Phase 2: Core UI & Camera
|
||||
Fokus: Basis-Funktionalität implementieren
|
||||
- MealList mit erweiterten NativeWind-Komponenten
|
||||
- Floating Action Button mit Animationen
|
||||
- Camera-Modal mit Vorschau-Funktionalität
|
||||
- Foto-Speicherung im lokalen FileSystem
|
||||
- Basic Loading-States und Error-Handling
|
||||
Phase 3: AI Integration & Analysis
|
||||
Fokus: Gemini API vollständig integrieren
|
||||
- Optimierten Prompt-Service implementieren
|
||||
- Robuste API-Error-Handling-Strategien
|
||||
- Retry-Mechanismus mit exponential backoff
|
||||
- Offline-Queue für fehlgeschlagene Requests
|
||||
- Erweiterte Nährwert-Darstellung in UI
|
||||
Phase 4: Enhanced UX & Data Visualization
|
||||
Fokus: User Experience verbessern
|
||||
- Detailansicht für einzelne Mahlzeiten
|
||||
- Nährwert-Visualisierung (Balken, Kreise)
|
||||
- Tagesstatistiken und einfache Trends
|
||||
- User-Korrektur-Interface für KI-Ergebnisse
|
||||
- Mahlzeit-Typ-Erkennung und -Kategorisierung
|
||||
Phase 5: Sync Preparation & Cloud-Ready
|
||||
Fokus: Für Cloud-Sync vorbereiten
|
||||
- Sync-Metadaten in lokaler Datenbank
|
||||
- Data-Transformation-Layer implementieren
|
||||
- Conflict-Resolution-Algorithmus
|
||||
- Settings-Screen für Sync-Präferenzen
|
||||
- Export/Import-Funktionalität als Backup
|
||||
Phase 6: Supabase Integration (Optional)
|
||||
Fokus: Cloud-Synchronisation aktivieren
|
||||
- Supabase-Client-Setup und Authentication
|
||||
- Bidirektionale Sync-Implementation
|
||||
- Photo-Upload zu Supabase Storage
|
||||
- Real-time Conflict-Resolution
|
||||
- Multi-Device-Support
|
||||
|
||||
7. MVP Success Criteria
|
||||
Funktionale Anforderungen:
|
||||
✅ Core Functionality
|
||||
* Foto aufnehmen und lokal speichern
|
||||
* Gemini API erfolgreich aufrufen
|
||||
* Strukturierte Nährwert-Analyse erhalten
|
||||
* Daten persistent in SQLite speichern
|
||||
✅ User Experience
|
||||
* Intuitive Ein-Screen-App mit Liste
|
||||
* Smooth Kamera-Integration
|
||||
* Verständliche Nährwert-Darstellung
|
||||
* Responsive UI mit NativeWind
|
||||
✅ Data Quality
|
||||
* Realistische Kalorienschätzungen (±20% Genauigkeit)
|
||||
* Vollständige Nährwert-Breakdown
|
||||
* Plausible Gesundheitsbewertungen
|
||||
* Robuste Error-Handling
|
||||
Performance-Anforderungen:
|
||||
* App-Start unter 3 Sekunden
|
||||
* Foto-zu-Analyse unter 15 Sekunden
|
||||
* Smooth 60fps Scrolling in Meal-Liste
|
||||
* Maximaler Memory-Footprint: 100MB
|
||||
Stabilität:
|
||||
* Keine Crashes bei normaler Nutzung
|
||||
* Graceful Degradation bei API-Fehlern
|
||||
* Offline-Foto-Speicherung funktioniert zuverlässig
|
||||
* Daten-Integrität bei App-Kills gewährleistet
|
||||
Funktionale Anforderungen:
|
||||
✅ Core Functionality
|
||||
|
||||
- Foto aufnehmen und lokal speichern
|
||||
- Gemini API erfolgreich aufrufen
|
||||
- Strukturierte Nährwert-Analyse erhalten
|
||||
- Daten persistent in SQLite speichern
|
||||
✅ User Experience
|
||||
- Intuitive Ein-Screen-App mit Liste
|
||||
- Smooth Kamera-Integration
|
||||
- Verständliche Nährwert-Darstellung
|
||||
- Responsive UI mit NativeWind
|
||||
✅ Data Quality
|
||||
- Realistische Kalorienschätzungen (±20% Genauigkeit)
|
||||
- Vollständige Nährwert-Breakdown
|
||||
- Plausible Gesundheitsbewertungen
|
||||
- Robuste Error-Handling
|
||||
Performance-Anforderungen:
|
||||
- App-Start unter 3 Sekunden
|
||||
- Foto-zu-Analyse unter 15 Sekunden
|
||||
- Smooth 60fps Scrolling in Meal-Liste
|
||||
- Maximaler Memory-Footprint: 100MB
|
||||
Stabilität:
|
||||
- Keine Crashes bei normaler Nutzung
|
||||
- Graceful Degradation bei API-Fehlern
|
||||
- Offline-Foto-Speicherung funktioniert zuverlässig
|
||||
- Daten-Integrität bei App-Kills gewährleistet
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
Expo ImagePicker
|
||||
|
||||
|
||||
A library that provides access to the system's UI for selecting images and videos from the phone's library or taking a photo with the camera.
|
||||
|
||||
Bundled version:
|
||||
|
|
@ -26,32 +25,31 @@ app.json
|
|||
|
||||
Copy
|
||||
|
||||
|
||||
{
|
||||
"expo": {
|
||||
"plugins": [
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "The app accesses your photos to let you share them with your friends."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
"expo": {
|
||||
"plugins": [
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "The app accesses your photos to let you share them with your friends."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
Configurable properties
|
||||
Name Default Description
|
||||
photosPermission "Allow $(PRODUCT_NAME) to access your photos"
|
||||
Name Default Description
|
||||
photosPermission "Allow $(PRODUCT_NAME) to access your photos"
|
||||
Only for:
|
||||
|
||||
A string to set the NSPhotoLibraryUsageDescription permission message.
|
||||
|
||||
cameraPermission "Allow $(PRODUCT_NAME) to access your camera"
|
||||
cameraPermission "Allow $(PRODUCT_NAME) to access your camera"
|
||||
Only for:
|
||||
|
||||
A string to set the NSCameraUsageDescription permission message.
|
||||
|
||||
microphonePermission "Allow $(PRODUCT_NAME) to access your microphone"
|
||||
microphonePermission "Allow $(PRODUCT_NAME) to access your microphone"
|
||||
Only for:
|
||||
|
||||
A string to set the NSMicrophoneUsageDescription permission message.
|
||||
|
|
@ -62,72 +60,71 @@ Image Picker
|
|||
|
||||
Copy
|
||||
|
||||
|
||||
Open in Snack
|
||||
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, View, StyleSheet } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import \* as ImagePicker from 'expo-image-picker';
|
||||
|
||||
export default function ImagePickerExample() {
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
|
||||
const pickImage = async () => {
|
||||
// No permissions request is necessary for launching the image library
|
||||
let result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images', 'videos'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 1,
|
||||
});
|
||||
const pickImage = async () => {
|
||||
// No permissions request is necessary for launching the image library
|
||||
let result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images', 'videos'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
|
||||
if (!result.canceled) {
|
||||
setImage(result.assets[0].uri);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Button title="Pick an image from camera roll" onPress={pickImage} />
|
||||
{image && <Image source={{ uri: image }} style={styles.image} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Button title="Pick an image from camera roll" onPress={pickImage} />
|
||||
{image && <Image source={{ uri: image }} style={styles.image} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
image: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
image: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
});
|
||||
|
||||
Show More
|
||||
When you run this example and pick an image, you will see the image that you picked show up in your app, and a similar log will be shown in the console:
|
||||
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"assetId": "C166F9F5-B5FE-4501-9531",
|
||||
"base64": null,
|
||||
"duration": null,
|
||||
"exif": null,
|
||||
"fileName": "IMG.HEIC",
|
||||
"fileSize": 6018901,
|
||||
"height": 3025,
|
||||
"type": "image",
|
||||
"uri": "file:///data/user/0/host.exp.exponent/cache/cropped1814158652.jpg"
|
||||
"width": 3024
|
||||
}
|
||||
],
|
||||
"canceled": false
|
||||
"assets": [
|
||||
{
|
||||
"assetId": "C166F9F5-B5FE-4501-9531",
|
||||
"base64": null,
|
||||
"duration": null,
|
||||
"exif": null,
|
||||
"fileName": "IMG.HEIC",
|
||||
"fileSize": 6018901,
|
||||
"height": 3025,
|
||||
"type": "image",
|
||||
"uri": "file:///data/user/0/host.exp.exponent/cache/cropped1814158652.jpg"
|
||||
"width": 3024
|
||||
}
|
||||
],
|
||||
"canceled": false
|
||||
}
|
||||
With AWS S3
|
||||
AWS storage example
|
||||
|
|
@ -142,10 +139,10 @@ An example of how to use Firebase storage can be found in with-firebase-storage-
|
|||
See Using Firebase guide to set up your project correctly.
|
||||
|
||||
API
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import \* as ImagePicker from 'expo-image-picker';
|
||||
Hooks
|
||||
useCameraPermissions(options)
|
||||
Parameter Type
|
||||
Parameter Type
|
||||
options
|
||||
(optional)
|
||||
PermissionHookOptions<object>
|
||||
|
|
@ -158,11 +155,11 @@ Example
|
|||
|
||||
const [status, requestPermission] = ImagePicker.useCameraPermissions();
|
||||
useMediaLibraryPermissions(options)
|
||||
Parameter Type
|
||||
Parameter Type
|
||||
options
|
||||
(optional)
|
||||
PermissionHookOptions<{
|
||||
writeOnly: boolean
|
||||
writeOnly: boolean
|
||||
}>
|
||||
|
||||
Check or request permissions to access the media library. This uses both requestMediaLibraryPermissionsAsync and getMediaLibraryPermissionsAsync to interact with the permissions.
|
||||
|
|
@ -181,10 +178,10 @@ Promise<CameraPermissionResponse>
|
|||
A promise that fulfills with an object of type CameraPermissionResponse.
|
||||
|
||||
ImagePicker.getMediaLibraryPermissionsAsync(writeOnly)
|
||||
Parameter Type Description
|
||||
Parameter Type Description
|
||||
writeOnly
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Whether to request write or read and write permissions. Defaults to false
|
||||
|
||||
Default:
|
||||
|
|
@ -204,10 +201,10 @@ Promise<ImagePickerResult | ImagePickerErrorResult | null>
|
|||
On Android: a promise that resolves to an object of exactly same type as in ImagePicker.launchImageLibraryAsync or ImagePicker.launchCameraAsync if the ImagePicker finished successfully. Otherwise, an object of type ImagePickerErrorResult.
|
||||
On other platforms: null
|
||||
ImagePicker.launchCameraAsync(options)
|
||||
Parameter Type Description
|
||||
Parameter Type Description
|
||||
options
|
||||
(optional)
|
||||
ImagePickerOptions
|
||||
ImagePickerOptions
|
||||
An ImagePickerOptions object.
|
||||
|
||||
Default:
|
||||
|
|
@ -222,10 +219,10 @@ Promise<ImagePickerResult>
|
|||
A promise that resolves to an object with canceled and assets fields. When the user canceled the action the assets is always null, otherwise it's an array of the selected media assets which have a form of ImagePickerAsset.
|
||||
|
||||
ImagePicker.launchImageLibraryAsync(options)
|
||||
Parameter Type Description
|
||||
Parameter Type Description
|
||||
options
|
||||
(optional)
|
||||
ImagePickerOptions
|
||||
ImagePickerOptions
|
||||
An object extended by ImagePickerOptions.
|
||||
|
||||
Default:
|
||||
|
|
@ -249,10 +246,10 @@ Promise<CameraPermissionResponse>
|
|||
A promise that fulfills with an object of type CameraPermissionResponse.
|
||||
|
||||
ImagePicker.requestMediaLibraryPermissionsAsync(writeOnly)
|
||||
Parameter Type Description
|
||||
Parameter Type Description
|
||||
writeOnly
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Whether to request write or read and write permissions. Defaults to false
|
||||
|
||||
Default:
|
||||
|
|
@ -282,10 +279,10 @@ Acceptable values are: 'photos' | 'albums'
|
|||
ImagePickerAsset
|
||||
Represents an asset (image or video) returned by the image picker or camera.
|
||||
|
||||
Property Type Description
|
||||
Property Type Description
|
||||
assetId
|
||||
(optional)
|
||||
string | null
|
||||
string | null
|
||||
Only for:
|
||||
|
||||
The unique ID that represents the picked image or video, if picked from the library. It can be used by expo-media-library to manage the picked asset.
|
||||
|
|
@ -294,100 +291,100 @@ This might be null when the ID is unavailable or the user gave limited permissio
|
|||
|
||||
base64
|
||||
(optional)
|
||||
string | null
|
||||
string | null
|
||||
When the base64 option is truthy, it is a Base64-encoded string of the selected image's JPEG data, otherwise null. If you prepend this with 'data:image/jpeg;base64,' to create a data URI, you can use it as the source of an Image element; for example:
|
||||
|
||||
<Image
|
||||
source={{ uri: 'data:image/jpeg;base64,' + asset.base64 }}
|
||||
style={{ width: 200, height: 200 }}
|
||||
source={{ uri: 'data:image/jpeg;base64,' + asset.base64 }}
|
||||
style={{ width: 200, height: 200 }}
|
||||
/>
|
||||
duration
|
||||
(optional)
|
||||
number | null
|
||||
number | null
|
||||
Length of the video in milliseconds or null if the asset is not a video.
|
||||
|
||||
exif
|
||||
(optional)
|
||||
Record<string, any> | null
|
||||
Record<string, any> | null
|
||||
Only for:
|
||||
|
||||
The exif field is included if the exif option is truthy, and is an object containing the image's EXIF data. The names of this object's properties are EXIF tags and the values are the respective EXIF values for those tags.
|
||||
|
||||
file
|
||||
(optional)
|
||||
File
|
||||
File
|
||||
Only for:
|
||||
|
||||
The web File object containing the selected media. This property is web-only and can be used to upload to a server with FormData.
|
||||
|
||||
fileName
|
||||
(optional)
|
||||
string | null
|
||||
string | null
|
||||
Preferred filename to use when saving this item. This might be null when the name is unavailable or user gave limited permission to access the media library.
|
||||
|
||||
fileSize
|
||||
(optional)
|
||||
number
|
||||
number
|
||||
File size of the picked image or video, in bytes.
|
||||
|
||||
height number
|
||||
height number
|
||||
Height of the image or video.
|
||||
|
||||
mimeType
|
||||
(optional)
|
||||
string
|
||||
string
|
||||
The MIME type of the selected asset or null if could not be determined.
|
||||
|
||||
pairedVideoAsset
|
||||
(optional)
|
||||
ImagePickerAsset | null
|
||||
ImagePickerAsset | null
|
||||
Only for:
|
||||
|
||||
Contains information about the video paired with the image file. This property is only set when livePhotos media type was present in the mediaTypes array when launching the picker and a live photo was selected.
|
||||
|
||||
type
|
||||
(optional)
|
||||
'image' | 'video' | 'livePhoto' | 'pairedVideo'
|
||||
'image' | 'video' | 'livePhoto' | 'pairedVideo'
|
||||
The type of the asset.
|
||||
|
||||
'image' - for images.
|
||||
'video' - for videos.
|
||||
'livePhoto' - for live photos. (iOS only)
|
||||
'pairedVideo' - for videos paired with photos, which can be combined to create a live photo. (iOS only)
|
||||
uri string
|
||||
uri string
|
||||
URI to the local image or video file (usable as the source of an Image element, in the case of an image) and width and height specify the dimensions of the media.
|
||||
|
||||
width number
|
||||
width number
|
||||
Width of the image or video.
|
||||
|
||||
ImagePickerCanceledResult
|
||||
Type representing canceled pick result.
|
||||
|
||||
Property Type Description
|
||||
assets null
|
||||
Property Type Description
|
||||
assets null
|
||||
null signifying that the request was canceled.
|
||||
|
||||
canceled true
|
||||
canceled true
|
||||
Boolean flag set to true showing that the request was canceled.
|
||||
|
||||
ImagePickerErrorResult
|
||||
Property Type Description
|
||||
code string
|
||||
Property Type Description
|
||||
code string
|
||||
The error code.
|
||||
|
||||
exception
|
||||
(optional)
|
||||
string
|
||||
string
|
||||
The exception which caused the error.
|
||||
|
||||
message string
|
||||
message string
|
||||
The error message.
|
||||
|
||||
ImagePickerOptions
|
||||
Property Type Description
|
||||
Property Type Description
|
||||
allowsEditing
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Whether to show a UI to edit the image after it is picked. On Android the user can crop and rotate the image and on iOS simply crop it.
|
||||
|
|
@ -399,7 +396,7 @@ Default:
|
|||
false
|
||||
allowsMultipleSelection
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Whether or not to allow selecting multiple media files at once.
|
||||
|
|
@ -410,17 +407,17 @@ Default:
|
|||
false
|
||||
aspect
|
||||
(optional)
|
||||
[number, number]
|
||||
[number, number]
|
||||
An array with two entries [x, y] specifying the aspect ratio to maintain if the user is allowed to edit the image (by passing allowsEditing: true). This is only applicable on Android, since on iOS the crop rectangle is always a square.
|
||||
|
||||
base64
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Whether to also include the image data in Base64 format.
|
||||
|
||||
cameraType
|
||||
(optional)
|
||||
CameraType
|
||||
CameraType
|
||||
Selects the camera-facing type. The CameraType enum provides two options: front for the front-facing camera and back for the back-facing camera.
|
||||
|
||||
On Android, the behavior of this option may vary based on the camera app installed on the device.
|
||||
|
|
@ -429,7 +426,7 @@ Default:
|
|||
CameraType.back
|
||||
defaultTab
|
||||
(optional)
|
||||
DefaultTab
|
||||
DefaultTab
|
||||
Only for:
|
||||
|
||||
Choose the default tab with which the image picker will be opened.
|
||||
|
|
@ -438,14 +435,14 @@ Default:
|
|||
'photos'
|
||||
exif
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Whether to also include the EXIF data for the image. On iOS the EXIF data does not include GPS tags in the camera case.
|
||||
|
||||
legacy
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Uses the legacy image picker on Android. This will allow media to be selected from outside the users photo library.
|
||||
|
|
@ -454,14 +451,14 @@ Default:
|
|||
false
|
||||
mediaTypes
|
||||
(optional)
|
||||
MediaType | MediaType[] | MediaTypeOptions
|
||||
MediaType | MediaType[] | MediaTypeOptions
|
||||
Choose what type of media to pick.
|
||||
|
||||
Default:
|
||||
'images'
|
||||
orderedSelection
|
||||
(optional)
|
||||
boolean
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Whether to display number badges when assets are selected. The badges are numbered in selection order. Assets are then returned in the exact same order they were selected.
|
||||
|
|
@ -472,7 +469,7 @@ Default:
|
|||
false
|
||||
preferredAssetRepresentationMode
|
||||
(optional)
|
||||
UIImagePickerPreferredAssetRepresentationMode
|
||||
UIImagePickerPreferredAssetRepresentationMode
|
||||
Only for:
|
||||
|
||||
Choose preferred asset representation mode to use when loading assets.
|
||||
|
|
@ -481,7 +478,7 @@ Default:
|
|||
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Automatic
|
||||
presentationStyle
|
||||
(optional)
|
||||
UIImagePickerPresentationStyle
|
||||
UIImagePickerPresentationStyle
|
||||
Only for:
|
||||
|
||||
Choose presentation style to customize view during taking photo/video.
|
||||
|
|
@ -490,7 +487,7 @@ Default:
|
|||
ImagePicker.UIImagePickerPresentationStyle.Automatic
|
||||
quality
|
||||
(optional)
|
||||
number
|
||||
number
|
||||
Only for:
|
||||
|
||||
Specify the quality of compression, from 0 to 1. 0 means compress for small size, 1 means compress for maximum quality.
|
||||
|
|
@ -503,7 +500,7 @@ Default:
|
|||
1.0
|
||||
selectionLimit
|
||||
(optional)
|
||||
number
|
||||
number
|
||||
Only for:
|
||||
|
||||
The maximum number of items that user can select. Applicable when allowsMultipleSelection is enabled. Setting the value to 0 sets the selection limit to the maximum that the system supports.
|
||||
|
|
@ -512,7 +509,7 @@ Default:
|
|||
0
|
||||
videoExportPreset
|
||||
(optional)
|
||||
VideoExportPreset
|
||||
VideoExportPreset
|
||||
Deprecated See videoExportPreset in Apple documentation.
|
||||
|
||||
Specify preset which will be used to compress selected video.
|
||||
|
|
@ -521,7 +518,7 @@ Default:
|
|||
ImagePicker.VideoExportPreset.Passthrough
|
||||
videoMaxDuration
|
||||
(optional)
|
||||
number
|
||||
number
|
||||
Maximum duration, in seconds, for video recording. Setting this to 0 disables the limit. Defaults to 0 (no limit).
|
||||
|
||||
On iOS, when allowsEditing is set to true, maximum duration is limited to 10 minutes. This limit is applied automatically, if 0 or no value is specified.
|
||||
|
|
@ -529,7 +526,7 @@ On Android, effect of this option depends on support of installed camera app.
|
|||
On Web this option has no effect - the limit is browser-dependant.
|
||||
videoQuality
|
||||
(optional)
|
||||
UIImagePickerControllerQualityType
|
||||
UIImagePickerControllerQualityType
|
||||
Only for:
|
||||
|
||||
Specify the quality of recorded videos. Defaults to the highest quality available for the device.
|
||||
|
|
@ -546,11 +543,11 @@ Acceptable values are: ImagePickerSuccessResult | ImagePickerCanceledResult
|
|||
ImagePickerSuccessResult
|
||||
Type representing successful pick result.
|
||||
|
||||
Property Type Description
|
||||
assets ImagePickerAsset[]
|
||||
Property Type Description
|
||||
assets ImagePickerAsset[]
|
||||
An array of picked assets.
|
||||
|
||||
canceled false
|
||||
canceled false
|
||||
Boolean flag set to false showing that the request was successful.
|
||||
|
||||
MediaLibraryPermissionResponse
|
||||
|
|
@ -558,10 +555,10 @@ Extends PermissionResponse type exported by expo-modules-core, containing additi
|
|||
|
||||
Type: PermissionResponse extended by:
|
||||
|
||||
Property Type Description
|
||||
Property Type Description
|
||||
accessPrivileges
|
||||
(optional)
|
||||
'all' | 'limited' | 'none'
|
||||
'all' | 'limited' | 'none'
|
||||
Indicates if your app has access to the whole or only part of the photo library. Possible values are:
|
||||
|
||||
'all' if the user granted your app access to the whole photo library
|
||||
|
|
@ -596,17 +593,17 @@ Acceptable values are: PermissionHookBehavior | Options
|
|||
PermissionResponse
|
||||
An object obtained by permissions get and request functions.
|
||||
|
||||
Property Type Description
|
||||
canAskAgain boolean
|
||||
Property Type Description
|
||||
canAskAgain boolean
|
||||
Indicates if user can be asked again for specific permission. If not, one should be directed to the Settings app in order to enable/disable the permission.
|
||||
|
||||
expires PermissionExpiration
|
||||
expires PermissionExpiration
|
||||
Determines time when the permission expires.
|
||||
|
||||
granted boolean
|
||||
granted boolean
|
||||
A convenience boolean that indicates if the permission is granted.
|
||||
|
||||
status PermissionStatus
|
||||
status PermissionStatus
|
||||
Determines the status of the permission.
|
||||
|
||||
Enums
|
||||
|
|
@ -771,7 +768,7 @@ Permissions
|
|||
Android
|
||||
The following permissions are added automatically through the library's AndroidManifest.xml.
|
||||
|
||||
Android Permission Description
|
||||
Android Permission Description
|
||||
CAMERA
|
||||
|
||||
Required to be able to access the camera device.
|
||||
|
|
@ -787,7 +784,7 @@ Allows an application to write to external storage.
|
|||
iOS
|
||||
The following usage description keys are used by the APIs in this library.
|
||||
|
||||
Info.plist Key Description
|
||||
Info.plist Key Description
|
||||
NSMicrophoneUsageDescription
|
||||
|
||||
A message that tells the user why the app is requesting access to the device’s microphone.
|
||||
|
|
@ -796,4 +793,4 @@ NSPhotoLibraryUsageDescription
|
|||
A message that tells the user why the app is requesting access to the user’s photo library.
|
||||
NSCameraUsageDescription
|
||||
|
||||
A message that tells the user why the app is requesting access to the device’s camera.
|
||||
A message that tells the user why the app is requesting access to the device’s camera.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
{
|
||||
"name": "@nutriphi/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/nutriphi-database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@nutriphi/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/nutriphi-database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { MealsModule } from './meals/meals.module';
|
|||
import { SyncModule } from './sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
GeminiModule,
|
||||
MealsModule,
|
||||
SyncModule,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
GeminiModule,
|
||||
MealsModule,
|
||||
SyncModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,66 +1,60 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Mana Core Auth URL from config
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
'http://localhost:3001';
|
||||
try {
|
||||
// Get Mana Core Auth URL from config
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
|
||||
|
||||
// Validate token with Mana Core Auth
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
// Validate token with Mana Core Auth
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const { valid, payload } = await response.json();
|
||||
const { valid, payload } = await response.json();
|
||||
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,23 +6,23 @@ export const DATABASE_TOKEN = 'DATABASE';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return createClient(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return createClient(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeDb();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
|
|
|
|||
|
|
@ -3,39 +3,39 @@ import { ConfigService } from '@nestjs/config';
|
|||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
export interface NutritionAnalysis {
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
servingSize: string;
|
||||
confidence: number;
|
||||
ingredients?: string[];
|
||||
healthTips?: string[];
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
servingSize: string;
|
||||
confidence: number;
|
||||
ingredients?: string[];
|
||||
healthTips?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GeminiService {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private readonly genAI: GoogleGenerativeAI;
|
||||
private readonly model;
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private readonly genAI: GoogleGenerativeAI;
|
||||
private readonly model;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is not configured');
|
||||
}
|
||||
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
||||
}
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is not configured');
|
||||
}
|
||||
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
||||
}
|
||||
|
||||
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food image with Gemini Vision');
|
||||
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food image with Gemini Vision');
|
||||
|
||||
const prompt = `Analyze this food image and provide detailed nutritional information.
|
||||
const prompt = `Analyze this food image and provide detailed nutritional information.
|
||||
|
||||
Return a JSON object with the following structure:
|
||||
{
|
||||
|
|
@ -56,38 +56,38 @@ export class GeminiService {
|
|||
Be as accurate as possible with the nutritional estimates based on what you can see.
|
||||
Only return valid JSON, no additional text.`;
|
||||
|
||||
try {
|
||||
const result = await this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const result = await this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food image', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food image', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food description with Gemini');
|
||||
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food description with Gemini');
|
||||
|
||||
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
|
||||
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
|
||||
|
||||
Return a JSON object with the following structure:
|
||||
{
|
||||
|
|
@ -107,22 +107,22 @@ export class GeminiService {
|
|||
|
||||
Only return valid JSON, no additional text.`;
|
||||
|
||||
try {
|
||||
const result = await this.model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
try {
|
||||
const result = await this.model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food description', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food description', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Controller, Get } from '@nestjs/common';
|
|||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'nutriphi-backend',
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'nutriphi-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,34 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Nutriphi backend running on http://localhost:${port}`);
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Nutriphi backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,87 +1,87 @@
|
|||
import { IsString, IsOptional, IsBase64 } from 'class-validator';
|
||||
|
||||
export class AnalyzeMealImageDto {
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
}
|
||||
|
||||
export class AnalyzeMealTextDto {
|
||||
@IsString()
|
||||
description: string;
|
||||
@IsString()
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class CreateMealDto {
|
||||
@IsString()
|
||||
foodName: string;
|
||||
@IsString()
|
||||
foodName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
|
||||
@IsString()
|
||||
servingSize: string;
|
||||
@IsString()
|
||||
servingSize: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UploadMealDto {
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
}
|
||||
|
||||
export class UpdateMealDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
foodName?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
foodName?: string;
|
||||
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { MealsService } from './meals.service';
|
||||
import {
|
||||
AnalyzeMealImageDto,
|
||||
AnalyzeMealTextDto,
|
||||
CreateMealDto,
|
||||
UpdateMealDto,
|
||||
UploadMealDto,
|
||||
AnalyzeMealImageDto,
|
||||
AnalyzeMealTextDto,
|
||||
CreateMealDto,
|
||||
UpdateMealDto,
|
||||
UploadMealDto,
|
||||
} from './dto/analyze-meal.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
|
@ -25,75 +25,57 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
|
|||
@Controller('meals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MealsController {
|
||||
constructor(private readonly mealsService: MealsService) {}
|
||||
constructor(private readonly mealsService: MealsService) {}
|
||||
|
||||
@Post('analyze/image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
|
||||
return this.mealsService.analyzeImage(dto.imageBase64);
|
||||
}
|
||||
@Post('analyze/image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
|
||||
return this.mealsService.analyzeImage(dto.imageBase64);
|
||||
}
|
||||
|
||||
@Post('analyze/text')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
|
||||
return this.mealsService.analyzeText(dto.description);
|
||||
}
|
||||
@Post('analyze/text')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
|
||||
return this.mealsService.analyzeText(dto.description);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createMeal(
|
||||
@Body() dto: CreateMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.createMeal(dto, user.userId);
|
||||
}
|
||||
@Post()
|
||||
async createMeal(@Body() dto: CreateMealDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.createMeal(dto, user.userId);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
async uploadMeal(
|
||||
@Body() dto: UploadMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
|
||||
}
|
||||
@Post('upload')
|
||||
async uploadMeal(@Body() dto: UploadMealDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getMeals(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('date') date?: string,
|
||||
) {
|
||||
return this.mealsService.getMealsByUser(user.userId, date);
|
||||
}
|
||||
@Get()
|
||||
async getMeals(@CurrentUser() user: CurrentUserData, @Query('date') date?: string) {
|
||||
return this.mealsService.getMealsByUser(user.userId, date);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
async getDailySummary(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('date') date: string,
|
||||
) {
|
||||
return this.mealsService.getDailySummary(user.userId, date);
|
||||
}
|
||||
@Get('summary')
|
||||
async getDailySummary(@CurrentUser() user: CurrentUserData, @Query('date') date: string) {
|
||||
return this.mealsService.getDailySummary(user.userId, date);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getMealById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.getMealById(id, user.userId);
|
||||
}
|
||||
@Get(':id')
|
||||
async getMealById(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.getMealById(id, user.userId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateMeal(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.updateMeal(id, dto, user.userId);
|
||||
}
|
||||
@Put(':id')
|
||||
async updateMeal(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMealDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.mealsService.updateMeal(id, dto, user.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMeal(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.deleteMeal(id, user.userId);
|
||||
}
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMeal(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.deleteMeal(id, user.userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { MealsService } from './meals.service';
|
|||
import { GeminiModule } from '../gemini/gemini.module';
|
||||
|
||||
@Module({
|
||||
imports: [GeminiModule],
|
||||
controllers: [MealsController],
|
||||
providers: [MealsService],
|
||||
imports: [GeminiModule],
|
||||
controllers: [MealsController],
|
||||
providers: [MealsService],
|
||||
})
|
||||
export class MealsModule {}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc,
|
||||
type Meal as DbMeal,
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc,
|
||||
type Meal as DbMeal,
|
||||
} from '@manacore/nutriphi-database';
|
||||
import { DATABASE_TOKEN } from '../database/database.module';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
|
@ -15,267 +15,270 @@ import { GeminiService, NutritionAnalysis } from '../gemini/gemini.service';
|
|||
import { CreateMealDto, UpdateMealDto, UploadMealDto } from './dto/analyze-meal.dto';
|
||||
|
||||
export interface Meal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_name: string;
|
||||
image_url?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
serving_size: string;
|
||||
meal_type?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_name: string;
|
||||
image_url?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
serving_size: string;
|
||||
meal_type?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbohydrates: number;
|
||||
totalFat: number;
|
||||
totalFiber: number;
|
||||
totalSugar: number;
|
||||
totalSodium: number;
|
||||
mealCount: number;
|
||||
date: string;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbohydrates: number;
|
||||
totalFat: number;
|
||||
totalFiber: number;
|
||||
totalSugar: number;
|
||||
totalSodium: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MealsService {
|
||||
private readonly logger = new Logger(MealsService.name);
|
||||
private readonly logger = new Logger(MealsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_TOKEN) private readonly db: Database,
|
||||
private geminiService: GeminiService,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_TOKEN) private readonly db: Database,
|
||||
private geminiService: GeminiService,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
|
||||
return {
|
||||
id: dbMeal.id,
|
||||
user_id: dbMeal.userId,
|
||||
food_name: dbMeal.foodName,
|
||||
image_url: dbMeal.imageUrl ?? undefined,
|
||||
calories: dbMeal.calories ?? 0,
|
||||
protein: dbMeal.protein ?? 0,
|
||||
carbohydrates: dbMeal.carbohydrates ?? 0,
|
||||
fat: dbMeal.fat ?? 0,
|
||||
fiber: dbMeal.fiber ?? 0,
|
||||
sugar: dbMeal.sugar ?? 0,
|
||||
sodium: dbMeal.sodium ?? 0,
|
||||
serving_size: dbMeal.servingSize ?? '',
|
||||
meal_type: dbMeal.mealType ?? undefined,
|
||||
notes: dbMeal.notes ?? undefined,
|
||||
created_at: dbMeal.createdAt.toISOString(),
|
||||
updated_at: dbMeal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
|
||||
return {
|
||||
id: dbMeal.id,
|
||||
user_id: dbMeal.userId,
|
||||
food_name: dbMeal.foodName,
|
||||
image_url: dbMeal.imageUrl ?? undefined,
|
||||
calories: dbMeal.calories ?? 0,
|
||||
protein: dbMeal.protein ?? 0,
|
||||
carbohydrates: dbMeal.carbohydrates ?? 0,
|
||||
fat: dbMeal.fat ?? 0,
|
||||
fiber: dbMeal.fiber ?? 0,
|
||||
sugar: dbMeal.sugar ?? 0,
|
||||
sodium: dbMeal.sodium ?? 0,
|
||||
serving_size: dbMeal.servingSize ?? '',
|
||||
meal_type: dbMeal.mealType ?? undefined,
|
||||
notes: dbMeal.notes ?? undefined,
|
||||
created_at: dbMeal.createdAt.toISOString(),
|
||||
updated_at: dbMeal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodImage(imageBase64);
|
||||
}
|
||||
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodImage(imageBase64);
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodText(description);
|
||||
}
|
||||
async analyzeText(description: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodText(description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image to storage, analyze it, and create a meal
|
||||
*/
|
||||
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
|
||||
/**
|
||||
* Upload an image to storage, analyze it, and create a meal
|
||||
*/
|
||||
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
|
||||
|
||||
// Step 1: Upload image to storage
|
||||
let imageUrl: string | undefined;
|
||||
let storagePath: string | undefined;
|
||||
// Step 1: Upload image to storage
|
||||
let imageUrl: string | undefined;
|
||||
let storagePath: string | undefined;
|
||||
|
||||
try {
|
||||
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
|
||||
imageUrl = uploadResult.url;
|
||||
storagePath = uploadResult.key;
|
||||
this.logger.log(`Image uploaded: ${storagePath}`);
|
||||
} catch (error) {
|
||||
this.logger.warn('Storage not configured, skipping image upload', error);
|
||||
}
|
||||
try {
|
||||
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
|
||||
imageUrl = uploadResult.url;
|
||||
storagePath = uploadResult.key;
|
||||
this.logger.log(`Image uploaded: ${storagePath}`);
|
||||
} catch (error) {
|
||||
this.logger.warn('Storage not configured, skipping image upload', error);
|
||||
}
|
||||
|
||||
// Step 2: Analyze the image with Gemini
|
||||
// Extract base64 data without the data URL prefix
|
||||
let base64Data = dto.imageBase64;
|
||||
if (base64Data.includes(',')) {
|
||||
base64Data = base64Data.split(',')[1];
|
||||
}
|
||||
// Step 2: Analyze the image with Gemini
|
||||
// Extract base64 data without the data URL prefix
|
||||
let base64Data = dto.imageBase64;
|
||||
if (base64Data.includes(',')) {
|
||||
base64Data = base64Data.split(',')[1];
|
||||
}
|
||||
|
||||
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
|
||||
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
|
||||
|
||||
// Step 3: Create the meal record
|
||||
const [result] = await this.db.insert(meals).values({
|
||||
userId,
|
||||
foodName: analysis.foodName || 'Unbekanntes Gericht',
|
||||
imageUrl,
|
||||
storagePath,
|
||||
calories: analysis.calories,
|
||||
protein: analysis.protein,
|
||||
carbohydrates: analysis.carbohydrates,
|
||||
fat: analysis.fat,
|
||||
fiber: analysis.fiber,
|
||||
sugar: analysis.sugar,
|
||||
servingSize: analysis.servingSize || '1 Portion',
|
||||
mealType: dto.mealType,
|
||||
analysisStatus: 'completed',
|
||||
}).returning();
|
||||
// Step 3: Create the meal record
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: analysis.foodName || 'Unbekanntes Gericht',
|
||||
imageUrl,
|
||||
storagePath,
|
||||
calories: analysis.calories,
|
||||
protein: analysis.protein,
|
||||
carbohydrates: analysis.carbohydrates,
|
||||
fat: analysis.fat,
|
||||
fiber: analysis.fiber,
|
||||
sugar: analysis.sugar,
|
||||
servingSize: analysis.servingSize || '1 Portion',
|
||||
mealType: dto.mealType,
|
||||
analysisStatus: 'completed',
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Meal created: ${result.id}`);
|
||||
this.logger.log(`Meal created: ${result.id}`);
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Creating meal for user: ${userId}`);
|
||||
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Creating meal for user: ${userId}`);
|
||||
|
||||
const [result] = await this.db.insert(meals).values({
|
||||
userId,
|
||||
foodName: dto.foodName,
|
||||
imageUrl: dto.imageUrl,
|
||||
calories: dto.calories,
|
||||
protein: dto.protein,
|
||||
carbohydrates: dto.carbohydrates,
|
||||
fat: dto.fat,
|
||||
fiber: dto.fiber,
|
||||
sugar: dto.sugar,
|
||||
sodium: dto.sodium,
|
||||
servingSize: dto.servingSize,
|
||||
mealType: dto.mealType,
|
||||
notes: dto.notes,
|
||||
}).returning();
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: dto.foodName,
|
||||
imageUrl: dto.imageUrl,
|
||||
calories: dto.calories,
|
||||
protein: dto.protein,
|
||||
carbohydrates: dto.carbohydrates,
|
||||
fat: dto.fat,
|
||||
fiber: dto.fiber,
|
||||
sugar: dto.sugar,
|
||||
sodium: dto.sodium,
|
||||
servingSize: dto.servingSize,
|
||||
mealType: dto.mealType,
|
||||
notes: dto.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async getMealsByUser(
|
||||
userId: string,
|
||||
date?: string,
|
||||
): Promise<Meal[]> {
|
||||
this.logger.log(`Fetching meals for user: ${userId}`);
|
||||
async getMealsByUser(userId: string, date?: string): Promise<Meal[]> {
|
||||
this.logger.log(`Fetching meals for user: ${userId}`);
|
||||
|
||||
let query;
|
||||
let query;
|
||||
|
||||
if (date) {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
if (date) {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(
|
||||
and(
|
||||
eq(meals.userId, userId),
|
||||
gte(meals.createdAt, startOfDay),
|
||||
lte(meals.createdAt, endOfDay)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(meals.createdAt));
|
||||
} else {
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId))
|
||||
.orderBy(desc(meals.createdAt));
|
||||
}
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(
|
||||
and(
|
||||
eq(meals.userId, userId),
|
||||
gte(meals.createdAt, startOfDay),
|
||||
lte(meals.createdAt, endOfDay)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(meals.createdAt));
|
||||
} else {
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId))
|
||||
.orderBy(desc(meals.createdAt));
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results.map(this.mapDbMealToMeal);
|
||||
}
|
||||
const results = await query;
|
||||
return results.map(this.mapDbMealToMeal);
|
||||
}
|
||||
|
||||
async getMealById(id: string, userId: string): Promise<Meal> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
|
||||
async getMealById(id: string, userId: string): Promise<Meal> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
|
||||
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
|
||||
|
||||
const updateData: Partial<typeof meals.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updateData: Partial<typeof meals.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
|
||||
if (dto.calories !== undefined) updateData.calories = dto.calories;
|
||||
if (dto.protein !== undefined) updateData.protein = dto.protein;
|
||||
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
|
||||
if (dto.fat !== undefined) updateData.fat = dto.fat;
|
||||
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
|
||||
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
|
||||
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
|
||||
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
|
||||
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
|
||||
if (dto.notes !== undefined) updateData.notes = dto.notes;
|
||||
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
|
||||
if (dto.calories !== undefined) updateData.calories = dto.calories;
|
||||
if (dto.protein !== undefined) updateData.protein = dto.protein;
|
||||
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
|
||||
if (dto.fat !== undefined) updateData.fat = dto.fat;
|
||||
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
|
||||
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
|
||||
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
|
||||
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
|
||||
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
|
||||
if (dto.notes !== undefined) updateData.notes = dto.notes;
|
||||
|
||||
const [result] = await this.db
|
||||
.update(meals)
|
||||
.set(updateData)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
const [result] = await this.db
|
||||
.update(meals)
|
||||
.set(updateData)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async deleteMeal(id: string, userId: string): Promise<void> {
|
||||
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
|
||||
async deleteMeal(id: string, userId: string): Promise<void> {
|
||||
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
|
||||
|
||||
const result = await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
const result = await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
}
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
const userMeals = await this.getMealsByUser(userId, date);
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
const userMeals = await this.getMealsByUser(userId, date);
|
||||
|
||||
const summary: DailySummary = {
|
||||
date,
|
||||
totalCalories: 0,
|
||||
totalProtein: 0,
|
||||
totalCarbohydrates: 0,
|
||||
totalFat: 0,
|
||||
totalFiber: 0,
|
||||
totalSugar: 0,
|
||||
totalSodium: 0,
|
||||
mealCount: userMeals.length,
|
||||
};
|
||||
const summary: DailySummary = {
|
||||
date,
|
||||
totalCalories: 0,
|
||||
totalProtein: 0,
|
||||
totalCarbohydrates: 0,
|
||||
totalFat: 0,
|
||||
totalFiber: 0,
|
||||
totalSugar: 0,
|
||||
totalSodium: 0,
|
||||
mealCount: userMeals.length,
|
||||
};
|
||||
|
||||
for (const meal of userMeals) {
|
||||
summary.totalCalories += meal.calories || 0;
|
||||
summary.totalProtein += meal.protein || 0;
|
||||
summary.totalCarbohydrates += meal.carbohydrates || 0;
|
||||
summary.totalFat += meal.fat || 0;
|
||||
summary.totalFiber += meal.fiber || 0;
|
||||
summary.totalSugar += meal.sugar || 0;
|
||||
summary.totalSodium += meal.sodium || 0;
|
||||
}
|
||||
for (const meal of userMeals) {
|
||||
summary.totalCalories += meal.calories || 0;
|
||||
summary.totalProtein += meal.protein || 0;
|
||||
summary.totalCarbohydrates += meal.carbohydrates || 0;
|
||||
summary.totalFat += meal.fat || 0;
|
||||
summary.totalFiber += meal.fiber || 0;
|
||||
summary.totalSugar += meal.sugar || 0;
|
||||
summary.totalSodium += meal.sodium || 0;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { StorageService } from './storage.service';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
|
|
|||
|
|
@ -1,166 +1,160 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
key: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly publicUrl: string;
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly publicUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// Hetzner Object Storage (S3-compatible)
|
||||
const endpoint = this.configService.get<string>('S3_ENDPOINT');
|
||||
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
|
||||
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
|
||||
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
|
||||
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
|
||||
constructor(private configService: ConfigService) {
|
||||
// Hetzner Object Storage (S3-compatible)
|
||||
const endpoint = this.configService.get<string>('S3_ENDPOINT');
|
||||
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
|
||||
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
|
||||
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
|
||||
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
|
||||
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn('S3 configuration incomplete - storage features disabled');
|
||||
this.s3Client = null as unknown as S3Client;
|
||||
return;
|
||||
}
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn('S3 configuration incomplete - storage features disabled');
|
||||
this.s3Client = null as unknown as S3Client;
|
||||
return;
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for Hetzner Object Storage
|
||||
});
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for Hetzner Object Storage
|
||||
});
|
||||
|
||||
this.logger.log('Hetzner Object Storage initialized successfully');
|
||||
}
|
||||
this.logger.log('Hetzner Object Storage initialized successfully');
|
||||
}
|
||||
|
||||
private isConfigured(): boolean {
|
||||
return this.s3Client !== null;
|
||||
}
|
||||
private isConfigured(): boolean {
|
||||
return this.s3Client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to R2 storage
|
||||
* @param buffer - File buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async upload(
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
folder = 'meals',
|
||||
): Promise<UploadResult> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Upload a file to R2 storage
|
||||
* @param buffer - File buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async upload(buffer: Buffer, contentType: string, folder = 'meals'): Promise<UploadResult> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
const extension = this.getExtensionFromContentType(contentType);
|
||||
const key = `${folder}/${randomUUID()}${extension}`;
|
||||
const extension = this.getExtensionFromContentType(contentType);
|
||||
const key = `${folder}/${randomUUID()}${extension}`;
|
||||
|
||||
this.logger.log(`Uploading file to R2: ${key}`);
|
||||
this.logger.log(`Uploading file to R2: ${key}`);
|
||||
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
const url = this.publicUrl
|
||||
? `${this.publicUrl}/${key}`
|
||||
: await this.getSignedUrl(key);
|
||||
const url = this.publicUrl ? `${this.publicUrl}/${key}` : await this.getSignedUrl(key);
|
||||
|
||||
return { key, url };
|
||||
}
|
||||
return { key, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a base64-encoded image
|
||||
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
|
||||
* @param folder - Optional folder path
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
|
||||
let data = base64Data;
|
||||
let contentType = 'image/jpeg';
|
||||
/**
|
||||
* Upload a base64-encoded image
|
||||
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
|
||||
* @param folder - Optional folder path
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
|
||||
let data = base64Data;
|
||||
let contentType = 'image/jpeg';
|
||||
|
||||
// Extract content type from data URI if present
|
||||
if (data.includes(',')) {
|
||||
const matches = data.match(/^data:(.+);base64,/);
|
||||
if (matches) {
|
||||
contentType = matches[1];
|
||||
data = data.split(',')[1];
|
||||
}
|
||||
}
|
||||
// Extract content type from data URI if present
|
||||
if (data.includes(',')) {
|
||||
const matches = data.match(/^data:(.+);base64,/);
|
||||
if (matches) {
|
||||
contentType = matches[1];
|
||||
data = data.split(',')[1];
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
return this.upload(buffer, contentType, folder);
|
||||
}
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
return this.upload(buffer, contentType, folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from R2 storage
|
||||
* @param key - File key/path in the bucket
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Delete a file from R2 storage
|
||||
* @param key - File key/path in the bucket
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
this.logger.log(`Deleting file from R2: ${key}`);
|
||||
this.logger.log(`Deleting file from R2: ${key}`);
|
||||
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL for temporary access to a file
|
||||
* @param key - File key/path in the bucket
|
||||
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
|
||||
* @returns Signed URL
|
||||
*/
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Get a signed URL for temporary access to a file
|
||||
* @param key - File key/path in the bucket
|
||||
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
|
||||
* @returns Signed URL
|
||||
*/
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
}
|
||||
return getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
private getExtensionFromContentType(contentType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/heic': '.heic',
|
||||
'image/heif': '.heif',
|
||||
};
|
||||
return mapping[contentType] || '.jpg';
|
||||
}
|
||||
private getExtensionFromContentType(contentType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/heic': '.heic',
|
||||
'image/heif': '.heif',
|
||||
};
|
||||
return mapping[contentType] || '.jpg';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,153 @@
|
|||
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Local meal data from mobile app
|
||||
*/
|
||||
export class LocalMealDto {
|
||||
@IsNumber()
|
||||
localId: number;
|
||||
@IsNumber()
|
||||
localId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cloudId?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cloudId?: string;
|
||||
|
||||
@IsString()
|
||||
foodName: string;
|
||||
@IsString()
|
||||
foodName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
analysisStatus?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
analysisStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
healthScore?: number;
|
||||
@IsOptional()
|
||||
healthScore?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
healthCategory?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
healthCategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
userRating?: number;
|
||||
@IsOptional()
|
||||
userRating?: number;
|
||||
|
||||
@IsOptional()
|
||||
foodItems?: any[];
|
||||
@IsOptional()
|
||||
foodItems?: any[];
|
||||
|
||||
@IsNumber()
|
||||
version: number;
|
||||
@IsNumber()
|
||||
version: number;
|
||||
|
||||
@IsString()
|
||||
createdAt: string;
|
||||
@IsString()
|
||||
createdAt: string;
|
||||
|
||||
@IsString()
|
||||
updatedAt: string;
|
||||
@IsString()
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push request - local changes to server
|
||||
*/
|
||||
export class SyncPushDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LocalMealDto)
|
||||
meals: LocalMealDto[];
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LocalMealDto)
|
||||
meals: LocalMealDto[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
deletedIds: string[];
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
deletedIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastSyncAt?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastSyncAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push response
|
||||
*/
|
||||
export interface SyncPushResponse {
|
||||
created: { localId: number; cloudId: string }[];
|
||||
updated: string[];
|
||||
conflicts: ConflictInfo[];
|
||||
serverTime: string;
|
||||
created: { localId: number; cloudId: string }[];
|
||||
updated: string[];
|
||||
conflicts: ConflictInfo[];
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict information
|
||||
*/
|
||||
export interface ConflictInfo {
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull query parameters
|
||||
*/
|
||||
export class SyncPullQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
since?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
since?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull response
|
||||
*/
|
||||
export interface SyncPullResponse {
|
||||
meals: any[];
|
||||
deletedIds: string[];
|
||||
serverTime: string;
|
||||
meals: any[];
|
||||
deletedIds: string[];
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status response
|
||||
*/
|
||||
export interface SyncStatusResponse {
|
||||
lastSyncAt: string | null;
|
||||
pendingChanges: number;
|
||||
serverTime: string;
|
||||
lastSyncAt: string | null;
|
||||
pendingChanges: number;
|
||||
serverTime: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { SyncService } from './sync.service';
|
||||
import {
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullQueryDto,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullQueryDto,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
} from './dto/sync.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
|
@ -13,38 +13,38 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
|
|||
@Controller('sync')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
* POST /api/sync/push
|
||||
*/
|
||||
@Post('push')
|
||||
async pushChanges(
|
||||
@Body() dto: SyncPushDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SyncPushResponse> {
|
||||
return this.syncService.pushChanges(user.userId, dto);
|
||||
}
|
||||
/**
|
||||
* Push local changes to server
|
||||
* POST /api/sync/push
|
||||
*/
|
||||
@Post('push')
|
||||
async pushChanges(
|
||||
@Body() dto: SyncPushDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SyncPushResponse> {
|
||||
return this.syncService.pushChanges(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
|
||||
*/
|
||||
@Get('pull')
|
||||
async pullChanges(
|
||||
@Query() query: SyncPullQueryDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SyncPullResponse> {
|
||||
return this.syncService.pullChanges(user.userId, query.since);
|
||||
}
|
||||
/**
|
||||
* Pull changes from server
|
||||
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
|
||||
*/
|
||||
@Get('pull')
|
||||
async pullChanges(
|
||||
@Query() query: SyncPullQueryDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SyncPullResponse> {
|
||||
return this.syncService.pullChanges(user.userId, query.since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
* GET /api/sync/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
|
||||
return this.syncService.getStatus(user.userId);
|
||||
}
|
||||
/**
|
||||
* Get sync status
|
||||
* GET /api/sync/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
|
||||
return this.syncService.getStatus(user.userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { SyncController } from './sync.controller';
|
|||
import { SyncService } from './sync.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
exports: [SyncService],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
|
|
|
|||
|
|
@ -1,251 +1,246 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gt,
|
||||
type Meal as DbMeal,
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gt,
|
||||
type Meal as DbMeal,
|
||||
} from '@manacore/nutriphi-database';
|
||||
import { DATABASE_TOKEN } from '../database/database.module';
|
||||
import {
|
||||
LocalMealDto,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
ConflictInfo,
|
||||
LocalMealDto,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
ConflictInfo,
|
||||
} from './dto/sync.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
|
||||
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
|
||||
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
|
||||
|
||||
const created: { localId: number; cloudId: string }[] = [];
|
||||
const updated: string[] = [];
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
const serverTime = new Date().toISOString();
|
||||
const created: { localId: number; cloudId: string }[] = [];
|
||||
const updated: string[] = [];
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
// Process each meal
|
||||
for (const localMeal of dto.meals) {
|
||||
try {
|
||||
if (localMeal.cloudId) {
|
||||
// Update existing meal
|
||||
const result = await this.updateExistingMeal(userId, localMeal);
|
||||
if (result.conflict) {
|
||||
conflicts.push(result.conflict);
|
||||
} else if (result.updated) {
|
||||
updated.push(localMeal.cloudId);
|
||||
}
|
||||
} else {
|
||||
// Create new meal
|
||||
const cloudId = await this.createNewMeal(userId, localMeal);
|
||||
created.push({ localId: localMeal.localId, cloudId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
|
||||
}
|
||||
}
|
||||
// Process each meal
|
||||
for (const localMeal of dto.meals) {
|
||||
try {
|
||||
if (localMeal.cloudId) {
|
||||
// Update existing meal
|
||||
const result = await this.updateExistingMeal(userId, localMeal);
|
||||
if (result.conflict) {
|
||||
conflicts.push(result.conflict);
|
||||
} else if (result.updated) {
|
||||
updated.push(localMeal.cloudId);
|
||||
}
|
||||
} else {
|
||||
// Create new meal
|
||||
const cloudId = await this.createNewMeal(userId, localMeal);
|
||||
created.push({ localId: localMeal.localId, cloudId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for (const cloudId of dto.deletedIds) {
|
||||
try {
|
||||
await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
|
||||
this.logger.log(`Deleted meal: ${cloudId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting meal ${cloudId}:`, error);
|
||||
}
|
||||
}
|
||||
// Process deletions
|
||||
for (const cloudId of dto.deletedIds) {
|
||||
try {
|
||||
await this.db.delete(meals).where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
|
||||
this.logger.log(`Deleted meal: ${cloudId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting meal ${cloudId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, conflicts, serverTime };
|
||||
}
|
||||
return { created, updated, conflicts, serverTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server since given timestamp
|
||||
*/
|
||||
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
|
||||
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
|
||||
/**
|
||||
* Pull changes from server since given timestamp
|
||||
*/
|
||||
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
|
||||
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
|
||||
|
||||
const serverTime = new Date().toISOString();
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
let query;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
|
||||
} else {
|
||||
// Full sync - get all meals
|
||||
query = this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
}
|
||||
let query;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
|
||||
} else {
|
||||
// Full sync - get all meals
|
||||
query = this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
const results = await query;
|
||||
|
||||
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
|
||||
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
|
||||
|
||||
return {
|
||||
meals: mappedMeals,
|
||||
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
meals: mappedMeals,
|
||||
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
async getStatus(userId: string): Promise<SyncStatusResponse> {
|
||||
const serverTime = new Date().toISOString();
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
async getStatus(userId: string): Promise<SyncStatusResponse> {
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
// Count user's meals
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId));
|
||||
// Count user's meals
|
||||
const result = await this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
|
||||
return {
|
||||
lastSyncAt: null, // Could be stored in a user preferences table
|
||||
pendingChanges: 0,
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lastSyncAt: null, // Could be stored in a user preferences table
|
||||
pendingChanges: 0,
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meal from local data
|
||||
*/
|
||||
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus ?? 'completed',
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
createdAt: new Date(localMeal.createdAt),
|
||||
updatedAt: new Date(localMeal.updatedAt),
|
||||
})
|
||||
.returning();
|
||||
/**
|
||||
* Create a new meal from local data
|
||||
*/
|
||||
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus ?? 'completed',
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
createdAt: new Date(localMeal.createdAt),
|
||||
updatedAt: new Date(localMeal.updatedAt),
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
|
||||
return result.id;
|
||||
}
|
||||
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing meal, checking for conflicts
|
||||
*/
|
||||
private async updateExistingMeal(
|
||||
userId: string,
|
||||
localMeal: LocalMealDto,
|
||||
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
|
||||
// Get current server version
|
||||
const [serverMeal] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
|
||||
/**
|
||||
* Update existing meal, checking for conflicts
|
||||
*/
|
||||
private async updateExistingMeal(
|
||||
userId: string,
|
||||
localMeal: LocalMealDto
|
||||
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
|
||||
// Get current server version
|
||||
const [serverMeal] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
|
||||
|
||||
if (!serverMeal) {
|
||||
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
|
||||
return { updated: false };
|
||||
}
|
||||
if (!serverMeal) {
|
||||
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
// Simple last-write-wins strategy
|
||||
// In production, you might want more sophisticated conflict resolution
|
||||
const localUpdateTime = new Date(localMeal.updatedAt);
|
||||
const serverUpdateTime = serverMeal.updatedAt;
|
||||
// Simple last-write-wins strategy
|
||||
// In production, you might want more sophisticated conflict resolution
|
||||
const localUpdateTime = new Date(localMeal.updatedAt);
|
||||
const serverUpdateTime = serverMeal.updatedAt;
|
||||
|
||||
// If local is newer, update server
|
||||
if (localUpdateTime >= serverUpdateTime) {
|
||||
await this.db
|
||||
.update(meals)
|
||||
.set({
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus,
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meals.id, localMeal.cloudId!));
|
||||
// If local is newer, update server
|
||||
if (localUpdateTime >= serverUpdateTime) {
|
||||
await this.db
|
||||
.update(meals)
|
||||
.set({
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus,
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meals.id, localMeal.cloudId!));
|
||||
|
||||
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
|
||||
return { updated: true };
|
||||
}
|
||||
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
// Server is newer - report conflict
|
||||
return {
|
||||
updated: false,
|
||||
conflict: {
|
||||
cloudId: localMeal.cloudId!,
|
||||
localVersion: localMeal.version,
|
||||
serverVersion: 1, // Would need version tracking in DB
|
||||
serverData: this.mapDbMealToSync(serverMeal),
|
||||
message: 'Server has newer data',
|
||||
},
|
||||
};
|
||||
}
|
||||
// Server is newer - report conflict
|
||||
return {
|
||||
updated: false,
|
||||
conflict: {
|
||||
cloudId: localMeal.cloudId!,
|
||||
localVersion: localMeal.version,
|
||||
serverVersion: 1, // Would need version tracking in DB
|
||||
serverData: this.mapDbMealToSync(serverMeal),
|
||||
message: 'Server has newer data',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database meal to sync format
|
||||
*/
|
||||
private mapDbMealToSync(meal: DbMeal): any {
|
||||
return {
|
||||
cloudId: meal.id,
|
||||
userId: meal.userId,
|
||||
foodName: meal.foodName,
|
||||
imageUrl: meal.imageUrl,
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
sodium: meal.sodium,
|
||||
servingSize: meal.servingSize,
|
||||
mealType: meal.mealType,
|
||||
analysisStatus: meal.analysisStatus,
|
||||
healthScore: meal.healthScore,
|
||||
healthCategory: meal.healthCategory,
|
||||
notes: meal.notes,
|
||||
userRating: meal.userRating,
|
||||
foodItems: meal.foodItems,
|
||||
createdAt: meal.createdAt.toISOString(),
|
||||
updatedAt: meal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Map database meal to sync format
|
||||
*/
|
||||
private mapDbMealToSync(meal: DbMeal): any {
|
||||
return {
|
||||
cloudId: meal.id,
|
||||
userId: meal.userId,
|
||||
foodName: meal.foodName,
|
||||
imageUrl: meal.imageUrl,
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
sodium: meal.sodium,
|
||||
servingSize: meal.servingSize,
|
||||
mealType: meal.mealType,
|
||||
analysisStatus: meal.analysisStatus,
|
||||
healthScore: meal.healthScore,
|
||||
healthCategory: meal.healthCategory,
|
||||
notes: meal.notes,
|
||||
userRating: meal.userRating,
|
||||
foodItems: meal.foodItems,
|
||||
createdAt: meal.createdAt.toISOString(),
|
||||
updatedAt: meal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"name": "@nutriphi/landing",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
"name": "@nutriphi/landing",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,82 @@
|
|||
---
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/imprint', label: 'Impressum' }
|
||||
]
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/imprint', label: 'Impressum' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-background-card border-t border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten
|
||||
und erhalte sofort detaillierte Nährwertinformationen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten und erhalte sofort
|
||||
detaillierte Nährwertinformationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.product.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.product.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.legal.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.legal.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Made with 💚 in Germany
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💚 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,89 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: 'So funktioniert\'s' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: "So funktioniert's" },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
];
|
||||
---
|
||||
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="#download"
|
||||
class="btn-primary text-sm px-4 py-2"
|
||||
>
|
||||
App herunterladen
|
||||
</a>
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,46 +2,49 @@
|
|||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse'
|
||||
title,
|
||||
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,274 +14,300 @@ import Card from '@manacore/shared-landing-ui/atoms/Card.astro';
|
|||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Foto-Analyse',
|
||||
description: 'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.'
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Google Gemini KI',
|
||||
description: 'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.'
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Tagesbilanz',
|
||||
description: 'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.'
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Persönliche Ziele',
|
||||
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.'
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Plattformübergreifend',
|
||||
description: 'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
|
||||
},
|
||||
{
|
||||
icon: '💡',
|
||||
title: 'Gesundheitstipps',
|
||||
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.'
|
||||
}
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Foto-Analyse',
|
||||
description:
|
||||
'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Google Gemini KI',
|
||||
description:
|
||||
'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Tagesbilanz',
|
||||
description:
|
||||
'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Persönliche Ziele',
|
||||
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Plattformübergreifend',
|
||||
description:
|
||||
'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.',
|
||||
},
|
||||
{
|
||||
icon: '💡',
|
||||
title: 'Gesundheitstipps',
|
||||
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Mahlzeit fotografieren',
|
||||
description: 'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
|
||||
image: '/screenshots/photo.png'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI analysiert',
|
||||
description: 'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
|
||||
image: '/screenshots/analyze.png'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Fortschritt verfolgen',
|
||||
description: 'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
|
||||
image: '/screenshots/track.png'
|
||||
}
|
||||
{
|
||||
number: '1',
|
||||
title: 'Mahlzeit fotografieren',
|
||||
description:
|
||||
'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
|
||||
image: '/screenshots/photo.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI analysiert',
|
||||
description:
|
||||
'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
|
||||
image: '/screenshots/analyze.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Fortschritt verfolgen',
|
||||
description:
|
||||
'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
|
||||
image: '/screenshots/track.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Foto-Analysen/Tag', included: true },
|
||||
{ text: 'Basis-Nährwertdaten', included: true },
|
||||
{ text: 'Tagesübersicht', included: true },
|
||||
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
|
||||
{ text: 'Unbegrenzte Analysen', included: false },
|
||||
{ text: 'Erweiterte Statistiken', included: false }
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '6,99',
|
||||
period: '/Monat',
|
||||
description: 'Für ernsthafte Tracker',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Foto-Analysen', included: true },
|
||||
{ text: 'Detaillierte Mikronährstoffe', included: true },
|
||||
{ text: 'Wochen- & Monatsstatistiken', included: true },
|
||||
{ text: 'Unbegrenzte Historie', included: true },
|
||||
{ text: 'Export als CSV/PDF', included: true },
|
||||
{ text: 'Prioritäts-Analyse', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download'
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt'
|
||||
},
|
||||
{
|
||||
name: 'Family',
|
||||
price: '12,99',
|
||||
period: '/Monat',
|
||||
description: 'Für die ganze Familie',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Bis zu 5 Profile', included: true },
|
||||
{ text: 'Familien-Dashboard', included: true },
|
||||
{ text: 'Gemeinsame Mahlzeiten', included: true },
|
||||
{ text: 'Kinder-Modus', included: true },
|
||||
{ text: 'Premium-Support', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Family starten',
|
||||
href: '#download'
|
||||
}
|
||||
}
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Foto-Analysen/Tag', included: true },
|
||||
{ text: 'Basis-Nährwertdaten', included: true },
|
||||
{ text: 'Tagesübersicht', included: true },
|
||||
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
|
||||
{ text: 'Unbegrenzte Analysen', included: false },
|
||||
{ text: 'Erweiterte Statistiken', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '6,99',
|
||||
period: '/Monat',
|
||||
description: 'Für ernsthafte Tracker',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Foto-Analysen', included: true },
|
||||
{ text: 'Detaillierte Mikronährstoffe', included: true },
|
||||
{ text: 'Wochen- & Monatsstatistiken', included: true },
|
||||
{ text: 'Unbegrenzte Historie', included: true },
|
||||
{ text: 'Export als CSV/PDF', included: true },
|
||||
{ text: 'Prioritäts-Analyse', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download',
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt',
|
||||
},
|
||||
{
|
||||
name: 'Family',
|
||||
price: '12,99',
|
||||
period: '/Monat',
|
||||
description: 'Für die ganze Familie',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Bis zu 5 Profile', included: true },
|
||||
{ text: 'Familien-Dashboard', included: true },
|
||||
{ text: 'Gemeinsame Mahlzeiten', included: true },
|
||||
{ text: 'Kinder-Modus', included: true },
|
||||
{ text: 'Premium-Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Family starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie genau ist die KI-Analyse?',
|
||||
answer: 'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.'
|
||||
},
|
||||
{
|
||||
question: 'Welche Nährwerte werden analysiert?',
|
||||
answer: 'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.'
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert die App auch offline?',
|
||||
answer: 'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
|
||||
answer: 'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.'
|
||||
},
|
||||
{
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer: 'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.'
|
||||
}
|
||||
{
|
||||
question: 'Wie genau ist die KI-Analyse?',
|
||||
answer:
|
||||
'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.',
|
||||
},
|
||||
{
|
||||
question: 'Welche Nährwerte werden analysiert?',
|
||||
answer:
|
||||
'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.',
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert die App auch offline?',
|
||||
answer:
|
||||
'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
|
||||
answer:
|
||||
'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.',
|
||||
},
|
||||
{
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer:
|
||||
'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer:
|
||||
'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="Nutriphi - KI-gestützter Ernährungs-Tracker">
|
||||
<Navigation />
|
||||
<Navigation />
|
||||
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Ernährung tracken war nie einfacher"
|
||||
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download'
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary'
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '📸', text: 'Foto-Analyse' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS, Android & Web' }
|
||||
]}
|
||||
/>
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Ernährung tracken war nie einfacher"
|
||||
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download',
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary',
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '📸', text: 'Foto-Analyse' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS, Android & Web' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles für dein Ernährungstracking"
|
||||
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
>
|
||||
<!-- AI Technology Highlight -->
|
||||
<div class="mt-12 md:mt-16 px-4" slot="highlight">
|
||||
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
<div class="text-5xl sm:text-6xl">🧠</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h3 class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3">
|
||||
Powered by Google Gemini
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
|
||||
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu analysieren.
|
||||
Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte mit hoher Genauigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm sm:text-base">AI</span>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">85-95%</div>
|
||||
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles für dein Ernährungstracking"
|
||||
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
>
|
||||
<!-- AI Technology Highlight -->
|
||||
<div class="mt-12 md:mt-16 px-4" slot="highlight">
|
||||
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
<div class="text-5xl sm:text-6xl">🧠</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h3
|
||||
class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3"
|
||||
>
|
||||
Powered by Google Gemini
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
|
||||
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu
|
||||
analysieren. Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte
|
||||
mit hoher Genauigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white font-bold text-sm sm:text-base">AI</span>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">
|
||||
85-95%
|
||||
</div>
|
||||
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum Ziel"
|
||||
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum Ziel"
|
||||
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über Nutriphi wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über Nutriphi wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Starte deine gesunde Ernährungsreise"
|
||||
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Starte deine gesunde Ernährungsreise"
|
||||
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,74 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "nutriphi",
|
||||
"slug": "nutriphi",
|
||||
"version": "1.0.0",
|
||||
"scheme": "nutriphi",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.nutriphi",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.nutriphi",
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
|
||||
}
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "nutriphi",
|
||||
"slug": "nutriphi",
|
||||
"version": "1.0.0",
|
||||
"scheme": "nutriphi",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.nutriphi",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.nutriphi",
|
||||
"permissions": ["android.permission.CAMERA", "android.permission.RECORD_AUDIO"]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,41 +5,42 @@ import { useAppStore } from '../../store/AppStore';
|
|||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { showCameraModal, cameraMode } = useAppStore();
|
||||
const { isDark } = useTheme();
|
||||
const { showCameraModal, cameraMode } = useAppStore();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Meals',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Meals',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
|
|||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import { Link, Stack } from 'expo-router';
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,56 +8,56 @@ import { useEffect } from 'react';
|
|||
import { PhotoService } from '../services/storage/PhotoService';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { isReady, error } = useDatabase();
|
||||
const { isReady, error } = useDatabase();
|
||||
|
||||
// Initialize theme on app start
|
||||
useTheme();
|
||||
// Initialize theme on app start
|
||||
useTheme();
|
||||
|
||||
// Clean up temporary photos when app comes to foreground
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = async (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
try {
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up on app foreground');
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos on foreground:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Clean up temporary photos when app comes to foreground
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = async (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
try {
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up on app foreground');
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos on foreground:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
{error ? (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
|
||||
<Text className="px-4 text-center text-gray-600">{error}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
<Text className="text-gray-600">Initializing Nutriphi...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
{error ? (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
|
||||
<Text className="px-4 text-center text-gray-600">{error}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
<Text className="text-gray-600">Initializing Nutriphi...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,45 +8,45 @@ import { MealWithItems } from '../types/Database';
|
|||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export default function Home() {
|
||||
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
|
||||
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
|
||||
|
||||
const handleMealPress = (meal: MealWithItems) => {
|
||||
router.push(`/meal/${meal.id}`);
|
||||
};
|
||||
const handleMealPress = (meal: MealWithItems) => {
|
||||
router.push(`/meal/${meal.id}`);
|
||||
};
|
||||
|
||||
const handleCameraPress = () => {
|
||||
toggleCameraModal(true, 'camera');
|
||||
};
|
||||
const handleCameraPress = () => {
|
||||
toggleCameraModal(true, 'camera');
|
||||
};
|
||||
|
||||
const handleGalleryPress = () => {
|
||||
toggleCameraModal(true, 'gallery');
|
||||
};
|
||||
const handleGalleryPress = () => {
|
||||
toggleCameraModal(true, 'gallery');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<MealList onMealPress={handleMealPress} />
|
||||
return (
|
||||
<>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<MealList onMealPress={handleMealPress} />
|
||||
|
||||
{/* Camera Button (larger, centered) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleCameraPress}
|
||||
sfSymbol="camera"
|
||||
fallbackIcon="camera"
|
||||
size="large"
|
||||
position="center"
|
||||
/>
|
||||
{/* Camera Button (larger, centered) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleCameraPress}
|
||||
sfSymbol="camera"
|
||||
fallbackIcon="camera"
|
||||
size="large"
|
||||
position="center"
|
||||
/>
|
||||
|
||||
{/* Gallery Button (smaller, right) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleGalleryPress}
|
||||
sfSymbol="photo"
|
||||
fallbackIcon="image"
|
||||
size="normal"
|
||||
position="right"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
{/* Gallery Button (smaller, right) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleGalleryPress}
|
||||
sfSymbol="photo"
|
||||
fallbackIcon="image"
|
||||
size="normal"
|
||||
position="right"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,245 +9,246 @@ import { FoodItemList } from '@/components/meals/FoodItemList';
|
|||
import { AnalysisStatusIndicator } from '@/components/meals/AnalysisStatusIndicator';
|
||||
|
||||
export default function MealDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { selectedMeal, loadMealById, isLoading } = useMealStore();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { selectedMeal, loadMealById, isLoading } = useMealStore();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMealById(parseInt(id));
|
||||
setImageError(false); // Reset image error state when loading new meal
|
||||
}
|
||||
}, [id, loadMealById]);
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMealById(parseInt(id));
|
||||
setImageError(false); // Reset image error state when loading new meal
|
||||
}
|
||||
}, [id, loadMealById]);
|
||||
|
||||
// Poll for updates if analysis is pending
|
||||
useEffect(() => {
|
||||
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
// Poll for updates if analysis is pending
|
||||
useEffect(() => {
|
||||
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMealById(selectedMeal.id);
|
||||
}, 2000);
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMealById(selectedMeal.id);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
|
||||
|
||||
// Add debug logging when component renders
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'Meal detail component rendered with selectedMeal:',
|
||||
selectedMeal?.id,
|
||||
'photo_path:',
|
||||
selectedMeal?.photo_path
|
||||
);
|
||||
}, [selectedMeal]);
|
||||
// Add debug logging when component renders
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'Meal detail component rendered with selectedMeal:',
|
||||
selectedMeal?.id,
|
||||
'photo_path:',
|
||||
selectedMeal?.photo_path
|
||||
);
|
||||
}, [selectedMeal]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedMeal) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!selectedMeal) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const generateMealTitle = (meal: any): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item: any) => item.name);
|
||||
const generateMealTitle = (meal: any): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item: any) => item.name);
|
||||
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
|
||||
}
|
||||
}
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to meal type if no food items
|
||||
return getMealTypeLabel(meal.meal_type);
|
||||
};
|
||||
// Fallback to meal type if no food items
|
||||
return getMealTypeLabel(meal.meal_type);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const formatDate = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'sunny-outline';
|
||||
case 'lunch':
|
||||
return 'restaurant-outline';
|
||||
case 'dinner':
|
||||
return 'moon-outline';
|
||||
case 'snack':
|
||||
return 'cafe-outline';
|
||||
default:
|
||||
return 'restaurant-outline';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'sunny-outline';
|
||||
case 'lunch':
|
||||
return 'restaurant-outline';
|
||||
case 'dinner':
|
||||
return 'moon-outline';
|
||||
case 'snack':
|
||||
return 'cafe-outline';
|
||||
default:
|
||||
return 'restaurant-outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeLabel = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'Frühstück';
|
||||
case 'lunch':
|
||||
return 'Mittagessen';
|
||||
case 'dinner':
|
||||
return 'Abendessen';
|
||||
case 'snack':
|
||||
return 'Snack';
|
||||
default:
|
||||
return 'Mahlzeit';
|
||||
}
|
||||
};
|
||||
const getMealTypeLabel = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'Frühstück';
|
||||
case 'lunch':
|
||||
return 'Mittagessen';
|
||||
case 'dinner':
|
||||
return 'Abendessen';
|
||||
case 'snack':
|
||||
return 'Snack';
|
||||
default:
|
||||
return 'Mahlzeit';
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
const renderStars = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Ionicons
|
||||
key={star}
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={20}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Ionicons
|
||||
key={star}
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={20}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="relative">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2">
|
||||
<Ionicons name="arrow-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="relative">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2"
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Photo */}
|
||||
<View className="h-80 bg-gray-200">
|
||||
{selectedMeal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: selectedMeal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('Detail page image loading error:', error);
|
||||
console.log('Detail page photo_path:', selectedMeal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
|
||||
<Text className="mt-2 text-sm text-gray-500">
|
||||
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* Photo */}
|
||||
<View className="h-80 bg-gray-200">
|
||||
{selectedMeal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: selectedMeal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('Detail page image loading error:', error);
|
||||
console.log('Detail page photo_path:', selectedMeal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
|
||||
<Text className="mt-2 text-sm text-gray-500">
|
||||
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="p-4">
|
||||
{/* Meal Title and Rating */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
|
||||
{generateMealTitle(selectedMeal)}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedMeal.user_rating && (
|
||||
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Content */}
|
||||
<View className="p-4">
|
||||
{/* Meal Title and Rating */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
|
||||
{generateMealTitle(selectedMeal)}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedMeal.user_rating && (
|
||||
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Meal Type and Date */}
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons
|
||||
name={getMealTypeIcon(selectedMeal.meal_type)}
|
||||
size={20}
|
||||
color="#6b7280"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text className="text-base text-gray-600">
|
||||
{getMealTypeLabel(selectedMeal.meal_type)}
|
||||
</Text>
|
||||
<Text className="mx-2 text-gray-400">•</Text>
|
||||
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Meal Type and Date */}
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons
|
||||
name={getMealTypeIcon(selectedMeal.meal_type)}
|
||||
size={20}
|
||||
color="#6b7280"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text className="text-base text-gray-600">
|
||||
{getMealTypeLabel(selectedMeal.meal_type)}
|
||||
</Text>
|
||||
<Text className="mx-2 text-gray-400">•</Text>
|
||||
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Location */}
|
||||
{selectedMeal.location && (
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Location */}
|
||||
{selectedMeal.location && (
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Nutrition Overview */}
|
||||
{selectedMeal.analysis_status === 'completed' && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
|
||||
<NutritionBar meal={selectedMeal} showDetailed={true} />
|
||||
</View>
|
||||
)}
|
||||
{/* Nutrition Overview */}
|
||||
{selectedMeal.analysis_status === 'completed' && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
|
||||
<NutritionBar meal={selectedMeal} showDetailed={true} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Status */}
|
||||
<View className="mb-6">
|
||||
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
|
||||
</View>
|
||||
{/* Analysis Status */}
|
||||
<View className="mb-6">
|
||||
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
|
||||
</View>
|
||||
|
||||
{/* Food Items */}
|
||||
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<FoodItemList foodItems={selectedMeal.food_items} />
|
||||
</View>
|
||||
)}
|
||||
{/* Food Items */}
|
||||
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<FoodItemList foodItems={selectedMeal.food_items} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* User Notes */}
|
||||
{selectedMeal.user_notes && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
|
||||
<View className="rounded-lg bg-blue-50 p-3">
|
||||
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* User Notes */}
|
||||
{selectedMeal.user_notes && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
|
||||
<View className="rounded-lg bg-blue-50 p-3">
|
||||
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Confidence */}
|
||||
{selectedMeal.analysis_confidence && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm text-gray-600">
|
||||
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
{/* Analysis Confidence */}
|
||||
{selectedMeal.analysis_confidence && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm text-gray-600">
|
||||
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { Platform } from 'react-native';
|
|||
import { ScreenContent } from '~/components/ScreenContent';
|
||||
|
||||
export default function Modal() {
|
||||
return (
|
||||
<>
|
||||
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,288 +9,294 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
|
|||
import LoadingOverlay from '../components/ui/LoadingOverlay';
|
||||
|
||||
export default function Settings() {
|
||||
const { theme, updateTheme } = useTheme();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [locationEnabled, setLocationEnabled] = useState(true);
|
||||
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
|
||||
const { theme, updateTheme } = useTheme();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [locationEnabled, setLocationEnabled] = useState(true);
|
||||
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'system', label: 'System', icon: '📱' },
|
||||
];
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'system', label: 'System', icon: '📱' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const prefs = await prefsService.getPreferences();
|
||||
setLocationEnabled(prefs.locationEnabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
} finally {
|
||||
setIsLoadingPrefs(false);
|
||||
}
|
||||
};
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const prefs = await prefsService.getPreferences();
|
||||
setLocationEnabled(prefs.locationEnabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
} finally {
|
||||
setIsLoadingPrefs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
|
||||
updateTheme(selectedTheme);
|
||||
};
|
||||
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
|
||||
updateTheme(selectedTheme);
|
||||
};
|
||||
|
||||
const handleLocationToggle = async (value: boolean) => {
|
||||
setLocationEnabled(value);
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.setLocationEnabled(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to update location preference:', error);
|
||||
// Revert on error
|
||||
setLocationEnabled(!value);
|
||||
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
|
||||
}
|
||||
};
|
||||
const handleLocationToggle = async (value: boolean) => {
|
||||
setLocationEnabled(value);
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.setLocationEnabled(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to update location preference:', error);
|
||||
// Revert on error
|
||||
setLocationEnabled(!value);
|
||||
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
|
||||
}
|
||||
};
|
||||
|
||||
const openAppSettings = () => {
|
||||
Linking.openSettings();
|
||||
};
|
||||
const openAppSettings = () => {
|
||||
Linking.openSettings();
|
||||
};
|
||||
|
||||
const handleDeleteAllData = () => {
|
||||
Alert.alert(
|
||||
'Alle Daten löschen',
|
||||
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Alles löschen',
|
||||
style: 'destructive',
|
||||
onPress: confirmDeleteAllData,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
const handleDeleteAllData = () => {
|
||||
Alert.alert(
|
||||
'Alle Daten löschen',
|
||||
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Alles löschen',
|
||||
style: 'destructive',
|
||||
onPress: confirmDeleteAllData,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDeleteAllData = async () => {
|
||||
setIsClearing(true);
|
||||
const confirmDeleteAllData = async () => {
|
||||
setIsClearing(true);
|
||||
|
||||
try {
|
||||
const dataClearingService = DataClearingService.getInstance();
|
||||
const result = await dataClearingService.clearAllData();
|
||||
try {
|
||||
const dataClearingService = DataClearingService.getInstance();
|
||||
const result = await dataClearingService.clearAllData();
|
||||
|
||||
if (result.success) {
|
||||
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/(tabs)'),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Teilweise erfolgreich',
|
||||
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
|
||||
{ text: 'OK' },
|
||||
]);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
if (result.success) {
|
||||
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/(tabs)'),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Teilweise erfolgreich',
|
||||
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
|
||||
{ text: 'OK' },
|
||||
]);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Settings',
|
||||
headerShown: true,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2">
|
||||
<Text className="text-lg">←</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Settings',
|
||||
headerShown: true,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2">
|
||||
<Text className="text-lg">←</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<ScrollView className="flex-1">
|
||||
{/* App Info Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
App Info
|
||||
</Text>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<ScrollView className="flex-1">
|
||||
{/* App Info Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
App Info
|
||||
</Text>
|
||||
|
||||
<View className="space-y-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
|
||||
</View>
|
||||
<View className="space-y-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
|
||||
</View>
|
||||
|
||||
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Track your nutrition with AI-powered meal analysis
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Track your nutrition with AI-powered meal analysis
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Theme Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Appearance
|
||||
</Text>
|
||||
{/* Theme Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Appearance
|
||||
</Text>
|
||||
|
||||
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
|
||||
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
|
||||
|
||||
<View className="space-y-2">
|
||||
{themeOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
theme === option.value
|
||||
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-3 text-lg">{option.icon}</Text>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
theme === option.value
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="space-y-2">
|
||||
{themeOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
theme === option.value
|
||||
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-3 text-lg">{option.icon}</Text>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
theme === option.value
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{theme === option.value && (
|
||||
<Text className="text-lg text-indigo-500 dark:text-indigo-400">✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{theme === option.value && (
|
||||
<Text className="text-lg text-indigo-500 dark:text-indigo-400">✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Privacy & Location Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Privatsphäre & Standort
|
||||
</Text>
|
||||
{/* Privacy & Location Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Privatsphäre & Standort
|
||||
</Text>
|
||||
|
||||
{/* Location Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
Standort speichern
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={locationEnabled}
|
||||
onValueChange={handleLocationToggle}
|
||||
disabled={isLoadingPrefs}
|
||||
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
|
||||
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
|
||||
ios_backgroundColor="#d1d5db"
|
||||
/>
|
||||
</View>
|
||||
{/* Location Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
Standort speichern
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={locationEnabled}
|
||||
onValueChange={handleLocationToggle}
|
||||
disabled={isLoadingPrefs}
|
||||
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
|
||||
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
|
||||
ios_backgroundColor="#d1d5db"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* App Settings Link */}
|
||||
<TouchableOpacity
|
||||
onPress={openAppSettings}
|
||||
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-white">
|
||||
App-Berechtigungen
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Verwalte Kamera- und Standortberechtigungen
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* App Settings Link */}
|
||||
<TouchableOpacity
|
||||
onPress={openAppSettings}
|
||||
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-white">
|
||||
App-Berechtigungen
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Verwalte Kamera- und Standortberechtigungen
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Datenverwaltung
|
||||
</Text>
|
||||
{/* Data Management Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Datenverwaltung
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDeleteAllData}
|
||||
disabled={isClearing}
|
||||
className={`rounded-lg p-4 ${
|
||||
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
|
||||
}`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
Alle Daten löschen
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-sm ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
Löscht alle Mahlzeiten, Fotos und Einstellungen
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleDeleteAllData}
|
||||
disabled={isClearing}
|
||||
className={`rounded-lg p-4 ${
|
||||
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
Alle Daten löschen
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-sm ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
Löscht alle Mahlzeiten, Fotos und Einstellungen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isClearing ? (
|
||||
<Text className="ml-3 text-gray-400 dark:text-gray-500">⏳</Text>
|
||||
) : (
|
||||
<Text className="ml-3 text-red-500 dark:text-red-400">🗑️</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isClearing ? (
|
||||
<Text className="ml-3 text-gray-400 dark:text-gray-500">⏳</Text>
|
||||
) : (
|
||||
<Text className="ml-3 text-red-500 dark:text-red-400">🗑️</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Diese Aktion kann nicht rückgängig gemacht werden
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Diese Aktion kann nicht rückgängig gemacht werden
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="mx-4 mb-4 mt-8">
|
||||
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Made with ❤️ for better nutrition tracking
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{/* Footer */}
|
||||
<View className="mx-4 mb-4 mt-8">
|
||||
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Made with ❤️ for better nutrition tracking
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
{
|
||||
"cesVersion": "2.18.3",
|
||||
"projectName": "nutriphi",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "mana-core-auth",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
"cesVersion": "2.18.3",
|
||||
"projectName": "nutriphi",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "mana-core-auth",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,24 @@ import { forwardRef } from 'react';
|
|||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
title: string;
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}
|
||||
>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,31 +3,31 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|||
import { Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
|
||||
({ onPress }, ref) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color="gray"
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
({ onPress }, ref) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color="gray"
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HeaderButton.displayName = 'HeaderButton';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
|
|||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,25 +2,25 @@ import { StyleSheet } from 'react-native';
|
|||
import { SFSymbol } from './ui/SFSymbol';
|
||||
|
||||
interface TabBarIconProps {
|
||||
sfSymbol: string;
|
||||
fallbackIcon: string;
|
||||
color: string;
|
||||
sfSymbol: string;
|
||||
fallbackIcon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const TabBarIcon = ({ sfSymbol, fallbackIcon, color }: TabBarIconProps) => {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
color={color}
|
||||
size={24}
|
||||
style={styles.tabBarIcon}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
color={color}
|
||||
size={24}
|
||||
style={styles.tabBarIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,404 +17,407 @@ import { UserPreferencesService } from '../../services/UserPreferencesService';
|
|||
import { LocationPermissionModal } from '../location/LocationPermissionModal';
|
||||
|
||||
interface CameraModalProps {
|
||||
mode: 'camera' | 'gallery';
|
||||
mode: 'camera' | 'gallery';
|
||||
}
|
||||
|
||||
export const CameraModal: React.FC<CameraModalProps> = ({ mode }) => {
|
||||
const [capturedPhoto, setCapturedPhoto] = useState<{
|
||||
uri: string;
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: any;
|
||||
} | null>(null);
|
||||
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
|
||||
const [showLocationPermission, setShowLocationPermission] = useState(false);
|
||||
const [capturedPhoto, setCapturedPhoto] = useState<{
|
||||
uri: string;
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: any;
|
||||
} | null>(null);
|
||||
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
|
||||
const [showLocationPermission, setShowLocationPermission] = useState(false);
|
||||
|
||||
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
|
||||
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
|
||||
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
|
||||
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
|
||||
|
||||
const {
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
} = useCamera();
|
||||
const {
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
} = useCamera();
|
||||
|
||||
const handleClose = () => {
|
||||
setCapturedPhoto(null);
|
||||
setIsGalleryLoading(false);
|
||||
toggleCameraModal(false);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setCapturedPhoto(null);
|
||||
setIsGalleryLoading(false);
|
||||
toggleCameraModal(false);
|
||||
};
|
||||
|
||||
const handleTakePicture = async () => {
|
||||
try {
|
||||
const photo = await takePicture();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
const handleTakePicture = async () => {
|
||||
try {
|
||||
const photo = await takePicture();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetake = () => {
|
||||
setCapturedPhoto(null);
|
||||
};
|
||||
const handleRetake = () => {
|
||||
setCapturedPhoto(null);
|
||||
};
|
||||
|
||||
const handleLocationPermissionAllow = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationService = LocationService.getInstance();
|
||||
const handleLocationPermissionAllow = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// Mark that we've asked
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
// Mark that we've asked
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
|
||||
// Request permission
|
||||
const granted = await locationService.requestPermissions();
|
||||
// Request permission
|
||||
const granted = await locationService.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
await prefsService.setLocationEnabled(true);
|
||||
} else {
|
||||
await prefsService.setLocationEnabled(false);
|
||||
}
|
||||
if (granted) {
|
||||
await prefsService.setLocationEnabled(true);
|
||||
} else {
|
||||
await prefsService.setLocationEnabled(false);
|
||||
}
|
||||
|
||||
setShowLocationPermission(false);
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// Continue with photo processing
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
// Continue with photo processing
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocationPermissionDeny = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const handleLocationPermissionDeny = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
|
||||
// Mark that we've asked and user denied
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
await prefsService.setLocationEnabled(false);
|
||||
// Mark that we've asked and user denied
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
await prefsService.setLocationEnabled(false);
|
||||
|
||||
setShowLocationPermission(false);
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// Continue with photo processing without location
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
// Continue with photo processing without location
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-trigger gallery picker when mode is 'gallery'
|
||||
React.useEffect(() => {
|
||||
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
|
||||
const pickFromGallery = async () => {
|
||||
try {
|
||||
setIsGalleryLoading(true);
|
||||
const photo = await pickImageFromGallery();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
} else {
|
||||
// User cancelled, close modal
|
||||
toggleCameraModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
toggleCameraModal(false);
|
||||
} finally {
|
||||
setIsGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
pickFromGallery();
|
||||
}
|
||||
}, [
|
||||
showCameraModal,
|
||||
mode,
|
||||
capturedPhoto,
|
||||
isGalleryLoading,
|
||||
pickImageFromGallery,
|
||||
toggleCameraModal,
|
||||
]);
|
||||
// Auto-trigger gallery picker when mode is 'gallery'
|
||||
React.useEffect(() => {
|
||||
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
|
||||
const pickFromGallery = async () => {
|
||||
try {
|
||||
setIsGalleryLoading(true);
|
||||
const photo = await pickImageFromGallery();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
} else {
|
||||
// User cancelled, close modal
|
||||
toggleCameraModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
toggleCameraModal(false);
|
||||
} finally {
|
||||
setIsGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
pickFromGallery();
|
||||
}
|
||||
}, [
|
||||
showCameraModal,
|
||||
mode,
|
||||
capturedPhoto,
|
||||
isGalleryLoading,
|
||||
pickImageFromGallery,
|
||||
toggleCameraModal,
|
||||
]);
|
||||
|
||||
const handleUsePhoto = async () => {
|
||||
if (!capturedPhoto) return;
|
||||
const handleUsePhoto = async () => {
|
||||
if (!capturedPhoto) return;
|
||||
|
||||
try {
|
||||
setPhotoProcessing(true);
|
||||
try {
|
||||
setPhotoProcessing(true);
|
||||
|
||||
// Check location preferences and permissions
|
||||
let locationInfo: any = {};
|
||||
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationEnabled = await prefsService.isLocationEnabled();
|
||||
// Check location preferences and permissions
|
||||
let locationInfo: any = {};
|
||||
|
||||
if (locationEnabled) {
|
||||
const locationService = LocationService.getInstance();
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationEnabled = await prefsService.isLocationEnabled();
|
||||
|
||||
// Check if we need to ask for permission first time
|
||||
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
|
||||
if (!hasAskedBefore) {
|
||||
const hasPermission = await locationService.checkPermissions();
|
||||
if (!hasPermission) {
|
||||
// Show permission modal
|
||||
setShowLocationPermission(true);
|
||||
setPhotoProcessing(false);
|
||||
return; // Wait for user response
|
||||
}
|
||||
}
|
||||
if (locationEnabled) {
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// Get location
|
||||
try {
|
||||
const locationData = await locationService.getCurrentLocation();
|
||||
if (locationData && locationData.latitude && locationData.longitude) {
|
||||
locationInfo = {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
location_accuracy: locationData.accuracy,
|
||||
location: locationData.address
|
||||
? locationService.formatLocationForDisplay(locationData.address)
|
||||
: undefined,
|
||||
};
|
||||
console.log('Location captured:', locationInfo);
|
||||
}
|
||||
} catch (locationError) {
|
||||
console.warn('Failed to get location:', locationError);
|
||||
// Continue without location
|
||||
}
|
||||
}
|
||||
} catch (prefsError) {
|
||||
console.error('Failed to check location preferences:', prefsError);
|
||||
// Continue without location
|
||||
}
|
||||
// Check if we need to ask for permission first time
|
||||
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
|
||||
if (!hasAskedBefore) {
|
||||
const hasPermission = await locationService.checkPermissions();
|
||||
if (!hasPermission) {
|
||||
// Show permission modal
|
||||
setShowLocationPermission(true);
|
||||
setPhotoProcessing(false);
|
||||
return; // Wait for user response
|
||||
}
|
||||
}
|
||||
|
||||
// Create meal record with initial data including location
|
||||
const mealId = await createMeal({
|
||||
photo_path: capturedPhoto.path,
|
||||
photo_size: capturedPhoto.size,
|
||||
photo_dimensions: capturedPhoto.dimensions,
|
||||
meal_type: 'lunch', // Default, will be updated by AI
|
||||
analysis_status: 'pending',
|
||||
...locationInfo,
|
||||
});
|
||||
// Get location
|
||||
try {
|
||||
const locationData = await locationService.getCurrentLocation();
|
||||
if (locationData && locationData.latitude && locationData.longitude) {
|
||||
locationInfo = {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
location_accuracy: locationData.accuracy,
|
||||
location: locationData.address
|
||||
? locationService.formatLocationForDisplay(locationData.address)
|
||||
: undefined,
|
||||
};
|
||||
console.log('Location captured:', locationInfo);
|
||||
}
|
||||
} catch (locationError) {
|
||||
console.warn('Failed to get location:', locationError);
|
||||
// Continue without location
|
||||
}
|
||||
}
|
||||
} catch (prefsError) {
|
||||
console.error('Failed to check location preferences:', prefsError);
|
||||
// Continue without location
|
||||
}
|
||||
|
||||
console.log('Meal created with ID:', mealId);
|
||||
// Create meal record with initial data including location
|
||||
const mealId = await createMeal({
|
||||
photo_path: capturedPhoto.path,
|
||||
photo_size: capturedPhoto.size,
|
||||
photo_dimensions: capturedPhoto.dimensions,
|
||||
meal_type: 'lunch', // Default, will be updated by AI
|
||||
analysis_status: 'pending',
|
||||
...locationInfo,
|
||||
});
|
||||
|
||||
// Convert temporary photo to permanent storage
|
||||
const photoService = PhotoService.getInstance();
|
||||
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
|
||||
console.log('Meal created with ID:', mealId);
|
||||
|
||||
// Update meal record with permanent photo path
|
||||
await updateMeal(mealId, {
|
||||
photo_path: permanentPhoto.path,
|
||||
photo_size: permanentPhoto.size,
|
||||
photo_dimensions: permanentPhoto.dimensions,
|
||||
});
|
||||
// Convert temporary photo to permanent storage
|
||||
const photoService = PhotoService.getInstance();
|
||||
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
|
||||
|
||||
console.log('Photo converted to permanent storage:', permanentPhoto.path);
|
||||
// Update meal record with permanent photo path
|
||||
await updateMeal(mealId, {
|
||||
photo_path: permanentPhoto.path,
|
||||
photo_size: permanentPhoto.size,
|
||||
photo_dimensions: permanentPhoto.dimensions,
|
||||
});
|
||||
|
||||
// Close modal immediately, analysis will happen in background
|
||||
handleClose();
|
||||
console.log('Photo converted to permanent storage:', permanentPhoto.path);
|
||||
|
||||
// Start AI analysis in background
|
||||
try {
|
||||
console.log('Starting Gemini analysis...');
|
||||
const geminiService = GeminiService.getInstance();
|
||||
// Close modal immediately, analysis will happen in background
|
||||
handleClose();
|
||||
|
||||
// Get current time for meal type context
|
||||
const hour = new Date().getHours();
|
||||
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
|
||||
// Start AI analysis in background
|
||||
try {
|
||||
console.log('Starting Gemini analysis...');
|
||||
const geminiService = GeminiService.getInstance();
|
||||
|
||||
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
|
||||
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
|
||||
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
|
||||
else mealTypeContext = 'snack';
|
||||
// Get current time for meal type context
|
||||
const hour = new Date().getHours();
|
||||
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
|
||||
|
||||
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
|
||||
mealType: mealTypeContext,
|
||||
});
|
||||
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
|
||||
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
|
||||
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
|
||||
else mealTypeContext = 'snack';
|
||||
|
||||
console.log('Gemini analysis completed:', analysisResult);
|
||||
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
|
||||
mealType: mealTypeContext,
|
||||
});
|
||||
|
||||
// Update meal with AI analysis results
|
||||
await updateMeal(mealId, {
|
||||
// Aggregate nutrition data
|
||||
total_calories: analysisResult.meal_analysis.total_calories,
|
||||
total_protein: analysisResult.meal_analysis.total_protein,
|
||||
total_carbs: analysisResult.meal_analysis.total_carbs,
|
||||
total_fat: analysisResult.meal_analysis.total_fat,
|
||||
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
|
||||
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
|
||||
console.log('Gemini analysis completed:', analysisResult);
|
||||
|
||||
// Health assessment
|
||||
health_score: analysisResult.meal_analysis.health_score,
|
||||
health_category: analysisResult.meal_analysis.health_category,
|
||||
// Update meal with AI analysis results
|
||||
await updateMeal(mealId, {
|
||||
// Aggregate nutrition data
|
||||
total_calories: analysisResult.meal_analysis.total_calories,
|
||||
total_protein: analysisResult.meal_analysis.total_protein,
|
||||
total_carbs: analysisResult.meal_analysis.total_carbs,
|
||||
total_fat: analysisResult.meal_analysis.total_fat,
|
||||
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
|
||||
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
|
||||
|
||||
// AI metadata
|
||||
analysis_result: JSON.stringify(analysisResult),
|
||||
analysis_confidence: analysisResult.meal_analysis.confidence,
|
||||
analysis_status: 'completed',
|
||||
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
|
||||
// Health assessment
|
||||
health_score: analysisResult.meal_analysis.health_score,
|
||||
health_category: analysisResult.meal_analysis.health_category,
|
||||
|
||||
// API metadata
|
||||
api_provider: 'gemini',
|
||||
processing_time: analysisResult._metadata?.processingTime || 0,
|
||||
});
|
||||
// AI metadata
|
||||
analysis_result: JSON.stringify(analysisResult),
|
||||
analysis_confidence: analysisResult.meal_analysis.confidence,
|
||||
analysis_status: 'completed',
|
||||
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
|
||||
|
||||
// Create all food items in a single batch
|
||||
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
|
||||
meal_id: mealId,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
portion_size: item.portion_size,
|
||||
calories: item.calories,
|
||||
protein: item.protein,
|
||||
carbs: item.carbs,
|
||||
fat: item.fat,
|
||||
fiber: item.fiber || 0,
|
||||
sugar: item.sugar || 0,
|
||||
confidence: item.confidence,
|
||||
is_organic: item.is_organic ? 1 : 0,
|
||||
is_processed: item.is_processed ? 1 : 0,
|
||||
allergens: JSON.stringify(item.allergens || []),
|
||||
}));
|
||||
// API metadata
|
||||
api_provider: 'gemini',
|
||||
processing_time: analysisResult._metadata?.processingTime || 0,
|
||||
});
|
||||
|
||||
await createFoodItemsBatch(foodItemsToCreate);
|
||||
// Create all food items in a single batch
|
||||
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
|
||||
meal_id: mealId,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
portion_size: item.portion_size,
|
||||
calories: item.calories,
|
||||
protein: item.protein,
|
||||
carbs: item.carbs,
|
||||
fat: item.fat,
|
||||
fiber: item.fiber || 0,
|
||||
sugar: item.sugar || 0,
|
||||
confidence: item.confidence,
|
||||
is_organic: item.is_organic ? 1 : 0,
|
||||
is_processed: item.is_processed ? 1 : 0,
|
||||
allergens: JSON.stringify(item.allergens || []),
|
||||
}));
|
||||
|
||||
console.log('Meal analysis completed and saved to database');
|
||||
} catch (analysisError) {
|
||||
console.error('AI analysis failed:', analysisError);
|
||||
await createFoodItemsBatch(foodItemsToCreate);
|
||||
|
||||
// Update meal status to failed
|
||||
await updateMeal(mealId, {
|
||||
analysis_status: 'failed',
|
||||
analysis_result: JSON.stringify({
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save meal:', error);
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
setPhotoProcessing(false);
|
||||
}
|
||||
};
|
||||
console.log('Meal analysis completed and saved to database');
|
||||
} catch (analysisError) {
|
||||
console.error('AI analysis failed:', analysisError);
|
||||
|
||||
const renderPermissionRequest = () => (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">📷</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">
|
||||
Camera Permission Required
|
||||
</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
|
||||
</Text>
|
||||
// Update meal status to failed
|
||||
await updateMeal(mealId, {
|
||||
analysis_status: 'failed',
|
||||
analysis_result: JSON.stringify({
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save meal:', error);
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
setPhotoProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
{canAskPermission ? (
|
||||
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-center text-sm text-gray-300">
|
||||
Camera permission was denied. Please enable it in your device settings.
|
||||
</Text>
|
||||
<Button title="Close" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
const renderPermissionRequest = () => (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">📷</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">
|
||||
Camera Permission Required
|
||||
</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
|
||||
</Text>
|
||||
|
||||
const renderCamera = () => (
|
||||
<View className="flex-1">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing={facing}
|
||||
onCameraReady={() => setIsReady(true)}>
|
||||
{/* Header */}
|
||||
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
|
||||
<View className="flex-row items-center justify-between px-6 py-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
|
||||
<Text className="text-lg text-white">✕</Text>
|
||||
</TouchableOpacity>
|
||||
{canAskPermission ? (
|
||||
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-center text-sm text-gray-300">
|
||||
Camera permission was denied. Please enable it in your device settings.
|
||||
</Text>
|
||||
<Button title="Close" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
|
||||
const renderCamera = () => (
|
||||
<View className="flex-1">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing={facing}
|
||||
onCameraReady={() => setIsReady(true)}
|
||||
>
|
||||
{/* Header */}
|
||||
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
|
||||
<View className="flex-row items-center justify-between px-6 py-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<Text className="text-lg text-white">✕</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={toggleCameraFacing}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
|
||||
<Text className="text-lg text-white">🔄</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<SafeAreaView className="items-center pb-8">
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="px-8 text-center text-sm text-white">
|
||||
Position your food in the frame and tap the capture button
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={toggleCameraFacing}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<Text className="text-lg text-white">🔄</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<PhotoButton
|
||||
onPress={handleTakePicture}
|
||||
disabled={!isReady}
|
||||
isCapturing={isCapturing}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
{/* Bottom Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<SafeAreaView className="items-center pb-8">
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="px-8 text-center text-sm text-white">
|
||||
Position your food in the frame and tap the capture button
|
||||
</Text>
|
||||
|
||||
if (!showCameraModal) return null;
|
||||
<PhotoButton
|
||||
onPress={handleTakePicture}
|
||||
disabled={!isReady}
|
||||
isCapturing={isCapturing}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
|
||||
<StatusBar barStyle="light-content" backgroundColor="black" />
|
||||
if (!showCameraModal) return null;
|
||||
|
||||
{capturedPhoto ? (
|
||||
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
|
||||
) : mode === 'camera' ? (
|
||||
hasPermission ? (
|
||||
renderCamera()
|
||||
) : (
|
||||
renderPermissionRequest()
|
||||
)
|
||||
) : (
|
||||
// Gallery mode - show loading while picking or error state
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
{isGalleryLoading ? (
|
||||
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
|
||||
) : (
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">🖼️</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Please wait while we access your photo library...
|
||||
</Text>
|
||||
<Button title="Cancel" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
return (
|
||||
<>
|
||||
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
|
||||
<StatusBar barStyle="light-content" backgroundColor="black" />
|
||||
|
||||
<LocationPermissionModal
|
||||
visible={showLocationPermission}
|
||||
onAllow={handleLocationPermissionAllow}
|
||||
onDeny={handleLocationPermissionDeny}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
{capturedPhoto ? (
|
||||
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
|
||||
) : mode === 'camera' ? (
|
||||
hasPermission ? (
|
||||
renderCamera()
|
||||
) : (
|
||||
renderPermissionRequest()
|
||||
)
|
||||
) : (
|
||||
// Gallery mode - show loading while picking or error state
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
{isGalleryLoading ? (
|
||||
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
|
||||
) : (
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">🖼️</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Please wait while we access your photo library...
|
||||
</Text>
|
||||
<Button title="Cancel" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<LocationPermissionModal
|
||||
visible={showLocationPermission}
|
||||
onAllow={handleLocationPermissionAllow}
|
||||
onDeny={handleLocationPermissionDeny}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,75 +1,78 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface PhotoButtonProps {
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isCapturing?: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isCapturing?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const PhotoButton: React.FC<PhotoButtonProps> = ({
|
||||
onPress,
|
||||
disabled = false,
|
||||
isCapturing = false,
|
||||
onPress,
|
||||
disabled = false,
|
||||
isCapturing = false,
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
if (!disabled) {
|
||||
pressed.value = true;
|
||||
}
|
||||
};
|
||||
const handlePressIn = () => {
|
||||
if (!disabled) {
|
||||
pressed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || isCapturing}
|
||||
activeOpacity={0.8}
|
||||
style={animatedStyle}
|
||||
className="items-center justify-center">
|
||||
{/* Outer Ring */}
|
||||
<View
|
||||
className={`
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || isCapturing}
|
||||
activeOpacity={0.8}
|
||||
style={animatedStyle}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{/* Outer Ring */}
|
||||
<View
|
||||
className={`
|
||||
h-20 w-20 items-center justify-center rounded-full border-4
|
||||
${disabled || isCapturing ? 'border-gray-400' : 'border-white'}
|
||||
`}>
|
||||
{/* Inner Circle */}
|
||||
<View
|
||||
className={`
|
||||
`}
|
||||
>
|
||||
{/* Inner Circle */}
|
||||
<View
|
||||
className={`
|
||||
h-16 w-16 rounded-full
|
||||
${disabled || isCapturing ? 'bg-gray-400' : 'bg-white'}
|
||||
`}>
|
||||
{isCapturing && (
|
||||
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
|
||||
<View className="h-8 w-8 rounded bg-white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
`}
|
||||
>
|
||||
{isCapturing && (
|
||||
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
|
||||
<View className="h-8 w-8 rounded bg-white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,60 +4,61 @@ import { Card } from '../ui/Card';
|
|||
import { Button } from '../Button';
|
||||
|
||||
interface PhotoPreviewProps {
|
||||
uri: string;
|
||||
onRetake: () => void;
|
||||
onUse: () => void;
|
||||
isProcessing?: boolean;
|
||||
uri: string;
|
||||
onRetake: () => void;
|
||||
onUse: () => void;
|
||||
isProcessing?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoPreview: React.FC<PhotoPreviewProps> = ({
|
||||
uri,
|
||||
onRetake,
|
||||
onUse,
|
||||
isProcessing = false,
|
||||
uri,
|
||||
onRetake,
|
||||
onUse,
|
||||
isProcessing = false,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Photo */}
|
||||
<View className="flex-1 justify-center">
|
||||
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
|
||||
</View>
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Photo */}
|
||||
<View className="flex-1 justify-center">
|
||||
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
|
||||
<Card className="bg-white/90 backdrop-blur">
|
||||
<View className="space-y-4">
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
How does this look?
|
||||
</Text>
|
||||
{/* Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
|
||||
<Card className="bg-white/90 backdrop-blur">
|
||||
<View className="space-y-4">
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
How does this look?
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-sm text-gray-600">
|
||||
Make sure your food is clearly visible and well-lit for the best analysis results.
|
||||
</Text>
|
||||
<Text className="text-center text-sm text-gray-600">
|
||||
Make sure your food is clearly visible and well-lit for the best analysis results.
|
||||
</Text>
|
||||
|
||||
<View className="flex-row space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onRetake}
|
||||
disabled={isProcessing}
|
||||
className={`
|
||||
<View className="flex-row space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onRetake}
|
||||
disabled={isProcessing}
|
||||
className={`
|
||||
flex-1 items-center rounded-lg border-2 px-4 py-3
|
||||
${isProcessing ? 'border-gray-300 bg-gray-100' : 'border-gray-300 bg-white'}
|
||||
`}>
|
||||
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
Retake
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
`}
|
||||
>
|
||||
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
Retake
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
|
||||
onPress={onUse}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<Button
|
||||
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
|
||||
onPress={onUse}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,84 +4,84 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { Button } from '../Button';
|
||||
|
||||
interface LocationPermissionModalProps {
|
||||
visible: boolean;
|
||||
onAllow: () => void;
|
||||
onDeny: () => void;
|
||||
visible: boolean;
|
||||
onAllow: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
export const LocationPermissionModal: React.FC<LocationPermissionModalProps> = ({
|
||||
visible,
|
||||
onAllow,
|
||||
onDeny,
|
||||
visible,
|
||||
onAllow,
|
||||
onDeny,
|
||||
}) => {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-6 pb-8">
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
<Ionicons name="location" size={40} color="#3b82f6" />
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-6 pb-8">
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
<Ionicons name="location" size={40} color="#3b82f6" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
|
||||
Standort speichern?
|
||||
</Text>
|
||||
{/* Title */}
|
||||
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
|
||||
Standort speichern?
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
|
||||
zu geben:
|
||||
</Text>
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
|
||||
zu geben:
|
||||
</Text>
|
||||
|
||||
{/* Benefits */}
|
||||
<View className="mb-6 space-y-3">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Automatische Restaurant-Erkennung
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Analyse wo du am gesündesten isst
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="map-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Ernährungstracking auf Reisen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Benefits */}
|
||||
<View className="mb-6 space-y-3">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Automatische Restaurant-Erkennung
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Analyse wo du am gesündesten isst
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="map-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Ernährungstracking auf Reisen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Privacy Note */}
|
||||
<View className="mb-6 rounded-lg bg-gray-50 p-3">
|
||||
<Text className="text-xs text-gray-600">
|
||||
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
|
||||
{'\n'}
|
||||
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
|
||||
den Einstellungen deaktiviert werden.
|
||||
</Text>
|
||||
</View>
|
||||
{/* Privacy Note */}
|
||||
<View className="mb-6 rounded-lg bg-gray-50 p-3">
|
||||
<Text className="text-xs text-gray-600">
|
||||
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
|
||||
{'\n'}
|
||||
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
|
||||
den Einstellungen deaktiviert werden.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="space-y-3">
|
||||
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
|
||||
<TouchableOpacity onPress={onDeny} className="py-3">
|
||||
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* Buttons */}
|
||||
<View className="space-y-3">
|
||||
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
|
||||
<TouchableOpacity onPress={onDeny} className="py-3">
|
||||
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Settings hint */}
|
||||
<Text className="mt-4 text-center text-xs text-gray-500">
|
||||
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
{/* Settings hint */}
|
||||
<Text className="mt-4 text-center text-xs text-gray-500">
|
||||
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,100 +4,100 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalysisStatusIndicatorProps {
|
||||
status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
mini?: boolean;
|
||||
status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
export const AnalysisStatusIndicator: React.FC<AnalysisStatusIndicatorProps> = ({
|
||||
status,
|
||||
mini = false,
|
||||
status,
|
||||
mini = false,
|
||||
}) => {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
bgColor: 'bg-yellow-100',
|
||||
textColor: 'text-yellow-800',
|
||||
icon: null,
|
||||
text: 'Wird analysiert...',
|
||||
showSpinner: true,
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-800',
|
||||
icon: 'checkmark-circle' as const,
|
||||
text: 'Analysiert',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-800',
|
||||
icon: 'alert-circle' as const,
|
||||
text: 'Analyse fehlgeschlagen',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'manual':
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'create-outline' as const,
|
||||
text: 'Manuell',
|
||||
showSpinner: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'help-circle-outline' as const,
|
||||
text: 'Unbekannt',
|
||||
showSpinner: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
bgColor: 'bg-yellow-100',
|
||||
textColor: 'text-yellow-800',
|
||||
icon: null,
|
||||
text: 'Wird analysiert...',
|
||||
showSpinner: true,
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-800',
|
||||
icon: 'checkmark-circle' as const,
|
||||
text: 'Analysiert',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-800',
|
||||
icon: 'alert-circle' as const,
|
||||
text: 'Analyse fehlgeschlagen',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'manual':
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'create-outline' as const,
|
||||
text: 'Manuell',
|
||||
showSpinner: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'help-circle-outline' as const,
|
||||
text: 'Unbekannt',
|
||||
showSpinner: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const config = getStatusConfig();
|
||||
|
||||
if (mini) {
|
||||
return (
|
||||
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={12} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
|
||||
)}
|
||||
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (mini) {
|
||||
return (
|
||||
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={12} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
|
||||
)}
|
||||
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`rounded-lg p-3 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={20} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={20}
|
||||
color={
|
||||
config.textColor === 'text-green-800'
|
||||
? '#166534'
|
||||
: config.textColor === 'text-red-800'
|
||||
? '#991b1b'
|
||||
: config.textColor === 'text-yellow-800'
|
||||
? '#854d0e'
|
||||
: '#1f2937'
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={`rounded-lg p-3 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={20} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={20}
|
||||
color={
|
||||
config.textColor === 'text-green-800'
|
||||
? '#166534'
|
||||
: config.textColor === 'text-red-800'
|
||||
? '#991b1b'
|
||||
: config.textColor === 'text-yellow-800'
|
||||
? '#854d0e'
|
||||
: '#1f2937'
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,154 +1,157 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { MealWithItems } from '../../types/Database';
|
||||
import { useMealStore } from '../../store/MealStore';
|
||||
|
||||
interface EditMealModalProps {
|
||||
meal: MealWithItems | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
meal: MealWithItems | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditMealModal: React.FC<EditMealModalProps> = ({ meal, visible, onClose }) => {
|
||||
const { updateMeal } = useMealStore();
|
||||
const [notes, setNotes] = useState(meal?.user_notes || '');
|
||||
const [rating, setRating] = useState(meal?.user_rating || 0);
|
||||
const [location, setLocation] = useState(meal?.location || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { updateMeal } = useMealStore();
|
||||
const [notes, setNotes] = useState(meal?.user_notes || '');
|
||||
const [rating, setRating] = useState(meal?.user_rating || 0);
|
||||
const [location, setLocation] = useState(meal?.location || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (meal) {
|
||||
setNotes(meal.user_notes || '');
|
||||
setRating(meal.user_rating || 0);
|
||||
setLocation(meal.location || '');
|
||||
}
|
||||
}, [meal]);
|
||||
React.useEffect(() => {
|
||||
if (meal) {
|
||||
setNotes(meal.user_notes || '');
|
||||
setRating(meal.user_rating || 0);
|
||||
setLocation(meal.location || '');
|
||||
}
|
||||
}, [meal]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!meal) return;
|
||||
const handleSave = async () => {
|
||||
if (!meal) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMeal(meal.id, {
|
||||
user_notes: notes.trim() || null,
|
||||
user_rating: rating || null,
|
||||
location: location.trim() || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update meal:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMeal(meal.id, {
|
||||
user_notes: notes.trim() || null,
|
||||
user_rating: rating || null,
|
||||
location: location.trim() || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update meal:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<View className="flex-row justify-center space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
|
||||
<Ionicons
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={32}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<View className="flex-row justify-center space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
|
||||
<Ionicons
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={32}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (!meal) return null;
|
||||
if (!meal) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
|
||||
<TouchableOpacity onPress={onClose} className="p-2">
|
||||
<Text className="text-base text-blue-600">Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
|
||||
<Text
|
||||
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}>
|
||||
{isSaving ? 'Speichert...' : 'Fertig'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white"
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
|
||||
<TouchableOpacity onPress={onClose} className="p-2">
|
||||
<Text className="text-base text-blue-600">Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
|
||||
<Text
|
||||
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}
|
||||
>
|
||||
{isSaving ? 'Speichert...' : 'Fertig'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{/* Rating */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
|
||||
{renderStars()}
|
||||
{rating > 0 && (
|
||||
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
|
||||
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{/* Content */}
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{/* Rating */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
|
||||
{renderStars()}
|
||||
{rating > 0 && (
|
||||
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
|
||||
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Location */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="z.B. Restaurant, Zuhause, Büro..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
{/* Location */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="z.B. Restaurant, Zuhause, Büro..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
|
||||
<TextInput
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</View>
|
||||
{/* Notes */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
|
||||
<TextInput
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Meal Info */}
|
||||
<View className="rounded-lg bg-gray-50 p-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
|
||||
</Text>
|
||||
{meal.total_calories && (
|
||||
<Text className="mt-1 text-sm text-gray-600">
|
||||
{Math.round(meal.total_calories)} kcal
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
{/* Meal Info */}
|
||||
<View className="rounded-lg bg-gray-50 p-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
|
||||
</Text>
|
||||
{meal.total_calories && (
|
||||
<Text className="mt-1 text-sm text-gray-600">
|
||||
{Math.round(meal.total_calories)} kcal
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,158 +4,159 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { FoodItem } from '@/types/Database';
|
||||
|
||||
interface FoodItemCardProps {
|
||||
foodItem: FoodItem;
|
||||
categoryColor?: string;
|
||||
onPress?: () => void;
|
||||
showDetails?: boolean;
|
||||
foodItem: FoodItem;
|
||||
categoryColor?: string;
|
||||
onPress?: () => void;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const FoodItemCard: React.FC<FoodItemCardProps> = (props) => {
|
||||
const {
|
||||
foodItem,
|
||||
categoryColor = 'border-gray-200 bg-gray-50',
|
||||
onPress,
|
||||
showDetails = true,
|
||||
} = props;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
const {
|
||||
foodItem,
|
||||
categoryColor = 'border-gray-200 bg-gray-50',
|
||||
onPress,
|
||||
showDetails = true,
|
||||
} = props;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return 'text-gray-400';
|
||||
if (confidence >= 0.8) return 'text-green-600';
|
||||
if (confidence >= 0.6) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return 'text-gray-400';
|
||||
if (confidence >= 0.8) return 'text-green-600';
|
||||
if (confidence >= 0.6) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getConfidenceIcon = (confidence?: number) => {
|
||||
if (!confidence) return 'help-outline';
|
||||
if (confidence >= 0.8) return 'checkmark-circle-outline';
|
||||
if (confidence >= 0.6) return 'warning-outline';
|
||||
return 'alert-circle-outline';
|
||||
};
|
||||
const getConfidenceIcon = (confidence?: number) => {
|
||||
if (!confidence) return 'help-outline';
|
||||
if (confidence >= 0.8) return 'checkmark-circle-outline';
|
||||
if (confidence >= 0.6) return 'warning-outline';
|
||||
return 'alert-circle-outline';
|
||||
};
|
||||
|
||||
const renderNutritionValue = (
|
||||
label: string,
|
||||
value?: number,
|
||||
unit: string = 'g',
|
||||
color: string = 'text-gray-700'
|
||||
) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const renderNutritionValue = (
|
||||
label: string,
|
||||
value?: number,
|
||||
unit: string = 'g',
|
||||
color: string = 'text-gray-700'
|
||||
) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
return (
|
||||
<View className="items-center">
|
||||
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="items-center">
|
||||
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CardComponent = onPress ? TouchableOpacity : View;
|
||||
const CardComponent = onPress ? TouchableOpacity : View;
|
||||
|
||||
return (
|
||||
<CardComponent
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className={`rounded-lg border p-4 ${categoryColor}`}>
|
||||
{/* Header */}
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
|
||||
{foodItem.portion_size && (
|
||||
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<CardComponent
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className={`rounded-lg border p-4 ${categoryColor}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
|
||||
{foodItem.portion_size && (
|
||||
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Confidence Indicator */}
|
||||
{foodItem.confidence && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name={getConfidenceIcon(foodItem.confidence)}
|
||||
size={16}
|
||||
color={
|
||||
getConfidenceColor(foodItem.confidence) === 'text-green-600'
|
||||
? '#16a34a'
|
||||
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
|
||||
? '#ca8a04'
|
||||
: '#dc2626'
|
||||
}
|
||||
/>
|
||||
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
|
||||
{Math.round(foodItem.confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Confidence Indicator */}
|
||||
{foodItem.confidence && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name={getConfidenceIcon(foodItem.confidence)}
|
||||
size={16}
|
||||
color={
|
||||
getConfidenceColor(foodItem.confidence) === 'text-green-600'
|
||||
? '#16a34a'
|
||||
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
|
||||
? '#ca8a04'
|
||||
: '#dc2626'
|
||||
}
|
||||
/>
|
||||
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
|
||||
{Math.round(foodItem.confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Nutrition Information */}
|
||||
{showDetails && (
|
||||
<View className="space-y-3">
|
||||
{/* Main Calories */}
|
||||
{foodItem.calories && (
|
||||
<View className="rounded-lg border border-gray-100 bg-white p-3">
|
||||
<Text className="text-center text-lg font-bold text-gray-900">
|
||||
{formatValue(foodItem.calories, ' kcal')}
|
||||
</Text>
|
||||
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
|
||||
Kalorien
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Nutrition Information */}
|
||||
{showDetails && (
|
||||
<View className="space-y-3">
|
||||
{/* Main Calories */}
|
||||
{foodItem.calories && (
|
||||
<View className="rounded-lg border border-gray-100 bg-white p-3">
|
||||
<Text className="text-center text-lg font-bold text-gray-900">
|
||||
{formatValue(foodItem.calories, ' kcal')}
|
||||
</Text>
|
||||
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
|
||||
Kalorien
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Macronutrients */}
|
||||
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
|
||||
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
|
||||
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
|
||||
</View>
|
||||
)}
|
||||
{/* Macronutrients */}
|
||||
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
|
||||
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
|
||||
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Additional nutrients */}
|
||||
{(foodItem.fiber || foodItem.sugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
|
||||
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
|
||||
<View /> {/* Spacer for alignment */}
|
||||
</View>
|
||||
)}
|
||||
{/* Additional nutrients */}
|
||||
{(foodItem.fiber || foodItem.sugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
|
||||
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
|
||||
<View /> {/* Spacer for alignment */}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Food Properties */}
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{Boolean(foodItem.is_organic) && (
|
||||
<View className="rounded-full bg-green-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
|
||||
</View>
|
||||
)}
|
||||
{Boolean(foodItem.is_processed) && (
|
||||
<View className="rounded-full bg-orange-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Food Properties */}
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{Boolean(foodItem.is_organic) && (
|
||||
<View className="rounded-full bg-green-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
|
||||
</View>
|
||||
)}
|
||||
{Boolean(foodItem.is_processed) && (
|
||||
<View className="rounded-full bg-orange-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Allergens */}
|
||||
{foodItem.allergens && (
|
||||
<View>
|
||||
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
|
||||
<Text className="text-xs text-red-600">
|
||||
{(() => {
|
||||
try {
|
||||
const allergens = JSON.parse(foodItem.allergens);
|
||||
return Array.isArray(allergens) && allergens.length > 0
|
||||
? allergens.join(', ')
|
||||
: 'Keine';
|
||||
} catch {
|
||||
return 'Keine';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CardComponent>
|
||||
);
|
||||
{/* Allergens */}
|
||||
{foodItem.allergens && (
|
||||
<View>
|
||||
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
|
||||
<Text className="text-xs text-red-600">
|
||||
{(() => {
|
||||
try {
|
||||
const allergens = JSON.parse(foodItem.allergens);
|
||||
return Array.isArray(allergens) && allergens.length > 0
|
||||
? allergens.join(', ')
|
||||
: 'Keine';
|
||||
} catch {
|
||||
return 'Keine';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CardComponent>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,159 +4,159 @@ import { FoodItem } from '@/types/Database';
|
|||
import { FoodItemCard } from './FoodItemCard';
|
||||
|
||||
interface FoodItemListProps {
|
||||
foodItems: FoodItem[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
foodItems: FoodItem[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
export const FoodItemList: React.FC<FoodItemListProps> = ({
|
||||
foodItems,
|
||||
title = 'Erkannte Lebensmittel',
|
||||
showTitle = true,
|
||||
foodItems,
|
||||
title = 'Erkannte Lebensmittel',
|
||||
showTitle = true,
|
||||
}) => {
|
||||
if (!foodItems || foodItems.length === 0) {
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
|
||||
<View className="items-center rounded-lg bg-gray-50 p-6">
|
||||
<Text className="mb-2 text-4xl">🍽️</Text>
|
||||
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!foodItems || foodItems.length === 0) {
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
|
||||
<View className="items-center rounded-lg bg-gray-50 p-6">
|
||||
<Text className="mb-2 text-4xl">🍽️</Text>
|
||||
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return '🥩';
|
||||
case 'vegetable':
|
||||
return '🥕';
|
||||
case 'grain':
|
||||
return '🌾';
|
||||
case 'fruit':
|
||||
return '🍎';
|
||||
case 'dairy':
|
||||
return '🥛';
|
||||
case 'fat':
|
||||
return '🥑';
|
||||
case 'processed':
|
||||
return '📦';
|
||||
case 'beverage':
|
||||
return '🥤';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return '🥩';
|
||||
case 'vegetable':
|
||||
return '🥕';
|
||||
case 'grain':
|
||||
return '🌾';
|
||||
case 'fruit':
|
||||
return '🍎';
|
||||
case 'dairy':
|
||||
return '🥛';
|
||||
case 'fat':
|
||||
return '🥑';
|
||||
case 'processed':
|
||||
return '📦';
|
||||
case 'beverage':
|
||||
return '🥤';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'vegetable':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'grain':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
case 'fruit':
|
||||
return 'border-orange-200 bg-orange-50';
|
||||
case 'dairy':
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
case 'fat':
|
||||
return 'border-purple-200 bg-purple-50';
|
||||
case 'processed':
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
case 'beverage':
|
||||
return 'border-cyan-200 bg-cyan-50';
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
}
|
||||
};
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'vegetable':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'grain':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
case 'fruit':
|
||||
return 'border-orange-200 bg-orange-50';
|
||||
case 'dairy':
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
case 'fat':
|
||||
return 'border-purple-200 bg-purple-50';
|
||||
case 'processed':
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
case 'beverage':
|
||||
return 'border-cyan-200 bg-cyan-50';
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
// Group food items by category
|
||||
const groupedByCategory = foodItems.reduce(
|
||||
(acc, item) => {
|
||||
const category = item.category || 'other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FoodItem[]>
|
||||
);
|
||||
// Group food items by category
|
||||
const groupedByCategory = foodItems.reduce(
|
||||
(acc, item) => {
|
||||
const category = item.category || 'other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FoodItem[]>
|
||||
);
|
||||
|
||||
const categoryOrder = [
|
||||
'protein',
|
||||
'vegetable',
|
||||
'grain',
|
||||
'fruit',
|
||||
'dairy',
|
||||
'fat',
|
||||
'beverage',
|
||||
'processed',
|
||||
'other',
|
||||
];
|
||||
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
|
||||
const otherCategories = Object.keys(groupedByCategory).filter(
|
||||
(category) => !categoryOrder.includes(category)
|
||||
);
|
||||
const allCategories = [...sortedCategories, ...otherCategories];
|
||||
const categoryOrder = [
|
||||
'protein',
|
||||
'vegetable',
|
||||
'grain',
|
||||
'fruit',
|
||||
'dairy',
|
||||
'fat',
|
||||
'beverage',
|
||||
'processed',
|
||||
'other',
|
||||
];
|
||||
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
|
||||
const otherCategories = Object.keys(groupedByCategory).filter(
|
||||
(category) => !categoryOrder.includes(category)
|
||||
);
|
||||
const allCategories = [...sortedCategories, ...otherCategories];
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'Proteine';
|
||||
case 'vegetable':
|
||||
return 'Gemüse';
|
||||
case 'grain':
|
||||
return 'Getreide';
|
||||
case 'fruit':
|
||||
return 'Obst';
|
||||
case 'dairy':
|
||||
return 'Milchprodukte';
|
||||
case 'fat':
|
||||
return 'Fette';
|
||||
case 'processed':
|
||||
return 'Verarbeitete Lebensmittel';
|
||||
case 'beverage':
|
||||
return 'Getränke';
|
||||
default:
|
||||
return 'Sonstige';
|
||||
}
|
||||
};
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'Proteine';
|
||||
case 'vegetable':
|
||||
return 'Gemüse';
|
||||
case 'grain':
|
||||
return 'Getreide';
|
||||
case 'fruit':
|
||||
return 'Obst';
|
||||
case 'dairy':
|
||||
return 'Milchprodukte';
|
||||
case 'fat':
|
||||
return 'Fette';
|
||||
case 'processed':
|
||||
return 'Verarbeitete Lebensmittel';
|
||||
case 'beverage':
|
||||
return 'Getränke';
|
||||
default:
|
||||
return 'Sonstige';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && (
|
||||
<Text className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{title} ({foodItems.length})
|
||||
</Text>
|
||||
)}
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && (
|
||||
<Text className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{title} ({foodItems.length})
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{allCategories.map((category) => (
|
||||
<View key={category} className="mb-6">
|
||||
{/* Category Header */}
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
|
||||
<Text className="text-base font-medium text-gray-800">
|
||||
{getCategoryName(category)} ({groupedByCategory[category].length})
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{allCategories.map((category) => (
|
||||
<View key={category} className="mb-6">
|
||||
{/* Category Header */}
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
|
||||
<Text className="text-base font-medium text-gray-800">
|
||||
{getCategoryName(category)} ({groupedByCategory[category].length})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Category Items */}
|
||||
<View className="space-y-2">
|
||||
{groupedByCategory[category].map((item, index) => (
|
||||
<FoodItemCard
|
||||
key={item.id || `${category}-${index}`}
|
||||
foodItem={item}
|
||||
categoryColor={getCategoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
{/* Category Items */}
|
||||
<View className="space-y-2">
|
||||
{groupedByCategory[category].map((item, index) => (
|
||||
<FoodItemCard
|
||||
key={item.id || `${category}-${index}`}
|
||||
foodItem={item}
|
||||
categoryColor={getCategoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,186 +5,186 @@ import { MealWithItems } from '../../types/Database';
|
|||
import { AnalysisStatusIndicator } from './AnalysisStatusIndicator';
|
||||
|
||||
interface MealCardProps {
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealCard: React.FC<MealCardProps> = ({ meal, onPress }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const generateMealTitle = (meal: MealWithItems): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item) => item.name);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const generateMealTitle = (meal: MealWithItems): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item) => item.name);
|
||||
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} more`;
|
||||
}
|
||||
}
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} more`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to meal type if no food items
|
||||
const mealTypeLabels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
// Fallback to meal type if no food items
|
||||
const mealTypeLabels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
|
||||
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
|
||||
};
|
||||
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
|
||||
};
|
||||
|
||||
const getMealTypeLabel = (mealType?: string): string => {
|
||||
const labels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[mealType as keyof typeof labels] || 'Meal';
|
||||
};
|
||||
const getMealTypeLabel = (mealType?: string): string => {
|
||||
const labels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[mealType as keyof typeof labels] || 'Meal';
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'text-gray-400';
|
||||
if (score >= 80) return 'text-green-400';
|
||||
if (score >= 60) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'text-gray-400';
|
||||
if (score >= 80) return 'text-green-400';
|
||||
if (score >= 60) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
|
||||
{/* Background Image */}
|
||||
{meal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: meal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('MealCard image loading error:', error);
|
||||
console.log('MealCard photo_path:', meal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('MealCard image loaded successfully:', meal.photo_path);
|
||||
setImageError(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center bg-gray-300">
|
||||
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
|
||||
{/* Background Image */}
|
||||
{meal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: meal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('MealCard image loading error:', error);
|
||||
console.log('MealCard photo_path:', meal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('MealCard image loaded successfully:', meal.photo_path);
|
||||
setImageError(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center bg-gray-300">
|
||||
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Blurry Stats Overlay */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
|
||||
<View className="flex-row items-start justify-between">
|
||||
{/* Left side - Meal info */}
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-white" numberOfLines={1}>
|
||||
{generateMealTitle(meal)}
|
||||
</Text>
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
|
||||
<Text className="text-sm text-gray-400">•</Text>
|
||||
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Location if available */}
|
||||
{meal.location && (
|
||||
<View className="mt-1 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={12} color="#d1d5db" />
|
||||
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
|
||||
{meal.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Blurry Stats Overlay */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
|
||||
<View className="flex-row items-start justify-between">
|
||||
{/* Left side - Meal info */}
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-white" numberOfLines={1}>
|
||||
{generateMealTitle(meal)}
|
||||
</Text>
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
|
||||
<Text className="text-sm text-gray-400">•</Text>
|
||||
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Location if available */}
|
||||
{meal.location && (
|
||||
<View className="mt-1 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={12} color="#d1d5db" />
|
||||
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
|
||||
{meal.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Stats */}
|
||||
<View className="items-end">
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
{/* Calories */}
|
||||
{meal.total_calories && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">cal</Text>
|
||||
<Text className="font-bold text-white">
|
||||
{Math.round(meal.total_calories)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Right side - Stats */}
|
||||
<View className="items-end">
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
{/* Calories */}
|
||||
{meal.total_calories && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">cal</Text>
|
||||
<Text className="font-bold text-white">
|
||||
{Math.round(meal.total_calories)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Health Score */}
|
||||
{meal.health_score && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">health</Text>
|
||||
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
|
||||
{Math.round(meal.health_score)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Health Score */}
|
||||
{meal.health_score && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">health</Text>
|
||||
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
|
||||
{Math.round(meal.health_score)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
{meal.user_rating && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">rating</Text>
|
||||
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{/* Rating */}
|
||||
{meal.user_rating && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">rating</Text>
|
||||
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Status for non-completed */}
|
||||
{meal.analysis_status !== 'completed' && (
|
||||
<View className="rounded-full bg-black/30 p-1">
|
||||
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* Analysis Status for non-completed */}
|
||||
{meal.analysis_status !== 'completed' && (
|
||||
<View className="rounded-full bg-black/30 p-1">
|
||||
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* User Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
{/* User Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,39 +8,39 @@ import { EditMealModal } from './EditMealModal';
|
|||
import { useMealStore } from '../../store/MealStore';
|
||||
|
||||
interface MealCardContextMenuProps {
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal, onPress }) => {
|
||||
const { deleteMeal, updateMeal } = useMealStore();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { deleteMeal, updateMeal } = useMealStore();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Mahlzeit löschen',
|
||||
'Möchtest du diese Mahlzeit wirklich löschen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMeal(meal.id);
|
||||
} catch {
|
||||
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
};
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Mahlzeit löschen',
|
||||
'Möchtest du diese Mahlzeit wirklich löschen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMeal(meal.id);
|
||||
} catch {
|
||||
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
|
||||
📊 Nährwerte:
|
||||
• Kalorien: ${meal.total_calories || '--'} kcal
|
||||
|
|
@ -52,33 +52,33 @@ export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal,
|
|||
|
||||
Getrackt mit Nutriphi 🤖`;
|
||||
|
||||
await Share.share({
|
||||
message: nutritionInfo,
|
||||
title: 'Meine Mahlzeit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
};
|
||||
await Share.share({
|
||||
message: nutritionInfo,
|
||||
title: 'Meine Mahlzeit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRating = (rating: number) => {
|
||||
updateMeal(meal.id, { user_rating: rating });
|
||||
};
|
||||
const handleRating = (rating: number) => {
|
||||
updateMeal(meal.id, { user_rating: rating });
|
||||
};
|
||||
|
||||
const handleReanalyze = () => {
|
||||
Alert.alert(
|
||||
'Erneut analysieren',
|
||||
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
const handleReanalyze = () => {
|
||||
Alert.alert(
|
||||
'Erneut analysieren',
|
||||
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
const handleEdit = () => {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleCopyNutrition = () => {
|
||||
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
const handleCopyNutrition = () => {
|
||||
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
Kalorien: ${meal.total_calories || '--'} kcal
|
||||
Protein: ${meal.total_protein || '--'}g
|
||||
Kohlenhydrate: ${meal.total_carbs || '--'}g
|
||||
|
|
@ -87,100 +87,100 @@ Ballaststoffe: ${meal.total_fiber || '--'}g
|
|||
Zucker: ${meal.total_sugar || '--'}g
|
||||
Gesundheitsscore: ${meal.health_score ? Math.round(meal.health_score) : '--'}/100`;
|
||||
|
||||
Clipboard.setString(nutritionText);
|
||||
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
|
||||
};
|
||||
Clipboard.setString(nutritionText);
|
||||
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
|
||||
};
|
||||
|
||||
// Build context menu actions
|
||||
const actions = [
|
||||
{
|
||||
title: 'Bearbeiten',
|
||||
systemIcon: 'pencil',
|
||||
},
|
||||
{
|
||||
title: 'Bewerten',
|
||||
systemIcon: 'star',
|
||||
inlineChildren: true,
|
||||
actions: [
|
||||
{ title: '⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teilen',
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
},
|
||||
{
|
||||
title: 'Nährwerte kopieren',
|
||||
systemIcon: 'doc.on.doc',
|
||||
},
|
||||
];
|
||||
// Build context menu actions
|
||||
const actions = [
|
||||
{
|
||||
title: 'Bearbeiten',
|
||||
systemIcon: 'pencil',
|
||||
},
|
||||
{
|
||||
title: 'Bewerten',
|
||||
systemIcon: 'star',
|
||||
inlineChildren: true,
|
||||
actions: [
|
||||
{ title: '⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teilen',
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
},
|
||||
{
|
||||
title: 'Nährwerte kopieren',
|
||||
systemIcon: 'doc.on.doc',
|
||||
},
|
||||
];
|
||||
|
||||
// Add conditional actions
|
||||
if (meal.analysis_status === 'failed') {
|
||||
actions.push({
|
||||
title: 'Erneut analysieren',
|
||||
systemIcon: 'arrow.clockwise',
|
||||
});
|
||||
}
|
||||
// Add conditional actions
|
||||
if (meal.analysis_status === 'failed') {
|
||||
actions.push({
|
||||
title: 'Erneut analysieren',
|
||||
systemIcon: 'arrow.clockwise',
|
||||
});
|
||||
}
|
||||
|
||||
// Add destructive action at the end
|
||||
actions.push({
|
||||
title: 'Löschen',
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
});
|
||||
// Add destructive action at the end
|
||||
actions.push({
|
||||
title: 'Löschen',
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
const handlePress = (event: any) => {
|
||||
const { index, name } = event.nativeEvent;
|
||||
const handlePress = (event: any) => {
|
||||
const { index, name } = event.nativeEvent;
|
||||
|
||||
// Haptic feedback
|
||||
Vibration.vibrate(10);
|
||||
// Haptic feedback
|
||||
Vibration.vibrate(10);
|
||||
|
||||
switch (name || actions[index]?.title) {
|
||||
case 'Bearbeiten':
|
||||
handleEdit();
|
||||
break;
|
||||
case 'Löschen':
|
||||
handleDelete();
|
||||
break;
|
||||
case 'Teilen':
|
||||
handleShare();
|
||||
break;
|
||||
case 'Nährwerte kopieren':
|
||||
handleCopyNutrition();
|
||||
break;
|
||||
case 'Erneut analysieren':
|
||||
handleReanalyze();
|
||||
break;
|
||||
case '⭐':
|
||||
handleRating(1);
|
||||
break;
|
||||
case '⭐⭐':
|
||||
handleRating(2);
|
||||
break;
|
||||
case '⭐⭐⭐':
|
||||
handleRating(3);
|
||||
break;
|
||||
case '⭐⭐⭐⭐':
|
||||
handleRating(4);
|
||||
break;
|
||||
case '⭐⭐⭐⭐⭐':
|
||||
handleRating(5);
|
||||
break;
|
||||
}
|
||||
};
|
||||
switch (name || actions[index]?.title) {
|
||||
case 'Bearbeiten':
|
||||
handleEdit();
|
||||
break;
|
||||
case 'Löschen':
|
||||
handleDelete();
|
||||
break;
|
||||
case 'Teilen':
|
||||
handleShare();
|
||||
break;
|
||||
case 'Nährwerte kopieren':
|
||||
handleCopyNutrition();
|
||||
break;
|
||||
case 'Erneut analysieren':
|
||||
handleReanalyze();
|
||||
break;
|
||||
case '⭐':
|
||||
handleRating(1);
|
||||
break;
|
||||
case '⭐⭐':
|
||||
handleRating(2);
|
||||
break;
|
||||
case '⭐⭐⭐':
|
||||
handleRating(3);
|
||||
break;
|
||||
case '⭐⭐⭐⭐':
|
||||
handleRating(4);
|
||||
break;
|
||||
case '⭐⭐⭐⭐⭐':
|
||||
handleRating(5);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
|
||||
<MealCard meal={meal} onPress={onPress} />
|
||||
</ContextMenu>
|
||||
return (
|
||||
<>
|
||||
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
|
||||
<MealCard meal={meal} onPress={onPress} />
|
||||
</ContextMenu>
|
||||
|
||||
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
|
||||
</>
|
||||
);
|
||||
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,145 +5,146 @@ import { Card } from '../ui/Card';
|
|||
import { NutritionBar } from './NutritionBar';
|
||||
|
||||
interface MealItemProps {
|
||||
meal: Meal;
|
||||
onPress: () => void;
|
||||
meal: Meal;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealItem: React.FC<MealItemProps> = ({ meal, onPress }) => {
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getAnalysisStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'manual':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
const getAnalysisStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'manual':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getAnalysisStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'Analyzed';
|
||||
case 'pending':
|
||||
return 'Processing...';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
const getAnalysisStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'Analyzed';
|
||||
case 'pending':
|
||||
return 'Processing...';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Card variant="elevated" className="mb-4">
|
||||
<View className="flex-row space-x-4">
|
||||
{/* Photo */}
|
||||
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
|
||||
{meal.photo_path ? (
|
||||
<Image
|
||||
source={{ uri: `file://${meal.photo_path}` }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center">
|
||||
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Card variant="elevated" className="mb-4">
|
||||
<View className="flex-row space-x-4">
|
||||
{/* Photo */}
|
||||
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
|
||||
{meal.photo_path ? (
|
||||
<Image
|
||||
source={{ uri: `file://${meal.photo_path}` }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center">
|
||||
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold capitalize text-gray-900">
|
||||
{meal.meal_type || 'Meal'}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Content */}
|
||||
<View className="flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold capitalize text-gray-900">
|
||||
{meal.meal_type || 'Meal'}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}>
|
||||
<Text className="text-xs font-medium">
|
||||
{getAnalysisStatusText(meal.analysis_status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}
|
||||
>
|
||||
<Text className="text-xs font-medium">
|
||||
{getAnalysisStatusText(meal.analysis_status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Nutrition Summary */}
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<NutritionBar
|
||||
calories={meal.total_calories}
|
||||
healthScore={meal.health_score}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
{/* Nutrition Summary */}
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<NutritionBar
|
||||
calories={meal.total_calories}
|
||||
healthScore={meal.health_score}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="text-sm italic text-gray-600" numberOfLines={2}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
{/* Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="text-sm italic text-gray-600" numberOfLines={2}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Bottom Info */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{meal.location && <Text className="text-xs text-gray-500">📍 {meal.location}</Text>}
|
||||
</View>
|
||||
{/* Bottom Info */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{meal.location && <Text className="text-xs text-gray-500">📍 {meal.location}</Text>}
|
||||
</View>
|
||||
|
||||
{meal.user_rating && (
|
||||
<View className="flex-row">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Text key={i} className="text-xs">
|
||||
{i < meal.user_rating! ? '⭐' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
{meal.user_rating && (
|
||||
<View className="flex-row">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Text key={i} className="text-xs">
|
||||
{i < meal.user_rating! ? '⭐' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,93 +8,93 @@ import { Button } from '../Button';
|
|||
import { Header } from '../ui/Header';
|
||||
|
||||
interface MealListProps {
|
||||
onMealPress: (meal: MealWithItems) => void;
|
||||
onMealPress: (meal: MealWithItems) => void;
|
||||
}
|
||||
|
||||
export const MealList: React.FC<MealListProps> = ({ onMealPress }) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
|
||||
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadMeals();
|
||||
}, [loadMeals]);
|
||||
useEffect(() => {
|
||||
loadMeals();
|
||||
}, [loadMeals]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadMeals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadMeals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderMealItem = ({ item }: { item: MealWithItems }) => (
|
||||
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
|
||||
);
|
||||
const renderMealItem = ({ item }: { item: MealWithItems }) => (
|
||||
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-6xl">🍽️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
No meals yet
|
||||
</Text>
|
||||
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
|
||||
Start tracking your nutrition by taking a photo of your first meal using the camera button
|
||||
below!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
const renderEmptyState = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-6xl">🍽️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
No meals yet
|
||||
</Text>
|
||||
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
|
||||
Start tracking your nutrition by taking a photo of your first meal using the camera button
|
||||
below!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderError = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-4xl">⚠️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Oops! Something went wrong
|
||||
</Text>
|
||||
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
|
||||
<Button
|
||||
title="Try Again"
|
||||
onPress={() => {
|
||||
clearError();
|
||||
loadMeals();
|
||||
}}
|
||||
className="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
const renderError = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-4xl">⚠️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Oops! Something went wrong
|
||||
</Text>
|
||||
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
|
||||
<Button
|
||||
title="Try Again"
|
||||
onPress={() => {
|
||||
clearError();
|
||||
loadMeals();
|
||||
}}
|
||||
className="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (error && meals.length === 0) {
|
||||
return renderError();
|
||||
}
|
||||
if (error && meals.length === 0) {
|
||||
return renderError();
|
||||
}
|
||||
|
||||
if (isLoading && meals.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<LoadingSpinner text="Loading your meals..." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isLoading && meals.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<LoadingSpinner text="Loading your meals..." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Meal List */}
|
||||
<FlatList
|
||||
data={meals}
|
||||
renderItem={renderMealItem}
|
||||
keyExtractor={(item) => item.id!.toString()}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListHeaderComponent={<Header title="NutriPhi" />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Meal List */}
|
||||
<FlatList
|
||||
data={meals}
|
||||
renderItem={renderMealItem}
|
||||
keyExtractor={(item) => item.id!.toString()}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListHeaderComponent={<Header title="NutriPhi" />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
|
||||
</View>
|
||||
);
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,197 +3,197 @@ import { View, Text } from 'react-native';
|
|||
import { Meal } from '@/types/Database';
|
||||
|
||||
interface NutritionBarProps {
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
healthScore?: number;
|
||||
compact?: boolean;
|
||||
meal?: Meal;
|
||||
showDetailed?: boolean;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
healthScore?: number;
|
||||
compact?: boolean;
|
||||
meal?: Meal;
|
||||
showDetailed?: boolean;
|
||||
}
|
||||
|
||||
export const NutritionBar: React.FC<NutritionBarProps> = ({
|
||||
calories,
|
||||
protein,
|
||||
carbs,
|
||||
fat,
|
||||
healthScore,
|
||||
compact = false,
|
||||
meal,
|
||||
showDetailed = false,
|
||||
calories,
|
||||
protein,
|
||||
carbs,
|
||||
fat,
|
||||
healthScore,
|
||||
compact = false,
|
||||
meal,
|
||||
showDetailed = false,
|
||||
}) => {
|
||||
// Use meal data if provided, otherwise use individual props
|
||||
const mealCalories = meal?.total_calories || calories;
|
||||
const mealProtein = meal?.total_protein || protein;
|
||||
const mealCarbs = meal?.total_carbs || carbs;
|
||||
const mealFat = meal?.total_fat || fat;
|
||||
const mealHealthScore = meal?.health_score || healthScore;
|
||||
const mealFiber = meal?.total_fiber;
|
||||
const mealSugar = meal?.total_sugar;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
// Use meal data if provided, otherwise use individual props
|
||||
const mealCalories = meal?.total_calories || calories;
|
||||
const mealProtein = meal?.total_protein || protein;
|
||||
const mealCarbs = meal?.total_carbs || carbs;
|
||||
const mealFat = meal?.total_fat || fat;
|
||||
const mealHealthScore = meal?.health_score || healthScore;
|
||||
const mealFiber = meal?.total_fiber;
|
||||
const mealSugar = meal?.total_sugar;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'bg-gray-300';
|
||||
if (score >= 8) return 'bg-green-500';
|
||||
if (score >= 6) return 'bg-yellow-500';
|
||||
if (score >= 4) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'bg-gray-300';
|
||||
if (score >= 8) return 'bg-green-500';
|
||||
if (score >= 6) return 'bg-yellow-500';
|
||||
if (score >= 4) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const getHealthScoreText = (score?: number) => {
|
||||
if (!score) return 'Not analyzed';
|
||||
if (score >= 8) return 'Very Healthy';
|
||||
if (score >= 6) return 'Healthy';
|
||||
if (score >= 4) return 'Moderate';
|
||||
return 'Unhealthy';
|
||||
};
|
||||
const getHealthScoreText = (score?: number) => {
|
||||
if (!score) return 'Not analyzed';
|
||||
if (score >= 8) return 'Very Healthy';
|
||||
if (score >= 6) return 'Healthy';
|
||||
if (score >= 4) return 'Moderate';
|
||||
return 'Unhealthy';
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<View className="flex-row items-center space-x-4">
|
||||
<View className="flex-row items-center space-x-1">
|
||||
<Text className="text-lg font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
</View>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (compact) {
|
||||
return (
|
||||
<View className="flex-row items-center space-x-4">
|
||||
<View className="flex-row items-center space-x-1">
|
||||
<Text className="text-lg font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
</View>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="space-y-3">
|
||||
{/* Calories Header */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<View className="items-end">
|
||||
<Text className="text-sm font-medium text-gray-900">
|
||||
{mealHealthScore.toFixed(1)}/10
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<View className="space-y-3">
|
||||
{/* Calories Header */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<View className="items-end">
|
||||
<Text className="text-sm font-medium text-gray-900">
|
||||
{mealHealthScore.toFixed(1)}/10
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Macronutrients */}
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
|
||||
</View>
|
||||
{/* Macronutrients */}
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Additional nutrients for detailed view */}
|
||||
{showDetailed && (mealFiber || mealSugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{mealFiber && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-purple-600">
|
||||
{formatValue(mealFiber)}
|
||||
</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
|
||||
</View>
|
||||
)}
|
||||
{mealSugar && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-transparent">--</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Additional nutrients for detailed view */}
|
||||
{showDetailed && (mealFiber || mealSugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{mealFiber && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-purple-600">
|
||||
{formatValue(mealFiber)}
|
||||
</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
|
||||
</View>
|
||||
)}
|
||||
{mealSugar && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-transparent">--</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Visual Progress Bars */}
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">PROT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
|
||||
</View>
|
||||
{/* Visual Progress Bars */}
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">PROT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">CARB</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-green-500"
|
||||
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">CARB</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-green-500"
|
||||
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FAT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-orange-500"
|
||||
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FAT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-orange-500"
|
||||
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Additional progress bars for detailed view */}
|
||||
{showDetailed && mealFiber && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-purple-500"
|
||||
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Additional progress bars for detailed view */}
|
||||
{showDetailed && mealFiber && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-purple-500"
|
||||
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showDetailed && mealSugar && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-pink-500"
|
||||
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{showDetailed && mealSugar && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-pink-500"
|
||||
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@ import React from 'react';
|
|||
import { View, ViewProps } from 'react-native';
|
||||
|
||||
interface CardProps extends ViewProps {
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = 'rounded-xl p-4 bg-white';
|
||||
const baseStyles = 'rounded-xl p-4 bg-white';
|
||||
|
||||
const variantStyles = {
|
||||
default: 'border border-gray-100',
|
||||
elevated: 'shadow-lg shadow-gray-200',
|
||||
outline: 'border-2 border-gray-200',
|
||||
};
|
||||
const variantStyles = {
|
||||
default: 'border border-gray-100',
|
||||
elevated: 'shadow-lg shadow-gray-200',
|
||||
outline: 'border-2 border-gray-200',
|
||||
};
|
||||
|
||||
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
|
||||
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
|
||||
|
||||
return (
|
||||
<View className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,154 +1,156 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
import { SFSymbol } from './SFSymbol';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
onPress: () => void;
|
||||
icon?: string;
|
||||
sfSymbol?: string;
|
||||
fallbackIcon?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'normal' | 'large';
|
||||
position?: 'right' | 'center' | 'left';
|
||||
onPress: () => void;
|
||||
icon?: string;
|
||||
sfSymbol?: string;
|
||||
fallbackIcon?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'normal' | 'large';
|
||||
position?: 'right' | 'center' | 'left';
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
|
||||
onPress,
|
||||
icon = '+',
|
||||
sfSymbol,
|
||||
fallbackIcon,
|
||||
disabled = false,
|
||||
size = 'normal',
|
||||
position = 'right',
|
||||
onPress,
|
||||
icon = '+',
|
||||
sfSymbol,
|
||||
fallbackIcon,
|
||||
disabled = false,
|
||||
size = 'normal',
|
||||
position = 'right',
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
pressed.value = true;
|
||||
};
|
||||
const handlePressIn = () => {
|
||||
pressed.value = true;
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
|
||||
const getContainerStyle = () => {
|
||||
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
|
||||
switch (position) {
|
||||
case 'center':
|
||||
return {
|
||||
...base,
|
||||
width: '100%',
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
case 'left':
|
||||
return { ...base, left: 24 };
|
||||
case 'right':
|
||||
default:
|
||||
return { ...base, right: 24 };
|
||||
}
|
||||
};
|
||||
const getContainerStyle = () => {
|
||||
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
|
||||
switch (position) {
|
||||
case 'center':
|
||||
return {
|
||||
...base,
|
||||
width: '100%',
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
case 'left':
|
||||
return { ...base, left: 24 };
|
||||
case 'right':
|
||||
default:
|
||||
return { ...base, right: 24 };
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeStyle = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return { width: 80, height: 80 };
|
||||
case 'normal':
|
||||
default:
|
||||
return { width: 64, height: 64 };
|
||||
}
|
||||
};
|
||||
const getSizeStyle = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return { width: 80, height: 80 };
|
||||
case 'normal':
|
||||
default:
|
||||
return { width: 64, height: 64 };
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 32;
|
||||
case 'normal':
|
||||
default:
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 32;
|
||||
case 'normal':
|
||||
default:
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
case 'normal':
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
const getTextSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
case 'normal':
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (sfSymbol && fallbackIcon) {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
size={getIconSize()}
|
||||
color="white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
|
||||
};
|
||||
const renderIcon = () => {
|
||||
if (sfSymbol && fallbackIcon) {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
size={getIconSize()}
|
||||
color="white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
|
||||
};
|
||||
|
||||
const combinedStyle = [
|
||||
animatedStyle,
|
||||
getSizeStyle(),
|
||||
position === 'center' ? {} : getContainerStyle(),
|
||||
];
|
||||
const combinedStyle = [
|
||||
animatedStyle,
|
||||
getSizeStyle(),
|
||||
position === 'center' ? {} : getContainerStyle(),
|
||||
];
|
||||
|
||||
const containerStyle = position === 'center' ? getContainerStyle() : {};
|
||||
const containerStyle = position === 'center' ? getContainerStyle() : {};
|
||||
|
||||
if (position === 'center') {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={[animatedStyle, getSizeStyle()]}
|
||||
className={`
|
||||
if (position === 'center') {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={[animatedStyle, getSizeStyle()]}
|
||||
className={`
|
||||
items-center justify-center rounded-full shadow-lg
|
||||
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
|
||||
`}>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={combinedStyle}
|
||||
className={`
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={combinedStyle}
|
||||
className={`
|
||||
items-center justify-center rounded-full shadow-lg
|
||||
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
|
||||
`}>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,29 +4,30 @@ import { router } from 'expo-router';
|
|||
import { SFSymbol } from './SFSymbol';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onSettingsPress?: () => void;
|
||||
title: string;
|
||||
onSettingsPress?: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, onSettingsPress }) => {
|
||||
const handleSettingsPress = () => {
|
||||
if (onSettingsPress) {
|
||||
onSettingsPress();
|
||||
} else {
|
||||
router.push('/settings');
|
||||
}
|
||||
};
|
||||
const handleSettingsPress = () => {
|
||||
if (onSettingsPress) {
|
||||
onSettingsPress();
|
||||
} else {
|
||||
router.push('/settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
className="p-2"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button">
|
||||
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
className="p-2"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@ import React from 'react';
|
|||
import { View, Text, Modal, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default function LoadingOverlay({
|
||||
visible,
|
||||
message = 'Wird geladen...',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.7)',
|
||||
visible,
|
||||
message = 'Wird geladen...',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.7)',
|
||||
}: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade">
|
||||
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
|
||||
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" className="text-indigo-500" />
|
||||
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade">
|
||||
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
|
||||
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" className="text-indigo-500" />
|
||||
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,31 @@ import React from 'react';
|
|||
import { ActivityIndicator, View, Text } from 'react-native';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'large',
|
||||
color = '#6366f1',
|
||||
text,
|
||||
overlay = false,
|
||||
size = 'large',
|
||||
color = '#6366f1',
|
||||
text,
|
||||
overlay = false,
|
||||
}) => {
|
||||
const Container = overlay ? View : React.Fragment;
|
||||
const containerProps = overlay
|
||||
? {
|
||||
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
|
||||
}
|
||||
: {};
|
||||
const Container = overlay ? View : React.Fragment;
|
||||
const containerProps = overlay
|
||||
? {
|
||||
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Container {...containerProps}>
|
||||
<View className="items-center space-y-2">
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text className="text-sm text-gray-600">{text}</Text>}
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container {...containerProps}>
|
||||
<View className="items-center space-y-2">
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text className="text-sm text-gray-600">{text}</Text>}
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,50 +5,50 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|||
import { useColorScheme } from 'nativewind';
|
||||
|
||||
interface SFSymbolProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolViewProps['weight'];
|
||||
scale?: SymbolViewProps['scale'];
|
||||
mode?: SymbolViewProps['mode'];
|
||||
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
style?: SymbolViewProps['style'];
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolViewProps['weight'];
|
||||
scale?: SymbolViewProps['scale'];
|
||||
mode?: SymbolViewProps['mode'];
|
||||
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
style?: SymbolViewProps['style'];
|
||||
}
|
||||
|
||||
export const SFSymbol: React.FC<SFSymbolProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
weight = 'regular',
|
||||
scale = 'default',
|
||||
mode = 'monochrome',
|
||||
fallbackIcon,
|
||||
style,
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
weight = 'regular',
|
||||
scale = 'default',
|
||||
mode = 'monochrome',
|
||||
fallbackIcon,
|
||||
style,
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
// Use dynamic color if no color specified
|
||||
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
|
||||
// Use SF Symbols on iOS, fallback to FontAwesome on Android
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<SymbolView
|
||||
name={name}
|
||||
size={size}
|
||||
tintColor={dynamicColor}
|
||||
weight={weight}
|
||||
scale={scale}
|
||||
mode={mode}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Use dynamic color if no color specified
|
||||
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
|
||||
// Use SF Symbols on iOS, fallback to FontAwesome on Android
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<SymbolView
|
||||
name={name}
|
||||
size={size}
|
||||
tintColor={dynamicColor}
|
||||
weight={weight}
|
||||
scale={scale}
|
||||
mode={mode}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Android fallback
|
||||
if (fallbackIcon) {
|
||||
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
|
||||
}
|
||||
// Android fallback
|
||||
if (fallbackIcon) {
|
||||
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
|
||||
}
|
||||
|
||||
// Default fallback if no fallbackIcon provided
|
||||
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
|
||||
// Default fallback if no fallbackIcon provided
|
||||
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 16.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ const { defineConfig } = require('eslint/config');
|
|||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,102 +4,102 @@ import * as ImagePicker from 'expo-image-picker';
|
|||
import { PhotoService } from '../services/storage/PhotoService';
|
||||
|
||||
export function useCamera() {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [facing, setFacing] = useState<CameraType>('back');
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [facing, setFacing] = useState<CameraType>('back');
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
|
||||
const photoService = PhotoService.getInstance();
|
||||
const photoService = PhotoService.getInstance();
|
||||
|
||||
const toggleCameraFacing = () => {
|
||||
setFacing((current) => (current === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
const toggleCameraFacing = () => {
|
||||
setFacing((current) => (current === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isCapturing) return null;
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isCapturing) return null;
|
||||
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
|
||||
if (!photo) return null;
|
||||
if (!photo) return null;
|
||||
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(photo.uri);
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(photo.uri);
|
||||
|
||||
return {
|
||||
uri: photo.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
return {
|
||||
uri: photo.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
// Request permission
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
// Request permission
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
throw new Error('Permission to access gallery denied');
|
||||
}
|
||||
if (!permissionResult.granted) {
|
||||
throw new Error('Permission to access gallery denied');
|
||||
}
|
||||
|
||||
// Launch image picker
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
// Launch image picker
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
const asset = result.assets[0];
|
||||
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(asset.uri);
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(asset.uri);
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return {
|
||||
uri: asset.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = permission?.granted ?? false;
|
||||
const canAskPermission = permission?.canAskAgain ?? true;
|
||||
const hasPermission = permission?.granted ?? false;
|
||||
const canAskPermission = permission?.canAskAgain ?? true;
|
||||
|
||||
return {
|
||||
// Permission state
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
return {
|
||||
// Permission state
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
|
||||
// Camera state
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
// Camera state
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
|
||||
// Actions
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
};
|
||||
// Actions
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,71 +6,71 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
|
|||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export function useDatabase() {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setInitialized = useAppStore((state) => state.setInitialized);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setInitialized = useAppStore((state) => state.setInitialized);
|
||||
|
||||
useEffect(() => {
|
||||
initializeDatabase();
|
||||
}, [initializeDatabase]);
|
||||
useEffect(() => {
|
||||
initializeDatabase();
|
||||
}, [initializeDatabase]);
|
||||
|
||||
const initializeDatabase = useCallback(async () => {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
const initializeDatabase = useCallback(async () => {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
|
||||
// Initialize SQLite service
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.initialize();
|
||||
// Initialize SQLite service
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.initialize();
|
||||
|
||||
// Get database instance for migrations
|
||||
const db = (dbService as any).db; // Access private db property
|
||||
if (db) {
|
||||
const migrationService = MigrationService.getInstance();
|
||||
migrationService.setDatabase(db);
|
||||
await migrationService.runMigrations();
|
||||
}
|
||||
// Get database instance for migrations
|
||||
const db = (dbService as any).db; // Access private db property
|
||||
if (db) {
|
||||
const migrationService = MigrationService.getInstance();
|
||||
migrationService.setDatabase(db);
|
||||
await migrationService.runMigrations();
|
||||
}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
console.log('Database initialized successfully');
|
||||
|
||||
// Initialize user preferences
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.initialize();
|
||||
console.log('User preferences initialized');
|
||||
// Initialize user preferences
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.initialize();
|
||||
console.log('User preferences initialized');
|
||||
|
||||
// Clean up temporary photos on app start
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up');
|
||||
// Clean up temporary photos on app start
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up');
|
||||
|
||||
setIsReady(true);
|
||||
setInitialized(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
|
||||
console.error('Database initialization error:', errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [setInitialized]);
|
||||
setIsReady(true);
|
||||
setInitialized(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
|
||||
console.error('Database initialization error:', errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [setInitialized]);
|
||||
|
||||
const resetDatabase = async () => {
|
||||
try {
|
||||
setIsReady(false);
|
||||
setError(null);
|
||||
const resetDatabase = async () => {
|
||||
try {
|
||||
setIsReady(false);
|
||||
setError(null);
|
||||
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.close();
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.close();
|
||||
|
||||
// Reinitialize
|
||||
await initializeDatabase();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
// Reinitialize
|
||||
await initializeDatabase();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isReady,
|
||||
error,
|
||||
resetDatabase,
|
||||
retryInitialization: initializeDatabase,
|
||||
};
|
||||
return {
|
||||
isReady,
|
||||
error,
|
||||
resetDatabase,
|
||||
retryInitialization: initializeDatabase,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,57 +6,57 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||
const THEME_STORAGE_KEY = 'user-theme-preference';
|
||||
|
||||
export const useTheme = () => {
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { theme, setTheme } = useAppStore();
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { theme, setTheme } = useAppStore();
|
||||
|
||||
// Initialize theme from storage on app start
|
||||
useEffect(() => {
|
||||
const initializeTheme = async () => {
|
||||
try {
|
||||
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
|
||||
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
|
||||
setTheme(parsedTheme);
|
||||
// Initialize theme from storage on app start
|
||||
useEffect(() => {
|
||||
const initializeTheme = async () => {
|
||||
try {
|
||||
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
|
||||
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
|
||||
setTheme(parsedTheme);
|
||||
|
||||
// Apply the theme to NativeWind
|
||||
if (parsedTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(parsedTheme);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme from storage:', error);
|
||||
}
|
||||
};
|
||||
// Apply the theme to NativeWind
|
||||
if (parsedTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(parsedTheme);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme from storage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTheme();
|
||||
}, [setTheme, setColorScheme]);
|
||||
initializeTheme();
|
||||
}, [setTheme, setColorScheme]);
|
||||
|
||||
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
// Update AppStore
|
||||
setTheme(newTheme);
|
||||
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
// Update AppStore
|
||||
setTheme(newTheme);
|
||||
|
||||
// Update NativeWind
|
||||
if (newTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(newTheme);
|
||||
}
|
||||
// Update NativeWind
|
||||
if (newTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(newTheme);
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.error('Error saving theme to storage:', error);
|
||||
}
|
||||
};
|
||||
// Persist to storage
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.error('Error saving theme to storage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
colorScheme,
|
||||
updateTheme,
|
||||
isDark: colorScheme === 'dark',
|
||||
isLight: colorScheme === 'light',
|
||||
};
|
||||
return {
|
||||
theme,
|
||||
colorScheme,
|
||||
updateTheme,
|
||||
isDark: colorScheme === 'dark',
|
||||
isLight: colorScheme === 'light',
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ const config = getDefaultConfig(__dirname);
|
|||
|
||||
// Add path mapping for @ alias
|
||||
config.resolver.alias = {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
...config.resolver.alias,
|
||||
'@': path.resolve(__dirname, './'),
|
||||
...config.resolver.alias,
|
||||
};
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
{
|
||||
"name": "@nutriphi/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-clipboard/clipboard": "^1.16.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "^53.0.11",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-camera": "^16.1.8",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.0",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-image-picker": "^16.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-location": "^18.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-sqlite": "^15.2.12",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-context-menu-view": "^1.19.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
"name": "@nutriphi/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-clipboard/clipboard": "^1.16.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "^53.0.11",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-camera": "^16.1.8",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.0",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-image-picker": "^16.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-location": "^18.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-sqlite": "^15.2.12",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-context-menu-view": "^1.19.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,143 +8,143 @@ import { useAuthStore } from '../store/AuthStore';
|
|||
import { tokenManager } from './auth/tokenManager';
|
||||
|
||||
export class DataClearingService {
|
||||
private static instance: DataClearingService;
|
||||
private static instance: DataClearingService;
|
||||
|
||||
public static getInstance(): DataClearingService {
|
||||
if (!DataClearingService.instance) {
|
||||
DataClearingService.instance = new DataClearingService();
|
||||
}
|
||||
return DataClearingService.instance;
|
||||
}
|
||||
public static getInstance(): DataClearingService {
|
||||
if (!DataClearingService.instance) {
|
||||
DataClearingService.instance = new DataClearingService();
|
||||
}
|
||||
return DataClearingService.instance;
|
||||
}
|
||||
|
||||
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. Clear SQLite database
|
||||
await this.clearDatabase();
|
||||
} catch (error) {
|
||||
errors.push(`Database clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 1. Clear SQLite database
|
||||
await this.clearDatabase();
|
||||
} catch (error) {
|
||||
errors.push(`Database clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Clear photo storage
|
||||
await this.clearPhotoStorage();
|
||||
} catch (error) {
|
||||
errors.push(`Photo storage clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 2. Clear photo storage
|
||||
await this.clearPhotoStorage();
|
||||
} catch (error) {
|
||||
errors.push(`Photo storage clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. Reset Zustand stores
|
||||
this.resetZustandStores();
|
||||
} catch (error) {
|
||||
errors.push(`State reset failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 3. Reset Zustand stores
|
||||
this.resetZustandStores();
|
||||
} catch (error) {
|
||||
errors.push(`State reset failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Clear AsyncStorage
|
||||
await this.clearAsyncStorage();
|
||||
} catch (error) {
|
||||
errors.push(`AsyncStorage clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 4. Clear AsyncStorage
|
||||
await this.clearAsyncStorage();
|
||||
} catch (error) {
|
||||
errors.push(`AsyncStorage clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 5. Sign out and clear auth tokens
|
||||
await this.signOutAndClearAuth();
|
||||
} catch (error) {
|
||||
errors.push(`Auth clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 5. Sign out and clear auth tokens
|
||||
await this.signOutAndClearAuth();
|
||||
} catch (error) {
|
||||
errors.push(`Auth clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
private async signOutAndClearAuth(): Promise<void> {
|
||||
// Sign out from auth store
|
||||
await useAuthStore.getState().signOut();
|
||||
// Clear all tokens
|
||||
await tokenManager.clearTokens();
|
||||
}
|
||||
private async signOutAndClearAuth(): Promise<void> {
|
||||
// Sign out from auth store
|
||||
await useAuthStore.getState().signOut();
|
||||
// Clear all tokens
|
||||
await tokenManager.clearTokens();
|
||||
}
|
||||
|
||||
private async clearDatabase(): Promise<void> {
|
||||
const db = SQLiteService.getInstance();
|
||||
private async clearDatabase(): Promise<void> {
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
// Clear all main tables while preserving structure
|
||||
await db.executeRaw('DELETE FROM meals');
|
||||
await db.executeRaw('DELETE FROM food_items');
|
||||
await db.executeRaw('DELETE FROM sync_metadata');
|
||||
// Clear all main tables while preserving structure
|
||||
await db.executeRaw('DELETE FROM meals');
|
||||
await db.executeRaw('DELETE FROM food_items');
|
||||
await db.executeRaw('DELETE FROM sync_metadata');
|
||||
|
||||
// Reset user preferences to defaults but keep the table
|
||||
await db.executeRaw('DELETE FROM user_preferences');
|
||||
// Reset user preferences to defaults but keep the table
|
||||
await db.executeRaw('DELETE FROM user_preferences');
|
||||
|
||||
// Don't delete schema_migrations to preserve database version
|
||||
}
|
||||
// Don't delete schema_migrations to preserve database version
|
||||
}
|
||||
|
||||
private async clearPhotoStorage(): Promise<void> {
|
||||
const photosDir = `${FileSystem.documentDirectory}photos/`;
|
||||
private async clearPhotoStorage(): Promise<void> {
|
||||
const photosDir = `${FileSystem.documentDirectory}photos/`;
|
||||
|
||||
// Check if photos directory exists
|
||||
const dirInfo = await FileSystem.getInfoAsync(photosDir);
|
||||
if (!dirInfo.exists) return;
|
||||
// Check if photos directory exists
|
||||
const dirInfo = await FileSystem.getInfoAsync(photosDir);
|
||||
if (!dirInfo.exists) return;
|
||||
|
||||
// Get all files in photos directory
|
||||
const files = await FileSystem.readDirectoryAsync(photosDir);
|
||||
// Get all files in photos directory
|
||||
const files = await FileSystem.readDirectoryAsync(photosDir);
|
||||
|
||||
// Delete all photo files
|
||||
for (const file of files) {
|
||||
const filePath = `${photosDir}${file}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
}
|
||||
// Delete all photo files
|
||||
for (const file of files) {
|
||||
const filePath = `${photosDir}${file}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
}
|
||||
|
||||
// Also cleanup any temp photos
|
||||
await PhotoService.getInstance().cleanupTempPhotos();
|
||||
}
|
||||
// Also cleanup any temp photos
|
||||
await PhotoService.getInstance().cleanupTempPhotos();
|
||||
}
|
||||
|
||||
private resetZustandStores(): void {
|
||||
// Reset MealStore
|
||||
const mealStore = useMealStore.getState();
|
||||
mealStore.clearAllMeals();
|
||||
mealStore.setSelectedMeal(null);
|
||||
private resetZustandStores(): void {
|
||||
// Reset MealStore
|
||||
const mealStore = useMealStore.getState();
|
||||
mealStore.clearAllMeals();
|
||||
mealStore.setSelectedMeal(null);
|
||||
|
||||
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
|
||||
const appStore = useAppStore.getState();
|
||||
appStore.resetStats();
|
||||
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
|
||||
const appStore = useAppStore.getState();
|
||||
appStore.resetStats();
|
||||
|
||||
// Reset other app store states except theme
|
||||
const currentTheme = appStore.theme;
|
||||
appStore.resetToDefaults();
|
||||
appStore.setTheme(currentTheme); // Preserve current theme
|
||||
}
|
||||
// Reset other app store states except theme
|
||||
const currentTheme = appStore.theme;
|
||||
appStore.resetToDefaults();
|
||||
appStore.setTheme(currentTheme); // Preserve current theme
|
||||
}
|
||||
|
||||
private async clearAsyncStorage(): Promise<void> {
|
||||
// Get all keys
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
private async clearAsyncStorage(): Promise<void> {
|
||||
// Get all keys
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
|
||||
// Define keys to clear (all except we might want to preserve some)
|
||||
const keysToRemove = keys.filter(
|
||||
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
|
||||
);
|
||||
// Define keys to clear (all except we might want to preserve some)
|
||||
const keysToRemove = keys.filter(
|
||||
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
|
||||
);
|
||||
|
||||
// Clear selected keys
|
||||
if (keysToRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(keysToRemove);
|
||||
}
|
||||
}
|
||||
// Clear selected keys
|
||||
if (keysToRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(keysToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Clear everything including theme preference
|
||||
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const result = await this.clearAllData();
|
||||
// Optional: Clear everything including theme preference
|
||||
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const result = await this.clearAllData();
|
||||
|
||||
try {
|
||||
// Also clear theme preference
|
||||
await AsyncStorage.removeItem('user-theme-preference');
|
||||
} catch (error) {
|
||||
result.errors.push(`Theme preference clearing failed: ${error}`);
|
||||
result.success = false;
|
||||
}
|
||||
try {
|
||||
// Also clear theme preference
|
||||
await AsyncStorage.removeItem('user-theme-preference');
|
||||
} catch (error) {
|
||||
result.errors.push(`Theme preference clearing failed: ${error}`);
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,217 +1,217 @@
|
|||
import * as Location from 'expo-location';
|
||||
|
||||
export interface LocationData {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
altitudeAccuracy: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
timestamp: number;
|
||||
address?: LocationAddress;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
altitudeAccuracy: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
timestamp: number;
|
||||
address?: LocationAddress;
|
||||
}
|
||||
|
||||
export interface LocationAddress {
|
||||
name?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
formattedAddress?: string;
|
||||
name?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
export class LocationService {
|
||||
private static instance: LocationService;
|
||||
private hasPermission: boolean = false;
|
||||
private static instance: LocationService;
|
||||
private hasPermission: boolean = false;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LocationService {
|
||||
if (!LocationService.instance) {
|
||||
LocationService.instance = new LocationService();
|
||||
}
|
||||
return LocationService.instance;
|
||||
}
|
||||
public static getInstance(): LocationService {
|
||||
if (!LocationService.instance) {
|
||||
LocationService.instance = new LocationService();
|
||||
}
|
||||
return LocationService.instance;
|
||||
}
|
||||
|
||||
public async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to check location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to check location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async requestPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to request location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async requestPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to request location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentLocation(): Promise<LocationData | null> {
|
||||
try {
|
||||
// Check permissions first
|
||||
if (!this.hasPermission) {
|
||||
const granted = await this.requestPermissions();
|
||||
if (!granted) {
|
||||
console.log('Location permission denied');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public async getCurrentLocation(): Promise<LocationData | null> {
|
||||
try {
|
||||
// Check permissions first
|
||||
if (!this.hasPermission) {
|
||||
const granted = await this.requestPermissions();
|
||||
if (!granted) {
|
||||
console.log('Location permission denied');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current location with high accuracy
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.High,
|
||||
timeInterval: 5000, // 5 seconds
|
||||
mayShowUserSettingsDialog: true,
|
||||
});
|
||||
// Get current location with high accuracy
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.High,
|
||||
timeInterval: 5000, // 5 seconds
|
||||
mayShowUserSettingsDialog: true,
|
||||
});
|
||||
|
||||
const locationData: LocationData = {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
altitude: location.coords.altitude,
|
||||
altitudeAccuracy: location.coords.altitudeAccuracy,
|
||||
heading: location.coords.heading,
|
||||
speed: location.coords.speed,
|
||||
timestamp: location.timestamp,
|
||||
};
|
||||
const locationData: LocationData = {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
altitude: location.coords.altitude,
|
||||
altitudeAccuracy: location.coords.altitudeAccuracy,
|
||||
heading: location.coords.heading,
|
||||
speed: location.coords.speed,
|
||||
timestamp: location.timestamp,
|
||||
};
|
||||
|
||||
// Try to get address
|
||||
try {
|
||||
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
|
||||
locationData.address = address;
|
||||
} catch (error) {
|
||||
console.warn('Reverse geocoding failed:', error);
|
||||
}
|
||||
// Try to get address
|
||||
try {
|
||||
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
|
||||
locationData.address = address;
|
||||
} catch (error) {
|
||||
console.warn('Reverse geocoding failed:', error);
|
||||
}
|
||||
|
||||
return locationData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return locationData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<LocationAddress | null> {
|
||||
try {
|
||||
const results = await Location.reverseGeocodeAsync({
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
public async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<LocationAddress | null> {
|
||||
try {
|
||||
const results = await Location.reverseGeocodeAsync({
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
if (results && results.length > 0) {
|
||||
const result = results[0];
|
||||
if (results && results.length > 0) {
|
||||
const result = results[0];
|
||||
|
||||
// Build formatted address
|
||||
const addressParts = [];
|
||||
// Build formatted address
|
||||
const addressParts = [];
|
||||
|
||||
// Try to detect common places
|
||||
let placeName = result.name;
|
||||
if (!placeName && result.street) {
|
||||
placeName = result.street;
|
||||
}
|
||||
// Try to detect common places
|
||||
let placeName = result.name;
|
||||
if (!placeName && result.street) {
|
||||
placeName = result.street;
|
||||
}
|
||||
|
||||
// Build formatted address
|
||||
if (result.streetNumber) addressParts.push(result.streetNumber);
|
||||
if (result.street) addressParts.push(result.street);
|
||||
const streetAddress = addressParts.join(' ');
|
||||
// Build formatted address
|
||||
if (result.streetNumber) addressParts.push(result.streetNumber);
|
||||
if (result.street) addressParts.push(result.street);
|
||||
const streetAddress = addressParts.join(' ');
|
||||
|
||||
const cityParts = [];
|
||||
if (result.city) cityParts.push(result.city);
|
||||
if (result.region) cityParts.push(result.region);
|
||||
if (result.postalCode) cityParts.push(result.postalCode);
|
||||
const cityAddress = cityParts.join(', ');
|
||||
const cityParts = [];
|
||||
if (result.city) cityParts.push(result.city);
|
||||
if (result.region) cityParts.push(result.region);
|
||||
if (result.postalCode) cityParts.push(result.postalCode);
|
||||
const cityAddress = cityParts.join(', ');
|
||||
|
||||
const formattedAddress = [streetAddress, cityAddress, result.country]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const formattedAddress = [streetAddress, cityAddress, result.country]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
name: placeName || undefined,
|
||||
street: streetAddress || undefined,
|
||||
city: result.city || undefined,
|
||||
region: result.region || undefined,
|
||||
country: result.country || undefined,
|
||||
postalCode: result.postalCode || undefined,
|
||||
formattedAddress: formattedAddress || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: placeName || undefined,
|
||||
street: streetAddress || undefined,
|
||||
city: result.city || undefined,
|
||||
region: result.region || undefined,
|
||||
country: result.country || undefined,
|
||||
postalCode: result.postalCode || undefined,
|
||||
formattedAddress: formattedAddress || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public getReadableLocationName(address: LocationAddress | null): string {
|
||||
if (!address) return 'Unbekannter Ort';
|
||||
public getReadableLocationName(address: LocationAddress | null): string {
|
||||
if (!address) return 'Unbekannter Ort';
|
||||
|
||||
// Priority: name > street > city > region > country
|
||||
if (address.name) return address.name;
|
||||
if (address.street) return address.street;
|
||||
if (address.city) return address.city;
|
||||
if (address.region) return address.region;
|
||||
if (address.country) return address.country;
|
||||
// Priority: name > street > city > region > country
|
||||
if (address.name) return address.name;
|
||||
if (address.street) return address.street;
|
||||
if (address.city) return address.city;
|
||||
if (address.region) return address.region;
|
||||
if (address.country) return address.country;
|
||||
|
||||
return 'Unbekannter Ort';
|
||||
}
|
||||
return 'Unbekannter Ort';
|
||||
}
|
||||
|
||||
public formatLocationForDisplay(address: LocationAddress | null): string {
|
||||
if (!address) return '';
|
||||
public formatLocationForDisplay(address: LocationAddress | null): string {
|
||||
if (!address) return '';
|
||||
|
||||
// For display in UI, show a concise version
|
||||
if (address.name && address.city) {
|
||||
return `${address.name}, ${address.city}`;
|
||||
}
|
||||
// For display in UI, show a concise version
|
||||
if (address.name && address.city) {
|
||||
return `${address.name}, ${address.city}`;
|
||||
}
|
||||
|
||||
if (address.street && address.city) {
|
||||
return `${address.street}, ${address.city}`;
|
||||
}
|
||||
if (address.street && address.city) {
|
||||
return `${address.street}, ${address.city}`;
|
||||
}
|
||||
|
||||
if (address.city) {
|
||||
return address.city;
|
||||
}
|
||||
if (address.city) {
|
||||
return address.city;
|
||||
}
|
||||
|
||||
return address.formattedAddress || 'Unbekannter Ort';
|
||||
}
|
||||
return address.formattedAddress || 'Unbekannter Ort';
|
||||
}
|
||||
|
||||
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
// Haversine formula to calculate distance in meters
|
||||
const R = 6371e3; // Earth's radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
// Haversine formula to calculate distance in meters
|
||||
const R = 6371e3; // Earth's radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // Distance in meters
|
||||
}
|
||||
return R * c; // Distance in meters
|
||||
}
|
||||
|
||||
public isNearLocation(
|
||||
currentLat: number,
|
||||
currentLon: number,
|
||||
targetLat: number,
|
||||
targetLon: number,
|
||||
thresholdMeters: number = 100
|
||||
): boolean {
|
||||
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
public isNearLocation(
|
||||
currentLat: number,
|
||||
currentLon: number,
|
||||
targetLat: number,
|
||||
targetLon: number,
|
||||
thresholdMeters: number = 100
|
||||
): boolean {
|
||||
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,207 @@
|
|||
import { SQLiteService } from './database/SQLiteService';
|
||||
|
||||
export interface UserPreferences {
|
||||
locationEnabled: boolean;
|
||||
locationPermissionAsked: boolean;
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
healthGoalCalories?: number;
|
||||
healthGoalProtein?: number;
|
||||
healthGoalCarbs?: number;
|
||||
healthGoalFat?: number;
|
||||
notificationsEnabled: boolean;
|
||||
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: 'de' | 'en';
|
||||
locationEnabled: boolean;
|
||||
locationPermissionAsked: boolean;
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
healthGoalCalories?: number;
|
||||
healthGoalProtein?: number;
|
||||
healthGoalCarbs?: number;
|
||||
healthGoalFat?: number;
|
||||
notificationsEnabled: boolean;
|
||||
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: 'de' | 'en';
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
locationEnabled: true,
|
||||
locationPermissionAsked: false,
|
||||
defaultMealType: 'lunch',
|
||||
notificationsEnabled: true,
|
||||
reminderTimes: [],
|
||||
theme: 'system',
|
||||
language: 'de',
|
||||
locationEnabled: true,
|
||||
locationPermissionAsked: false,
|
||||
defaultMealType: 'lunch',
|
||||
notificationsEnabled: true,
|
||||
reminderTimes: [],
|
||||
theme: 'system',
|
||||
language: 'de',
|
||||
};
|
||||
|
||||
export class UserPreferencesService {
|
||||
private static instance: UserPreferencesService;
|
||||
private dbService: SQLiteService;
|
||||
private cachedPreferences: UserPreferences | null = null;
|
||||
private static instance: UserPreferencesService;
|
||||
private dbService: SQLiteService;
|
||||
private cachedPreferences: UserPreferences | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.dbService = SQLiteService.getInstance();
|
||||
}
|
||||
private constructor() {
|
||||
this.dbService = SQLiteService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): UserPreferencesService {
|
||||
if (!UserPreferencesService.instance) {
|
||||
UserPreferencesService.instance = new UserPreferencesService();
|
||||
}
|
||||
return UserPreferencesService.instance;
|
||||
}
|
||||
public static getInstance(): UserPreferencesService {
|
||||
if (!UserPreferencesService.instance) {
|
||||
UserPreferencesService.instance = new UserPreferencesService();
|
||||
}
|
||||
return UserPreferencesService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Load preferences into cache
|
||||
await this.loadPreferences();
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
// Load preferences into cache
|
||||
await this.loadPreferences();
|
||||
}
|
||||
|
||||
private async loadPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists first
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, using defaults');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
private async loadPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
|
||||
'SELECT key, value, type FROM user_preferences'
|
||||
);
|
||||
// Check if table exists first
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
const preferences = { ...DEFAULT_PREFERENCES };
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, using defaults');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const value = this.parseValue(row.value, row.type);
|
||||
(preferences as any)[row.key] = value;
|
||||
}
|
||||
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
|
||||
'SELECT key, value, type FROM user_preferences'
|
||||
);
|
||||
|
||||
this.cachedPreferences = preferences;
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
}
|
||||
const preferences = { ...DEFAULT_PREFERENCES };
|
||||
|
||||
private parseValue(value: string, type: string): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'number':
|
||||
return parseFloat(value);
|
||||
case 'array':
|
||||
return JSON.parse(value);
|
||||
case 'string':
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
const value = this.parseValue(row.value, row.type);
|
||||
(preferences as any)[row.key] = value;
|
||||
}
|
||||
|
||||
private getValueType(value: any): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
this.cachedPreferences = preferences;
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
public async getPreferences(): Promise<UserPreferences> {
|
||||
if (!this.cachedPreferences) {
|
||||
await this.loadPreferences();
|
||||
}
|
||||
return this.cachedPreferences!;
|
||||
}
|
||||
private parseValue(value: string, type: string): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'number':
|
||||
return parseFloat(value);
|
||||
case 'array':
|
||||
return JSON.parse(value);
|
||||
case 'string':
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public async updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update cache immediately for responsive UI
|
||||
if (!this.cachedPreferences) {
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
this.cachedPreferences[key] = value;
|
||||
private getValueType(value: any): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, cache updated only');
|
||||
return;
|
||||
}
|
||||
public async getPreferences(): Promise<UserPreferences> {
|
||||
if (!this.cachedPreferences) {
|
||||
await this.loadPreferences();
|
||||
}
|
||||
return this.cachedPreferences!;
|
||||
}
|
||||
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
public async updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update cache immediately for responsive UI
|
||||
if (!this.cachedPreferences) {
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
this.cachedPreferences[key] = value;
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, cache updated only');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update preference ${key}:`, error);
|
||||
// Don't throw - we already updated the cache
|
||||
}
|
||||
}
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update preference ${key}:`, error);
|
||||
// Don't throw - we already updated the cache
|
||||
}
|
||||
}
|
||||
|
||||
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
|
||||
const db = await this.dbService.getDatabase();
|
||||
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
try {
|
||||
await db.execAsync('BEGIN TRANSACTION');
|
||||
try {
|
||||
await db.execAsync('BEGIN TRANSACTION');
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
}
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
}
|
||||
|
||||
await db.execAsync('COMMIT');
|
||||
await db.execAsync('COMMIT');
|
||||
|
||||
// Update cache
|
||||
if (this.cachedPreferences) {
|
||||
Object.assign(this.cachedPreferences, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
await db.execAsync('ROLLBACK');
|
||||
console.error('Failed to update preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Update cache
|
||||
if (this.cachedPreferences) {
|
||||
Object.assign(this.cachedPreferences, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
await db.execAsync('ROLLBACK');
|
||||
console.error('Failed to update preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async resetToDefaults(): Promise<void> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
await db.execAsync('DELETE FROM user_preferences');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
} catch (error) {
|
||||
console.error('Failed to reset preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public async resetToDefaults(): Promise<void> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
await db.execAsync('DELETE FROM user_preferences');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
} catch (error) {
|
||||
console.error('Failed to reset preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
public async isLocationEnabled(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationEnabled;
|
||||
}
|
||||
// Convenience methods
|
||||
public async isLocationEnabled(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationEnabled;
|
||||
}
|
||||
|
||||
public async setLocationEnabled(enabled: boolean): Promise<void> {
|
||||
await this.updatePreference('locationEnabled', enabled);
|
||||
}
|
||||
public async setLocationEnabled(enabled: boolean): Promise<void> {
|
||||
await this.updatePreference('locationEnabled', enabled);
|
||||
}
|
||||
|
||||
public async hasAskedLocationPermission(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationPermissionAsked;
|
||||
}
|
||||
public async hasAskedLocationPermission(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationPermissionAsked;
|
||||
}
|
||||
|
||||
public async markLocationPermissionAsked(): Promise<void> {
|
||||
await this.updatePreference('locationPermissionAsked', true);
|
||||
}
|
||||
public async markLocationPermissionAsked(): Promise<void> {
|
||||
await this.updatePreference('locationPermissionAsked', true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,117 +5,117 @@ import Constants from 'expo-constants';
|
|||
import { GeminiAnalysisResult, GeminiError, PromptContext } from '../../types/API';
|
||||
|
||||
interface GeminiConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
private static instance: GeminiService;
|
||||
private genAI: GoogleGenerativeAI | null = null;
|
||||
private model: any = null;
|
||||
private static instance: GeminiService;
|
||||
private genAI: GoogleGenerativeAI | null = null;
|
||||
private model: any = null;
|
||||
|
||||
private config: GeminiConfig = {
|
||||
apiKey:
|
||||
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
|
||||
model: 'gemini-1.5-pro-latest',
|
||||
temperature: 0.1, // Low temperature for consistent analysis
|
||||
maxOutputTokens: 2048,
|
||||
};
|
||||
private config: GeminiConfig = {
|
||||
apiKey:
|
||||
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
|
||||
model: 'gemini-1.5-pro-latest',
|
||||
temperature: 0.1, // Low temperature for consistent analysis
|
||||
maxOutputTokens: 2048,
|
||||
};
|
||||
|
||||
private requestTimeout = 60000; // 60 seconds timeout
|
||||
private requestTimeout = 60000; // 60 seconds timeout
|
||||
|
||||
private retryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
private retryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public static getInstance(): GeminiService {
|
||||
if (!GeminiService.instance) {
|
||||
GeminiService.instance = new GeminiService();
|
||||
}
|
||||
return GeminiService.instance;
|
||||
}
|
||||
public static getInstance(): GeminiService {
|
||||
if (!GeminiService.instance) {
|
||||
GeminiService.instance = new GeminiService();
|
||||
}
|
||||
return GeminiService.instance;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
console.log('Initializing GeminiService...');
|
||||
console.log('API Key available:', !!this.config.apiKey);
|
||||
console.log('API Key length:', this.config.apiKey.length);
|
||||
private initialize() {
|
||||
console.log('Initializing GeminiService...');
|
||||
console.log('API Key available:', !!this.config.apiKey);
|
||||
console.log('API Key length:', this.config.apiKey.length);
|
||||
|
||||
if (!this.config.apiKey) {
|
||||
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
|
||||
return;
|
||||
}
|
||||
if (!this.config.apiKey) {
|
||||
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxOutputTokens,
|
||||
},
|
||||
});
|
||||
console.log('GeminiService initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize GeminiService:', error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxOutputTokens,
|
||||
},
|
||||
});
|
||||
console.log('GeminiService initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize GeminiService:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts local image file to base64 for Gemini API
|
||||
*/
|
||||
private async imageToBase64(imagePath: string): Promise<string> {
|
||||
try {
|
||||
console.log('Converting image to base64:', imagePath);
|
||||
/**
|
||||
* Converts local image file to base64 for Gemini API
|
||||
*/
|
||||
private async imageToBase64(imagePath: string): Promise<string> {
|
||||
try {
|
||||
console.log('Converting image to base64:', imagePath);
|
||||
|
||||
// Check if file exists
|
||||
const fileInfo = await FileSystem.getInfoAsync(imagePath);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error(`Image file not found: ${imagePath}`);
|
||||
}
|
||||
// Check if file exists
|
||||
const fileInfo = await FileSystem.getInfoAsync(imagePath);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error(`Image file not found: ${imagePath}`);
|
||||
}
|
||||
|
||||
// Check file size (limit to 20MB to prevent timeouts)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (fileInfo.size && fileInfo.size > maxSize) {
|
||||
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
|
||||
}
|
||||
// Check file size (limit to 20MB to prevent timeouts)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (fileInfo.size && fileInfo.size > maxSize) {
|
||||
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
|
||||
}
|
||||
|
||||
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
|
||||
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
|
||||
|
||||
const base64 = await FileSystem.readAsStringAsync(imagePath, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const base64 = await FileSystem.readAsStringAsync(imagePath, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
console.log('Base64 conversion completed, length:', base64.length);
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert image to base64:', error);
|
||||
throw new Error(`Failed to convert image to base64: ${error}`);
|
||||
}
|
||||
}
|
||||
console.log('Base64 conversion completed, length:', base64.length);
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert image to base64:', error);
|
||||
throw new Error(`Failed to convert image to base64: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the optimized prompt based on context
|
||||
*/
|
||||
private generatePrompt(context?: PromptContext): string {
|
||||
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
|
||||
/**
|
||||
* Generates the optimized prompt based on context
|
||||
*/
|
||||
private generatePrompt(context?: PromptContext): string {
|
||||
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
|
||||
|
||||
AUFGABE:
|
||||
1. Erkenne alle sichtbaren Lebensmittel und schätze realistische Portionsgrößen
|
||||
|
|
@ -194,347 +194,347 @@ WICHTIG:
|
|||
- Versteckte Fette/Öle nicht vergessen
|
||||
- Mehrere gleiche Items separat listen`;
|
||||
|
||||
return basePrompt;
|
||||
}
|
||||
return basePrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds contextual information to the prompt
|
||||
*/
|
||||
private getContextualPrompt(context?: PromptContext): string {
|
||||
if (!context) return '';
|
||||
/**
|
||||
* Adds contextual information to the prompt
|
||||
*/
|
||||
private getContextualPrompt(context?: PromptContext): string {
|
||||
if (!context) return '';
|
||||
|
||||
const contextPrompts = {
|
||||
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
|
||||
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
|
||||
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
|
||||
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
|
||||
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
|
||||
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
|
||||
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
|
||||
};
|
||||
const contextPrompts = {
|
||||
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
|
||||
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
|
||||
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
|
||||
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
|
||||
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
|
||||
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
|
||||
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
|
||||
};
|
||||
|
||||
const contextStrings: string[] = [];
|
||||
const contextStrings: string[] = [];
|
||||
|
||||
if (context.mealType) {
|
||||
contextStrings.push(contextPrompts[context.mealType] || '');
|
||||
}
|
||||
if (context.mealType) {
|
||||
contextStrings.push(contextPrompts[context.mealType] || '');
|
||||
}
|
||||
|
||||
if (context.location) {
|
||||
contextStrings.push(contextPrompts[context.location] || '');
|
||||
}
|
||||
if (context.location) {
|
||||
contextStrings.push(contextPrompts[context.location] || '');
|
||||
}
|
||||
|
||||
if (context.additional) {
|
||||
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
|
||||
}
|
||||
if (context.additional) {
|
||||
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
|
||||
}
|
||||
|
||||
return contextStrings.filter(Boolean).join('\n');
|
||||
}
|
||||
return contextStrings.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements exponential backoff retry logic
|
||||
*/
|
||||
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= this.retryConfig.maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
/**
|
||||
* Implements exponential backoff retry logic
|
||||
*/
|
||||
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= this.retryConfig.maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
|
||||
this.retryConfig.maxDelay
|
||||
);
|
||||
const delay = Math.min(
|
||||
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
|
||||
this.retryConfig.maxDelay
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
|
||||
);
|
||||
console.log(
|
||||
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.retry(operation, attempt + 1);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.retry(operation, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and parses the Gemini API response
|
||||
*/
|
||||
private validateResponse(response: string): GeminiAnalysisResult {
|
||||
try {
|
||||
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
|
||||
/**
|
||||
* Validates and parses the Gemini API response
|
||||
*/
|
||||
private validateResponse(response: string): GeminiAnalysisResult {
|
||||
try {
|
||||
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
|
||||
|
||||
// Clean the response - remove any markdown formatting
|
||||
const cleanResponse = response
|
||||
.replace(/```json\n?/g, '')
|
||||
.replace(/```\n?/g, '')
|
||||
.trim();
|
||||
// Clean the response - remove any markdown formatting
|
||||
const cleanResponse = response
|
||||
.replace(/```json\n?/g, '')
|
||||
.replace(/```\n?/g, '')
|
||||
.trim();
|
||||
|
||||
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
|
||||
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
|
||||
|
||||
const parsed = JSON.parse(cleanResponse);
|
||||
console.log('Parsed JSON structure:', {
|
||||
hasMealAnalysis: !!parsed.meal_analysis,
|
||||
hasFoodItems: !!parsed.food_items,
|
||||
hasAnalysisNotes: !!parsed.analysis_notes,
|
||||
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
|
||||
});
|
||||
const parsed = JSON.parse(cleanResponse);
|
||||
console.log('Parsed JSON structure:', {
|
||||
hasMealAnalysis: !!parsed.meal_analysis,
|
||||
hasFoodItems: !!parsed.food_items,
|
||||
hasAnalysisNotes: !!parsed.analysis_notes,
|
||||
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
|
||||
throw new Error('Missing required fields in API response');
|
||||
}
|
||||
// Validate required fields
|
||||
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
|
||||
throw new Error('Missing required fields in API response');
|
||||
}
|
||||
|
||||
// Validate meal_analysis structure with fallbacks
|
||||
const mealAnalysis = parsed.meal_analysis;
|
||||
// Validate meal_analysis structure with fallbacks
|
||||
const mealAnalysis = parsed.meal_analysis;
|
||||
|
||||
// Set defaults for missing fields
|
||||
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
|
||||
console.warn('health_score missing, setting default value');
|
||||
mealAnalysis.health_score = 5.0; // Default neutral score
|
||||
}
|
||||
// Set defaults for missing fields
|
||||
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
|
||||
console.warn('health_score missing, setting default value');
|
||||
mealAnalysis.health_score = 5.0; // Default neutral score
|
||||
}
|
||||
|
||||
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
|
||||
console.warn('health_category missing, setting default value');
|
||||
mealAnalysis.health_category = 'moderate';
|
||||
}
|
||||
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
|
||||
console.warn('health_category missing, setting default value');
|
||||
mealAnalysis.health_category = 'moderate';
|
||||
}
|
||||
|
||||
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
|
||||
console.warn('confidence missing, setting default value');
|
||||
mealAnalysis.confidence = 0.7; // Default medium confidence
|
||||
}
|
||||
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
|
||||
console.warn('confidence missing, setting default value');
|
||||
mealAnalysis.confidence = 0.7; // Default medium confidence
|
||||
}
|
||||
|
||||
// Ensure required numerical fields exist
|
||||
const requiredNumericalFields = [
|
||||
'total_calories',
|
||||
'total_protein',
|
||||
'total_carbs',
|
||||
'total_fat',
|
||||
];
|
||||
for (const field of requiredNumericalFields) {
|
||||
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
|
||||
console.warn(`${field} missing, setting to 0`);
|
||||
mealAnalysis[field] = 0;
|
||||
}
|
||||
}
|
||||
// Ensure required numerical fields exist
|
||||
const requiredNumericalFields = [
|
||||
'total_calories',
|
||||
'total_protein',
|
||||
'total_carbs',
|
||||
'total_fat',
|
||||
];
|
||||
for (const field of requiredNumericalFields) {
|
||||
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
|
||||
console.warn(`${field} missing, setting to 0`);
|
||||
mealAnalysis[field] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate health_score range
|
||||
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
|
||||
console.warn('Health score out of range, clamping to 1-10');
|
||||
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
|
||||
}
|
||||
// Validate health_score range
|
||||
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
|
||||
console.warn('Health score out of range, clamping to 1-10');
|
||||
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
|
||||
}
|
||||
|
||||
// Validate confidence range
|
||||
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
|
||||
console.warn('Confidence out of range, clamping to 0-1');
|
||||
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
|
||||
}
|
||||
// Validate confidence range
|
||||
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
|
||||
console.warn('Confidence out of range, clamping to 0-1');
|
||||
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
|
||||
}
|
||||
|
||||
// Ensure food_items is an array
|
||||
if (!Array.isArray(parsed.food_items)) {
|
||||
console.warn('food_items is not an array, creating empty array');
|
||||
parsed.food_items = [];
|
||||
}
|
||||
// Ensure food_items is an array
|
||||
if (!Array.isArray(parsed.food_items)) {
|
||||
console.warn('food_items is not an array, creating empty array');
|
||||
parsed.food_items = [];
|
||||
}
|
||||
|
||||
console.log('Response validation successful');
|
||||
return parsed as GeminiAnalysisResult;
|
||||
} catch (error) {
|
||||
console.error('Full response that failed to parse:', response);
|
||||
throw new Error(`Failed to parse Gemini response: ${error}`);
|
||||
}
|
||||
}
|
||||
console.log('Response validation successful');
|
||||
return parsed as GeminiAnalysisResult;
|
||||
} catch (error) {
|
||||
console.error('Full response that failed to parse:', response);
|
||||
throw new Error(`Failed to parse Gemini response: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to analyze food image
|
||||
*/
|
||||
public async analyzeFoodImage(
|
||||
imagePath: string,
|
||||
context?: PromptContext
|
||||
): Promise<GeminiAnalysisResult> {
|
||||
if (!this.model) {
|
||||
console.error('GeminiService not properly initialized');
|
||||
console.log('Attempting re-initialization...');
|
||||
this.initialize();
|
||||
/**
|
||||
* Main method to analyze food image
|
||||
*/
|
||||
public async analyzeFoodImage(
|
||||
imagePath: string,
|
||||
context?: PromptContext
|
||||
): Promise<GeminiAnalysisResult> {
|
||||
if (!this.model) {
|
||||
console.error('GeminiService not properly initialized');
|
||||
console.log('Attempting re-initialization...');
|
||||
this.initialize();
|
||||
|
||||
if (!this.model) {
|
||||
throw new GeminiError(
|
||||
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
|
||||
'INITIALIZATION_ERROR',
|
||||
'PERMANENT'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.model) {
|
||||
throw new GeminiError(
|
||||
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
|
||||
'INITIALIZATION_ERROR',
|
||||
'PERMANENT'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log('Starting Gemini food analysis...');
|
||||
console.log('Analysis parameters:', {
|
||||
imagePath,
|
||||
context,
|
||||
timeout: this.requestTimeout,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
});
|
||||
try {
|
||||
console.log('Starting Gemini food analysis...');
|
||||
console.log('Analysis parameters:', {
|
||||
imagePath,
|
||||
context,
|
||||
timeout: this.requestTimeout,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
});
|
||||
|
||||
// Convert image to base64
|
||||
console.log('Step 1: Converting image to base64...');
|
||||
const base64Image = await this.imageToBase64(imagePath);
|
||||
console.log('Step 1 completed: Base64 conversion successful');
|
||||
// Convert image to base64
|
||||
console.log('Step 1: Converting image to base64...');
|
||||
const base64Image = await this.imageToBase64(imagePath);
|
||||
console.log('Step 1 completed: Base64 conversion successful');
|
||||
|
||||
// Generate prompt
|
||||
console.log('Step 2: Generating prompt...');
|
||||
const prompt = this.generatePrompt(context);
|
||||
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
|
||||
// Generate prompt
|
||||
console.log('Step 2: Generating prompt...');
|
||||
const prompt = this.generatePrompt(context);
|
||||
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
|
||||
|
||||
// Call Gemini API with retry logic
|
||||
console.log('Step 3: Making Gemini API request...');
|
||||
const result = await this.retry(async () => {
|
||||
console.log('Making Gemini API request with timeout:', this.requestTimeout);
|
||||
const requestStartTime = Date.now();
|
||||
// Call Gemini API with retry logic
|
||||
console.log('Step 3: Making Gemini API request...');
|
||||
const result = await this.retry(async () => {
|
||||
console.log('Making Gemini API request with timeout:', this.requestTimeout);
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
|
||||
}, this.requestTimeout);
|
||||
});
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
|
||||
}, this.requestTimeout);
|
||||
});
|
||||
|
||||
// Race between API call and timeout
|
||||
const response = await Promise.race([
|
||||
this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: base64Image,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]),
|
||||
timeoutPromise,
|
||||
]);
|
||||
// Race between API call and timeout
|
||||
const response = await Promise.race([
|
||||
this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: base64Image,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
const requestDuration = Date.now() - requestStartTime;
|
||||
console.log('Gemini API request completed in:', requestDuration, 'ms');
|
||||
const requestDuration = Date.now() - requestStartTime;
|
||||
console.log('Gemini API request completed in:', requestDuration, 'ms');
|
||||
|
||||
if (!response || !response.response) {
|
||||
throw new Error('Invalid response structure from Gemini API');
|
||||
}
|
||||
if (!response || !response.response) {
|
||||
throw new Error('Invalid response structure from Gemini API');
|
||||
}
|
||||
|
||||
const text = response.response.text();
|
||||
if (!text) {
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
const text = response.response.text();
|
||||
if (!text) {
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
|
||||
console.log('Gemini API response received, length:', text.length);
|
||||
return text;
|
||||
});
|
||||
console.log('Step 3 completed: API request successful');
|
||||
console.log('Gemini API response received, length:', text.length);
|
||||
return text;
|
||||
});
|
||||
console.log('Step 3 completed: API request successful');
|
||||
|
||||
// Validate and parse response
|
||||
console.log('Step 4: Validating and parsing response...');
|
||||
const analysisResult = this.validateResponse(result);
|
||||
console.log('Step 4 completed: Response validation successful');
|
||||
// Validate and parse response
|
||||
console.log('Step 4: Validating and parsing response...');
|
||||
const analysisResult = this.validateResponse(result);
|
||||
console.log('Step 4 completed: Response validation successful');
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
|
||||
|
||||
return {
|
||||
...analysisResult,
|
||||
_metadata: {
|
||||
processingTime,
|
||||
apiProvider: 'gemini',
|
||||
model: this.config.model,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.error('Gemini analysis failed:', error);
|
||||
return {
|
||||
...analysisResult,
|
||||
_metadata: {
|
||||
processingTime,
|
||||
apiProvider: 'gemini',
|
||||
model: this.config.model,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.error('Gemini analysis failed:', error);
|
||||
|
||||
throw new GeminiError(
|
||||
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
this.categorizeError(error),
|
||||
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
|
||||
{
|
||||
processingTime,
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new GeminiError(
|
||||
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
this.categorizeError(error),
|
||||
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
|
||||
{
|
||||
processingTime,
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes errors for better handling
|
||||
*/
|
||||
private categorizeError(error: any): string {
|
||||
if (!error) return 'UNKNOWN_ERROR';
|
||||
/**
|
||||
* Categorizes errors for better handling
|
||||
*/
|
||||
private categorizeError(error: any): string {
|
||||
if (!error) return 'UNKNOWN_ERROR';
|
||||
|
||||
const message = error.message || error.toString();
|
||||
const message = error.message || error.toString();
|
||||
|
||||
if (message.includes('API key')) return 'API_KEY_ERROR';
|
||||
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
|
||||
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
|
||||
return 'TIMEOUT_ERROR';
|
||||
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
|
||||
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
|
||||
return 'IMAGE_ERROR';
|
||||
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
|
||||
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
|
||||
if (message.includes('API key')) return 'API_KEY_ERROR';
|
||||
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
|
||||
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
|
||||
return 'TIMEOUT_ERROR';
|
||||
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
|
||||
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
|
||||
return 'IMAGE_ERROR';
|
||||
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
|
||||
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
|
||||
|
||||
return 'API_ERROR';
|
||||
}
|
||||
return 'API_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is retryable
|
||||
*/
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
/**
|
||||
* Determines if an error is retryable
|
||||
*/
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const message = error.message || error.toString();
|
||||
const message = error.message || error.toString();
|
||||
|
||||
// Don't retry these errors
|
||||
if (message.includes('API key')) return false;
|
||||
if (message.includes('quota exceeded')) return false;
|
||||
if (message.includes('base64')) return false;
|
||||
if (message.includes('file not found')) return false;
|
||||
if (message.includes('too large')) return false;
|
||||
if (message.includes('Invalid response structure')) return false;
|
||||
// Don't retry these errors
|
||||
if (message.includes('API key')) return false;
|
||||
if (message.includes('quota exceeded')) return false;
|
||||
if (message.includes('base64')) return false;
|
||||
if (message.includes('file not found')) return false;
|
||||
if (message.includes('too large')) return false;
|
||||
if (message.includes('Invalid response structure')) return false;
|
||||
|
||||
// Retry these errors (but with caution for timeouts)
|
||||
if (message.includes('network')) return true;
|
||||
if (message.includes('timeout') || message.includes('aborted')) {
|
||||
console.log('Timeout detected - will retry with exponential backoff');
|
||||
return true;
|
||||
}
|
||||
if (message.includes('500')) return true;
|
||||
if (message.includes('502')) return true;
|
||||
if (message.includes('503')) return true;
|
||||
// Retry these errors (but with caution for timeouts)
|
||||
if (message.includes('network')) return true;
|
||||
if (message.includes('timeout') || message.includes('aborted')) {
|
||||
console.log('Timeout detected - will retry with exponential backoff');
|
||||
return true;
|
||||
}
|
||||
if (message.includes('500')) return true;
|
||||
if (message.includes('502')) return true;
|
||||
if (message.includes('503')) return true;
|
||||
|
||||
return false; // Default to non-retryable for unknown errors to prevent infinite loops
|
||||
}
|
||||
return false; // Default to non-retryable for unknown errors to prevent infinite loops
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets service status and configuration
|
||||
*/
|
||||
public getStatus() {
|
||||
return {
|
||||
initialized: !!this.model,
|
||||
hasApiKey: !!this.config.apiKey,
|
||||
model: this.config.model,
|
||||
retryConfig: this.retryConfig,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets service status and configuration
|
||||
*/
|
||||
public getStatus() {
|
||||
return {
|
||||
initialized: !!this.model,
|
||||
hasApiKey: !!this.config.apiKey,
|
||||
model: this.config.model,
|
||||
retryConfig: this.retryConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates retry configuration
|
||||
*/
|
||||
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
|
||||
this.retryConfig = { ...this.retryConfig, ...newConfig };
|
||||
}
|
||||
/**
|
||||
* Updates retry configuration
|
||||
*/
|
||||
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
|
||||
this.retryConfig = { ...this.retryConfig, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates request timeout
|
||||
*/
|
||||
public updateTimeout(timeout: number) {
|
||||
this.requestTimeout = timeout;
|
||||
console.log('Updated request timeout to:', timeout);
|
||||
}
|
||||
/**
|
||||
* Updates request timeout
|
||||
*/
|
||||
public updateTimeout(timeout: number) {
|
||||
this.requestTimeout = timeout;
|
||||
console.log('Updated request timeout to:', timeout);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,426 +14,427 @@ const APP_ID = process.env.EXPO_PUBLIC_MIDDLEWARE_APP_ID || 'nutriphi';
|
|||
* Get device information for authentication
|
||||
*/
|
||||
function getDeviceInfo() {
|
||||
return {
|
||||
deviceId: Application.getIosIdForVendorAsync ?
|
||||
Application.androidId || `${Platform.OS}-${Date.now()}` :
|
||||
`${Platform.OS}-${Date.now()}`,
|
||||
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
|
||||
deviceType: Device.isDevice ? 'mobile' : 'simulator',
|
||||
platform: Platform.OS,
|
||||
};
|
||||
return {
|
||||
deviceId: Application.getIosIdForVendorAsync
|
||||
? Application.androidId || `${Platform.OS}-${Date.now()}`
|
||||
: `${Platform.OS}-${Date.now()}`,
|
||||
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
|
||||
deviceType: Device.isDevice ? 'mobile' : 'simulator',
|
||||
platform: Platform.OS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
function decodeToken(token: string): any | null {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Use atob equivalent for React Native
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
Array.from(atob(base64))
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
)
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Use atob equivalent for React Native
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
Array.from(atob(base64))
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
)
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
|
||||
// Add 10 second buffer
|
||||
const bufferTime = 10 * 1000;
|
||||
return Date.now() >= payload.exp * 1000 - bufferTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
// Add 10 second buffer
|
||||
const bufferTime = 10 * 1000;
|
||||
return Date.now() >= payload.exp * 1000 - bufferTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
appToken?: string;
|
||||
refreshToken?: string;
|
||||
email?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
appToken?: string;
|
||||
refreshToken?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication service
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (response.status === 401) {
|
||||
if (
|
||||
errorData.message?.includes('Firebase user detected') ||
|
||||
errorData.message?.includes('password reset required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
|
||||
};
|
||||
}
|
||||
if (response.status === 401) {
|
||||
if (
|
||||
errorData.message?.includes('Firebase user detected') ||
|
||||
errorData.message?.includes('password reset required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
errorData.message?.includes('Email not confirmed') ||
|
||||
errorData.message?.includes('Email not verified')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMAIL_NOT_VERIFIED',
|
||||
};
|
||||
}
|
||||
if (
|
||||
errorData.message?.includes('Email not confirmed') ||
|
||||
errorData.message?.includes('Email not verified')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMAIL_NOT_VERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed',
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'This email is already in use',
|
||||
};
|
||||
}
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'This email is already in use',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
};
|
||||
}
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during registration',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during registration',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google ID token
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with Google ID token
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Google Sign-In failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Google Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Google:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Google:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple ID token
|
||||
*/
|
||||
async signInWithApple(idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with Apple ID token
|
||||
*/
|
||||
async signInWithApple(
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, user, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, user, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Apple Sign-In failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Apple Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
let email = responseData.email || user?.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || '';
|
||||
}
|
||||
let email = responseData.email || user?.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
async refreshTokens(
|
||||
currentRefreshToken: string
|
||||
): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: UserData | null;
|
||||
}> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
async refreshTokens(currentRefreshToken: string): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: UserData | null;
|
||||
}> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh');
|
||||
}
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh');
|
||||
}
|
||||
|
||||
let userData: UserData | null = null;
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (payload) {
|
||||
userData = {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding refreshed token:', error);
|
||||
}
|
||||
let userData: UserData | null = null;
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (payload) {
|
||||
userData = {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding refreshed token:', error);
|
||||
}
|
||||
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => console.error('Error logging out on server:', err));
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => console.error('Error logging out on server:', err));
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (errorData.message?.includes('rate limit')) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Too many password reset attempts. Please wait a few minutes before trying again.',
|
||||
};
|
||||
}
|
||||
if (errorData.message?.includes('rate limit')) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Too many password reset attempts. Please wait a few minutes before trying again.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Password reset failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Password reset failed',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending password reset email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
||||
};
|
||||
}
|
||||
},
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending password reset email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user data from token
|
||||
*/
|
||||
getUserFromToken(appToken: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (!payload) return null;
|
||||
/**
|
||||
* Get user data from token
|
||||
*/
|
||||
getUserFromToken(appToken: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is valid locally (without network call)
|
||||
*/
|
||||
isTokenValidLocally(token: string): boolean {
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
/**
|
||||
* Check if token is valid locally (without network call)
|
||||
*/
|
||||
isTokenValidLocally(token: string): boolean {
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email',
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -11,110 +11,110 @@ const STORAGE_KEYS = {
|
|||
* Uses Expo SecureStore for encrypted storage on device
|
||||
*/
|
||||
export const tokenManager = {
|
||||
/**
|
||||
* Get the app token (JWT)
|
||||
*/
|
||||
async getAppToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting app token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the app token (JWT)
|
||||
*/
|
||||
async getAppToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting app token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the app token
|
||||
*/
|
||||
async setAppToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting app token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the app token
|
||||
*/
|
||||
async setAppToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting app token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the refresh token
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting refresh token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the refresh token
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting refresh token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the refresh token
|
||||
*/
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting refresh token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the refresh token
|
||||
*/
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting refresh token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the user email
|
||||
*/
|
||||
async getUserEmail(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
|
||||
} catch (error) {
|
||||
console.error('Error getting user email:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the user email
|
||||
*/
|
||||
async getUserEmail(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
|
||||
} catch (error) {
|
||||
console.error('Error getting user email:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the user email
|
||||
*/
|
||||
async setUserEmail(email: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
|
||||
} catch (error) {
|
||||
console.error('Error setting user email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the user email
|
||||
*/
|
||||
async setUserEmail(email: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
|
||||
} catch (error) {
|
||||
console.error('Error setting user email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all tokens (logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Clear all tokens (logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Authorization header for API requests
|
||||
*/
|
||||
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
|
||||
const token = await this.getAppToken();
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* Get Authorization header for API requests
|
||||
*/
|
||||
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
|
||||
const token = await this.getAppToken();
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has tokens stored
|
||||
*/
|
||||
async hasTokens(): Promise<boolean> {
|
||||
const token = await this.getAppToken();
|
||||
return !!token;
|
||||
},
|
||||
/**
|
||||
* Check if user has tokens stored
|
||||
*/
|
||||
async hasTokens(): Promise<boolean> {
|
||||
const token = await this.getAppToken();
|
||||
return !!token;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
private static instance: MigrationService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
public static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
public setDatabase(db: SQLite.SQLiteDatabase): void {
|
||||
this.db = db;
|
||||
}
|
||||
public setDatabase(db: SQLite.SQLiteDatabase): void {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'Initial Schema',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
|
||||
// Hier nur als Referenz für zukünftige Migrationen
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'Add indexes for performance',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
private migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'Initial Schema',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
|
||||
// Hier nur als Referenz für zukünftige Migrationen
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'Add indexes for performance',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_analysis_status ON meals(analysis_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_health_category ON meals(health_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_food_items_confidence ON food_items(confidence DESC);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'Add user preferences table',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'Add user preferences table',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -58,157 +58,157 @@ export class MigrationService {
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
name: 'Add GPS location fields to meals',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
name: 'Add GPS location fields to meals',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
ALTER TABLE meals ADD COLUMN latitude REAL;
|
||||
ALTER TABLE meals ADD COLUMN longitude REAL;
|
||||
ALTER TABLE meals ADD COLUMN location_accuracy REAL;
|
||||
`);
|
||||
|
||||
// Create index for geo queries
|
||||
await db.execAsync(`
|
||||
// Create index for geo queries
|
||||
await db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_location ON meals(latitude, longitude);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
public async initializeMigrationTable(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async initializeMigrationTable(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
await this.db.execAsync(`
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentVersion(): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async getCurrentVersion(): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ version: number }>(
|
||||
'SELECT MAX(version) as version FROM schema_migrations'
|
||||
);
|
||||
return result?.version || 0;
|
||||
} catch {
|
||||
// Tabelle existiert noch nicht
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ version: number }>(
|
||||
'SELECT MAX(version) as version FROM schema_migrations'
|
||||
);
|
||||
return result?.version || 0;
|
||||
} catch {
|
||||
// Tabelle existiert noch nicht
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async runMigrations(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async runMigrations(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
await this.initializeMigrationTable();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
await this.initializeMigrationTable();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
console.log(`Current database version: ${currentVersion}`);
|
||||
console.log(`Current database version: ${currentVersion}`);
|
||||
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => migration.version > currentVersion
|
||||
);
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => migration.version > currentVersion
|
||||
);
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('No pending migrations');
|
||||
return;
|
||||
}
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('No pending migrations');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running ${pendingMigrations.length} migrations...`);
|
||||
console.log(`Running ${pendingMigrations.length} migrations...`);
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
console.log(`Applying migration ${migration.version}: ${migration.name}`);
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
console.log(`Applying migration ${migration.version}: ${migration.name}`);
|
||||
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.up(this.db);
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.up(this.db);
|
||||
|
||||
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]);
|
||||
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]);
|
||||
|
||||
await this.db.execAsync('COMMIT;');
|
||||
await this.db.execAsync('COMMIT;');
|
||||
|
||||
console.log(`Migration ${migration.version} completed successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log(`Migration ${migration.version} completed successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All migrations completed successfully');
|
||||
}
|
||||
console.log('All migrations completed successfully');
|
||||
}
|
||||
|
||||
public async rollbackToVersion(targetVersion: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async rollbackToVersion(targetVersion: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
if (targetVersion >= currentVersion) {
|
||||
console.log('Target version is not lower than current version');
|
||||
return;
|
||||
}
|
||||
if (targetVersion >= currentVersion) {
|
||||
console.log('Target version is not lower than current version');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsToRollback = this.migrations
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version); // Descending order
|
||||
const migrationsToRollback = this.migrations
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version); // Descending order
|
||||
|
||||
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
|
||||
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
|
||||
|
||||
for (const migration of migrationsToRollback) {
|
||||
if (!migration.down) {
|
||||
console.warn(`No rollback defined for migration ${migration.version}`);
|
||||
continue;
|
||||
}
|
||||
for (const migration of migrationsToRollback) {
|
||||
if (!migration.down) {
|
||||
console.warn(`No rollback defined for migration ${migration.version}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
|
||||
try {
|
||||
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
|
||||
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.down(this.db);
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.down(this.db);
|
||||
|
||||
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
|
||||
migration.version,
|
||||
]);
|
||||
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
|
||||
migration.version,
|
||||
]);
|
||||
|
||||
await this.db.execAsync('COMMIT;');
|
||||
await this.db.execAsync('COMMIT;');
|
||||
|
||||
console.log(`Migration ${migration.version} rolled back successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Rollback of migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log(`Migration ${migration.version} rolled back successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Rollback of migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Rollback to version ${targetVersion} completed`);
|
||||
}
|
||||
console.log(`Rollback to version ${targetVersion} completed`);
|
||||
}
|
||||
|
||||
public async addMigration(migration: Migration): Promise<void> {
|
||||
// Überprüfe, ob die Version bereits existiert
|
||||
const existingMigration = this.migrations.find((m) => m.version === migration.version);
|
||||
if (existingMigration) {
|
||||
throw new Error(`Migration version ${migration.version} already exists`);
|
||||
}
|
||||
public async addMigration(migration: Migration): Promise<void> {
|
||||
// Überprüfe, ob die Version bereits existiert
|
||||
const existingMigration = this.migrations.find((m) => m.version === migration.version);
|
||||
if (existingMigration) {
|
||||
throw new Error(`Migration version ${migration.version} already exists`);
|
||||
}
|
||||
|
||||
this.migrations.push(migration);
|
||||
this.migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
this.migrations.push(migration);
|
||||
this.migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
return this.db.getAllAsync(
|
||||
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
}
|
||||
return this.db.getAllAsync(
|
||||
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,41 +2,41 @@ import * as SQLite from 'expo-sqlite';
|
|||
import { Meal, FoodItem, CreateMealInput, MealWithItems } from '../../types/Database';
|
||||
|
||||
export class SQLiteService {
|
||||
private static instance: SQLiteService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
private static instance: SQLiteService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SQLiteService {
|
||||
if (!SQLiteService.instance) {
|
||||
SQLiteService.instance = new SQLiteService();
|
||||
}
|
||||
return SQLiteService.instance;
|
||||
}
|
||||
public static getInstance(): SQLiteService {
|
||||
if (!SQLiteService.instance) {
|
||||
SQLiteService.instance = new SQLiteService();
|
||||
}
|
||||
return SQLiteService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
|
||||
await this.createTables();
|
||||
await this.createIndices();
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
|
||||
await this.createTables();
|
||||
await this.createIndices();
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Meals Table
|
||||
await this.db.execAsync(`
|
||||
// Meals Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
|
|
@ -73,8 +73,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// Food Items Table
|
||||
await this.db.execAsync(`
|
||||
// Food Items Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS food_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
|
|
@ -100,8 +100,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// Sync Metadata Table
|
||||
await this.db.execAsync(`
|
||||
// Sync Metadata Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
table_name TEXT NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
|
|
@ -113,8 +113,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// User Preferences Table
|
||||
await this.db.execAsync(`
|
||||
// User Preferences Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -124,12 +124,12 @@ export class SQLiteService {
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createIndices(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
private async createIndices(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.execAsync(`
|
||||
await this.db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_timestamp ON meals(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meals(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_meal_type ON meals(meal_type);
|
||||
|
|
@ -137,225 +137,225 @@ export class SQLiteService {
|
|||
CREATE INDEX IF NOT EXISTS idx_food_items_category ON food_items(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations für Meals
|
||||
public async createMeal(input: CreateMealInput): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// CRUD Operations für Meals
|
||||
public async createMeal(input: CreateMealInput): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
|
||||
const now = new Date().toISOString();
|
||||
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
INSERT INTO meals (
|
||||
photo_path, photo_size, photo_dimensions, timestamp,
|
||||
created_at, updated_at, meal_type, location, latitude, longitude, location_accuracy,
|
||||
user_notes, analysis_status, api_provider
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
input.photo_path,
|
||||
input.photo_size || null,
|
||||
dimensions,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
input.meal_type || null,
|
||||
input.location || null,
|
||||
input.latitude || null,
|
||||
input.longitude || null,
|
||||
input.location_accuracy || null,
|
||||
input.user_notes || null,
|
||||
input.analysis_status || 'pending',
|
||||
input.api_provider || 'gemini',
|
||||
]
|
||||
);
|
||||
[
|
||||
input.photo_path,
|
||||
input.photo_size || null,
|
||||
dimensions,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
input.meal_type || null,
|
||||
input.location || null,
|
||||
input.latitude || null,
|
||||
input.longitude || null,
|
||||
input.location_accuracy || null,
|
||||
input.user_notes || null,
|
||||
input.analysis_status || 'pending',
|
||||
input.api_provider || 'gemini',
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
public async getMealById(id: number): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getMealById(id: number): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
|
||||
|
||||
return result || null;
|
||||
}
|
||||
return result || null;
|
||||
}
|
||||
|
||||
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const meal = await this.getMealById(id);
|
||||
if (!meal) return null;
|
||||
const meal = await this.getMealById(id);
|
||||
if (!meal) return null;
|
||||
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[id]
|
||||
);
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
};
|
||||
}
|
||||
|
||||
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
}
|
||||
|
||||
public async getAllMealsWithItems(
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<MealWithItems[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getAllMealsWithItems(
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<MealWithItems[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const meals = await this.getAllMeals(limit, offset);
|
||||
const mealsWithItems: MealWithItems[] = [];
|
||||
const meals = await this.getAllMeals(limit, offset);
|
||||
const mealsWithItems: MealWithItems[] = [];
|
||||
|
||||
for (const meal of meals) {
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[meal.id!]
|
||||
);
|
||||
for (const meal of meals) {
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[meal.id!]
|
||||
);
|
||||
|
||||
mealsWithItems.push({
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
});
|
||||
}
|
||||
mealsWithItems.push({
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
});
|
||||
}
|
||||
|
||||
return mealsWithItems;
|
||||
}
|
||||
return mealsWithItems;
|
||||
}
|
||||
|
||||
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
|
||||
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
|
||||
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
|
||||
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
|
||||
|
||||
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
|
||||
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
|
||||
|
||||
await this.db.runAsync(
|
||||
`
|
||||
await this.db.runAsync(
|
||||
`
|
||||
UPDATE meals SET ${setClause}, updated_at = datetime('now') WHERE id = ?
|
||||
`,
|
||||
[...updateValues, id]
|
||||
);
|
||||
}
|
||||
[...updateValues, id]
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteMeal(id: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async deleteMeal(id: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
|
||||
}
|
||||
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// CRUD Operations für Food Items
|
||||
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// CRUD Operations für Food Items
|
||||
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
INSERT INTO food_items (
|
||||
meal_id, name, category, portion_size, calories, protein, carbs, fat,
|
||||
fiber, sugar, confidence, bounding_box, is_organic, is_processed, allergens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category,
|
||||
foodItem.portion_size,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category,
|
||||
foodItem.portion_size,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
if (foodItems.length === 0) return [];
|
||||
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
if (foodItems.length === 0) return [];
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
// Use a transaction for better performance
|
||||
await this.db.execAsync('BEGIN TRANSACTION');
|
||||
// Use a transaction for better performance
|
||||
await this.db.execAsync('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
for (const foodItem of foodItems) {
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO food_items (
|
||||
try {
|
||||
for (const foodItem of foodItems) {
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO food_items (
|
||||
meal_id, name, category, portion_size,
|
||||
calories, protein, carbs, fat, fiber, sugar,
|
||||
confidence, bounding_box, is_organic, is_processed, allergens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category || null,
|
||||
foodItem.portion_size || null,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
insertedIds.push(result.lastInsertRowId);
|
||||
}
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category || null,
|
||||
foodItem.portion_size || null,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
insertedIds.push(result.lastInsertRowId);
|
||||
}
|
||||
|
||||
await this.db.execAsync('COMMIT');
|
||||
return insertedIds;
|
||||
} catch (error) {
|
||||
await this.db.execAsync('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.db.execAsync('COMMIT');
|
||||
return insertedIds;
|
||||
} catch (error) {
|
||||
await this.db.execAsync('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[mealId]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[mealId]
|
||||
);
|
||||
}
|
||||
|
||||
// Statistiken und Aggregationen
|
||||
public async getMealStats(days: number = 7): Promise<{
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
}> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// Statistiken und Aggregationen
|
||||
public async getMealStats(days: number = 7): Promise<{
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
}> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<{
|
||||
count: number;
|
||||
avg_calories: number;
|
||||
avg_health_score: number;
|
||||
}>(`
|
||||
const result = await this.db.getFirstAsync<{
|
||||
count: number;
|
||||
avg_calories: number;
|
||||
avg_health_score: number;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) as count,
|
||||
AVG(total_calories) as avg_calories,
|
||||
|
|
@ -365,18 +365,18 @@ export class SQLiteService {
|
|||
AND analysis_status = 'completed'
|
||||
`);
|
||||
|
||||
return {
|
||||
totalMeals: result?.count || 0,
|
||||
avgCalories: Math.round(result?.avg_calories || 0),
|
||||
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
|
||||
};
|
||||
}
|
||||
return {
|
||||
totalMeals: result?.count || 0,
|
||||
avgCalories: Math.round(result?.avg_calories || 0),
|
||||
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
public async searchMeals(query: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async searchMeals(query: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`
|
||||
SELECT DISTINCT m.* FROM meals m
|
||||
LEFT JOIN food_items fi ON m.id = fi.meal_id
|
||||
WHERE m.user_notes LIKE ?
|
||||
|
|
@ -384,147 +384,146 @@ export class SQLiteService {
|
|||
OR fi.name LIKE ?
|
||||
ORDER BY m.timestamp DESC
|
||||
`,
|
||||
[`%${query}%`, `%${query}%`, `%${query}%`]
|
||||
);
|
||||
}
|
||||
[`%${query}%`, `%${query}%`, `%${query}%`]
|
||||
);
|
||||
}
|
||||
|
||||
// Hilfsmethoden
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.closeAsync();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
// Hilfsmethoden
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.closeAsync();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
return await this.db.runAsync(sql, params);
|
||||
}
|
||||
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
return await this.db.runAsync(sql, params);
|
||||
}
|
||||
|
||||
// ==================== Sync Methods ====================
|
||||
// ==================== Sync Methods ====================
|
||||
|
||||
/**
|
||||
* Get all unsynced meals (sync_status = 'local' or 'pending')
|
||||
*/
|
||||
public async getUnsyncedMeals(): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get all unsynced meals (sync_status = 'local' or 'pending')
|
||||
*/
|
||||
public async getUnsyncedMeals(): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meal by cloud ID
|
||||
*/
|
||||
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get meal by cloud ID
|
||||
*/
|
||||
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<Meal>(
|
||||
'SELECT * FROM meals WHERE cloud_id = ?',
|
||||
[cloudId]
|
||||
);
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE cloud_id = ?', [
|
||||
cloudId,
|
||||
]);
|
||||
|
||||
return result || null;
|
||||
}
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cloud_id for a local meal
|
||||
*/
|
||||
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Update cloud_id for a local meal
|
||||
*/
|
||||
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[cloudId, localId]
|
||||
);
|
||||
}
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[cloudId, localId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a meal as synced
|
||||
*/
|
||||
public async markSynced(localId: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Mark a meal as synced
|
||||
*/
|
||||
public async markSynced(localId: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
|
||||
[localId]
|
||||
);
|
||||
}
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
|
||||
[localId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a meal by cloud ID
|
||||
*/
|
||||
public async deleteByCloudId(cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Delete a meal by cloud ID
|
||||
*/
|
||||
public async deleteByCloudId(cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
|
||||
}
|
||||
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a meal from server data
|
||||
*/
|
||||
public async createMealFromServer(serverMeal: any): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Create a meal from server data
|
||||
*/
|
||||
public async createMealFromServer(serverMeal: any): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO meals (
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO meals (
|
||||
cloud_id, sync_status, version, last_sync_at,
|
||||
photo_path, photo_url, timestamp, created_at, updated_at,
|
||||
meal_type, analysis_result, analysis_status,
|
||||
total_calories, total_protein, total_carbs, total_fat, total_fiber, total_sugar,
|
||||
health_score, health_category, user_notes, user_rating
|
||||
) VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
serverMeal.cloudId,
|
||||
'synced',
|
||||
1,
|
||||
serverMeal.imageUrl || '',
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.updatedAt,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
]
|
||||
);
|
||||
[
|
||||
serverMeal.cloudId,
|
||||
'synced',
|
||||
1,
|
||||
serverMeal.imageUrl || '',
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.updatedAt,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a local meal from server data
|
||||
*/
|
||||
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Update a local meal from server data
|
||||
*/
|
||||
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET
|
||||
sync_status = 'synced',
|
||||
last_sync_at = datetime('now'),
|
||||
photo_url = ?,
|
||||
|
|
@ -543,36 +542,36 @@ export class SQLiteService {
|
|||
user_rating = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
serverMeal.updatedAt,
|
||||
localId,
|
||||
]
|
||||
);
|
||||
}
|
||||
[
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
serverMeal.updatedAt,
|
||||
localId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meals modified since a given timestamp
|
||||
*/
|
||||
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get meals modified since a given timestamp
|
||||
*/
|
||||
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
|
||||
[since]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
|
||||
[since]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,200 +2,200 @@ import * as FileSystem from 'expo-file-system';
|
|||
import { PhotoDimensions } from '../../types/Database';
|
||||
|
||||
export class PhotoService {
|
||||
private static instance: PhotoService;
|
||||
private photosDirectory: string;
|
||||
private static instance: PhotoService;
|
||||
private photosDirectory: string;
|
||||
|
||||
private constructor() {
|
||||
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
|
||||
}
|
||||
private constructor() {
|
||||
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
|
||||
}
|
||||
|
||||
public static getInstance(): PhotoService {
|
||||
if (!PhotoService.instance) {
|
||||
PhotoService.instance = new PhotoService();
|
||||
}
|
||||
return PhotoService.instance;
|
||||
}
|
||||
public static getInstance(): PhotoService {
|
||||
if (!PhotoService.instance) {
|
||||
PhotoService.instance = new PhotoService();
|
||||
}
|
||||
return PhotoService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Create photos directory if it doesn't exist
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
|
||||
}
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
// Create photos directory if it doesn't exist
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
|
||||
}
|
||||
}
|
||||
|
||||
public async savePhoto(
|
||||
uri: string,
|
||||
mealId?: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
public async savePhoto(
|
||||
uri: string,
|
||||
mealId?: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
|
||||
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
|
||||
// Copy file to app directory
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: destPath,
|
||||
});
|
||||
// Copy file to app directory
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: destPath,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions (basic implementation)
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
// Get image dimensions (basic implementation)
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
public async makePhotoPermanent(
|
||||
tempPath: string,
|
||||
mealId: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
public async makePhotoPermanent(
|
||||
tempPath: string,
|
||||
mealId: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
|
||||
// Generate permanent filename
|
||||
const timestamp = Date.now();
|
||||
const filename = `meal_${mealId}_${timestamp}.jpg`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
// Generate permanent filename
|
||||
const timestamp = Date.now();
|
||||
const filename = `meal_${mealId}_${timestamp}.jpg`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
|
||||
// Copy temp file to permanent location
|
||||
await FileSystem.copyAsync({
|
||||
from: tempPath,
|
||||
to: destPath,
|
||||
});
|
||||
// Copy temp file to permanent location
|
||||
await FileSystem.copyAsync({
|
||||
from: tempPath,
|
||||
to: destPath,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
// Get image dimensions
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
// Delete the temporary file
|
||||
await this.deletePhoto(tempPath);
|
||||
// Delete the temporary file
|
||||
await this.deletePhoto(tempPath);
|
||||
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
public async deletePhoto(path: string): Promise<void> {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete photo:', error);
|
||||
}
|
||||
}
|
||||
public async deletePhoto(path: string): Promise<void> {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete photo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getPhotoAsBase64(path: string): Promise<string> {
|
||||
try {
|
||||
const base64 = await FileSystem.readAsStringAsync(path, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to read photo as base64:', error);
|
||||
throw new Error('Failed to process image');
|
||||
}
|
||||
}
|
||||
public async getPhotoAsBase64(path: string): Promise<string> {
|
||||
try {
|
||||
const base64 = await FileSystem.readAsStringAsync(path, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to read photo as base64:', error);
|
||||
throw new Error('Failed to process image');
|
||||
}
|
||||
}
|
||||
|
||||
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
|
||||
// This is a simplified implementation
|
||||
// In a real app, you might use expo-image-manipulator or similar
|
||||
// to get actual image dimensions
|
||||
return {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
}
|
||||
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
|
||||
// This is a simplified implementation
|
||||
// In a real app, you might use expo-image-manipulator or similar
|
||||
// to get actual image dimensions
|
||||
return {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
}
|
||||
|
||||
public async cleanupTempPhotos(): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
public async cleanupTempPhotos(): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
|
||||
// Check if directory exists before trying to read it
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
console.log('Photos directory does not exist yet, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
// Check if directory exists before trying to read it
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
console.log('Photos directory does not exist yet, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const tempFiles = files.filter((file) => file.startsWith('temp_'));
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const tempFiles = files.filter((file) => file.startsWith('temp_'));
|
||||
|
||||
// Delete temp files older than 1 hour
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
// Delete temp files older than 1 hour
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
for (const file of tempFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
for (const file of tempFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (
|
||||
fileInfo.exists &&
|
||||
fileInfo.modificationTime &&
|
||||
fileInfo.modificationTime < oneHourAgo
|
||||
) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
}
|
||||
}
|
||||
if (
|
||||
fileInfo.exists &&
|
||||
fileInfo.modificationTime &&
|
||||
fileInfo.modificationTime < oneHourAgo
|
||||
) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos:', error);
|
||||
}
|
||||
}
|
||||
if (tempFiles.length > 0) {
|
||||
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStorageStats(): Promise<{
|
||||
totalPhotos: number;
|
||||
totalSize: number;
|
||||
averageSize: number;
|
||||
}> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
|
||||
public async getStorageStats(): Promise<{
|
||||
totalPhotos: number;
|
||||
totalSize: number;
|
||||
averageSize: number;
|
||||
}> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of photoFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
totalSize += fileInfo.size || 0;
|
||||
}
|
||||
let totalSize = 0;
|
||||
for (const file of photoFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
totalSize += fileInfo.size || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
totalPhotos: photoFiles.length,
|
||||
totalSize,
|
||||
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get storage stats:', error);
|
||||
return {
|
||||
totalPhotos: 0,
|
||||
totalSize: 0,
|
||||
averageSize: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalPhotos: photoFiles.length,
|
||||
totalSize,
|
||||
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get storage stats:', error);
|
||||
return {
|
||||
totalPhotos: 0,
|
||||
totalSize: 0,
|
||||
averageSize: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,343 +5,341 @@ import type { Meal } from '../../types/Database';
|
|||
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
conflicts: ConflictInfo[];
|
||||
error?: string;
|
||||
success: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
conflicts: ConflictInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LocalMealForSync {
|
||||
localId: number;
|
||||
cloudId?: string;
|
||||
foodName: string;
|
||||
imageUrl?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbohydrates?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
sodium?: number;
|
||||
servingSize?: string;
|
||||
mealType?: string;
|
||||
analysisStatus?: string;
|
||||
healthScore?: number;
|
||||
healthCategory?: string;
|
||||
notes?: string;
|
||||
userRating?: number;
|
||||
foodItems?: any[];
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
localId: number;
|
||||
cloudId?: string;
|
||||
foodName: string;
|
||||
imageUrl?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbohydrates?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
sodium?: number;
|
||||
servingSize?: string;
|
||||
mealType?: string;
|
||||
analysisStatus?: string;
|
||||
healthScore?: number;
|
||||
healthCategory?: string;
|
||||
notes?: string;
|
||||
userRating?: number;
|
||||
foodItems?: any[];
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Service for synchronizing local SQLite data with the backend
|
||||
*/
|
||||
export class SyncService {
|
||||
private static instance: SyncService;
|
||||
private isSyncing = false;
|
||||
private lastSyncAt: string | null = null;
|
||||
private static instance: SyncService;
|
||||
private isSyncing = false;
|
||||
private lastSyncAt: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService();
|
||||
}
|
||||
return SyncService.instance;
|
||||
}
|
||||
public static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService();
|
||||
}
|
||||
return SyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
/**
|
||||
* Check if sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last sync timestamp
|
||||
*/
|
||||
public getLastSyncAt(): string | null {
|
||||
return this.lastSyncAt;
|
||||
}
|
||||
/**
|
||||
* Get last sync timestamp
|
||||
*/
|
||||
public getLastSyncAt(): string | null {
|
||||
return this.lastSyncAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full sync (push + pull)
|
||||
*/
|
||||
public async fullSync(): Promise<SyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Sync already in progress',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Perform a full sync (push + pull)
|
||||
*/
|
||||
public async fullSync(): Promise<SyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Sync already in progress',
|
||||
};
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
// First push local changes
|
||||
const pushResult = await this.pushChanges();
|
||||
if (!pushResult.success) {
|
||||
return pushResult;
|
||||
}
|
||||
try {
|
||||
// First push local changes
|
||||
const pushResult = await this.pushChanges();
|
||||
if (!pushResult.success) {
|
||||
return pushResult;
|
||||
}
|
||||
|
||||
// Then pull server changes
|
||||
const pullResult = await this.pullChanges();
|
||||
// Then pull server changes
|
||||
const pullResult = await this.pullChanges();
|
||||
|
||||
return {
|
||||
success: pullResult.success,
|
||||
created: pushResult.created + pullResult.created,
|
||||
updated: pushResult.updated + pullResult.updated,
|
||||
deleted: pullResult.deleted,
|
||||
conflicts: pushResult.conflicts,
|
||||
error: pullResult.error,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: pullResult.success,
|
||||
created: pushResult.created + pullResult.created,
|
||||
updated: pushResult.updated + pullResult.updated,
|
||||
deleted: pullResult.deleted,
|
||||
conflicts: pushResult.conflicts,
|
||||
error: pullResult.error,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
public async pushChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
public async pushChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
const db = SQLiteService.getInstance();
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
// Get unsynced meals
|
||||
const unsyncedMeals = await db.getUnsyncedMeals();
|
||||
// Get unsynced meals
|
||||
const unsyncedMeals = await db.getUnsyncedMeals();
|
||||
|
||||
if (unsyncedMeals.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
if (unsyncedMeals.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Map to sync format
|
||||
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
|
||||
this.mapMealToSyncFormat(meal)
|
||||
);
|
||||
// Map to sync format
|
||||
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
|
||||
this.mapMealToSyncFormat(meal)
|
||||
);
|
||||
|
||||
// Get deleted meals (meals marked for deletion)
|
||||
const deletedIds: string[] = []; // TODO: Implement delete tracking
|
||||
// Get deleted meals (meals marked for deletion)
|
||||
const deletedIds: string[] = []; // TODO: Implement delete tracking
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meals: mealsForSync,
|
||||
deletedIds,
|
||||
lastSyncAt: this.lastSyncAt,
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meals: mealsForSync,
|
||||
deletedIds,
|
||||
lastSyncAt: this.lastSyncAt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Push failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Push failed',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const result = await response.json();
|
||||
|
||||
// Update local records with cloud IDs
|
||||
for (const created of result.created) {
|
||||
await db.updateCloudId(created.localId, created.cloudId);
|
||||
await db.markSynced(created.localId);
|
||||
}
|
||||
// Update local records with cloud IDs
|
||||
for (const created of result.created) {
|
||||
await db.updateCloudId(created.localId, created.cloudId);
|
||||
await db.markSynced(created.localId);
|
||||
}
|
||||
|
||||
// Mark updated records as synced
|
||||
for (const cloudId of result.updated) {
|
||||
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
|
||||
if (meal && meal.id) {
|
||||
await db.markSynced(meal.id);
|
||||
}
|
||||
}
|
||||
// Mark updated records as synced
|
||||
for (const cloudId of result.updated) {
|
||||
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
|
||||
if (meal && meal.id) {
|
||||
await db.markSynced(meal.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSyncAt = result.serverTime;
|
||||
this.lastSyncAt = result.serverTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created: result.created.length,
|
||||
updated: result.updated.length,
|
||||
deleted: 0,
|
||||
conflicts: result.conflicts || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Push sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Push failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
created: result.created.length,
|
||||
updated: result.updated.length,
|
||||
deleted: 0,
|
||||
conflicts: result.conflicts || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Push sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Push failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
public async pullChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
public async pullChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
|
||||
if (this.lastSyncAt) {
|
||||
url.searchParams.set('since', this.lastSyncAt);
|
||||
}
|
||||
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
|
||||
if (this.lastSyncAt) {
|
||||
url.searchParams.set('since', this.lastSyncAt);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
});
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Pull failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Pull failed',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const db = SQLiteService.getInstance();
|
||||
const result = await response.json();
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
||||
// Process server meals
|
||||
for (const serverMeal of result.meals) {
|
||||
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
|
||||
// Process server meals
|
||||
for (const serverMeal of result.meals) {
|
||||
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
|
||||
|
||||
if (existingMeal) {
|
||||
// Update existing local meal
|
||||
await db.updateMealFromServer(existingMeal.id!, serverMeal);
|
||||
updated++;
|
||||
} else {
|
||||
// Create new local meal
|
||||
await db.createMealFromServer(serverMeal);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
if (existingMeal) {
|
||||
// Update existing local meal
|
||||
await db.updateMealFromServer(existingMeal.id!, serverMeal);
|
||||
updated++;
|
||||
} else {
|
||||
// Create new local meal
|
||||
await db.createMealFromServer(serverMeal);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for (const cloudId of result.deletedIds) {
|
||||
await db.deleteByCloudId(cloudId);
|
||||
deleted++;
|
||||
}
|
||||
// Process deletions
|
||||
for (const cloudId of result.deletedIds) {
|
||||
await db.deleteByCloudId(cloudId);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
this.lastSyncAt = result.serverTime;
|
||||
this.lastSyncAt = result.serverTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
conflicts: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Pull sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Pull failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
conflicts: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Pull sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Pull failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map local meal to sync format
|
||||
*/
|
||||
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
|
||||
return {
|
||||
localId: meal.id!,
|
||||
cloudId: meal.cloud_id || undefined,
|
||||
foodName: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
|
||||
: 'Unbekanntes Gericht',
|
||||
imageUrl: meal.photo_url || undefined,
|
||||
calories: meal.total_calories || undefined,
|
||||
protein: meal.total_protein || undefined,
|
||||
carbohydrates: meal.total_carbs || undefined,
|
||||
fat: meal.total_fat || undefined,
|
||||
fiber: meal.total_fiber || undefined,
|
||||
sugar: meal.total_sugar || undefined,
|
||||
servingSize: undefined,
|
||||
mealType: meal.meal_type || undefined,
|
||||
analysisStatus: meal.analysis_status || 'completed',
|
||||
healthScore: meal.health_score || undefined,
|
||||
healthCategory: meal.health_category || undefined,
|
||||
notes: meal.user_notes || undefined,
|
||||
userRating: meal.user_rating || undefined,
|
||||
foodItems: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodItems
|
||||
: [],
|
||||
version: meal.version || 1,
|
||||
createdAt: meal.created_at || new Date().toISOString(),
|
||||
updatedAt: meal.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Map local meal to sync format
|
||||
*/
|
||||
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
|
||||
return {
|
||||
localId: meal.id!,
|
||||
cloudId: meal.cloud_id || undefined,
|
||||
foodName: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
|
||||
: 'Unbekanntes Gericht',
|
||||
imageUrl: meal.photo_url || undefined,
|
||||
calories: meal.total_calories || undefined,
|
||||
protein: meal.total_protein || undefined,
|
||||
carbohydrates: meal.total_carbs || undefined,
|
||||
fat: meal.total_fat || undefined,
|
||||
fiber: meal.total_fiber || undefined,
|
||||
sugar: meal.total_sugar || undefined,
|
||||
servingSize: undefined,
|
||||
mealType: meal.meal_type || undefined,
|
||||
analysisStatus: meal.analysis_status || 'completed',
|
||||
healthScore: meal.health_score || undefined,
|
||||
healthCategory: meal.health_category || undefined,
|
||||
notes: meal.user_notes || undefined,
|
||||
userRating: meal.user_rating || undefined,
|
||||
foodItems: meal.analysis_result ? JSON.parse(meal.analysis_result).foodItems : [],
|
||||
version: meal.version || 1,
|
||||
createdAt: meal.created_at || new Date().toISOString(),
|
||||
updatedAt: meal.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +1,118 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
|
||||
|
||||
// UI States
|
||||
showCameraModal: boolean;
|
||||
cameraMode: 'camera' | 'gallery' | null;
|
||||
isPhotoProcessing: boolean;
|
||||
// UI States
|
||||
showCameraModal: boolean;
|
||||
cameraMode: 'camera' | 'gallery' | null;
|
||||
isPhotoProcessing: boolean;
|
||||
|
||||
// User Preferences
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
|
||||
enableNotifications: boolean;
|
||||
preferredUnits: 'metric' | 'imperial';
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
// User Preferences
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
|
||||
enableNotifications: boolean;
|
||||
preferredUnits: 'metric' | 'imperial';
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
|
||||
// Stats Cache
|
||||
statsCache: {
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
lastUpdated: string | null;
|
||||
};
|
||||
// Stats Cache
|
||||
statsCache: {
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
lastUpdated: string | null;
|
||||
};
|
||||
|
||||
// Actions
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
setOnlineStatus: (online: boolean) => void;
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => void;
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
|
||||
setPhotoProcessing: (processing: boolean) => void;
|
||||
updateUserPreferences: (
|
||||
prefs: Partial<
|
||||
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
|
||||
>
|
||||
) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
|
||||
resetStats: () => void;
|
||||
resetToDefaults: () => void;
|
||||
// Actions
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
setOnlineStatus: (online: boolean) => void;
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => void;
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
|
||||
setPhotoProcessing: (processing: boolean) => void;
|
||||
updateUserPreferences: (
|
||||
prefs: Partial<
|
||||
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
|
||||
>
|
||||
) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
|
||||
resetStats: () => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
isInitialized: false,
|
||||
isOnline: true,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
theme: 'system',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
isInitialized: false,
|
||||
isOnline: true,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
theme: 'system',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
|
||||
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
||||
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
||||
|
||||
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
|
||||
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
|
||||
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
|
||||
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
|
||||
const currentShow = get().showCameraModal;
|
||||
const newShow = show !== undefined ? show : !currentShow;
|
||||
set({
|
||||
showCameraModal: newShow,
|
||||
cameraMode: newShow ? mode || 'camera' : null,
|
||||
});
|
||||
},
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
|
||||
const currentShow = get().showCameraModal;
|
||||
const newShow = show !== undefined ? show : !currentShow;
|
||||
set({
|
||||
showCameraModal: newShow,
|
||||
cameraMode: newShow ? mode || 'camera' : null,
|
||||
});
|
||||
},
|
||||
|
||||
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
|
||||
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
|
||||
|
||||
updateUserPreferences: (prefs) => set(prefs),
|
||||
updateUserPreferences: (prefs) => set(prefs),
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
|
||||
|
||||
updateStatsCache: (stats) =>
|
||||
set({
|
||||
statsCache: {
|
||||
...stats,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
updateStatsCache: (stats) =>
|
||||
set({
|
||||
statsCache: {
|
||||
...stats,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
|
||||
resetStats: () =>
|
||||
set({
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
resetStats: () =>
|
||||
set({
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
|
||||
resetToDefaults: () =>
|
||||
set({
|
||||
isInitialized: false,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
resetToDefaults: () =>
|
||||
set({
|
||||
isInitialized: false,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,298 +3,307 @@ import { authService, type UserData, type AuthResult } from '../services/auth/au
|
|||
import { tokenManager } from '../services/auth/tokenManager';
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
user: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
// State
|
||||
user: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signInWithApple: (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => Promise<{ success: boolean; error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshAuth: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signUp: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signInWithApple: (
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshAuth: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
initialize: async () => {
|
||||
if (get().isInitialized) return;
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
initialize: async () => {
|
||||
if (get().isInitialized) return;
|
||||
|
||||
set({ isLoading: true });
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const token = await tokenManager.getAppToken();
|
||||
try {
|
||||
const token = await tokenManager.getAppToken();
|
||||
|
||||
if (!token) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is still valid
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
const userData = authService.getUserFromToken(token);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check if token is still valid
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
const userData = authService.getUserFromToken(token);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to refresh token
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
// Try to refresh token
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token on init:', error);
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token on init:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear invalid tokens
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
error: 'Failed to initialize authentication',
|
||||
});
|
||||
}
|
||||
},
|
||||
// Clear invalid tokens
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
error: 'Failed to initialize authentication',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
signIn: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
signIn: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign in failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign in failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
signUp: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
signUp: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign up failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign up failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.needsVerification) {
|
||||
set({ isLoading: false, error: null });
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
if (result.needsVerification) {
|
||||
set({ isLoading: false, error: null });
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
signInWithGoogle: async (idToken: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
signInWithGoogle: async (idToken: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
try {
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
signInWithApple: async (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
signInWithApple: async (
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signInWithApple(idToken, user);
|
||||
try {
|
||||
const result = await authService.signInWithApple(idToken, user);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
signOut: async () => {
|
||||
set({ isLoading: true });
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
signOut: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authService.signOut(refreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sign out:', error);
|
||||
} finally {
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
}
|
||||
},
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authService.signOut(refreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sign out:', error);
|
||||
} finally {
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
forgotPassword: async (email: string) => {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
forgotPassword: async (email: string) => {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
refreshAuth: async () => {
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
refreshAuth: async () => {
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
|
||||
if (result.userData) {
|
||||
set({ user: result.userData });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error refreshing auth:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
if (result.userData) {
|
||||
set({ user: result.userData });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error refreshing auth:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => set({ error: null }),
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,188 +3,188 @@ import { Meal, MealWithItems, CreateMealInput, CreateFoodItemInput } from '../ty
|
|||
import { SQLiteService } from '../services/database/SQLiteService';
|
||||
|
||||
interface MealState {
|
||||
meals: MealWithItems[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedMeal: MealWithItems | null;
|
||||
meals: MealWithItems[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedMeal: MealWithItems | null;
|
||||
|
||||
// Actions
|
||||
loadMeals: () => Promise<void>;
|
||||
loadMealById: (id: number) => Promise<void>;
|
||||
createMeal: (input: CreateMealInput) => Promise<number>;
|
||||
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
|
||||
deleteMeal: (id: number) => Promise<void>;
|
||||
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
|
||||
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
|
||||
searchMeals: (query: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
setSelectedMeal: (meal: MealWithItems | null) => void;
|
||||
clearAllMeals: () => void;
|
||||
// Actions
|
||||
loadMeals: () => Promise<void>;
|
||||
loadMealById: (id: number) => Promise<void>;
|
||||
createMeal: (input: CreateMealInput) => Promise<number>;
|
||||
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
|
||||
deleteMeal: (id: number) => Promise<void>;
|
||||
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
|
||||
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
|
||||
searchMeals: (query: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
setSelectedMeal: (meal: MealWithItems | null) => void;
|
||||
clearAllMeals: () => void;
|
||||
}
|
||||
|
||||
export const useMealStore = create<MealState>((set, get) => ({
|
||||
meals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedMeal: null,
|
||||
meals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedMeal: null,
|
||||
|
||||
loadMeals: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals = await dbService.getAllMealsWithItems(50, 0);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
loadMeals: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals = await dbService.getAllMealsWithItems(50, 0);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadMealById: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meal = await dbService.getMealWithItems(id);
|
||||
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
|
||||
set({ selectedMeal: meal, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
loadMealById: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meal = await dbService.getMealWithItems(id);
|
||||
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
|
||||
set({ selectedMeal: meal, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createMeal: async (input: CreateMealInput) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const mealId = await dbService.createMeal(input);
|
||||
createMeal: async (input: CreateMealInput) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const mealId = await dbService.createMeal(input);
|
||||
|
||||
// Reload meals to update the list
|
||||
await get().loadMeals();
|
||||
// Reload meals to update the list
|
||||
await get().loadMeals();
|
||||
|
||||
set({ isLoading: false });
|
||||
return mealId;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create meal',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
set({ isLoading: false });
|
||||
return mealId;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create meal',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateMeal: async (id: number, updates: Partial<Meal>) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.updateMeal(id, updates);
|
||||
updateMeal: async (id: number, updates: Partial<Meal>) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.updateMeal(id, updates);
|
||||
|
||||
// If this is a completed analysis, reload the meal with all food items
|
||||
if (updates.analysis_status === 'completed') {
|
||||
const updatedMealWithItems = await dbService.getMealWithItems(id);
|
||||
// If this is a completed analysis, reload the meal with all food items
|
||||
if (updates.analysis_status === 'completed') {
|
||||
const updatedMealWithItems = await dbService.getMealWithItems(id);
|
||||
|
||||
// Update meals in store with the full meal data
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
|
||||
// Update meals in store with the full meal data
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
|
||||
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: updatedMealWithItems });
|
||||
}
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: updatedMealWithItems });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
} else {
|
||||
// For other updates, just update the fields we have
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
|
||||
set({ meals, isLoading: false });
|
||||
} else {
|
||||
// For other updates, just update the fields we have
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
|
||||
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: { ...selectedMeal, ...updates } });
|
||||
}
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: { ...selectedMeal, ...updates } });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
}
|
||||
set({ meals, isLoading: false });
|
||||
}
|
||||
|
||||
console.log(`Meal ${id} updated with:`, updates);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to update meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
console.log(`Meal ${id} updated with:`, updates);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to update meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteMeal: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.deleteMeal(id);
|
||||
deleteMeal: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.deleteMeal(id);
|
||||
|
||||
// Remove from meals array
|
||||
const meals = get().meals.filter((meal) => meal.id !== id);
|
||||
// Remove from meals array
|
||||
const meals = get().meals.filter((meal) => meal.id !== id);
|
||||
|
||||
// Clear selected meal if it was deleted
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: null });
|
||||
}
|
||||
// Clear selected meal if it was deleted
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: null });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
searchMeals: async (query: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals =
|
||||
query.trim() === ''
|
||||
? await dbService.getAllMeals(50, 0)
|
||||
: await dbService.searchMeals(query);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to search meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
searchMeals: async (query: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals =
|
||||
query.trim() === ''
|
||||
? await dbService.getAllMeals(50, 0)
|
||||
: await dbService.searchMeals(query);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to search meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createFoodItem: async (input: CreateFoodItemInput) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemId = await dbService.createFoodItem(input);
|
||||
return foodItemId;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createFoodItem: async (input: CreateFoodItemInput) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemId = await dbService.createFoodItem(input);
|
||||
return foodItemId;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
|
||||
return foodItemIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food items batch:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
|
||||
return foodItemIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food items batch:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
|
||||
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
|
||||
|
||||
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
|
||||
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"],
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"],
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +1,97 @@
|
|||
// Gemini API Response Typen
|
||||
export interface GeminiAnalysisResult {
|
||||
meal_analysis: {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score: number; // 1.0-10.0
|
||||
health_category: 'healthy' | 'moderate' | 'unhealthy';
|
||||
confidence: number; // 0.0-1.0
|
||||
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
};
|
||||
food_items: GeminiFoodItem[];
|
||||
analysis_notes: {
|
||||
health_reasoning: string;
|
||||
improvement_suggestions: string[];
|
||||
cooking_method: string;
|
||||
estimated_freshness: string;
|
||||
hidden_ingredients: string[];
|
||||
portion_accuracy: 'low' | 'medium' | 'high';
|
||||
};
|
||||
_metadata?: {
|
||||
processingTime: number;
|
||||
apiProvider: string;
|
||||
model: string;
|
||||
timestamp: string;
|
||||
};
|
||||
meal_analysis: {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score: number; // 1.0-10.0
|
||||
health_category: 'healthy' | 'moderate' | 'unhealthy';
|
||||
confidence: number; // 0.0-1.0
|
||||
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
};
|
||||
food_items: GeminiFoodItem[];
|
||||
analysis_notes: {
|
||||
health_reasoning: string;
|
||||
improvement_suggestions: string[];
|
||||
cooking_method: string;
|
||||
estimated_freshness: string;
|
||||
hidden_ingredients: string[];
|
||||
portion_accuracy: 'low' | 'medium' | 'high';
|
||||
};
|
||||
_metadata?: {
|
||||
processingTime: number;
|
||||
apiProvider: string;
|
||||
model: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiFoodItem {
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence: number;
|
||||
is_organic: boolean;
|
||||
is_processed: boolean;
|
||||
allergens: string[];
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence: number;
|
||||
is_organic: boolean;
|
||||
is_processed: boolean;
|
||||
allergens: string[];
|
||||
}
|
||||
|
||||
// API Error Types
|
||||
export interface APIError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Prompt Context Types
|
||||
export interface PromptContext {
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: 'restaurant' | 'homemade' | 'fastfood';
|
||||
additional?: string;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: 'restaurant' | 'homemade' | 'fastfood';
|
||||
additional?: string;
|
||||
}
|
||||
|
||||
// Gemini Error Class
|
||||
export class GeminiError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly type: 'TEMPORARY' | 'PERMANENT';
|
||||
public readonly metadata?: any;
|
||||
public readonly code: string;
|
||||
public readonly type: 'TEMPORARY' | 'PERMANENT';
|
||||
public readonly metadata?: any;
|
||||
|
||||
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnalysisRequest {
|
||||
imageBase64: string;
|
||||
context?: PromptContext;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
imageBase64: string;
|
||||
context?: PromptContext;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
success: boolean;
|
||||
data?: GeminiAnalysisResult;
|
||||
error?: APIError;
|
||||
processingTime: number;
|
||||
cost?: number;
|
||||
success: boolean;
|
||||
data?: GeminiAnalysisResult;
|
||||
error?: APIError;
|
||||
processingTime: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,116 @@
|
|||
export interface Meal {
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
user_id?: string;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
last_sync_at?: string;
|
||||
photo_path: string;
|
||||
photo_url?: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
|
||||
timestamp: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
location_accuracy?: number;
|
||||
analysis_result?: string; // JSON der Gemini-Antwort
|
||||
analysis_confidence?: number;
|
||||
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
total_calories?: number;
|
||||
total_protein?: number;
|
||||
total_carbs?: number;
|
||||
total_fat?: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score?: number; // 1.0 - 10.0
|
||||
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
|
||||
user_notes?: string;
|
||||
user_modified: number; // Boolean als Integer
|
||||
user_rating?: number; // 1-5 Sterne
|
||||
api_provider: string;
|
||||
api_cost?: number; // Kosten in Cent
|
||||
processing_time?: number; // Millisekunden
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
user_id?: string;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
last_sync_at?: string;
|
||||
photo_path: string;
|
||||
photo_url?: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
|
||||
timestamp: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
location_accuracy?: number;
|
||||
analysis_result?: string; // JSON der Gemini-Antwort
|
||||
analysis_confidence?: number;
|
||||
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
total_calories?: number;
|
||||
total_protein?: number;
|
||||
total_carbs?: number;
|
||||
total_fat?: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score?: number; // 1.0 - 10.0
|
||||
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
|
||||
user_notes?: string;
|
||||
user_modified: number; // Boolean als Integer
|
||||
user_rating?: number; // 1-5 Sterne
|
||||
api_provider: string;
|
||||
api_cost?: number; // Kosten in Cent
|
||||
processing_time?: number; // Millisekunden
|
||||
}
|
||||
|
||||
export interface FoodItem {
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
meal_id: number;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number; // 0.0 - 1.0
|
||||
bounding_box?: string; // JSON: Position im Bild
|
||||
is_organic: number; // Boolean als Integer
|
||||
is_processed: number; // Boolean als Integer
|
||||
allergens?: string; // JSON Array
|
||||
created_at: string;
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
meal_id: number;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number; // 0.0 - 1.0
|
||||
bounding_box?: string; // JSON: Position im Bild
|
||||
is_organic: number; // Boolean als Integer
|
||||
is_processed: number; // Boolean als Integer
|
||||
allergens?: string; // JSON Array
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SyncMetadata {
|
||||
table_name: string;
|
||||
record_id: number;
|
||||
cloud_id?: string;
|
||||
last_sync_at?: string;
|
||||
conflict_data?: string; // JSON für Konfliktlösung
|
||||
retry_count: number;
|
||||
table_name: string;
|
||||
record_id: number;
|
||||
cloud_id?: string;
|
||||
last_sync_at?: string;
|
||||
conflict_data?: string; // JSON für Konfliktlösung
|
||||
retry_count: number;
|
||||
}
|
||||
|
||||
export interface PhotoDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Eingabe für neue Mahlzeiten
|
||||
export interface CreateMealInput {
|
||||
photo_path: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: PhotoDimensions;
|
||||
meal_type?: Meal['meal_type'];
|
||||
location?: string;
|
||||
user_notes?: string;
|
||||
analysis_status?: Meal['analysis_status'];
|
||||
photo_path: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: PhotoDimensions;
|
||||
meal_type?: Meal['meal_type'];
|
||||
location?: string;
|
||||
user_notes?: string;
|
||||
analysis_status?: Meal['analysis_status'];
|
||||
}
|
||||
|
||||
// Eingabe für neue Lebensmittel
|
||||
export interface CreateFoodItemInput {
|
||||
meal_id: number;
|
||||
name: string;
|
||||
category?: FoodItem['category'];
|
||||
portion_size?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number;
|
||||
is_organic?: number;
|
||||
is_processed?: number;
|
||||
allergens?: string;
|
||||
meal_id: number;
|
||||
name: string;
|
||||
category?: FoodItem['category'];
|
||||
portion_size?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number;
|
||||
is_organic?: number;
|
||||
is_processed?: number;
|
||||
allergens?: string;
|
||||
}
|
||||
|
||||
// Vollständige Mahlzeit mit FoodItems
|
||||
export interface MealWithItems extends Meal {
|
||||
food_items: FoodItem[];
|
||||
food_items: FoodItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
{
|
||||
"name": "@nutriphi/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*"
|
||||
}
|
||||
"name": "@nutriphi/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
apps/nutriphi/apps/web/src/app.d.ts
vendored
22
apps/nutriphi/apps/web/src/app.d.ts
vendored
|
|
@ -2,17 +2,17 @@
|
|||
// for information about these interfaces
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
// Authentication handled via Mana Middleware (client-side)
|
||||
}
|
||||
interface PageData {
|
||||
// Page data types
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
// Authentication handled via Mana Middleware (client-side)
|
||||
}
|
||||
interface PageData {
|
||||
// Page data types
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import { type Handle } from '@sveltejs/kit';
|
|||
* Authentication is handled client-side via Mana Middleware
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,67 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { FoodItem } from '$lib/types/meal';
|
||||
import type { FoodItem } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
items: FoodItem[];
|
||||
}
|
||||
interface Props {
|
||||
items: FoodItem[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
let { items }: Props = $props();
|
||||
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
protein: 'Protein',
|
||||
vegetable: 'Gemüse',
|
||||
grain: 'Getreide',
|
||||
fruit: 'Obst',
|
||||
dairy: 'Milchprodukt',
|
||||
fat: 'Fett',
|
||||
processed: 'Verarbeitet',
|
||||
beverage: 'Getränk'
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
protein: 'Protein',
|
||||
vegetable: 'Gemüse',
|
||||
grain: 'Getreide',
|
||||
fruit: 'Obst',
|
||||
dairy: 'Milchprodukt',
|
||||
fat: 'Fett',
|
||||
processed: 'Verarbeitet',
|
||||
beverage: 'Getränk',
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||
};
|
||||
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
|
||||
};
|
||||
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
|
||||
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
|
||||
{getCategoryLabel(item.category)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if item.calories}
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(item.calories)} kcal
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.confidence}
|
||||
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
|
||||
{getCategoryLabel(item.category)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if item.calories}
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(item.calories)} kcal
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.confidence}
|
||||
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,110 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
onclick?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { meal, onclick }: Props = $props();
|
||||
let { meal, onclick }: Props = $props();
|
||||
|
||||
function getMealTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
function getMealTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
const healthColor = $derived(() => {
|
||||
if (!meal.health_score) return 'text-gray-400';
|
||||
if (meal.health_score >= 8) return 'text-green-500';
|
||||
if (meal.health_score >= 6) return 'text-yellow-500';
|
||||
if (meal.health_score >= 4) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
});
|
||||
const healthColor = $derived(() => {
|
||||
if (!meal.health_score) return 'text-gray-400';
|
||||
if (meal.health_score >= 8) return 'text-green-500';
|
||||
if (meal.health_score >= 6) return 'text-yellow-500';
|
||||
if (meal.health_score >= 4) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
});
|
||||
|
||||
function formatDate(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
}
|
||||
function formatDate(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
{onclick}
|
||||
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
|
||||
{onclick}
|
||||
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
|
||||
>
|
||||
{#if meal.photo_url}
|
||||
<img
|
||||
src={meal.photo_url}
|
||||
alt={getMealTypeLabel(meal.meal_type)}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
|
||||
{/if}
|
||||
{#if meal.photo_url}
|
||||
<img
|
||||
src={meal.photo_url}
|
||||
alt={getMealTypeLabel(meal.meal_type)}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
{formatDate(meal.timestamp)} • {formatTime(meal.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if meal.total_calories}
|
||||
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
|
||||
<p class="text-xs text-gray-300">kcal</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4"
|
||||
>
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
{formatDate(meal.timestamp)} • {formatTime(meal.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if meal.total_calories}
|
||||
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
|
||||
<p class="text-xs text-gray-300">kcal</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if meal.health_score}
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
class="h-full rounded-full {meal.health_score >= 7
|
||||
? 'bg-green-500'
|
||||
: meal.health_score >= 5
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
style="width: {meal.health_score * 10}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if meal.health_score}
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
class="h-full rounded-full {meal.health_score >= 7
|
||||
? 'bg-green-500'
|
||||
: meal.health_score >= 5
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
style="width: {meal.health_score * 10}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Analysis Status Badge -->
|
||||
{#if meal.analysis_status === 'pending'}
|
||||
<div class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
Analysiert...
|
||||
</div>
|
||||
{:else if meal.analysis_status === 'failed'}
|
||||
<div class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white">
|
||||
Fehler
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Analysis Status Badge -->
|
||||
{#if meal.analysis_status === 'pending'}
|
||||
<div
|
||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
Analysiert...
|
||||
</div>
|
||||
{:else if meal.analysis_status === 'failed'}
|
||||
<div
|
||||
class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
Fehler
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,164 +1,173 @@
|
|||
<script lang="ts">
|
||||
import type { Meal, MealType } from '$lib/types/meal';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import type { Meal, MealType } from '$lib/types/meal';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { meal, isOpen, onClose }: Props = $props();
|
||||
let { meal, isOpen, onClose }: Props = $props();
|
||||
|
||||
// Form state - initialized from meal
|
||||
let mealType = $state<MealType>(meal.meal_type);
|
||||
let userNotes = $state(meal.user_notes || '');
|
||||
let userRating = $state(meal.user_rating || 0);
|
||||
let isSaving = $state(false);
|
||||
// Form state - initialized from meal
|
||||
let mealType = $state<MealType>(meal.meal_type);
|
||||
let userNotes = $state(meal.user_notes || '');
|
||||
let userRating = $state(meal.user_rating || 0);
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Reset form when meal changes
|
||||
$effect(() => {
|
||||
mealType = meal.meal_type;
|
||||
userNotes = meal.user_notes || '';
|
||||
userRating = meal.user_rating || 0;
|
||||
});
|
||||
// Reset form when meal changes
|
||||
$effect(() => {
|
||||
mealType = meal.meal_type;
|
||||
userNotes = meal.user_notes || '';
|
||||
userRating = meal.user_rating || 0;
|
||||
});
|
||||
|
||||
const mealTypes: { value: MealType; label: string }[] = [
|
||||
{ value: 'breakfast', label: 'Frühstück' },
|
||||
{ value: 'lunch', label: 'Mittagessen' },
|
||||
{ value: 'dinner', label: 'Abendessen' },
|
||||
{ value: 'snack', label: 'Snack' }
|
||||
];
|
||||
const mealTypes: { value: MealType; label: string }[] = [
|
||||
{ value: 'breakfast', label: 'Frühstück' },
|
||||
{ value: 'lunch', label: 'Mittagessen' },
|
||||
{ value: 'dinner', label: 'Abendessen' },
|
||||
{ value: 'snack', label: 'Snack' },
|
||||
];
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await mealsStore.updateMeal(meal.id, {
|
||||
meal_type: mealType,
|
||||
user_notes: userNotes || undefined,
|
||||
user_rating: userRating || undefined
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save meal:', err);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await mealsStore.updateMeal(meal.id, {
|
||||
meal_type: mealType,
|
||||
user_notes: userNotes || undefined,
|
||||
user_rating: userRating || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save meal:', err);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRatingClick(rating: number) {
|
||||
userRating = userRating === rating ? 0 : rating;
|
||||
}
|
||||
function handleRatingClick(rating: number) {
|
||||
userRating = userRating === rating ? 0 : rating;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Meal Type -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Art der Mahlzeit
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each mealTypes as type}
|
||||
<button
|
||||
onclick={() => (mealType = type.value)}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType === type.value
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Meal Type -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Art der Mahlzeit
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each mealTypes as type}
|
||||
<button
|
||||
onclick={() => (mealType = type.value)}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType ===
|
||||
type.value
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung
|
||||
</label>
|
||||
<div class="flex gap-1">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
onclick={() => handleRatingClick(star)}
|
||||
class="text-2xl transition-transform hover:scale-110 {star <= userRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung
|
||||
</label>
|
||||
<div class="flex gap-1">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
onclick={() => handleRatingClick(star)}
|
||||
class="text-2xl transition-transform hover:scale-110 {star <= userRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="notes" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={userNotes}
|
||||
rows="3"
|
||||
placeholder="Notizen zu dieser Mahlzeit..."
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label
|
||||
for="notes"
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={userNotes}
|
||||
rows="3"
|
||||
placeholder="Notizen zu dieser Mahlzeit..."
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import MealCard from './MealCard.svelte';
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import MealCard from './MealCard.svelte';
|
||||
|
||||
interface Props {
|
||||
meals: Meal[];
|
||||
onMealClick: (meal: Meal) => void;
|
||||
}
|
||||
interface Props {
|
||||
meals: Meal[];
|
||||
onMealClick: (meal: Meal) => void;
|
||||
}
|
||||
|
||||
let { meals, onMealClick }: Props = $props();
|
||||
let { meals, onMealClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if meals.length === 0}
|
||||
<div class="flex h-64 flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 text-6xl">🥗</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Erfasse deine erste Mahlzeit mit einem Foto
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex h-64 flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 text-6xl">🥗</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Erfasse deine erste Mahlzeit mit einem Foto</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each meals as meal (meal.id)}
|
||||
<MealCard {meal} onclick={() => onMealClick(meal)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each meals as meal (meal.id)}
|
||||
<MealCard {meal} onclick={() => onMealClick(meal)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue