mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(nutriphi): migrate from Supabase to PostgreSQL + Hetzner S3
- Add nutriphi-database package with Drizzle ORM - meals and nutrition_goals schemas - PostgreSQL 16 Docker setup - Drizzle Kit configuration - Migrate backend from Supabase to Drizzle - Add DatabaseModule with connection pooling - Add StorageService for Hetzner Object Storage (S3-compatible) - Update MealsService with Drizzle queries - Add /api/meals/upload endpoint for image upload + analysis - Update web app to use backend for uploads - Remove Supabase Storage direct upload - Update uploadService to send images to backend - Remove Supabase dependencies from package.json - Simplify hooks.server.ts - Add Coolify deployment configuration - Dockerfile for production build - docker-compose.coolify.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ce71db2fc0
commit
6537863696
156 changed files with 15236 additions and 170 deletions
25
nutriphi/.gitignore
vendored
Normal file
25
nutriphi/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
171
nutriphi/CLAUDE.md
Normal file
171
nutriphi/CLAUDE.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Nutriphi Project Guide
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Nutriphi is a KI-gestützter Ernährungs-Tracker (AI-powered nutrition tracker) that uses Google Gemini Vision API to analyze food photos and provide detailed nutritional information.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
nutriphi/
|
||||
├── apps/
|
||||
│ ├── mobile/ # Expo/React Native mobile app (@nutriphi/mobile)
|
||||
│ ├── web/ # SvelteKit web application (@nutriphi/web)
|
||||
│ └── landing/ # Astro marketing landing page (@nutriphi/landing)
|
||||
├── backend/ # NestJS API server (@nutriphi/backend)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
```bash
|
||||
pnpm nutriphi:dev # Run all nutriphi apps
|
||||
pnpm dev:nutriphi:mobile # Start mobile app
|
||||
pnpm dev:nutriphi:web # Start web app
|
||||
pnpm dev:nutriphi:landing # Start landing page
|
||||
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
|
||||
pnpm android # Run on Android emulator
|
||||
pnpm build:dev # Build development version
|
||||
pnpm build:preview # Build preview version
|
||||
pnpm build:prod # Build production version
|
||||
pnpm type-check # Run TypeScript checks
|
||||
```
|
||||
|
||||
### Backend (nutriphi/backend)
|
||||
```bash
|
||||
pnpm start:dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm type-check # Run TypeScript checks
|
||||
```
|
||||
|
||||
### Web App (nutriphi/apps/web)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
pnpm type-check # Run type checks
|
||||
```
|
||||
|
||||
### Landing Page (nutriphi/apps/landing)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
pnpm type-check # Run Astro checks
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Mobile**: React Native 0.79 + Expo SDK 53, NativeWind, Expo Router, Zustand
|
||||
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
|
||||
- **Landing**: Astro 5.x, Tailwind CSS
|
||||
- **Backend**: NestJS 10, Google Gemini Vision API, Supabase
|
||||
- **Authentication**: Mana Core Auth (shared with ecosystem)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check |
|
||||
| `/api/meals/analyze/image` | POST | Analyze food image with AI |
|
||||
| `/api/meals/analyze/text` | POST | Analyze food description |
|
||||
| `/api/meals` | POST | Create new meal entry |
|
||||
| `/api/meals/user/:userId` | GET | Get user's meals |
|
||||
| `/api/meals/user/:userId/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 |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
MANACORE_AUTH_URL=https://auth.manacore.de
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
```
|
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **AI Food Analysis**: Upload a photo of your meal and get instant nutritional information
|
||||
2. **Manual Entry**: Enter food descriptions for text-based analysis
|
||||
3. **Daily Tracking**: View daily summaries of calories, protein, carbs, fat, fiber
|
||||
4. **Meal History**: Browse and edit past meal entries
|
||||
5. **Health Tips**: Receive personalized nutrition recommendations
|
||||
|
||||
## 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
|
||||
- `components/` - Reusable UI components
|
||||
- `store/` - Zustand state management
|
||||
- `services/` - API and database services
|
||||
- `hooks/` - Custom React hooks
|
||||
- `utils/` - Utility functions
|
||||
|
||||
### Styling
|
||||
- NativeWind (Tailwind for React Native)
|
||||
- Components use `className` prop with Tailwind utility classes
|
||||
|
||||
### State Management
|
||||
- Zustand stores for meals, user settings
|
||||
- SQLite for local offline storage
|
||||
- Supabase for cloud sync
|
||||
|
||||
## Shared Packages Used
|
||||
|
||||
- `@manacore/shared-auth-ui` - Authentication UI components
|
||||
- `@manacore/shared-branding` - Branding assets
|
||||
- `@manacore/shared-i18n` - Internationalization
|
||||
- `@manacore/shared-icons` - Icon library
|
||||
- `@manacore/shared-supabase` - Supabase client utilities
|
||||
- `@manacore/shared-tailwind` - Tailwind configuration
|
||||
- `@manacore/shared-theme` - Theme tokens
|
||||
- `@manacore/shared-theme-ui` - Theme UI components
|
||||
- `@manacore/shared-ui` - Common UI components
|
||||
- `@manacore/shared-utils` - Utility functions
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Mobile**: Functional components with hooks
|
||||
- **Web**: Svelte 5 runes mode
|
||||
- **Styling**: Tailwind CSS everywhere
|
||||
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Security**: API keys are stored in the backend only - never in client apps
|
||||
2. **Authentication**: Uses Mana Core Auth, shared with ecosystem
|
||||
3. **Database**: Supabase PostgreSQL with RLS policies
|
||||
4. **Deployment**: Backend runs on port 3002 by default
|
||||
381
nutriphi/DoneSteps.md
Normal file
381
nutriphi/DoneSteps.md
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
# 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
|
||||
- `sync_metadata` Tabelle für zukünftige Cloud-Sync
|
||||
- Performance-Indizes für optimierte Abfragen
|
||||
- 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
|
||||
- Versionierung und Konfliktlösung
|
||||
- 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
|
||||
- Prompt-Context-Definitionen
|
||||
|
||||
## ✅ 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)
|
||||
- Status-Badges für Analyse-Status
|
||||
- Benutzer-Bewertungen und Notizen
|
||||
- Relative Zeitanzeigen ("2h ago")
|
||||
|
||||
- **NutritionBar.tsx** ✅
|
||||
- Detaillierte und kompakte Modi
|
||||
- Visuell ansprechende Makronährstoff-Darstellung
|
||||
- Gesundheitsscore mit Farbkodierung
|
||||
- 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
|
||||
- Automatische Temp-File-Cleanup
|
||||
- Storage-Statistiken
|
||||
- `expo-image-picker` Integration mit Permission-Handling
|
||||
|
||||
- **Camera-UI-Komponenten** ✅
|
||||
- `CameraModal.tsx`: Vollscreen-Kamera mit mode-based Funktionalität
|
||||
- `PhotoButton.tsx`: Animierter Auslöse-Button mit Capturing-States
|
||||
- `PhotoPreview.tsx`: Foto-Vorschau mit Benutzer-Feedback
|
||||
- Native Kamera-Controls (Flip, Close)
|
||||
- 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)
|
||||
- User-Preferences-Struktur
|
||||
- Stats-Caching-System
|
||||
- `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
|
||||
- Konsistente Header-Styles
|
||||
- Dual Floating Action Buttons (Camera + Gallery)
|
||||
|
||||
## ✅ 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
|
||||
- Qualitäts-Optimierung (0.8) für Performance
|
||||
- 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
|
||||
- Direkter Workflow ohne Auswahl-Menü
|
||||
- Smooth Animationen mit React Native Reanimated
|
||||
|
||||
- **Vereinfachter UI-Flow** ✅
|
||||
- "Add Your First Meal" Button entfernt für cleaner Design
|
||||
- Empty State Text überarbeitet
|
||||
- PhotoSourceSelector Komponente entfernt
|
||||
- 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."
|
||||
- Automatische Permission-Requests mit Fallback-Handling
|
||||
|
||||
## 🔄 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
|
||||
4. **UI/UX-Flow** - Kompletter Benutzerflow von Foto bis Datenbankpeicherung
|
||||
5. **Responsive Design** - Alle Komponenten mit NativeWind/Tailwind optimiert
|
||||
6. **State Management** - Reaktive Updates zwischen allen Komponenten
|
||||
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
|
||||
- UI zeigt bereits Analysis-Status und kann AI-Ergebnisse darstellen
|
||||
|
||||
## 🎯 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
|
||||
- **Loading States**: Benutzer wird immer über App-Status informiert
|
||||
- **Offline-First**: Funktioniert komplett ohne Internet
|
||||
- **Progressive Enhancement**: Bereit für Cloud-Features
|
||||
|
||||
## ✅ 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
|
||||
- Strukturierte JSON-Responses mit Validation
|
||||
- Retry-Mechanismen mit exponential backoff
|
||||
- Timeout-Handling (60s) für API-Stabilität
|
||||
- Base64-Bildkonvertierung mit FileSystem
|
||||
|
||||
### 3.2 AI-Analyse-Features
|
||||
- **Intelligente Lebensmittel-Erkennung** ✅
|
||||
- Automatische Erkennung von Mahlzeiten und Zutaten
|
||||
- Portionsgrößen-Schätzung
|
||||
- Nährwert-Berechnung (Kalorien, Protein, Kohlenhydrate, Fett, etc.)
|
||||
- Gesundheitsscore-Bewertung (0-100)
|
||||
- Allergen-Erkennung
|
||||
- Bio/Verarbeitet-Klassifizierung
|
||||
|
||||
### 3.3 Background Processing
|
||||
- **Asynchrone Foto-Analyse** ✅
|
||||
- Foto-Upload und sofortige UI-Rückkehr
|
||||
- Background-Verarbeitung mit Gemini API
|
||||
- Automatische Datenbank-Updates nach Analyse
|
||||
- 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
|
||||
- Detaillierte Nährwert-Anzeige
|
||||
- Food-Item-Liste mit Confidence-Scores
|
||||
|
||||
## ✅ 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
|
||||
- Doppeltes `file://` Präfix entfernt
|
||||
- Error-Handling für fehlende Bilder mit Fallback-UI
|
||||
|
||||
- **Performance-Optimierungen** ✅
|
||||
- Batch-Insert für Food Items (createFoodItemsBatch)
|
||||
- Transaktionale DB-Updates
|
||||
- Intelligentes State-Management mit selective Reloads
|
||||
- Optimierte Meal-Updates nach Analyse
|
||||
|
||||
- **Cleanup & Maintenance** ✅
|
||||
- PhotoService prüft Verzeichnis-Existenz
|
||||
- Graceful Handling von nicht-existierenden Ordnern
|
||||
- Verbesserte Temp-File-Cleanup-Logik
|
||||
- Console-Logs für besseres Debugging
|
||||
|
||||
### 3.6 Technische Verbesserungen
|
||||
- **Database Layer** ✅
|
||||
- SQLite-Transaktionen für Batch-Operations
|
||||
- Rollback-Fähigkeit bei Fehlern
|
||||
- Optimierte Queries mit Indizes
|
||||
|
||||
- **State Management** ✅
|
||||
- Automatisches Reload von MealWithItems nach Analyse
|
||||
- Konsistente Updates zwischen Liste und Detail-View
|
||||
- Optimistische UI-Updates
|
||||
|
||||
- **Error Resilience** ✅
|
||||
- Retry-Mechanismen für Gemini API
|
||||
- Graceful Degradation bei API-Fehlern
|
||||
- User-freundliche Fehlermeldungen
|
||||
|
||||
## ✅ 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`
|
||||
- Haptic Feedback bei Aktionen
|
||||
- Conditional Actions basierend auf Meal-Status
|
||||
|
||||
- **Context Menu Actions** ✅
|
||||
- 📝 **Bearbeiten**: Öffnet Edit Modal für Notizen/Rating/Location
|
||||
- ⭐ **Bewerten**: Schnellbewertung mit Sub-Menu (1-5 Sterne)
|
||||
- 📤 **Teilen**: System Share Sheet für Nährwerte
|
||||
- 📋 **Kopieren**: Nährwerte in Zwischenablage
|
||||
- 🔄 **Erneut analysieren**: Bei fehlgeschlagener Analyse
|
||||
- 🗑️ **Löschen**: Mit Bestätigungsdialog (destruktive Action)
|
||||
|
||||
- **EditMealModal.tsx** ✅
|
||||
- Modal für Meal-Bearbeitung
|
||||
- Sterne-Bewertung mit Touch-Feedback
|
||||
- Location-Bearbeitung
|
||||
- Notizen-Eingabe mit Multiline-Support
|
||||
- Optimistisches UI-Update
|
||||
|
||||
### 4.2 Location Tracking
|
||||
- **Automatische GPS-Erfassung** ✅
|
||||
- GPS-Koordinaten werden beim Foto-Capture erfasst
|
||||
- Reverse Geocoding für lesbare Adressen
|
||||
- Smart Location-Formatting (Name > Street > City)
|
||||
- Distanz-Berechnungen mit Haversine-Formel
|
||||
|
||||
- **LocationService.ts** ✅
|
||||
- Permission Handling (Check & Request)
|
||||
- getCurrentLocation() mit High Accuracy
|
||||
- reverseGeocode() für Adress-Konvertierung
|
||||
- isNearLocation() für Geofencing-Features
|
||||
|
||||
- **Location Privacy** ✅
|
||||
- LocationPermissionModal beim ersten Mal
|
||||
- UserPreferencesService für persistente Settings
|
||||
- Location On/Off Toggle in Settings
|
||||
- Opt-in Ansatz - Privacy by Design
|
||||
|
||||
### 4.3 Enhanced Settings
|
||||
- **Erweiterte Settings-Seite** ✅
|
||||
- Neue "Privatsphäre & Standort" Sektion
|
||||
- Location-Toggle mit Echtzeit-Updates
|
||||
- Link zu System-Einstellungen
|
||||
- UserPreferences mit Cache-Layer
|
||||
|
||||
- **UserPreferencesService.ts** ✅
|
||||
- Zentrale Verwaltung aller Präferenzen
|
||||
- SQLite-basierte Persistierung
|
||||
- Type-safe Interface
|
||||
- Batch-Update-Fähigkeit
|
||||
|
||||
### 4.4 Database Enhancements
|
||||
- **Location-Felder in Meals** ✅
|
||||
- latitude, longitude, location_accuracy
|
||||
- Migration #4 für bestehende DBs
|
||||
- Geo-Index für Location-Queries
|
||||
- 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
|
||||
- user_preferences Tabelle wird in SQLiteService.createTables() erstellt
|
||||
- UserPreferencesService mit Resilience gegen DB-Timing-Issues
|
||||
- Cache-First Approach für sofortige UI-Updates
|
||||
- Graceful Degradation wenn DB-Tabellen noch nicht existieren
|
||||
|
||||
- **iOS Location Permission** ✅
|
||||
- expo-location Plugin bereits in app.json konfiguriert
|
||||
- Benötigt Rebuild (expo prebuild) für System-Settings-Eintrag
|
||||
- Permission-Text für klare Nutzer-Kommunikation
|
||||
|
||||
- **Settings Toggle Fix** ✅
|
||||
- Location-Toggle in App-Settings funktioniert jetzt zuverlässig
|
||||
- Cache wird sofort aktualisiert für responsive UI
|
||||
- Fallback zu Default-Werten bei DB-Fehlern
|
||||
|
||||
## 🎯 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
|
||||
4. **Location Tracking** - Automatische GPS-Erfassung mit Privacy-First
|
||||
5. **Advanced Settings** - Umfangreiche Einstellungsmöglichkeiten
|
||||
6. **Robuste Fehlerbehandlung** - Graceful Error-Handling auf allen Ebenen
|
||||
7. **Performance-optimiert** - Batch-Operations und intelligentes State-Management
|
||||
8. **Vollständige Offline-Funktionalität** - Lokale Speicherung aller Daten
|
||||
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
|
||||
- **Performance-optimiert** mit SQLite-Transaktionen
|
||||
- **Production-ready** Error-Handling und Logging
|
||||
- **Privacy-First** Design mit expliziten Permissions
|
||||
- **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
|
||||
- **UX**: Sofortiges Feedback, keine blockierende UI
|
||||
- **Privacy**: Opt-in Location, lokale Datenhaltung
|
||||
|
||||
## 📋 Nächste Schritte (Phase 4+)
|
||||
|
||||
### Geplante Features:
|
||||
1. **Cloud-Sync** mit Supabase-Backend
|
||||
2. **Erweiterte Statistiken** und Trends
|
||||
3. **Benutzerdefinierte Ernährungsziele**
|
||||
4. **Export-Funktionen** (CSV, PDF)
|
||||
5. **Social-Features** (Teilen, Vergleichen)
|
||||
6. **Barcode-Scanner** für verpackte Lebensmittel
|
||||
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!
|
||||
357
nutriphi/Plan.md
Normal file
357
nutriphi/Plan.md
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
|
||||
|
||||
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
|
||||
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
|
||||
3. Erweiterte Datenbank-Architektur
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
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)
|
||||
);
|
||||
Performance-Indizes
|
||||
|
||||
sql
|
||||
CREATE INDEX idx_meals_timestamp ON meals(timestamp DESC);
|
||||
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
|
||||
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
|
||||
4. Berücksichtige versteckte Zutaten (Öle, Saucen, Gewürze)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
BEWERTUNGSKRITERIEN health_score:
|
||||
10: Optimal (viel Gemüse, mageres Protein, Vollkorn, minimal verarbeitet)
|
||||
8-9: Sehr gesund (ausgewogen, natürliche Zutaten)
|
||||
6-7: Gesund (gute Balance, moderate Verarbeitung)
|
||||
4-5: Mittelmäßig (gemischt, einige verarbeitete Komponenten)
|
||||
2-3: Ungesund (viel verarbeitet, hoher Zucker/Fett)
|
||||
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
|
||||
- fruit: Alle Früchte
|
||||
- dairy: Milchprodukte
|
||||
- fat: Öle, Butter, Avocado
|
||||
- processed: Verarbeitete Lebensmittel
|
||||
- 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:
|
||||
|
||||
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
|
||||
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
|
||||
├── 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
|
||||
├── stores/
|
||||
│ ├── 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
|
||||
├── hooks/
|
||||
│ ├── useMeals.ts # Mahlzeit-Management
|
||||
│ ├── useCamera.ts # Kamera-Funktionen
|
||||
│ └── useSync.ts # Synchronisation
|
||||
└── utils/
|
||||
├── 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
|
||||
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
|
||||
799
nutriphi/ReadMes/Expo-ImagePicker.md
Normal file
799
nutriphi/ReadMes/Expo-ImagePicker.md
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
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:
|
||||
~16.1.4
|
||||
expo-image-picker provides access to the system's UI for selecting images and videos from the phone's library or taking a photo with the camera.
|
||||
|
||||
Installation
|
||||
Terminal
|
||||
|
||||
Copy
|
||||
|
||||
npx expo install expo-image-picker
|
||||
If you are installing this in an existing React Native app, make sure to install expo in your project.
|
||||
|
||||
Known issues
|
||||
On iOS, when an image (usually of a higher resolution) is picked from the camera roll, the result of the cropped image gives the wrong value for the cropped rectangle in some cases. Unfortunately, this issue is with the underlying UIImagePickerController due to a bug in the closed-source tools built into iOS.
|
||||
|
||||
Configuration in app config
|
||||
You can configure expo-image-picker using its built-in config plugin if you use config plugins in your project (EAS Build or npx expo run:[android|ios]). The plugin allows you to configure various properties that cannot be set at runtime and require building a new app binary to take effect.
|
||||
|
||||
Example app.json with config plugin
|
||||
app.json
|
||||
|
||||
Copy
|
||||
|
||||
|
||||
{
|
||||
"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"
|
||||
Only for:
|
||||
|
||||
A string to set the NSPhotoLibraryUsageDescription permission message.
|
||||
|
||||
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"
|
||||
Only for:
|
||||
|
||||
A string to set the NSMicrophoneUsageDescription permission message.
|
||||
|
||||
Are you using this library in an existing React Native app?
|
||||
Usage
|
||||
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';
|
||||
|
||||
export default function ImagePickerExample() {
|
||||
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,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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
|
||||
}
|
||||
With AWS S3
|
||||
AWS storage example
|
||||
An example of how to use AWS storage can be found in with-aws-storage-upload.
|
||||
|
||||
See Amplify documentation guide to set up your project correctly.
|
||||
|
||||
With Firebase
|
||||
Firebase storage example
|
||||
An example of how to use Firebase storage can be found in with-firebase-storage-upload.
|
||||
|
||||
See Using Firebase guide to set up your project correctly.
|
||||
|
||||
API
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
Hooks
|
||||
useCameraPermissions(options)
|
||||
Parameter Type
|
||||
options
|
||||
(optional)
|
||||
PermissionHookOptions<object>
|
||||
|
||||
Check or request permissions to access the camera. This uses both requestCameraPermissionsAsync and getCameraPermissionsAsync to interact with the permissions.
|
||||
|
||||
Returns:
|
||||
[null | PermissionResponse, RequestPermissionMethod<PermissionResponse>, GetPermissionMethod<PermissionResponse>]
|
||||
Example
|
||||
|
||||
const [status, requestPermission] = ImagePicker.useCameraPermissions();
|
||||
useMediaLibraryPermissions(options)
|
||||
Parameter Type
|
||||
options
|
||||
(optional)
|
||||
PermissionHookOptions<{
|
||||
writeOnly: boolean
|
||||
}>
|
||||
|
||||
Check or request permissions to access the media library. This uses both requestMediaLibraryPermissionsAsync and getMediaLibraryPermissionsAsync to interact with the permissions.
|
||||
|
||||
Returns:
|
||||
[null | MediaLibraryPermissionResponse, RequestPermissionMethod<MediaLibraryPermissionResponse>, GetPermissionMethod<MediaLibraryPermissionResponse>]
|
||||
Example
|
||||
|
||||
const [status, requestPermission] = ImagePicker.useMediaLibraryPermissions();
|
||||
Methods
|
||||
ImagePicker.getCameraPermissionsAsync()
|
||||
Checks user's permissions for accessing camera.
|
||||
|
||||
Returns:
|
||||
Promise<CameraPermissionResponse>
|
||||
A promise that fulfills with an object of type CameraPermissionResponse.
|
||||
|
||||
ImagePicker.getMediaLibraryPermissionsAsync(writeOnly)
|
||||
Parameter Type Description
|
||||
writeOnly
|
||||
(optional)
|
||||
boolean
|
||||
Whether to request write or read and write permissions. Defaults to false
|
||||
|
||||
Default:
|
||||
false
|
||||
|
||||
Checks user's permissions for accessing photos.
|
||||
|
||||
Returns:
|
||||
Promise<MediaLibraryPermissionResponse>
|
||||
A promise that fulfills with an object of type MediaLibraryPermissionResponse.
|
||||
|
||||
ImagePicker.getPendingResultAsync()
|
||||
Android system sometimes kills the MainActivity after the ImagePicker finishes. When this happens, we lose the data selected using the ImagePicker. However, you can retrieve the lost data by calling getPendingResultAsync. You can test this functionality by turning on Don't keep activities in the developer options.
|
||||
|
||||
Returns:
|
||||
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
|
||||
options
|
||||
(optional)
|
||||
ImagePickerOptions
|
||||
An ImagePickerOptions object.
|
||||
|
||||
Default:
|
||||
{}
|
||||
|
||||
Display the system UI for taking a photo with the camera. Requires Permissions.CAMERA. On Android and iOS 10 Permissions.CAMERA_ROLL is also required. On mobile web, this must be called immediately in a user interaction like a button press, otherwise the browser will block the request without a warning.
|
||||
|
||||
Note: Make sure that you handle MainActivity destruction on Android. See ImagePicker.getPendingResultAsync. Notes for Web: The system UI can only be shown after user activation (e.g. a Button press). Therefore, calling launchCameraAsync in componentDidMount, for example, will not work as intended. The cancelled event will not be returned in the browser due to platform restrictions and inconsistencies across browsers.
|
||||
|
||||
Returns:
|
||||
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
|
||||
options
|
||||
(optional)
|
||||
ImagePickerOptions
|
||||
An object extended by ImagePickerOptions.
|
||||
|
||||
Default:
|
||||
{}
|
||||
|
||||
Display the system UI for choosing an image or a video from the phone's library. Requires Permissions.MEDIA_LIBRARY on iOS 10 only. On mobile web, this must be called immediately in a user interaction like a button press, otherwise the browser will block the request without a warning.
|
||||
|
||||
Animated GIFs support: On Android, if the selected image is an animated GIF, the result image will be an animated GIF too if and only if quality is explicitly set to 1.0 and allowsEditing is set to false. Otherwise compression and/or cropper will pick the first frame of the GIF and return it as the result (on Android the result will be a PNG). On iOS, both quality and cropping are supported.
|
||||
|
||||
Notes for Web: The system UI can only be shown after user activation (e.g. a Button press). Therefore, calling launchImageLibraryAsync in componentDidMount, for example, will not work as intended. The cancelled event will not be returned in the browser due to platform restrictions and inconsistencies across browsers.
|
||||
|
||||
Returns:
|
||||
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.requestCameraPermissionsAsync()
|
||||
Asks the user to grant permissions for accessing camera. This does nothing on web because the browser camera is not used.
|
||||
|
||||
Returns:
|
||||
Promise<CameraPermissionResponse>
|
||||
A promise that fulfills with an object of type CameraPermissionResponse.
|
||||
|
||||
ImagePicker.requestMediaLibraryPermissionsAsync(writeOnly)
|
||||
Parameter Type Description
|
||||
writeOnly
|
||||
(optional)
|
||||
boolean
|
||||
Whether to request write or read and write permissions. Defaults to false
|
||||
|
||||
Default:
|
||||
false
|
||||
|
||||
Asks the user to grant permissions for accessing user's photo. This method does nothing on web.
|
||||
|
||||
Returns:
|
||||
Promise<MediaLibraryPermissionResponse>
|
||||
A promise that fulfills with an object of type MediaLibraryPermissionResponse.
|
||||
|
||||
Types
|
||||
CameraPermissionResponse
|
||||
Type: PermissionResponse
|
||||
|
||||
Alias for PermissionResponse type exported by expo-modules-core.
|
||||
|
||||
DefaultTab
|
||||
Literal Type: string
|
||||
|
||||
The default tab with which the image picker will be opened.
|
||||
|
||||
'photos' - the photos/videos tab will be opened.
|
||||
'albums' - the albums tab will be opened.
|
||||
Acceptable values are: 'photos' | 'albums'
|
||||
|
||||
ImagePickerAsset
|
||||
Represents an asset (image or video) returned by the image picker or camera.
|
||||
|
||||
Property Type Description
|
||||
assetId
|
||||
(optional)
|
||||
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.
|
||||
|
||||
This might be null when the ID is unavailable or the user gave limited permission to access the media library. On Android, the ID is unavailable when the user selects a photo by directly browsing file system.
|
||||
|
||||
base64
|
||||
(optional)
|
||||
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 }}
|
||||
/>
|
||||
duration
|
||||
(optional)
|
||||
number | null
|
||||
Length of the video in milliseconds or null if the asset is not a video.
|
||||
|
||||
exif
|
||||
(optional)
|
||||
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
|
||||
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
|
||||
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
|
||||
File size of the picked image or video, in bytes.
|
||||
|
||||
height number
|
||||
Height of the image or video.
|
||||
|
||||
mimeType
|
||||
(optional)
|
||||
string
|
||||
The MIME type of the selected asset or null if could not be determined.
|
||||
|
||||
pairedVideoAsset
|
||||
(optional)
|
||||
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'
|
||||
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 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 of the image or video.
|
||||
|
||||
ImagePickerCanceledResult
|
||||
Type representing canceled pick result.
|
||||
|
||||
Property Type Description
|
||||
assets null
|
||||
null signifying that the request was canceled.
|
||||
|
||||
canceled true
|
||||
Boolean flag set to true showing that the request was canceled.
|
||||
|
||||
ImagePickerErrorResult
|
||||
Property Type Description
|
||||
code string
|
||||
The error code.
|
||||
|
||||
exception
|
||||
(optional)
|
||||
string
|
||||
The exception which caused the error.
|
||||
|
||||
message string
|
||||
The error message.
|
||||
|
||||
ImagePickerOptions
|
||||
Property Type Description
|
||||
allowsEditing
|
||||
(optional)
|
||||
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.
|
||||
|
||||
Cropping multiple images is not supported - this option is mutually exclusive with allowsMultipleSelection.
|
||||
On iOS, this option is ignored if allowsMultipleSelection is enabled.
|
||||
On iOS cropping a .bmp image will convert it to .png.
|
||||
Default:
|
||||
false
|
||||
allowsMultipleSelection
|
||||
(optional)
|
||||
boolean
|
||||
Only for:
|
||||
|
||||
Whether or not to allow selecting multiple media files at once.
|
||||
|
||||
Cropping multiple images is not supported - this option is mutually exclusive with allowsEditing. If this option is enabled, then allowsEditing is ignored.
|
||||
|
||||
Default:
|
||||
false
|
||||
aspect
|
||||
(optional)
|
||||
[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
|
||||
Whether to also include the image data in Base64 format.
|
||||
|
||||
cameraType
|
||||
(optional)
|
||||
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.
|
||||
On Web, if this option is not provided, use "camera" as the default value of internal input element for backwards compatibility.
|
||||
Default:
|
||||
CameraType.back
|
||||
defaultTab
|
||||
(optional)
|
||||
DefaultTab
|
||||
Only for:
|
||||
|
||||
Choose the default tab with which the image picker will be opened.
|
||||
|
||||
Default:
|
||||
'photos'
|
||||
exif
|
||||
(optional)
|
||||
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
|
||||
Only for:
|
||||
|
||||
Uses the legacy image picker on Android. This will allow media to be selected from outside the users photo library.
|
||||
|
||||
Default:
|
||||
false
|
||||
mediaTypes
|
||||
(optional)
|
||||
MediaType | MediaType[] | MediaTypeOptions
|
||||
Choose what type of media to pick.
|
||||
|
||||
Default:
|
||||
'images'
|
||||
orderedSelection
|
||||
(optional)
|
||||
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.
|
||||
|
||||
Assets should be returned in the selection order regardless of this option, but there is no guarantee that it is always true when this option is disabled.
|
||||
|
||||
Default:
|
||||
false
|
||||
preferredAssetRepresentationMode
|
||||
(optional)
|
||||
UIImagePickerPreferredAssetRepresentationMode
|
||||
Only for:
|
||||
|
||||
Choose preferred asset representation mode to use when loading assets.
|
||||
|
||||
Default:
|
||||
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Automatic
|
||||
presentationStyle
|
||||
(optional)
|
||||
UIImagePickerPresentationStyle
|
||||
Only for:
|
||||
|
||||
Choose presentation style to customize view during taking photo/video.
|
||||
|
||||
Default:
|
||||
ImagePicker.UIImagePickerPresentationStyle.Automatic
|
||||
quality
|
||||
(optional)
|
||||
number
|
||||
Only for:
|
||||
|
||||
Specify the quality of compression, from 0 to 1. 0 means compress for small size, 1 means compress for maximum quality.
|
||||
|
||||
Note: If the selected image has been compressed before, the size of the output file may be bigger than the size of the original image.
|
||||
|
||||
Note: On iOS, if a .bmp or .png image is selected from the library, this option is ignored.
|
||||
|
||||
Default:
|
||||
1.0
|
||||
selectionLimit
|
||||
(optional)
|
||||
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.
|
||||
|
||||
Default:
|
||||
0
|
||||
videoExportPreset
|
||||
(optional)
|
||||
VideoExportPreset
|
||||
Deprecated See videoExportPreset in Apple documentation.
|
||||
|
||||
Specify preset which will be used to compress selected video.
|
||||
|
||||
Default:
|
||||
ImagePicker.VideoExportPreset.Passthrough
|
||||
videoMaxDuration
|
||||
(optional)
|
||||
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.
|
||||
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
|
||||
Only for:
|
||||
|
||||
Specify the quality of recorded videos. Defaults to the highest quality available for the device.
|
||||
|
||||
Default:
|
||||
ImagePicker.UIImagePickerControllerQualityType.High
|
||||
ImagePickerResult
|
||||
Literal Type: union
|
||||
|
||||
Type representing successful and canceled pick result.
|
||||
|
||||
Acceptable values are: ImagePickerSuccessResult | ImagePickerCanceledResult
|
||||
|
||||
ImagePickerSuccessResult
|
||||
Type representing successful pick result.
|
||||
|
||||
Property Type Description
|
||||
assets ImagePickerAsset[]
|
||||
An array of picked assets.
|
||||
|
||||
canceled false
|
||||
Boolean flag set to false showing that the request was successful.
|
||||
|
||||
MediaLibraryPermissionResponse
|
||||
Extends PermissionResponse type exported by expo-modules-core, containing additional iOS-specific field.
|
||||
|
||||
Type: PermissionResponse extended by:
|
||||
|
||||
Property Type Description
|
||||
accessPrivileges
|
||||
(optional)
|
||||
'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
|
||||
'limited' if the user granted your app access only to selected photos (only available on Android API 34+ and iOS 14.0+)
|
||||
'none' if user denied or hasn't yet granted the permission
|
||||
MediaType
|
||||
Literal Type: string
|
||||
|
||||
Media types that can be picked by the image picker.
|
||||
|
||||
'images' - for images.
|
||||
'videos' - for videos.
|
||||
'livePhotos' - for live photos (iOS only).
|
||||
When the livePhotos type is added to the media types array and a live photo is selected, the resulting ImagePickerAsset will contain an unaltered image and the pairedVideoAsset field will contain a video asset paired with the image. This option will be ignored when the allowsEditing option is enabled. Due to platform limitations live photos are returned at original quality, regardless of the quality option.
|
||||
|
||||
When on Android or Web livePhotos type passed as a media type will be ignored.
|
||||
|
||||
Acceptable values are: 'images' | 'videos' | 'livePhotos'
|
||||
|
||||
PermissionExpiration
|
||||
Literal Type: union
|
||||
|
||||
Permission expiration time. Currently, all permissions are granted permanently.
|
||||
|
||||
Acceptable values are: 'never' | number
|
||||
|
||||
PermissionHookOptions
|
||||
Literal Type: union
|
||||
|
||||
Acceptable values are: PermissionHookBehavior | Options
|
||||
|
||||
PermissionResponse
|
||||
An object obtained by permissions get and request functions.
|
||||
|
||||
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
|
||||
Determines time when the permission expires.
|
||||
|
||||
granted boolean
|
||||
A convenience boolean that indicates if the permission is granted.
|
||||
|
||||
status PermissionStatus
|
||||
Determines the status of the permission.
|
||||
|
||||
Enums
|
||||
CameraType
|
||||
back
|
||||
CameraType.back = "back"
|
||||
Back/rear camera.
|
||||
|
||||
front
|
||||
CameraType.front = "front"
|
||||
Front camera
|
||||
|
||||
Deprecated To set media types available in the image picker use an array of MediaType instead.
|
||||
|
||||
MediaTypeOptions
|
||||
All
|
||||
MediaTypeOptions.All = "All"
|
||||
Images and videos.
|
||||
|
||||
Images
|
||||
MediaTypeOptions.Images = "Images"
|
||||
Only images.
|
||||
|
||||
Videos
|
||||
MediaTypeOptions.Videos = "Videos"
|
||||
Only videos.
|
||||
|
||||
PermissionStatus
|
||||
DENIED
|
||||
PermissionStatus.DENIED = "denied"
|
||||
User has denied the permission.
|
||||
|
||||
GRANTED
|
||||
PermissionStatus.GRANTED = "granted"
|
||||
User has granted the permission.
|
||||
|
||||
UNDETERMINED
|
||||
PermissionStatus.UNDETERMINED = "undetermined"
|
||||
User hasn't granted or denied the permission yet.
|
||||
|
||||
UIImagePickerControllerQualityType
|
||||
High
|
||||
UIImagePickerControllerQualityType.High = 0
|
||||
Highest available resolution.
|
||||
|
||||
Medium
|
||||
UIImagePickerControllerQualityType.Medium = 1
|
||||
Depends on the device.
|
||||
|
||||
Low
|
||||
UIImagePickerControllerQualityType.Low = 2
|
||||
Depends on the device.
|
||||
|
||||
VGA640x480
|
||||
UIImagePickerControllerQualityType.VGA640x480 = 3
|
||||
640 × 480
|
||||
|
||||
IFrame1280x720
|
||||
UIImagePickerControllerQualityType.IFrame1280x720 = 4
|
||||
1280 × 720
|
||||
|
||||
IFrame960x540
|
||||
UIImagePickerControllerQualityType.IFrame960x540 = 5
|
||||
960 × 540
|
||||
|
||||
UIImagePickerPreferredAssetRepresentationMode
|
||||
Picker preferred asset representation mode. Its values are directly mapped to the PHPickerConfigurationAssetRepresentationMode.
|
||||
|
||||
Automatic
|
||||
UIImagePickerPreferredAssetRepresentationMode.Automatic = "automatic"
|
||||
A mode that indicates that the system chooses the appropriate asset representation.
|
||||
|
||||
Compatible
|
||||
UIImagePickerPreferredAssetRepresentationMode.Compatible = "compatible"
|
||||
A mode that uses the most compatible asset representation.
|
||||
|
||||
Current
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current = "current"
|
||||
A mode that uses the current representation to avoid transcoding, if possible.
|
||||
|
||||
UIImagePickerPresentationStyle
|
||||
Picker presentation style. Its values are directly mapped to the UIModalPresentationStyle.
|
||||
|
||||
AUTOMATIC
|
||||
UIImagePickerPresentationStyle.AUTOMATIC = "automatic"
|
||||
The default presentation style chosen by the system. On older iOS versions, falls back to WebBrowserPresentationStyle.FullScreen.
|
||||
|
||||
CURRENT_CONTEXT
|
||||
UIImagePickerPresentationStyle.CURRENT_CONTEXT = "currentContext"
|
||||
A presentation style where the picker is displayed over the app's content.
|
||||
|
||||
FORM_SHEET
|
||||
UIImagePickerPresentationStyle.FORM_SHEET = "formSheet"
|
||||
A presentation style that displays the picker centered in the screen.
|
||||
|
||||
FULL_SCREEN
|
||||
UIImagePickerPresentationStyle.FULL_SCREEN = "fullScreen"
|
||||
A presentation style in which the presented picker covers the screen.
|
||||
|
||||
OVER_CURRENT_CONTEXT
|
||||
UIImagePickerPresentationStyle.OVER_CURRENT_CONTEXT = "overCurrentContext"
|
||||
A presentation style where the picker is displayed over the app's content.
|
||||
|
||||
OVER_FULL_SCREEN
|
||||
UIImagePickerPresentationStyle.OVER_FULL_SCREEN = "overFullScreen"
|
||||
A presentation style in which the picker view covers the screen.
|
||||
|
||||
PAGE_SHEET
|
||||
UIImagePickerPresentationStyle.PAGE_SHEET = "pageSheet"
|
||||
A presentation style that partially covers the underlying content.
|
||||
|
||||
POPOVER
|
||||
UIImagePickerPresentationStyle.POPOVER = "popover"
|
||||
A presentation style where the picker is displayed in a popover view.
|
||||
|
||||
VideoExportPreset
|
||||
Passthrough
|
||||
VideoExportPreset.Passthrough = 0
|
||||
Resolution: Unchanged • Video compression: None • Audio compression: None
|
||||
|
||||
LowQuality
|
||||
VideoExportPreset.LowQuality = 1
|
||||
Resolution: Depends on the device • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
MediumQuality
|
||||
VideoExportPreset.MediumQuality = 2
|
||||
Resolution: Depends on the device • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
HighestQuality
|
||||
VideoExportPreset.HighestQuality = 3
|
||||
Resolution: Depends on the device • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
H264_640x480
|
||||
VideoExportPreset.H264_640x480 = 4
|
||||
Resolution: 640 × 480 • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
H264_960x540
|
||||
VideoExportPreset.H264_960x540 = 5
|
||||
Resolution: 960 × 540 • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
H264_1280x720
|
||||
VideoExportPreset.H264_1280x720 = 6
|
||||
Resolution: 1280 × 720 • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
H264_1920x1080
|
||||
VideoExportPreset.H264_1920x1080 = 7
|
||||
Resolution: 1920 × 1080 • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
H264_3840x2160
|
||||
VideoExportPreset.H264_3840x2160 = 8
|
||||
Resolution: 3840 × 2160 • Video compression: H.264 • Audio compression: AAC
|
||||
|
||||
HEVC_1920x1080
|
||||
VideoExportPreset.HEVC_1920x1080 = 9
|
||||
Resolution: 1920 × 1080 • Video compression: HEVC • Audio compression: AAC
|
||||
|
||||
HEVC_3840x2160
|
||||
VideoExportPreset.HEVC_3840x2160 = 10
|
||||
Resolution: 3840 × 2160 • Video compression: HEVC • Audio compression: AAC
|
||||
|
||||
Permissions
|
||||
Android
|
||||
The following permissions are added automatically through the library's AndroidManifest.xml.
|
||||
|
||||
Android Permission Description
|
||||
CAMERA
|
||||
|
||||
Required to be able to access the camera device.
|
||||
|
||||
READ_EXTERNAL_STORAGE
|
||||
|
||||
Allows an application to read from external storage.
|
||||
|
||||
WRITE_EXTERNAL_STORAGE
|
||||
|
||||
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
|
||||
NSMicrophoneUsageDescription
|
||||
|
||||
A message that tells the user why the app is requesting access to the device’s microphone.
|
||||
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.
|
||||
11
nutriphi/apps/landing/astro.config.mjs
Normal file
11
nutriphi/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://nutriphi.manacore.app',
|
||||
integrations: [
|
||||
tailwind(),
|
||||
sitemap()
|
||||
]
|
||||
});
|
||||
26
nutriphi/apps/landing/package.json
Normal file
26
nutriphi/apps/landing/package.json
Normal file
|
|
@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
78
nutriphi/apps/landing/src/components/Footer.astro
Normal file
78
nutriphi/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
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' }
|
||||
]
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
84
nutriphi/apps/landing/src/components/Navigation.astro
Normal file
84
nutriphi/apps/landing/src/components/Navigation.astro
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
47
nutriphi/apps/landing/src/layouts/Layout.astro
Normal file
47
nutriphi/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
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} />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
287
nutriphi/apps/landing/src/pages/index.astro
Normal file
287
nutriphi/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
// Shared components
|
||||
import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro';
|
||||
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
|
||||
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
|
||||
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
|
||||
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
|
||||
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
|
||||
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.'
|
||||
}
|
||||
];
|
||||
|
||||
// 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'
|
||||
}
|
||||
];
|
||||
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// 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.'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="Nutriphi - KI-gestützter Ernährungs-Tracker">
|
||||
<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' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
103
nutriphi/apps/landing/src/styles/global.css
Normal file
103
nutriphi/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Nutriphi Theme CSS Variables - Fresh Green */
|
||||
:root {
|
||||
/* Primary colors - Nutriphi Green */
|
||||
--color-primary: #22c55e;
|
||||
--color-primary-hover: #16a34a;
|
||||
--color-primary-glow: rgba(34, 197, 94, 0.3);
|
||||
|
||||
/* Text colors */
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Background colors */
|
||||
--color-background-page: #052e16;
|
||||
--color-background-card: #14532d;
|
||||
--color-background-card-hover: #166534;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #166534;
|
||||
--color-border-hover: #15803d;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--color-background-page);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-background-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #4ade80 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:border-border-hover hover:bg-background-card;
|
||||
}
|
||||
39
nutriphi/apps/landing/tailwind.config.mjs
Normal file
39
nutriphi/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Nutriphi Fresh Green Theme
|
||||
primary: {
|
||||
DEFAULT: '#22c55e',
|
||||
hover: '#16a34a',
|
||||
glow: 'rgba(34, 197, 94, 0.3)'
|
||||
},
|
||||
background: {
|
||||
page: '#052e16',
|
||||
card: '#14532d',
|
||||
'card-hover': '#166534'
|
||||
},
|
||||
text: {
|
||||
primary: '#f9fafb',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#6b7280'
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#166534',
|
||||
hover: '#15803d'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
]
|
||||
};
|
||||
9
nutriphi/apps/landing/tsconfig.json
Normal file
9
nutriphi/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
nutriphi/apps/mobile/app-env.d.ts
vendored
Normal file
2
nutriphi/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
79
nutriphi/apps/mobile/app.json
Normal file
79
nutriphi/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
nutriphi/apps/mobile/app/(tabs)/_layout.tsx
Normal file
45
nutriphi/apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Tabs } from 'expo-router';
|
||||
import { TabBarIcon } from '../../components/TabBarIcon';
|
||||
import { CameraModal } from '../../components/camera/CameraModal';
|
||||
import { useAppStore } from '../../store/AppStore';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
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>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
nutriphi/apps/mobile/app/+html.tsx
Normal file
46
nutriphi/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// 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" />
|
||||
|
||||
{/*
|
||||
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"
|
||||
/>
|
||||
{/*
|
||||
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 />
|
||||
|
||||
{/* 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 = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
24
nutriphi/apps/mobile/app/+not-found.tsx
Normal file
24
nutriphi/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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]`,
|
||||
};
|
||||
63
nutriphi/apps/mobile/app/_layout.tsx
Normal file
63
nutriphi/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import '../global.css';
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
import { useDatabase } from '../hooks/useDatabase';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { View, Text, ActivityIndicator, AppState } from 'react-native';
|
||||
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',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { isReady, error } = useDatabase();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
52
nutriphi/apps/mobile/app/index.tsx
Normal file
52
nutriphi/apps/mobile/app/index.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { router } from 'expo-router';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { MealList } from '../components/meals/MealList';
|
||||
import { FloatingActionButton } from '../components/ui/FloatingActionButton';
|
||||
import { CameraModal } from '../components/camera/CameraModal';
|
||||
import { MealWithItems } from '../types/Database';
|
||||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export default function Home() {
|
||||
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
|
||||
|
||||
const handleMealPress = (meal: MealWithItems) => {
|
||||
router.push(`/meal/${meal.id}`);
|
||||
};
|
||||
|
||||
const handleCameraPress = () => {
|
||||
toggleCameraModal(true, 'camera');
|
||||
};
|
||||
|
||||
const handleGalleryPress = () => {
|
||||
toggleCameraModal(true, 'gallery');
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Gallery Button (smaller, right) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleGalleryPress}
|
||||
sfSymbol="photo"
|
||||
fallbackIcon="image"
|
||||
size="normal"
|
||||
position="right"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
253
nutriphi/apps/mobile/app/meal/[id].tsx
Normal file
253
nutriphi/apps/mobile/app/meal/[id].tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ScrollView, Text, Image, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useMealStore } from '@/store/MealStore';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { NutritionBar } from '@/components/meals/NutritionBar';
|
||||
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);
|
||||
|
||||
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 every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMealById(selectedMeal.id);
|
||||
}, 2000);
|
||||
|
||||
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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 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 (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
13
nutriphi/apps/mobile/app/modal.tsx
Normal file
13
nutriphi/apps/mobile/app/modal.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StatusBar } from 'expo-status-bar';
|
||||
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'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
296
nutriphi/apps/mobile/app/settings.tsx
Normal file
296
nutriphi/apps/mobile/app/settings.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, Alert, Switch, Linking } from 'react-native';
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { DataClearingService } from '../services/DataClearingService';
|
||||
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 themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'system', label: 'System', icon: '📱' },
|
||||
];
|
||||
|
||||
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 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 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 confirmDeleteAllData = async () => {
|
||||
setIsClearing(true);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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">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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
nutriphi/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
nutriphi/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
nutriphi/apps/mobile/assets/favicon.png
Normal file
BIN
nutriphi/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
nutriphi/apps/mobile/assets/icon.png
Normal file
BIN
nutriphi/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
nutriphi/apps/mobile/assets/splash.png
Normal file
BIN
nutriphi/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
10
nutriphi/apps/mobile/babel.config.js
Normal file
10
nutriphi/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
44
nutriphi/apps/mobile/cesconfig.json
Normal file
44
nutriphi/apps/mobile/cesconfig.json
Normal file
|
|
@ -0,0 +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": "supabase",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
24
nutriphi/apps/mobile/components/Button.tsx
Normal file
24
nutriphi/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
9
nutriphi/apps/mobile/components/Container.tsx
Normal file
9
nutriphi/apps/mobile/components/Container.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
29
nutriphi/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
nutriphi/apps/mobile/components/EditScreenInfo.tsx
Normal file
|
|
@ -0,0 +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.';
|
||||
|
||||
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`,
|
||||
};
|
||||
33
nutriphi/apps/mobile/components/HeaderButton.tsx
Normal file
33
nutriphi/apps/mobile/components/HeaderButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { forwardRef } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HeaderButton.displayName = 'HeaderButton';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
});
|
||||
25
nutriphi/apps/mobile/components/ScreenContent.tsx
Normal file
25
nutriphi/apps/mobile/components/ScreenContent.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
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>
|
||||
);
|
||||
};
|
||||
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`,
|
||||
};
|
||||
26
nutriphi/apps/mobile/components/TabBarIcon.tsx
Normal file
26
nutriphi/apps/mobile/components/TabBarIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
import { SFSymbol } from './ui/SFSymbol';
|
||||
|
||||
interface TabBarIconProps {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
420
nutriphi/apps/mobile/components/camera/CameraModal.tsx
Normal file
420
nutriphi/apps/mobile/components/camera/CameraModal.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, TouchableOpacity, StatusBar } from 'react-native';
|
||||
import { CameraView } from 'expo-camera';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useCamera } from '../../hooks/useCamera';
|
||||
import { useAppStore } from '../../store/AppStore';
|
||||
import { useMealStore } from '../../store/MealStore';
|
||||
import { PhotoButton } from './PhotoButton';
|
||||
import { PhotoPreview } from './PhotoPreview';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { Button } from '../Button';
|
||||
import { GeminiService } from '../../services/api/GeminiService';
|
||||
import { PhotoService } from '../../services/storage/PhotoService';
|
||||
import { LocationService } from '../../services/LocationService';
|
||||
import { UserPreferencesService } from '../../services/UserPreferencesService';
|
||||
import { LocationPermissionModal } from '../location/LocationPermissionModal';
|
||||
|
||||
interface CameraModalProps {
|
||||
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 { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
|
||||
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
|
||||
|
||||
const {
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
} = useCamera();
|
||||
|
||||
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 handleRetake = () => {
|
||||
setCapturedPhoto(null);
|
||||
};
|
||||
|
||||
const handleLocationPermissionAllow = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// Mark that we've asked
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
|
||||
// Request permission
|
||||
const granted = await locationService.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
await prefsService.setLocationEnabled(true);
|
||||
} else {
|
||||
await prefsService.setLocationEnabled(false);
|
||||
}
|
||||
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// Continue with photo processing
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocationPermissionDeny = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
|
||||
// Mark that we've asked and user denied
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
await prefsService.setLocationEnabled(false);
|
||||
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// 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,
|
||||
]);
|
||||
|
||||
const handleUsePhoto = async () => {
|
||||
if (!capturedPhoto) return;
|
||||
|
||||
try {
|
||||
setPhotoProcessing(true);
|
||||
|
||||
// Check location preferences and permissions
|
||||
let locationInfo: any = {};
|
||||
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationEnabled = await prefsService.isLocationEnabled();
|
||||
|
||||
if (locationEnabled) {
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
console.log('Meal created with ID:', mealId);
|
||||
|
||||
// Convert temporary photo to permanent storage
|
||||
const photoService = PhotoService.getInstance();
|
||||
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
|
||||
|
||||
// Update meal record with permanent photo path
|
||||
await updateMeal(mealId, {
|
||||
photo_path: permanentPhoto.path,
|
||||
photo_size: permanentPhoto.size,
|
||||
photo_dimensions: permanentPhoto.dimensions,
|
||||
});
|
||||
|
||||
console.log('Photo converted to permanent storage:', permanentPhoto.path);
|
||||
|
||||
// Close modal immediately, analysis will happen in background
|
||||
handleClose();
|
||||
|
||||
// Start AI analysis in background
|
||||
try {
|
||||
console.log('Starting Gemini analysis...');
|
||||
const geminiService = GeminiService.getInstance();
|
||||
|
||||
// Get current time for meal type context
|
||||
const hour = new Date().getHours();
|
||||
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
|
||||
|
||||
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';
|
||||
|
||||
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
|
||||
mealType: mealTypeContext,
|
||||
});
|
||||
|
||||
console.log('Gemini analysis completed:', analysisResult);
|
||||
|
||||
// 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,
|
||||
|
||||
// Health assessment
|
||||
health_score: analysisResult.meal_analysis.health_score,
|
||||
health_category: analysisResult.meal_analysis.health_category,
|
||||
|
||||
// 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,
|
||||
|
||||
// API metadata
|
||||
api_provider: 'gemini',
|
||||
processing_time: analysisResult._metadata?.processingTime || 0,
|
||||
});
|
||||
|
||||
// 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 || []),
|
||||
}));
|
||||
|
||||
await createFoodItemsBatch(foodItemsToCreate);
|
||||
|
||||
console.log('Meal analysis completed and saved to database');
|
||||
} catch (analysisError) {
|
||||
console.error('AI analysis failed:', analysisError);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{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 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>
|
||||
|
||||
<Text className="text-lg font-semibold text-white">Take a Photo</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<PhotoButton
|
||||
onPress={handleTakePicture}
|
||||
disabled={!isReady}
|
||||
isCapturing={isCapturing}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!showCameraModal) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
|
||||
<StatusBar barStyle="light-content" backgroundColor="black" />
|
||||
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
75
nutriphi/apps/mobile/components/camera/PhotoButton.tsx
Normal file
75
nutriphi/apps/mobile/components/camera/PhotoButton.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface PhotoButtonProps {
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isCapturing?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const PhotoButton: React.FC<PhotoButtonProps> = ({
|
||||
onPress,
|
||||
disabled = false,
|
||||
isCapturing = false,
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
if (!disabled) {
|
||||
pressed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
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={`
|
||||
h-20 w-20 items-center justify-center rounded-full border-4
|
||||
${disabled || isCapturing ? 'border-gray-400' : 'border-white'}
|
||||
`}>
|
||||
{/* 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 && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
};
|
||||
63
nutriphi/apps/mobile/components/camera/PhotoPreview.tsx
Normal file
63
nutriphi/apps/mobile/components/camera/PhotoPreview.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Image, TouchableOpacity } from 'react-native';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface PhotoPreviewProps {
|
||||
uri: string;
|
||||
onRetake: () => void;
|
||||
onUse: () => void;
|
||||
isProcessing?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoPreview: React.FC<PhotoPreviewProps> = ({
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<Button
|
||||
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
|
||||
onPress={onUse}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
import { Modal, View, Text, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface LocationPermissionModalProps {
|
||||
visible: boolean;
|
||||
onAllow: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
export const LocationPermissionModal: React.FC<LocationPermissionModalProps> = ({
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalysisStatusIndicatorProps {
|
||||
status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
export const AnalysisStatusIndicator: React.FC<AnalysisStatusIndicatorProps> = ({
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
154
nutriphi/apps/mobile/components/meals/EditMealModal.tsx
Normal file
154
nutriphi/apps/mobile/components/meals/EditMealModal.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (meal) {
|
||||
setNotes(meal.user_notes || '');
|
||||
setRating(meal.user_rating || 0);
|
||||
setLocation(meal.location || '');
|
||||
}
|
||||
}, [meal]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
161
nutriphi/apps/mobile/components/meals/FoodItemCard.tsx
Normal file
161
nutriphi/apps/mobile/components/meals/FoodItemCard.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FoodItem } from '@/types/Database';
|
||||
|
||||
interface FoodItemCardProps {
|
||||
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 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
162
nutriphi/apps/mobile/components/meals/FoodItemList.tsx
Normal file
162
nutriphi/apps/mobile/components/meals/FoodItemList.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { View, Text, ScrollView } from 'react-native';
|
||||
import { FoodItem } from '@/types/Database';
|
||||
import { FoodItemCard } from './FoodItemCard';
|
||||
|
||||
interface FoodItemListProps {
|
||||
foodItems: FoodItem[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
export const FoodItemList: React.FC<FoodItemListProps> = ({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
// 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 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
190
nutriphi/apps/mobile/components/meals/MealCard.tsx
Normal file
190
nutriphi/apps/mobile/components/meals/MealCard.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useState } from 'react';
|
||||
import { TouchableOpacity, View, Text, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { MealWithItems } from '../../types/Database';
|
||||
import { AnalysisStatusIndicator } from './AnalysisStatusIndicator';
|
||||
|
||||
interface MealCardProps {
|
||||
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);
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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 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';
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
186
nutriphi/apps/mobile/components/meals/MealCardContextMenu.tsx
Normal file
186
nutriphi/apps/mobile/components/meals/MealCardContextMenu.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Alert, Share, Vibration } from 'react-native';
|
||||
import ContextMenu from 'react-native-context-menu-view';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { MealWithItems } from '../../types/Database';
|
||||
import { MealCard } from './MealCard';
|
||||
import { EditMealModal } from './EditMealModal';
|
||||
import { useMealStore } from '../../store/MealStore';
|
||||
|
||||
interface MealCardContextMenuProps {
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal, onPress }) => {
|
||||
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 handleShare = async () => {
|
||||
try {
|
||||
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
|
||||
📊 Nährwerte:
|
||||
• Kalorien: ${meal.total_calories || '--'} kcal
|
||||
• Protein: ${meal.total_protein || '--'}g
|
||||
• Kohlenhydrate: ${meal.total_carbs || '--'}g
|
||||
• Fett: ${meal.total_fat || '--'}g
|
||||
|
||||
💚 Gesundheitsscore: ${meal.health_score ? Math.round(meal.health_score) : '--'}/100
|
||||
|
||||
Getrackt mit Nutriphi 🤖`;
|
||||
|
||||
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 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 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
|
||||
Fett: ${meal.total_fat || '--'}g
|
||||
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.');
|
||||
};
|
||||
|
||||
// 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 destructive action at the end
|
||||
actions.push({
|
||||
title: 'Löschen',
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
const handlePress = (event: any) => {
|
||||
const { index, name } = event.nativeEvent;
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
|
||||
<MealCard meal={meal} onPress={onPress} />
|
||||
</ContextMenu>
|
||||
|
||||
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
149
nutriphi/apps/mobile/components/meals/MealItem.tsx
Normal file
149
nutriphi/apps/mobile/components/meals/MealItem.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text, Image } from 'react-native';
|
||||
import { Meal } from '../../types/Database';
|
||||
import { Card } from '../ui/Card';
|
||||
import { NutritionBar } from './NutritionBar';
|
||||
|
||||
interface MealItemProps {
|
||||
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);
|
||||
|
||||
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 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';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
100
nutriphi/apps/mobile/components/meals/MealList.tsx
Normal file
100
nutriphi/apps/mobile/components/meals/MealList.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { FlatList, View, Text, RefreshControl } from 'react-native';
|
||||
import { MealWithItems } from '../../types/Database';
|
||||
import { useMealStore } from '../../store/MealStore';
|
||||
import { MealCardContextMenu } from './MealCardContextMenu';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { Button } from '../Button';
|
||||
import { Header } from '../ui/Header';
|
||||
|
||||
interface MealListProps {
|
||||
onMealPress: (meal: MealWithItems) => void;
|
||||
}
|
||||
|
||||
export const MealList: React.FC<MealListProps> = ({ onMealPress }) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadMeals();
|
||||
}, [loadMeals]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadMeals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
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 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 (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}
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
199
nutriphi/apps/mobile/components/meals/NutritionBar.tsx
Normal file
199
nutriphi/apps/mobile/components/meals/NutritionBar.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
export const NutritionBar: React.FC<NutritionBarProps> = ({
|
||||
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}`;
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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-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>
|
||||
)}
|
||||
|
||||
{/* 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">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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
30
nutriphi/apps/mobile/components/ui/Card.tsx
Normal file
30
nutriphi/apps/mobile/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { View, ViewProps } from 'react-native';
|
||||
|
||||
interface CardProps extends ViewProps {
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
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 combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
|
||||
|
||||
return (
|
||||
<View className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
154
nutriphi/apps/mobile/components/ui/FloatingActionButton.tsx
Normal file
154
nutriphi/apps/mobile/components/ui/FloatingActionButton.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
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';
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
|
||||
onPress,
|
||||
icon = '+',
|
||||
sfSymbol,
|
||||
fallbackIcon,
|
||||
disabled = false,
|
||||
size = 'normal',
|
||||
position = 'right',
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
pressed.value = true;
|
||||
};
|
||||
|
||||
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 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 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 combinedStyle = [
|
||||
animatedStyle,
|
||||
getSizeStyle(),
|
||||
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={`
|
||||
items-center justify-center rounded-full shadow-lg
|
||||
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
|
||||
`}>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
32
nutriphi/apps/mobile/components/ui/Header.tsx
Normal file
32
nutriphi/apps/mobile/components/ui/Header.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { SFSymbol } from './SFSymbol';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onSettingsPress?: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, onSettingsPress }) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
31
nutriphi/apps/mobile/components/ui/LoadingOverlay.tsx
Normal file
31
nutriphi/apps/mobile/components/ui/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Modal, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default function LoadingOverlay({
|
||||
visible,
|
||||
message = 'Wird geladen...',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.7)',
|
||||
}: LoadingOverlayProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
32
nutriphi/apps/mobile/components/ui/LoadingSpinner.tsx
Normal file
32
nutriphi/apps/mobile/components/ui/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { ActivityIndicator, View, Text } from 'react-native';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
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',
|
||||
}
|
||||
: {};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
54
nutriphi/apps/mobile/components/ui/SFSymbol.tsx
Normal file
54
nutriphi/apps/mobile/components/ui/SFSymbol.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { SymbolView, SymbolViewProps } from 'expo-symbols';
|
||||
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'];
|
||||
}
|
||||
|
||||
export const SFSymbol: React.FC<SFSymbolProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
weight = 'regular',
|
||||
scale = 'default',
|
||||
mode = 'monochrome',
|
||||
fallbackIcon,
|
||||
style,
|
||||
}) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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} />;
|
||||
};
|
||||
21
nutriphi/apps/mobile/eas.json
Normal file
21
nutriphi/apps/mobile/eas.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
15
nutriphi/apps/mobile/eslint.config.js
Normal file
15
nutriphi/apps/mobile/eslint.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* eslint-env node */
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
3
nutriphi/apps/mobile/global.css
Normal file
3
nutriphi/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
105
nutriphi/apps/mobile/hooks/useCamera.ts
Normal file
105
nutriphi/apps/mobile/hooks/useCamera.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { CameraView, CameraType, useCameraPermissions } from 'expo-camera';
|
||||
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 photoService = PhotoService.getInstance();
|
||||
|
||||
const toggleCameraFacing = () => {
|
||||
setFacing((current) => (current === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isCapturing) return null;
|
||||
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
|
||||
if (!photo) return null;
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
// Request permission
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = permission?.granted ?? false;
|
||||
const canAskPermission = permission?.canAskAgain ?? true;
|
||||
|
||||
return {
|
||||
// Permission state
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
|
||||
// Camera state
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
|
||||
// Actions
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
};
|
||||
}
|
||||
76
nutriphi/apps/mobile/hooks/useDatabase.ts
Normal file
76
nutriphi/apps/mobile/hooks/useDatabase.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { SQLiteService } from '../services/database/SQLiteService';
|
||||
import { MigrationService } from '../services/database/MigrationService';
|
||||
import { PhotoService } from '../services/storage/PhotoService';
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
initializeDatabase();
|
||||
}, [initializeDatabase]);
|
||||
|
||||
const initializeDatabase = useCallback(async () => {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
|
||||
// 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');
|
||||
|
||||
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 dbService = SQLiteService.getInstance();
|
||||
await dbService.close();
|
||||
|
||||
// Reinitialize
|
||||
await initializeDatabase();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isReady,
|
||||
error,
|
||||
resetDatabase,
|
||||
retryInitialization: initializeDatabase,
|
||||
};
|
||||
}
|
||||
62
nutriphi/apps/mobile/hooks/useTheme.ts
Normal file
62
nutriphi/apps/mobile/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useColorScheme } from 'nativewind';
|
||||
import { useAppStore } from '../store/AppStore';
|
||||
import { useEffect } from 'react';
|
||||
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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTheme();
|
||||
}, [setTheme, setColorScheme]);
|
||||
|
||||
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
// Update AppStore
|
||||
setTheme(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);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
colorScheme,
|
||||
updateTheme,
|
||||
isDark: colorScheme === 'dark',
|
||||
isLight: colorScheme === 'light',
|
||||
};
|
||||
};
|
||||
17
nutriphi/apps/mobile/metro.config.js
Normal file
17
nutriphi/apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add path mapping for @ alias
|
||||
config.resolver.alias = {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
...config.resolver.alias,
|
||||
};
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
3
nutriphi/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
nutriphi/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
68
nutriphi/apps/mobile/package.json
Normal file
68
nutriphi/apps/mobile/package.json
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"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",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"expo": "^53.0.11",
|
||||
"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
|
||||
}
|
||||
10
nutriphi/apps/mobile/prettier.config.js
Normal file
10
nutriphi/apps/mobile/prettier.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
145
nutriphi/apps/mobile/services/DataClearingService.ts
Normal file
145
nutriphi/apps/mobile/services/DataClearingService.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { SQLiteService } from './database/SQLiteService';
|
||||
import { PhotoService } from './storage/PhotoService';
|
||||
import { useMealStore } from '../store/MealStore';
|
||||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export class DataClearingService {
|
||||
private static instance: DataClearingService;
|
||||
|
||||
public static getInstance(): DataClearingService {
|
||||
if (!DataClearingService.instance) {
|
||||
DataClearingService.instance = new DataClearingService();
|
||||
}
|
||||
return DataClearingService.instance;
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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 {
|
||||
// 4. Clear AsyncStorage
|
||||
await this.clearAsyncStorage();
|
||||
} catch (error) {
|
||||
errors.push(`AsyncStorage clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
// Note: Supabase integration will be added later
|
||||
// For now, we skip Supabase sign out
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Also cleanup any temp photos
|
||||
await PhotoService.getInstance().cleanupTempPhotos();
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement when Supabase is configured
|
||||
// private async signOutSupabase(): Promise<void> {
|
||||
// const { error } = await supabase.auth.signOut();
|
||||
// if (error) {
|
||||
// throw new Error(`Supabase sign out error: ${error.message}`);
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
217
nutriphi/apps/mobile/services/LocationService.ts
Normal file
217
nutriphi/apps/mobile/services/LocationService.ts
Normal file
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
||||
export interface LocationAddress {
|
||||
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 constructor() {}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
// Build formatted address
|
||||
const addressParts = [];
|
||||
|
||||
// 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(' ');
|
||||
|
||||
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(', ');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return 'Unbekannter Ort';
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
if (address.street && address.city) {
|
||||
return `${address.street}, ${address.city}`;
|
||||
}
|
||||
|
||||
if (address.city) {
|
||||
return address.city;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
207
nutriphi/apps/mobile/services/UserPreferencesService.ts
Normal file
207
nutriphi/apps/mobile/services/UserPreferencesService.ts
Normal file
|
|
@ -0,0 +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';
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
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 constructor() {
|
||||
this.dbService = SQLiteService.getInstance();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
|
||||
'SELECT key, value, type FROM user_preferences'
|
||||
);
|
||||
|
||||
const preferences = { ...DEFAULT_PREFERENCES };
|
||||
|
||||
for (const row of rows) {
|
||||
const value = this.parseValue(row.value, row.type);
|
||||
(preferences as any)[row.key] = value;
|
||||
}
|
||||
|
||||
this.cachedPreferences = preferences;
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
public async getPreferences(): Promise<UserPreferences> {
|
||||
if (!this.cachedPreferences) {
|
||||
await this.loadPreferences();
|
||||
}
|
||||
return this.cachedPreferences!;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
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);
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 markLocationPermissionAsked(): Promise<void> {
|
||||
await this.updatePreference('locationPermissionAsked', true);
|
||||
}
|
||||
}
|
||||
540
nutriphi/apps/mobile/services/api/GeminiService.ts
Normal file
540
nutriphi/apps/mobile/services/api/GeminiService.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
import { GeminiAnalysisResult, GeminiError, PromptContext } from '../../types/API';
|
||||
|
||||
interface GeminiConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
interface RetryConfig {
|
||||
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 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 retryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 });
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
2. Berechne Nährwerte basierend auf Standard-Portionen
|
||||
3. Bewerte die Gesundheit der gesamten Mahlzeit
|
||||
4. Berücksichtige versteckte Zutaten (Öle, Saucen, Gewürze)
|
||||
|
||||
${this.getContextualPrompt(context)}
|
||||
|
||||
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": "Beispiel Lebensmittel",
|
||||
"category": "protein|vegetable|grain|fruit|dairy|fat|processed|beverage",
|
||||
"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|fried|boiled|raw|baked|steamed",
|
||||
"estimated_freshness": "fresh|processed|mixed",
|
||||
"hidden_ingredients": ["Olivenöl (1 TL)", "Gewürze"],
|
||||
"portion_accuracy": "high|medium|low"
|
||||
}
|
||||
}
|
||||
|
||||
BEWERTUNGSKRITERIEN health_score:
|
||||
10: Optimal (viel Gemüse, mageres Protein, Vollkorn, minimal verarbeitet)
|
||||
8-9: Sehr gesund (ausgewogen, natürliche Zutaten)
|
||||
6-7: Gesund (gute Balance, moderate Verarbeitung)
|
||||
4-5: Mittelmäßig (gemischt, einige verarbeitete Komponenten)
|
||||
2-3: Ungesund (viel verarbeitet, hoher Zucker/Fett)
|
||||
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
|
||||
- fruit: Alle Früchte
|
||||
- dairy: Milchprodukte
|
||||
- fat: Öle, Butter, Avocado
|
||||
- processed: Verarbeitete Lebensmittel
|
||||
- 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`;
|
||||
|
||||
return basePrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 contextStrings: string[] = [];
|
||||
|
||||
if (context.mealType) {
|
||||
contextStrings.push(contextPrompts[context.mealType] || '');
|
||||
}
|
||||
|
||||
if (context.location) {
|
||||
contextStrings.push(contextPrompts[context.location] || '');
|
||||
}
|
||||
|
||||
if (context.additional) {
|
||||
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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})`
|
||||
);
|
||||
|
||||
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) + '...');
|
||||
|
||||
// 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) + '...');
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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 = [];
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!this.model) {
|
||||
throw new GeminiError(
|
||||
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
|
||||
'INITIALIZATION_ERROR',
|
||||
'PERMANENT'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
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');
|
||||
|
||||
// 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();
|
||||
|
||||
// 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,
|
||||
]);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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`);
|
||||
|
||||
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),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes errors for better handling
|
||||
*/
|
||||
private categorizeError(error: any): string {
|
||||
if (!error) return 'UNKNOWN_ERROR';
|
||||
|
||||
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';
|
||||
|
||||
return 'API_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is retryable
|
||||
*/
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 request timeout
|
||||
*/
|
||||
public updateTimeout(timeout: number) {
|
||||
this.requestTimeout = timeout;
|
||||
console.log('Updated request timeout to:', timeout);
|
||||
}
|
||||
}
|
||||
214
nutriphi/apps/mobile/services/database/MigrationService.ts
Normal file
214
nutriphi/apps/mobile/services/database/MigrationService.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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>;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
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(`
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT,
|
||||
type TEXT DEFAULT 'string',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 IF NOT EXISTS idx_meals_location ON meals(latitude, longitude);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
public async initializeMigrationTable(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
await this.initializeMigrationTable();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
console.log(`Current database version: ${currentVersion}`);
|
||||
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => migration.version > currentVersion
|
||||
);
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('No pending migrations');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running ${pendingMigrations.length} migrations...`);
|
||||
|
||||
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.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]);
|
||||
|
||||
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('All migrations completed successfully');
|
||||
}
|
||||
|
||||
public async rollbackToVersion(targetVersion: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
|
||||
|
||||
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.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(`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`);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
return this.db.getAllAsync(
|
||||
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
}
|
||||
}
|
||||
403
nutriphi/apps/mobile/services/database/SQLiteService.ts
Normal file
403
nutriphi/apps/mobile/services/database/SQLiteService.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
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 constructor() {}
|
||||
|
||||
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 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');
|
||||
|
||||
// Meals Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
user_id TEXT,
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
version INTEGER DEFAULT 1,
|
||||
last_sync_at TEXT,
|
||||
photo_path TEXT NOT NULL,
|
||||
photo_url TEXT,
|
||||
photo_size INTEGER,
|
||||
photo_dimensions TEXT,
|
||||
timestamp TEXT DEFAULT (datetime('now')),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
meal_type TEXT,
|
||||
location TEXT,
|
||||
analysis_result TEXT,
|
||||
analysis_confidence REAL,
|
||||
analysis_status TEXT DEFAULT 'pending',
|
||||
total_calories INTEGER,
|
||||
total_protein REAL,
|
||||
total_carbs REAL,
|
||||
total_fat REAL,
|
||||
total_fiber REAL,
|
||||
total_sugar REAL,
|
||||
health_score REAL,
|
||||
health_category TEXT,
|
||||
user_notes TEXT,
|
||||
user_modified INTEGER DEFAULT 0,
|
||||
user_rating INTEGER,
|
||||
api_provider TEXT DEFAULT 'gemini',
|
||||
api_cost REAL,
|
||||
processing_time INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Food Items Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS food_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
meal_id INTEGER NOT NULL,
|
||||
sync_status TEXT DEFAULT 'local',
|
||||
version INTEGER DEFAULT 1,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
portion_size TEXT,
|
||||
calories INTEGER,
|
||||
protein REAL,
|
||||
carbs REAL,
|
||||
fat REAL,
|
||||
fiber REAL,
|
||||
sugar REAL,
|
||||
confidence REAL,
|
||||
bounding_box TEXT,
|
||||
is_organic INTEGER DEFAULT 0,
|
||||
is_processed INTEGER DEFAULT 0,
|
||||
allergens TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (meal_id) REFERENCES meals(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Sync Metadata Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
table_name TEXT NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
cloud_id TEXT,
|
||||
last_sync_at TEXT,
|
||||
conflict_data TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (table_name, record_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// User Preferences Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT,
|
||||
type TEXT DEFAULT 'string',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private async createIndices(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
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);
|
||||
CREATE INDEX IF NOT EXISTS idx_food_items_meal ON food_items(meal_id);
|
||||
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');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
|
||||
|
||||
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',
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
return result || null;
|
||||
}
|
||||
|
||||
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 foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[id]
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
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!]
|
||||
);
|
||||
|
||||
mealsWithItems.push({
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
});
|
||||
}
|
||||
|
||||
return mealsWithItems;
|
||||
}
|
||||
|
||||
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 setClause = updateFields.map((key) => `${key} = ?`).join(', ');
|
||||
|
||||
await this.db.runAsync(
|
||||
`
|
||||
UPDATE meals SET ${setClause}, updated_at = datetime('now') WHERE id = ?
|
||||
`,
|
||||
[...updateValues, id]
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// 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(
|
||||
`
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
if (foodItems.length === 0) return [];
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
// 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 (
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
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,
|
||||
AVG(health_score) as avg_health_score
|
||||
FROM meals
|
||||
WHERE timestamp >= datetime('now', '-${days} days')
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
public async searchMeals(query: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
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 ?
|
||||
OR m.meal_type LIKE ?
|
||||
OR fi.name LIKE ?
|
||||
ORDER BY m.timestamp DESC
|
||||
`,
|
||||
[`%${query}%`, `%${query}%`, `%${query}%`]
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
201
nutriphi/apps/mobile/services/storage/PhotoService.ts
Normal file
201
nutriphi/apps/mobile/services/storage/PhotoService.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import { PhotoDimensions } from '../../types/Database';
|
||||
|
||||
export class PhotoService {
|
||||
private static instance: PhotoService;
|
||||
private photosDirectory: string;
|
||||
|
||||
private constructor() {
|
||||
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
|
||||
}
|
||||
|
||||
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 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`;
|
||||
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
|
||||
// Copy file to app directory
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: destPath,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions (basic implementation)
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
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();
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
// Delete the temporary file
|
||||
await this.deletePhoto(tempPath);
|
||||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 (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'));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
118
nutriphi/apps/mobile/store/AppStore.ts
Normal file
118
nutriphi/apps/mobile/store/AppStore.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
|
||||
|
||||
// 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';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
||||
|
||||
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
|
||||
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
|
||||
|
||||
updateUserPreferences: (prefs) => set(prefs),
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
|
||||
|
||||
updateStatsCache: (stats) =>
|
||||
set({
|
||||
statsCache: {
|
||||
...stats,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
190
nutriphi/apps/mobile/store/MealStore.ts
Normal file
190
nutriphi/apps/mobile/store/MealStore.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { create } from 'zustand';
|
||||
import { Meal, MealWithItems, CreateMealInput, CreateFoodItemInput } from '../types/Database';
|
||||
import { SQLiteService } from '../services/database/SQLiteService';
|
||||
|
||||
interface MealState {
|
||||
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;
|
||||
}
|
||||
|
||||
export const useMealStore = create<MealState>((set, get) => ({
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
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 }),
|
||||
|
||||
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
|
||||
|
||||
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
|
||||
}));
|
||||
3
nutriphi/apps/mobile/store/store.ts
Normal file
3
nutriphi/apps/mobile/store/store.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Re-export main stores for convenience
|
||||
export { useMealStore } from './MealStore';
|
||||
export { useAppStore } from './AppStore';
|
||||
10
nutriphi/apps/mobile/tailwind.config.js
Normal file
10
nutriphi/apps/mobile/tailwind.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
13
nutriphi/apps/mobile/tsconfig.json
Normal file
13
nutriphi/apps/mobile/tsconfig.json
Normal file
|
|
@ -0,0 +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"]
|
||||
}
|
||||
97
nutriphi/apps/mobile/types/API.ts
Normal file
97
nutriphi/apps/mobile/types/API.ts
Normal file
|
|
@ -0,0 +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;
|
||||
};
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
// API Error Types
|
||||
export interface APIError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Prompt Context Types
|
||||
export interface PromptContext {
|
||||
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;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
success: boolean;
|
||||
data?: GeminiAnalysisResult;
|
||||
error?: APIError;
|
||||
processingTime: number;
|
||||
cost?: number;
|
||||
}
|
||||
116
nutriphi/apps/mobile/types/Database.ts
Normal file
116
nutriphi/apps/mobile/types/Database.ts
Normal file
|
|
@ -0,0 +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
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PhotoDimensions {
|
||||
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'];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Vollständige Mahlzeit mit FoodItems
|
||||
export interface MealWithItems extends Meal {
|
||||
food_items: FoodItem[];
|
||||
}
|
||||
14
nutriphi/apps/mobile/utils/supabase.ts
Normal file
14
nutriphi/apps/mobile/utils/supabase.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
18
nutriphi/apps/web/.env.example
Normal file
18
nutriphi/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Supabase Configuration (same as mobile app)
|
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
|
||||
# Nutriphi Backend API
|
||||
PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# Mana Middleware (Auth)
|
||||
PUBLIC_NUTRIPHI_MIDDLEWARE_URL=https://api.manacore.de
|
||||
PUBLIC_MIDDLEWARE_APP_ID=nutriphi
|
||||
|
||||
# Storage
|
||||
PUBLIC_STORAGE_BUCKET=meal-photos
|
||||
|
||||
# OAuth (optional)
|
||||
PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id
|
||||
PUBLIC_APPLE_CLIENT_ID=your-apple-client-id
|
||||
PUBLIC_APPLE_REDIRECT_URI=https://nutriphi.app/auth/callback
|
||||
40
nutriphi/apps/web/package.json
Normal file
40
nutriphi/apps/web/package.json
Normal file
|
|
@ -0,0 +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:*"
|
||||
}
|
||||
}
|
||||
1
nutriphi/apps/web/src/app.css
Normal file
1
nutriphi/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import 'tailwindcss';
|
||||
21
nutriphi/apps/web/src/app.d.ts
vendored
Normal file
21
nutriphi/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
supabase: ReturnType<typeof import('@supabase/ssr').createServerClient>;
|
||||
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
|
||||
}
|
||||
interface PageData {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
nutriphi/apps/web/src/app.html
Normal file
12
nutriphi/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
9
nutriphi/apps/web/src/hooks.server.ts
Normal file
9
nutriphi/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { type Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server hooks for Nutriphi Web
|
||||
* Authentication is handled client-side via Mana Middleware
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { FoodItem } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
items: FoodItem[];
|
||||
}
|
||||
|
||||
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 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>
|
||||
{: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>
|
||||
{/if}
|
||||
104
nutriphi/apps/web/src/lib/components/meals/MealCard.svelte
Normal file
104
nutriphi/apps/web/src/lib/components/meals/MealCard.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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"
|
||||
>
|
||||
{#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>
|
||||
|
||||
{#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}
|
||||
</button>
|
||||
164
nutriphi/apps/web/src/lib/components/meals/MealEditModal.svelte
Normal file
164
nutriphi/apps/web/src/lib/components/meals/MealEditModal.svelte
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script lang="ts">
|
||||
import type { Meal, MealType } from '$lib/types/meal';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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' }
|
||||
];
|
||||
|
||||
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 handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
{/if}
|
||||
27
nutriphi/apps/web/src/lib/components/meals/MealGrid.svelte
Normal file
27
nutriphi/apps/web/src/lib/components/meals/MealGrid.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import MealCard from './MealCard.svelte';
|
||||
|
||||
interface Props {
|
||||
meals: Meal[];
|
||||
onMealClick: (meal: Meal) => void;
|
||||
}
|
||||
|
||||
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>
|
||||
{: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>
|
||||
{/if}
|
||||
162
nutriphi/apps/web/src/lib/components/meals/NutritionBar.svelte
Normal file
162
nutriphi/apps/web/src/lib/components/meals/NutritionBar.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
showDetailed?: boolean;
|
||||
}
|
||||
|
||||
let { meal, showDetailed = false }: Props = $props();
|
||||
|
||||
const healthColor = $derived(() => {
|
||||
if (!meal.health_score) return 'bg-gray-400';
|
||||
if (meal.health_score >= 8) return 'bg-green-500';
|
||||
if (meal.health_score >= 6) return 'bg-yellow-500';
|
||||
if (meal.health_score >= 4) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
});
|
||||
|
||||
const healthLabel = $derived(() => {
|
||||
if (!meal.health_category) return '';
|
||||
const labels: Record<string, string> = {
|
||||
very_healthy: 'Sehr gesund',
|
||||
healthy: 'Gesund',
|
||||
moderate: 'Moderat',
|
||||
unhealthy: 'Ungesund'
|
||||
};
|
||||
return labels[meal.health_category] || '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Calories Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{meal.total_calories ? Math.round(meal.total_calories) : '—'} kcal
|
||||
</p>
|
||||
{#if healthLabel()}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{healthLabel()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if meal.health_score}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 rounded-full {healthColor()}"></div>
|
||||
<span class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{meal.health_score}/10
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Macro Pills -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-xl bg-blue-50 p-3 text-center dark:bg-blue-900/20">
|
||||
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{meal.total_protein ? Math.round(meal.total_protein) : '—'}g
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Protein</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-green-50 p-3 text-center dark:bg-green-900/20">
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{meal.total_carbs ? Math.round(meal.total_carbs) : '—'}g
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Carbs</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-orange-50 p-3 text-center dark:bg-orange-900/20">
|
||||
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{meal.total_fat ? Math.round(meal.total_fat) : '—'}g
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Fett</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Progress Bars -->
|
||||
{#if showDetailed}
|
||||
<div class="space-y-3 pt-2">
|
||||
<!-- Protein -->
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Protein</span>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{meal.total_protein ? Math.round(meal.total_protein) : 0}g
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all"
|
||||
style="width: {Math.min(((meal.total_protein || 0) / 50) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carbs -->
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Kohlenhydrate</span>
|
||||
<span class="font-medium text-green-600 dark:text-green-400">
|
||||
{meal.total_carbs ? Math.round(meal.total_carbs) : 0}g
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-green-500 transition-all"
|
||||
style="width: {Math.min(((meal.total_carbs || 0) / 100) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fat -->
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Fett</span>
|
||||
<span class="font-medium text-orange-600 dark:text-orange-400">
|
||||
{meal.total_fat ? Math.round(meal.total_fat) : 0}g
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-orange-500 transition-all"
|
||||
style="width: {Math.min(((meal.total_fat || 0) / 65) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fiber -->
|
||||
{#if meal.total_fiber !== undefined}
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Ballaststoffe</span>
|
||||
<span class="font-medium text-purple-600 dark:text-purple-400">
|
||||
{Math.round(meal.total_fiber)}g
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-purple-500 transition-all"
|
||||
style="width: {Math.min((meal.total_fiber / 25) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sugar -->
|
||||
{#if meal.total_sugar !== undefined}
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Zucker</span>
|
||||
<span class="font-medium text-pink-600 dark:text-pink-400">
|
||||
{Math.round(meal.total_sugar)}g
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-pink-500 transition-all"
|
||||
style="width: {Math.min((meal.total_sugar / 50) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
42
nutriphi/apps/web/src/lib/config/env.ts
Normal file
42
nutriphi/apps/web/src/lib/config/env.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Environment configuration helper for Nutriphi Web
|
||||
* Provides type-safe access to environment variables
|
||||
*/
|
||||
|
||||
import { env as dynamicEnv } from '$env/dynamic/public';
|
||||
|
||||
export const env = {
|
||||
// Middleware APIs
|
||||
middleware: {
|
||||
nutriphiUrl: dynamicEnv.PUBLIC_NUTRIPHI_MIDDLEWARE_URL ?? 'https://api.manacore.de',
|
||||
appId: dynamicEnv.PUBLIC_MIDDLEWARE_APP_ID ?? 'nutriphi'
|
||||
},
|
||||
|
||||
// Backend API
|
||||
backend: {
|
||||
url: dynamicEnv.PUBLIC_BACKEND_URL ?? 'http://localhost:3002'
|
||||
},
|
||||
|
||||
// OAuth
|
||||
oauth: {
|
||||
googleClientId: dynamicEnv.PUBLIC_GOOGLE_CLIENT_ID ?? '',
|
||||
appleClientId: dynamicEnv.PUBLIC_APPLE_CLIENT_ID ?? '',
|
||||
appleRedirectUri: dynamicEnv.PUBLIC_APPLE_REDIRECT_URI ?? ''
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Helper to check if optional features are enabled
|
||||
export const features = {
|
||||
hasGoogleAuth: !!env.oauth.googleClientId,
|
||||
hasAppleAuth: !!env.oauth.appleClientId && !!env.oauth.appleRedirectUri
|
||||
} as const;
|
||||
|
||||
// Log environment configuration on startup (useful for debugging deployment issues)
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('Nutriphi Environment Configuration:', {
|
||||
middleware: !!env.middleware.nutriphiUrl ? 'Configured' : 'Missing',
|
||||
backend: !!env.backend.url ? 'Configured' : 'Missing',
|
||||
googleOAuth: features.hasGoogleAuth ? 'Enabled' : 'Disabled',
|
||||
appleOAuth: features.hasAppleAuth ? 'Enabled' : 'Disabled'
|
||||
});
|
||||
}
|
||||
116
nutriphi/apps/web/src/lib/services/api.ts
Normal file
116
nutriphi/apps/web/src/lib/services/api.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbohydrates: number;
|
||||
totalFat: number;
|
||||
totalFiber: number;
|
||||
totalSugar: number;
|
||||
totalSodium: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
return this.request('/meals/analyze/image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageBase64 }),
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<NutritionAnalysis> {
|
||||
return this.request('/meals/analyze/text', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMeals(userId: string, date?: string): Promise<Meal[]> {
|
||||
const params = date ? `?date=${date}` : '';
|
||||
return this.request(`/meals/user/${userId}${params}`);
|
||||
}
|
||||
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
return this.request(`/meals/user/${userId}/summary?date=${date}`);
|
||||
}
|
||||
|
||||
async createMeal(meal: Partial<Meal> & { userId: string }): Promise<Meal> {
|
||||
return this.request('/meals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(meal),
|
||||
});
|
||||
}
|
||||
|
||||
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
|
||||
return this.request(`/meals/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMeal(id: string): Promise<void> {
|
||||
await this.request(`/meals/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
|
||||
return this.request('/health');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
413
nutriphi/apps/web/src/lib/services/authService.ts
Normal file
413
nutriphi/apps/web/src/lib/services/authService.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
/**
|
||||
* Authentication service for Nutriphi Web
|
||||
* Uses Mana middleware for authentication
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
|
||||
const MIDDLEWARE_URL = env.middleware.nutriphiUrl;
|
||||
const APP_ID = env.middleware.appId;
|
||||
|
||||
// Storage keys for tokens
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get device information for authentication
|
||||
*/
|
||||
function getDeviceInfo() {
|
||||
return {
|
||||
deviceId: getBrowserFingerprint(),
|
||||
deviceName: getBrowserName(),
|
||||
deviceType: 'web',
|
||||
platform: 'web'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a browser fingerprint for device identification
|
||||
*/
|
||||
function getBrowserFingerprint(): string {
|
||||
const ua = navigator.userAgent;
|
||||
const screen = `${window.screen.width}x${window.screen.height}`;
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const lang = navigator.language;
|
||||
|
||||
const data = `${ua}|${screen}|${timezone}|${lang}`;
|
||||
return btoa(data).slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser name
|
||||
*/
|
||||
function getBrowserName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.includes('Chrome')) return 'Chrome';
|
||||
if (ua.includes('Firefox')) return 'Firefox';
|
||||
if (ua.includes('Safari')) return 'Safari';
|
||||
if (ua.includes('Edge')) return 'Edge';
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
function decodeToken(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(window.atob(base64));
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
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();
|
||||
|
||||
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.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'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed'
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'This email is already in use'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed'
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (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: 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;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
204
nutriphi/apps/web/src/lib/services/exportService.ts
Normal file
204
nutriphi/apps/web/src/lib/services/exportService.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Export Service for Nutriphi Web
|
||||
* Generates CSV and PDF exports of meal data
|
||||
*/
|
||||
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import type { ExportOptions } from '$lib/types/stats';
|
||||
import type { NutritionGoal, DailyProgress } from '$lib/types/goal';
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meal type label in German
|
||||
*/
|
||||
function getMealTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export meals to CSV
|
||||
*/
|
||||
export function exportToCSV(meals: Meal[]): Blob {
|
||||
const headers = [
|
||||
'Datum',
|
||||
'Uhrzeit',
|
||||
'Mahlzeit',
|
||||
'Kalorien (kcal)',
|
||||
'Protein (g)',
|
||||
'Kohlenhydrate (g)',
|
||||
'Fett (g)',
|
||||
'Ballaststoffe (g)',
|
||||
'Zucker (g)',
|
||||
'Health Score',
|
||||
'Notizen'
|
||||
];
|
||||
|
||||
const rows = meals.map((meal) => {
|
||||
const date = new Date(meal.timestamp);
|
||||
return [
|
||||
formatDate(meal.timestamp),
|
||||
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
|
||||
getMealTypeLabel(meal.meal_type),
|
||||
meal.total_calories ? Math.round(meal.total_calories).toString() : '',
|
||||
meal.total_protein ? Math.round(meal.total_protein).toString() : '',
|
||||
meal.total_carbs ? Math.round(meal.total_carbs).toString() : '',
|
||||
meal.total_fat ? Math.round(meal.total_fat).toString() : '',
|
||||
meal.total_fiber ? Math.round(meal.total_fiber).toString() : '',
|
||||
meal.total_sugar ? Math.round(meal.total_sugar).toString() : '',
|
||||
meal.health_score?.toString() || '',
|
||||
meal.user_notes?.replace(/"/g, '""') || ''
|
||||
];
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(';'),
|
||||
...rows.map((row) => row.map((cell) => `"${cell}"`).join(';'))
|
||||
].join('\n');
|
||||
|
||||
// Add BOM for Excel compatibility with UTF-8
|
||||
const BOM = '\uFEFF';
|
||||
return new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export daily summaries to CSV
|
||||
*/
|
||||
export function exportSummaryToCSV(
|
||||
summaries: Array<{ date: string; meals: Meal[] }>
|
||||
): Blob {
|
||||
const headers = [
|
||||
'Datum',
|
||||
'Mahlzeiten',
|
||||
'Kalorien (kcal)',
|
||||
'Protein (g)',
|
||||
'Kohlenhydrate (g)',
|
||||
'Fett (g)',
|
||||
'Durchschn. Health Score'
|
||||
];
|
||||
|
||||
const rows = summaries.map(({ date, meals }) => {
|
||||
const totalCalories = meals.reduce((sum, m) => sum + (m.total_calories || 0), 0);
|
||||
const totalProtein = meals.reduce((sum, m) => sum + (m.total_protein || 0), 0);
|
||||
const totalCarbs = meals.reduce((sum, m) => sum + (m.total_carbs || 0), 0);
|
||||
const totalFat = meals.reduce((sum, m) => sum + (m.total_fat || 0), 0);
|
||||
const healthScores = meals.filter((m) => m.health_score !== undefined).map((m) => m.health_score!);
|
||||
const avgHealth = healthScores.length > 0
|
||||
? (healthScores.reduce((a, b) => a + b, 0) / healthScores.length).toFixed(1)
|
||||
: '';
|
||||
|
||||
return [
|
||||
formatDate(date),
|
||||
meals.length.toString(),
|
||||
Math.round(totalCalories).toString(),
|
||||
Math.round(totalProtein).toString(),
|
||||
Math.round(totalCarbs).toString(),
|
||||
Math.round(totalFat).toString(),
|
||||
avgHealth
|
||||
];
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(';'),
|
||||
...rows.map((row) => row.map((cell) => `"${cell}"`).join(';'))
|
||||
].join('\n');
|
||||
|
||||
const BOM = '\uFEFF';
|
||||
return new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export goals to CSV
|
||||
*/
|
||||
export function exportGoalsToCSV(goals: NutritionGoal): Blob {
|
||||
const content = `Nutriphi Ernährungsziele
|
||||
Erstellt am: ${formatDate(new Date().toISOString())}
|
||||
|
||||
Ziel;Wert
|
||||
Kalorien (kcal/Tag);${goals.calories_target}
|
||||
Protein (g/Tag);${goals.protein_target}
|
||||
Kohlenhydrate (g/Tag);${goals.carbs_target}
|
||||
Fett (g/Tag);${goals.fat_target}
|
||||
${goals.fiber_target ? `Ballaststoffe (g/Tag);${goals.fiber_target}` : ''}
|
||||
${goals.sugar_limit ? `Zucker-Limit (g/Tag);${goals.sugar_limit}` : ''}
|
||||
`;
|
||||
|
||||
const BOM = '\uFEFF';
|
||||
return new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Group meals by date
|
||||
*/
|
||||
export function groupMealsByDate(meals: Meal[]): Map<string, Meal[]> {
|
||||
const grouped = new Map<string, Meal[]>();
|
||||
|
||||
meals.forEach((meal) => {
|
||||
const date = meal.timestamp.split('T')[0];
|
||||
const existing = grouped.get(date) || [];
|
||||
grouped.set(date, [...existing, meal]);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter meals by date range
|
||||
*/
|
||||
export function filterMealsByDateRange(
|
||||
meals: Meal[],
|
||||
dateFrom: string,
|
||||
dateTo: string
|
||||
): Meal[] {
|
||||
const from = new Date(dateFrom);
|
||||
const to = new Date(dateTo);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
|
||||
return meals.filter((meal) => {
|
||||
const mealDate = new Date(meal.timestamp);
|
||||
return mealDate >= from && mealDate <= to;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download blob as file
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate export filename
|
||||
*/
|
||||
export function generateFilename(
|
||||
type: 'meals' | 'summary' | 'goals',
|
||||
format: 'csv' | 'pdf',
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const range = dateFrom && dateTo ? `_${dateFrom}_${dateTo}` : '';
|
||||
return `nutriphi-${type}${range}_${date}.${format}`;
|
||||
}
|
||||
91
nutriphi/apps/web/src/lib/services/goalsService.ts
Normal file
91
nutriphi/apps/web/src/lib/services/goalsService.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Goals Service for Nutriphi Web
|
||||
* Handles nutrition goals API calls
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import type { NutritionGoal, DailyProgress, GoalProgress } from '$lib/types/goal';
|
||||
|
||||
const API_BASE = env.backend.url;
|
||||
|
||||
class GoalsService {
|
||||
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's nutrition goals
|
||||
*/
|
||||
async getGoals(userId: string): Promise<NutritionGoal | null> {
|
||||
try {
|
||||
return await this.request<NutritionGoal>(`/goals/${userId}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update nutrition goals
|
||||
*/
|
||||
async saveGoals(goals: Partial<NutritionGoal> & { user_id: string }): Promise<NutritionGoal> {
|
||||
return this.request<NutritionGoal>('/goals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(goals)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily progress
|
||||
*/
|
||||
async getDailyProgress(userId: string, date: string): Promise<DailyProgress> {
|
||||
return this.request<DailyProgress>(`/progress/${userId}?date=${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get goal progress with percentages
|
||||
*/
|
||||
async getGoalProgress(userId: string, date: string): Promise<GoalProgress | null> {
|
||||
try {
|
||||
const [goals, progress] = await Promise.all([
|
||||
this.getGoals(userId),
|
||||
this.getDailyProgress(userId, date)
|
||||
]);
|
||||
|
||||
if (!goals) return null;
|
||||
|
||||
const percentages = {
|
||||
calories: goals.calories_target ? (progress.calories / goals.calories_target) * 100 : 0,
|
||||
protein: goals.protein_target ? (progress.protein / goals.protein_target) * 100 : 0,
|
||||
carbs: goals.carbs_target ? (progress.carbs / goals.carbs_target) * 100 : 0,
|
||||
fat: goals.fat_target ? (progress.fat / goals.fat_target) * 100 : 0,
|
||||
fiber: goals.fiber_target ? (progress.fiber / goals.fiber_target) * 100 : 0
|
||||
};
|
||||
|
||||
return { goal: goals, progress, percentages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const goalsService = new GoalsService();
|
||||
154
nutriphi/apps/web/src/lib/services/mealService.ts
Normal file
154
nutriphi/apps/web/src/lib/services/mealService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Meal Service for Nutriphi Web
|
||||
* Handles all meal-related API calls
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import type { Meal, MealWithItems, FoodItem, DailySummary, MealFilters } from '$lib/types/meal';
|
||||
|
||||
const API_BASE = env.backend.url;
|
||||
|
||||
class MealService {
|
||||
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all meals for a user
|
||||
*/
|
||||
async getMeals(userId: string, filters?: MealFilters): Promise<Meal[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.date) params.set('date', filters.date);
|
||||
if (filters?.mealType) params.set('mealType', filters.mealType);
|
||||
if (filters?.minHealthScore) params.set('minHealthScore', String(filters.minHealthScore));
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return this.request<Meal[]>(`/meals/user/${userId}${query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single meal with its food items
|
||||
*/
|
||||
async getMealById(id: string): Promise<MealWithItems> {
|
||||
return this.request<MealWithItems>(`/meals/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily nutrition summary
|
||||
*/
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
return this.request<DailySummary>(`/meals/user/${userId}/summary?date=${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meal
|
||||
*/
|
||||
async createMeal(meal: Partial<Meal> & { user_id: string }): Promise<Meal> {
|
||||
return this.request<Meal>('/meals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(meal)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a meal photo and trigger analysis
|
||||
*/
|
||||
async uploadMealPhoto(data: {
|
||||
photoUrl: string;
|
||||
storagePath: string;
|
||||
userId: string;
|
||||
mealType?: string;
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
return this.request('/meals/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a meal
|
||||
*/
|
||||
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
|
||||
return this.request<Meal>(`/meals/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a meal
|
||||
*/
|
||||
async deleteMeal(id: string): Promise<void> {
|
||||
await this.request(`/meals/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a food image
|
||||
*/
|
||||
async analyzeImage(imageBase64: string): Promise<{
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
confidence: number;
|
||||
ingredients?: string[];
|
||||
}> {
|
||||
return this.request('/meals/analyze/image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageBase64 })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a food description
|
||||
*/
|
||||
async analyzeText(description: string): Promise<{
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
confidence: number;
|
||||
}> {
|
||||
return this.request('/meals/analyze/text', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ description })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
|
||||
return this.request('/health');
|
||||
}
|
||||
}
|
||||
|
||||
export const mealService = new MealService();
|
||||
222
nutriphi/apps/web/src/lib/services/statsService.ts
Normal file
222
nutriphi/apps/web/src/lib/services/statsService.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Stats Service for Nutriphi Web
|
||||
* Calculates and aggregates nutrition statistics
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import type { DateRange, StatsData, CalorieDataPoint, MacroDistribution, WeeklyData, HealthTrendPoint } from '$lib/types/stats';
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
|
||||
const API_BASE = env.backend.url;
|
||||
|
||||
class StatsService {
|
||||
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats from backend (if available) or calculate locally
|
||||
*/
|
||||
async getStats(userId: string, range: DateRange): Promise<StatsData> {
|
||||
try {
|
||||
// Try to get from backend first
|
||||
return await this.request<StatsData>(`/stats/${userId}?range=${range}`);
|
||||
} catch {
|
||||
// If backend doesn't have stats endpoint, return empty data
|
||||
return this.getEmptyStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stats from meals data locally
|
||||
*/
|
||||
calculateStats(meals: Meal[], range: DateRange): StatsData {
|
||||
const now = new Date();
|
||||
const rangeStart = this.getRangeStartDate(now, range);
|
||||
|
||||
// Filter meals by date range
|
||||
const filteredMeals = meals.filter((meal) => new Date(meal.timestamp) >= rangeStart);
|
||||
|
||||
if (filteredMeals.length === 0) {
|
||||
return this.getEmptyStats();
|
||||
}
|
||||
|
||||
return {
|
||||
calorieData: this.calculateCalorieData(filteredMeals, range),
|
||||
macroData: this.calculateMacroDistribution(filteredMeals),
|
||||
weeklyData: this.calculateWeeklyData(filteredMeals),
|
||||
healthData: this.calculateHealthTrend(filteredMeals),
|
||||
totals: this.calculateTotals(filteredMeals)
|
||||
};
|
||||
}
|
||||
|
||||
private getRangeStartDate(now: Date, range: DateRange): Date {
|
||||
const start = new Date(now);
|
||||
switch (range) {
|
||||
case 'week':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
case 'year':
|
||||
start.setFullYear(start.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private calculateCalorieData(meals: Meal[], range: DateRange): CalorieDataPoint[] {
|
||||
const dailyCalories = new Map<string, number>();
|
||||
|
||||
meals.forEach((meal) => {
|
||||
const date = meal.timestamp.split('T')[0];
|
||||
const current = dailyCalories.get(date) || 0;
|
||||
dailyCalories.set(date, current + (meal.total_calories || 0));
|
||||
});
|
||||
|
||||
return Array.from(dailyCalories.entries())
|
||||
.map(([date, calories]) => ({ date, calories }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
private calculateMacroDistribution(meals: Meal[]): MacroDistribution {
|
||||
const totals = meals.reduce(
|
||||
(acc, meal) => ({
|
||||
protein: acc.protein + (meal.total_protein || 0),
|
||||
carbs: acc.carbs + (meal.total_carbs || 0),
|
||||
fat: acc.fat + (meal.total_fat || 0)
|
||||
}),
|
||||
{ protein: 0, carbs: 0, fat: 0 }
|
||||
);
|
||||
|
||||
const total = totals.protein + totals.carbs + totals.fat;
|
||||
if (total === 0) return { protein: 33, carbs: 34, fat: 33 };
|
||||
|
||||
return {
|
||||
protein: Math.round((totals.protein / total) * 100),
|
||||
carbs: Math.round((totals.carbs / total) * 100),
|
||||
fat: Math.round((totals.fat / total) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateWeeklyData(meals: Meal[]): WeeklyData[] {
|
||||
const weeklyMeals = new Map<string, Meal[]>();
|
||||
|
||||
meals.forEach((meal) => {
|
||||
const date = new Date(meal.timestamp);
|
||||
const weekStart = this.getWeekStart(date);
|
||||
const weekKey = weekStart.toISOString().split('T')[0];
|
||||
|
||||
const existing = weeklyMeals.get(weekKey) || [];
|
||||
weeklyMeals.set(weekKey, [...existing, meal]);
|
||||
});
|
||||
|
||||
return Array.from(weeklyMeals.entries())
|
||||
.map(([week, weekMeals]) => {
|
||||
const days = new Set(weekMeals.map((m) => m.timestamp.split('T')[0])).size;
|
||||
return {
|
||||
week,
|
||||
avgCalories: Math.round(
|
||||
weekMeals.reduce((sum, m) => sum + (m.total_calories || 0), 0) / days
|
||||
),
|
||||
avgProtein: Math.round(
|
||||
weekMeals.reduce((sum, m) => sum + (m.total_protein || 0), 0) / days
|
||||
),
|
||||
avgCarbs: Math.round(
|
||||
weekMeals.reduce((sum, m) => sum + (m.total_carbs || 0), 0) / days
|
||||
),
|
||||
avgFat: Math.round(weekMeals.reduce((sum, m) => sum + (m.total_fat || 0), 0) / days),
|
||||
mealCount: weekMeals.length
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.week.localeCompare(b.week));
|
||||
}
|
||||
|
||||
private getWeekStart(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
return new Date(d.setDate(diff));
|
||||
}
|
||||
|
||||
private calculateHealthTrend(meals: Meal[]): HealthTrendPoint[] {
|
||||
const dailyHealth = new Map<string, { scores: number[]; count: number }>();
|
||||
|
||||
meals.forEach((meal) => {
|
||||
if (meal.health_score === undefined) return;
|
||||
const date = meal.timestamp.split('T')[0];
|
||||
const existing = dailyHealth.get(date) || { scores: [], count: 0 };
|
||||
dailyHealth.set(date, {
|
||||
scores: [...existing.scores, meal.health_score],
|
||||
count: existing.count + 1
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(dailyHealth.entries())
|
||||
.map(([date, data]) => ({
|
||||
date,
|
||||
avgHealthScore:
|
||||
Math.round((data.scores.reduce((a, b) => a + b, 0) / data.scores.length) * 10) / 10,
|
||||
mealCount: data.count
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
private calculateTotals(meals: Meal[]): StatsData['totals'] {
|
||||
const days = new Set(meals.map((m) => m.timestamp.split('T')[0])).size || 1;
|
||||
const totalCalories = meals.reduce((sum, m) => sum + (m.total_calories || 0), 0);
|
||||
const totalProtein = meals.reduce((sum, m) => sum + (m.total_protein || 0), 0);
|
||||
const totalCarbs = meals.reduce((sum, m) => sum + (m.total_carbs || 0), 0);
|
||||
const totalFat = meals.reduce((sum, m) => sum + (m.total_fat || 0), 0);
|
||||
const healthScores = meals.filter((m) => m.health_score !== undefined).map((m) => m.health_score!);
|
||||
|
||||
return {
|
||||
avgCalories: Math.round(totalCalories / days),
|
||||
avgProtein: Math.round(totalProtein / days),
|
||||
avgCarbs: Math.round(totalCarbs / days),
|
||||
avgFat: Math.round(totalFat / days),
|
||||
totalMeals: meals.length,
|
||||
avgHealthScore:
|
||||
healthScores.length > 0
|
||||
? Math.round((healthScores.reduce((a, b) => a + b, 0) / healthScores.length) * 10) / 10
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
private getEmptyStats(): StatsData {
|
||||
return {
|
||||
calorieData: [],
|
||||
macroData: { protein: 33, carbs: 34, fat: 33 },
|
||||
weeklyData: [],
|
||||
healthData: [],
|
||||
totals: {
|
||||
avgCalories: 0,
|
||||
avgProtein: 0,
|
||||
avgCarbs: 0,
|
||||
avgFat: 0,
|
||||
totalMeals: 0,
|
||||
avgHealthScore: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const statsService = new StatsService();
|
||||
342
nutriphi/apps/web/src/lib/services/tokenManager.ts
Normal file
342
nutriphi/apps/web/src/lib/services/tokenManager.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Token Manager for Nutriphi Web
|
||||
* Handles JWT token lifecycle, refresh logic, and request queueing
|
||||
*/
|
||||
|
||||
import { authService, type UserData } from './authService';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export enum TokenState {
|
||||
IDLE = 'idle',
|
||||
REFRESHING = 'refreshing',
|
||||
EXPIRED = 'expired',
|
||||
VALID = 'valid'
|
||||
}
|
||||
|
||||
interface QueuedRequest {
|
||||
id: string;
|
||||
input: RequestInfo | URL;
|
||||
init?: RequestInit;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface TokenRefreshResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type TokenStateObserver = (state: TokenState, token?: string) => void;
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email'
|
||||
};
|
||||
|
||||
class TokenManager {
|
||||
private state: TokenState = TokenState.IDLE;
|
||||
private refreshPromise: Promise<TokenRefreshResult> | null = null;
|
||||
private requestQueue: QueuedRequest[] = [];
|
||||
private observers: Set<TokenStateObserver> = new Set();
|
||||
|
||||
private readonly MAX_QUEUE_SIZE = 50;
|
||||
private readonly QUEUE_TIMEOUT_MS = 30000;
|
||||
private readonly MAX_REFRESH_ATTEMPTS = 3;
|
||||
private refreshAttempts = 0;
|
||||
private lastRefreshTime = 0;
|
||||
private readonly REFRESH_COOLDOWN_MS = 5000;
|
||||
|
||||
private static instance: TokenManager;
|
||||
|
||||
private constructor() {
|
||||
if (browser) {
|
||||
this.checkInitialState();
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
subscribe(observer: TokenStateObserver): () => void {
|
||||
this.observers.add(observer);
|
||||
return () => this.observers.delete(observer);
|
||||
}
|
||||
|
||||
private notifyObservers(state: TokenState, token?: string): void {
|
||||
this.observers.forEach((observer) => {
|
||||
try {
|
||||
observer(state, token);
|
||||
} catch (error) {
|
||||
console.debug('Error in token state observer:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setState(newState: TokenState, token?: string): void {
|
||||
if (this.state !== newState) {
|
||||
this.state = newState;
|
||||
this.notifyObservers(newState, token);
|
||||
}
|
||||
}
|
||||
|
||||
getState(): TokenState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private async checkInitialState(): Promise<void> {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const token = this.getStoredToken();
|
||||
if (!token) {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
this.setState(TokenState.VALID, token);
|
||||
} else {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
} catch {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredToken(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
|
||||
}
|
||||
|
||||
private getStoredRefreshToken(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
private storeTokens(appToken: string, refreshToken: string, email?: string): void {
|
||||
if (!browser) return;
|
||||
|
||||
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
if (email) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, email);
|
||||
}
|
||||
}
|
||||
|
||||
private clearStoredTokens(): void {
|
||||
if (!browser) return;
|
||||
|
||||
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_EMAIL);
|
||||
}
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const currentToken = this.getStoredToken();
|
||||
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
this.setState(TokenState.VALID, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
if (!currentToken) {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
return refreshResult.token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
if (this.state === TokenState.REFRESHING && this.refreshPromise) {
|
||||
return this.queueRequest(input, init);
|
||||
}
|
||||
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
return this.retryRequestWithToken(input, init, refreshResult.token);
|
||||
}
|
||||
throw new Error(refreshResult.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
private async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.requestQueue.length >= this.MAX_QUEUE_SIZE) {
|
||||
reject(new Error('Request queue full'));
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem: QueuedRequest = {
|
||||
id: Math.random().toString(36).substring(2, 11),
|
||||
input,
|
||||
init,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.requestQueue.push(queueItem);
|
||||
|
||||
setTimeout(() => {
|
||||
this.removeFromQueue(queueItem.id);
|
||||
reject(new Error('Queued request timeout'));
|
||||
}, this.QUEUE_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private removeFromQueue(requestId: string): void {
|
||||
const index = this.requestQueue.findIndex((item) => item.id === requestId);
|
||||
if (index !== -1) {
|
||||
this.requestQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<TokenRefreshResult> {
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefreshTime < this.REFRESH_COOLDOWN_MS) {
|
||||
return { success: false, error: 'Refresh cooldown active' };
|
||||
}
|
||||
|
||||
if (this.refreshAttempts >= this.MAX_REFRESH_ATTEMPTS) {
|
||||
await this.handleRefreshFailure();
|
||||
return { success: false, error: 'Max refresh attempts reached' };
|
||||
}
|
||||
|
||||
if (this.refreshPromise) {
|
||||
return await this.refreshPromise;
|
||||
}
|
||||
|
||||
this.setState(TokenState.REFRESHING);
|
||||
this.lastRefreshTime = now;
|
||||
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
const result = await this.refreshPromise;
|
||||
|
||||
if (result.success) {
|
||||
this.refreshAttempts = 0;
|
||||
this.setState(TokenState.VALID, result.token);
|
||||
await this.processQueuedRequests(result.token!);
|
||||
} else {
|
||||
this.refreshAttempts++;
|
||||
this.setState(TokenState.EXPIRED);
|
||||
await this.rejectQueuedRequests(result.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<TokenRefreshResult> {
|
||||
try {
|
||||
const refreshToken = this.getStoredRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const refreshResult = await authService.refreshTokens(refreshToken);
|
||||
const { appToken, refreshToken: newRefreshToken, userData } = refreshResult;
|
||||
|
||||
if (!appToken || !newRefreshToken) {
|
||||
throw new Error('Invalid tokens received from refresh');
|
||||
}
|
||||
|
||||
this.storeTokens(appToken, newRefreshToken, userData?.email);
|
||||
|
||||
return { success: true, token: appToken };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown refresh error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRefreshFailure(): Promise<void> {
|
||||
try {
|
||||
this.clearStoredTokens();
|
||||
this.setState(TokenState.EXPIRED);
|
||||
} catch (error) {
|
||||
console.debug('Error in handleRefreshFailure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueuedRequests(token: string): Promise<void> {
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const response = await this.retryRequestWithToken(request.input, request.init, token);
|
||||
request.resolve(response);
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectQueuedRequests(error: string): Promise<void> {
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async retryRequestWithToken(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
token: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = TokenState.IDLE;
|
||||
this.refreshPromise = null;
|
||||
this.refreshAttempts = 0;
|
||||
this.lastRefreshTime = 0;
|
||||
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error('Token manager reset'));
|
||||
}
|
||||
}
|
||||
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
this.clearStoredTokens();
|
||||
this.reset();
|
||||
} catch {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export default tokenManager;
|
||||
204
nutriphi/apps/web/src/lib/services/uploadService.ts
Normal file
204
nutriphi/apps/web/src/lib/services/uploadService.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Upload Service for Nutriphi Web
|
||||
* Handles meal photo uploads via backend (Hetzner Object Storage)
|
||||
*/
|
||||
|
||||
import { env } from '$lib/config/env';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import type { MealType } from '$lib/types/meal';
|
||||
|
||||
const API_BASE = env.backend.url;
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/heic', 'image/webp'];
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean;
|
||||
mealId?: string;
|
||||
photoUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UploadProgress {
|
||||
status: 'uploading' | 'analyzing' | 'complete' | 'error';
|
||||
progress: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: UploadProgress) => void;
|
||||
|
||||
/**
|
||||
* Validate file before upload
|
||||
*/
|
||||
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return { valid: false, error: 'Ungültiges Dateiformat. Erlaubt: JPG, PNG, HEIC, WebP' };
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: 'Datei zu groß. Maximal 10MB erlaubt.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert file to base64 for upload
|
||||
*/
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
resolve(result); // Keep the data URL format
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a meal photo and create a meal record
|
||||
* The backend handles storage to Hetzner Object Storage
|
||||
*/
|
||||
export async function uploadMealPhoto(
|
||||
file: File,
|
||||
userId: string,
|
||||
mealType: MealType = 'lunch',
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<UploadResult> {
|
||||
// Validate file
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Convert to base64
|
||||
onProgress?.({ status: 'uploading', progress: 10, message: 'Bild wird vorbereitet...' });
|
||||
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
onProgress?.({ status: 'uploading', progress: 30, message: 'Wird hochgeladen...' });
|
||||
|
||||
// Step 2: Get auth token
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
// Step 3: Send to backend for upload and analysis
|
||||
onProgress?.({ status: 'analyzing', progress: 50, message: 'Wird analysiert...' });
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/meals/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imageBase64: base64Data,
|
||||
userId,
|
||||
mealType
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.message || `Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
onProgress?.({ status: 'analyzing', progress: 80, message: 'KI analysiert...' });
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Step 4: Complete
|
||||
onProgress?.({ status: 'complete', progress: 100, message: 'Fertig!' });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mealId: result.id,
|
||||
photoUrl: result.imageUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
onProgress?.({
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: error instanceof Error ? error.message : 'Upload fehlgeschlagen'
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Upload fehlgeschlagen'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a meal photo from storage (via backend)
|
||||
*/
|
||||
export async function deleteMealPhoto(mealId: string): Promise<boolean> {
|
||||
try {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/meals/${mealId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize image before upload (optional, for performance)
|
||||
*/
|
||||
export async function resizeImage(
|
||||
file: File,
|
||||
maxWidth: number = 1920,
|
||||
maxHeight: number = 1920,
|
||||
quality: number = 0.85
|
||||
): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
|
||||
// Calculate new dimensions
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width *= ratio;
|
||||
height *= ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const resizedFile = new File([blob], file.name, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
resolve(resizedFile);
|
||||
} else {
|
||||
reject(new Error('Failed to resize image'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
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