refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,20 @@
# Google Gemini Configuration
GEMINI_API_KEY=your-gemini-api-key-here
# PostgreSQL Database (using nutriphi-database package)
DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi
# Hetzner Object Storage (S3-compatible)
# Create in Coolify Dashboard -> S3 Storages
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_BUCKET_NAME=nutriphi-meals
S3_REGION=fsn1
S3_PUBLIC_URL=https://nutriphi-meals.fsn1.your-objectstorage.com
# Mana Core Auth Service
MANACORE_AUTH_URL=https://auth.manacore.de
# Server Configuration
PORT=3002

View file

@ -0,0 +1,49 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml ./
# Copy the nutriphi-database package
COPY packages/nutriphi-database ./packages/nutriphi-database
# Copy the backend
COPY apps/nutriphi/apps/backend ./apps/nutriphi/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile --filter @nutriphi/backend...
# Build the database package first
RUN pnpm --filter @manacore/nutriphi-database build
# Build the backend
RUN pnpm --filter @nutriphi/backend build
# Production stage
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy built files
COPY --from=builder /app/apps/nutriphi/apps/backend/dist ./dist
COPY --from=builder /app/apps/nutriphi/apps/backend/package.json ./
COPY --from=builder /app/apps/nutriphi/apps/backend/node_modules ./node_modules
COPY --from=builder /app/packages/nutriphi-database/dist ./node_modules/@manacore/nutriphi-database/dist
COPY --from=builder /app/packages/nutriphi-database/package.json ./node_modules/@manacore/nutriphi-database/
# Set environment
ENV NODE_ENV=production
# Expose port
EXPOSE 3002
# Start the application
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,31 @@
version: '3.8'
services:
nutriphi-backend:
build:
context: .
dockerfile: Dockerfile
container_name: nutriphi-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=${PORT:-3002}
- DATABASE_URL=${DATABASE_URL}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME}
- S3_REGION=${S3_REGION:-fsn1}
- S3_PUBLIC_URL=${S3_PUBLIC_URL}
- MANACORE_AUTH_URL=${MANACORE_AUTH_URL}
ports:
- "${PORT:-3002}:${PORT:-3002}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "coolify.managed=true"

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,46 @@
{
"name": "@nutriphi/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@google/generative-ai": "^0.24.1",
"@manacore/nutriphi-database": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { StorageModule } from './storage/storage.module';
import { HealthModule } from './health/health.module';
import { GeminiModule } from './gemini/gemini.module';
import { MealsModule } from './meals/meals.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, closeDb, type Database } from '@manacore/nutriphi-database';
export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return createClient(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeDb();
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GeminiService } from './gemini.service';
@Module({
providers: [GeminiService],
exports: [GeminiService],
})
export class GeminiModule {}

View file

@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
export interface NutritionAnalysis {
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
servingSize: string;
confidence: number;
ingredients?: string[];
healthTips?: string[];
}
@Injectable()
export class GeminiService {
private readonly logger = new Logger(GeminiService.name);
private readonly genAI: GoogleGenerativeAI;
private readonly model;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
if (!apiKey) {
throw new Error('GEMINI_API_KEY is not configured');
}
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
}
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
this.logger.log('Analyzing food image with Gemini Vision');
const prompt = `Analyze this food image and provide detailed nutritional information.
Return a JSON object with the following structure:
{
"foodName": "Name of the food/meal",
"calories": number (kcal),
"protein": number (grams),
"carbohydrates": number (grams),
"fat": number (grams),
"fiber": number (grams),
"sugar": number (grams),
"sodium": number (mg),
"servingSize": "estimated serving size",
"confidence": number (0-1, how confident you are in the analysis),
"ingredients": ["list", "of", "visible", "ingredients"],
"healthTips": ["optional health tips about this food"]
}
Be as accurate as possible with the nutritional estimates based on what you can see.
Only return valid JSON, no additional text.`;
try {
const result = await this.model.generateContent([
prompt,
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
]);
const response = result.response.text();
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in response');
}
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
return analysis;
} catch (error) {
this.logger.error('Failed to analyze food image', error);
throw error;
}
}
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
this.logger.log('Analyzing food description with Gemini');
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
Return a JSON object with the following structure:
{
"foodName": "Name of the food/meal",
"calories": number (kcal),
"protein": number (grams),
"carbohydrates": number (grams),
"fat": number (grams),
"fiber": number (grams),
"sugar": number (grams),
"sodium": number (mg),
"servingSize": "estimated serving size",
"confidence": number (0-1, how confident you are),
"ingredients": ["list", "of", "likely", "ingredients"],
"healthTips": ["optional health tips about this food"]
}
Only return valid JSON, no additional text.`;
try {
const result = await this.model.generateContent(prompt);
const response = result.response.text();
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in response');
}
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
return analysis;
} catch (error) {
this.logger.error('Failed to analyze food description', error);
throw error;
}
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'nutriphi-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8081',
'exp://localhost:8081',
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3002;
await app.listen(port);
console.log(`Nutriphi backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,93 @@
import { IsString, IsOptional, IsBase64 } from 'class-validator';
export class AnalyzeMealImageDto {
@IsString()
imageBase64: string;
}
export class AnalyzeMealTextDto {
@IsString()
description: string;
}
export class CreateMealDto {
@IsString()
userId: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
@IsString()
servingSize: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
notes?: string;
}
export class UploadMealDto {
@IsString()
imageBase64: string;
@IsString()
userId: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
}
export class UpdateMealDto {
@IsOptional()
@IsString()
foodName?: string;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
notes?: string;
}

View file

@ -0,0 +1,79 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { MealsService } from './meals.service';
import {
AnalyzeMealImageDto,
AnalyzeMealTextDto,
CreateMealDto,
UpdateMealDto,
UploadMealDto,
} from './dto/analyze-meal.dto';
@Controller('meals')
export class MealsController {
constructor(private readonly mealsService: MealsService) {}
@Post('analyze/image')
@HttpCode(HttpStatus.OK)
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
return this.mealsService.analyzeImage(dto.imageBase64);
}
@Post('analyze/text')
@HttpCode(HttpStatus.OK)
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
return this.mealsService.analyzeText(dto.description);
}
@Post()
async createMeal(@Body() dto: CreateMealDto) {
return this.mealsService.createMeal(dto);
}
@Post('upload')
async uploadMeal(@Body() dto: UploadMealDto) {
return this.mealsService.uploadAndAnalyzeMeal(dto);
}
@Get('user/:userId')
async getMealsByUser(
@Param('userId') userId: string,
@Query('date') date?: string,
) {
return this.mealsService.getMealsByUser(userId, date);
}
@Get('user/:userId/summary')
async getDailySummary(
@Param('userId') userId: string,
@Query('date') date: string,
) {
return this.mealsService.getDailySummary(userId, date);
}
@Get(':id')
async getMealById(@Param('id') id: string) {
return this.mealsService.getMealById(id);
}
@Put(':id')
async updateMeal(@Param('id') id: string, @Body() dto: UpdateMealDto) {
return this.mealsService.updateMeal(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMeal(@Param('id') id: string) {
return this.mealsService.deleteMeal(id);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MealsController } from './meals.controller';
import { MealsService } from './meals.service';
import { GeminiModule } from '../gemini/gemini.module';
@Module({
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
})
export class MealsModule {}

View file

@ -0,0 +1,281 @@
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gte,
lte,
desc,
type Meal as DbMeal,
} from '@manacore/nutriphi-database';
import { DATABASE_TOKEN } from '../database/database.module';
import { StorageService } from '../storage/storage.service';
import { GeminiService, NutritionAnalysis } from '../gemini/gemini.service';
import { CreateMealDto, UpdateMealDto, UploadMealDto } from './dto/analyze-meal.dto';
export interface Meal {
id: string;
user_id: string;
food_name: string;
image_url?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
serving_size: string;
meal_type?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface DailySummary {
date: string;
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
totalSodium: number;
mealCount: number;
}
@Injectable()
export class MealsService {
private readonly logger = new Logger(MealsService.name);
constructor(
@Inject(DATABASE_TOKEN) private readonly db: Database,
private geminiService: GeminiService,
private storageService: StorageService,
) {}
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
return {
id: dbMeal.id,
user_id: dbMeal.userId,
food_name: dbMeal.foodName,
image_url: dbMeal.imageUrl ?? undefined,
calories: dbMeal.calories ?? 0,
protein: dbMeal.protein ?? 0,
carbohydrates: dbMeal.carbohydrates ?? 0,
fat: dbMeal.fat ?? 0,
fiber: dbMeal.fiber ?? 0,
sugar: dbMeal.sugar ?? 0,
sodium: dbMeal.sodium ?? 0,
serving_size: dbMeal.servingSize ?? '',
meal_type: dbMeal.mealType ?? undefined,
notes: dbMeal.notes ?? undefined,
created_at: dbMeal.createdAt.toISOString(),
updated_at: dbMeal.updatedAt.toISOString(),
};
}
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodImage(imageBase64);
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.geminiService.analyzeFoodText(description);
}
/**
* Upload an image to storage, analyze it, and create a meal
*/
async uploadAndAnalyzeMeal(dto: UploadMealDto): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${dto.userId}`);
// Step 1: Upload image to storage
let imageUrl: string | undefined;
let storagePath: string | undefined;
try {
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
imageUrl = uploadResult.url;
storagePath = uploadResult.key;
this.logger.log(`Image uploaded: ${storagePath}`);
} catch (error) {
this.logger.warn('Storage not configured, skipping image upload', error);
}
// Step 2: Analyze the image with Gemini
// Extract base64 data without the data URL prefix
let base64Data = dto.imageBase64;
if (base64Data.includes(',')) {
base64Data = base64Data.split(',')[1];
}
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
// Step 3: Create the meal record
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
foodName: analysis.foodName || 'Unbekanntes Gericht',
imageUrl,
storagePath,
calories: analysis.calories,
protein: analysis.protein,
carbohydrates: analysis.carbohydrates,
fat: analysis.fat,
fiber: analysis.fiber,
sugar: analysis.sugar,
servingSize: analysis.servingSize || '1 Portion',
mealType: dto.mealType,
analysisStatus: 'completed',
}).returning();
this.logger.log(`Meal created: ${result.id}`);
return this.mapDbMealToMeal(result);
}
async createMeal(dto: CreateMealDto): Promise<Meal> {
this.logger.log(`Creating meal for user: ${dto.userId}`);
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
foodName: dto.foodName,
imageUrl: dto.imageUrl,
calories: dto.calories,
protein: dto.protein,
carbohydrates: dto.carbohydrates,
fat: dto.fat,
fiber: dto.fiber,
sugar: dto.sugar,
sodium: dto.sodium,
servingSize: dto.servingSize,
mealType: dto.mealType,
notes: dto.notes,
}).returning();
return this.mapDbMealToMeal(result);
}
async getMealsByUser(
userId: string,
date?: string,
): Promise<Meal[]> {
this.logger.log(`Fetching meals for user: ${userId}`);
let query;
if (date) {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
query = this.db
.select()
.from(meals)
.where(
and(
eq(meals.userId, userId),
gte(meals.createdAt, startOfDay),
lte(meals.createdAt, endOfDay)
)
)
.orderBy(desc(meals.createdAt));
} else {
query = this.db
.select()
.from(meals)
.where(eq(meals.userId, userId))
.orderBy(desc(meals.createdAt));
}
const results = await query;
return results.map(this.mapDbMealToMeal);
}
async getMealById(id: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(eq(meals.id, id));
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
async updateMeal(id: string, dto: UpdateMealDto): Promise<Meal> {
this.logger.log(`Updating meal: ${id}`);
const updateData: Partial<typeof meals.$inferInsert> = {
updatedAt: new Date(),
};
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
if (dto.calories !== undefined) updateData.calories = dto.calories;
if (dto.protein !== undefined) updateData.protein = dto.protein;
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
if (dto.fat !== undefined) updateData.fat = dto.fat;
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
if (dto.notes !== undefined) updateData.notes = dto.notes;
const [result] = await this.db
.update(meals)
.set(updateData)
.where(eq(meals.id, id))
.returning();
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
return this.mapDbMealToMeal(result);
}
async deleteMeal(id: string): Promise<void> {
this.logger.log(`Deleting meal: ${id}`);
const result = await this.db
.delete(meals)
.where(eq(meals.id, id))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Meal with id ${id} not found`);
}
}
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
const userMeals = await this.getMealsByUser(userId, date);
const summary: DailySummary = {
date,
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0,
totalSugar: 0,
totalSodium: 0,
mealCount: userMeals.length,
};
for (const meal of userMeals) {
summary.totalCalories += meal.calories || 0;
summary.totalProtein += meal.protein || 0;
summary.totalCarbohydrates += meal.carbohydrates || 0;
summary.totalFat += meal.fat || 0;
summary.totalFiber += meal.fiber || 0;
summary.totalSugar += meal.sugar || 0;
summary.totalSodium += meal.sodium || 0;
}
return summary;
}
}

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
export interface UploadResult {
key: string;
url: string;
}
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly s3Client: S3Client;
private readonly bucketName: string;
private readonly publicUrl: string;
constructor(private configService: ConfigService) {
// Hetzner Object Storage (S3-compatible)
const endpoint = this.configService.get<string>('S3_ENDPOINT');
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
if (!endpoint || !accessKeyId || !secretAccessKey) {
this.logger.warn('S3 configuration incomplete - storage features disabled');
this.s3Client = null as unknown as S3Client;
return;
}
this.s3Client = new S3Client({
region,
endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: true, // Required for Hetzner Object Storage
});
this.logger.log('Hetzner Object Storage initialized successfully');
}
private isConfigured(): boolean {
return this.s3Client !== null;
}
/**
* Upload a file to R2 storage
* @param buffer - File buffer
* @param contentType - MIME type of the file
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
* @returns Upload result with key and public URL
*/
async upload(
buffer: Buffer,
contentType: string,
folder = 'meals',
): Promise<UploadResult> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
const extension = this.getExtensionFromContentType(contentType);
const key = `${folder}/${randomUUID()}${extension}`;
this.logger.log(`Uploading file to R2: ${key}`);
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: buffer,
ContentType: contentType,
}),
);
const url = this.publicUrl
? `${this.publicUrl}/${key}`
: await this.getSignedUrl(key);
return { key, url };
}
/**
* Upload a base64-encoded image
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
* @param folder - Optional folder path
* @returns Upload result with key and public URL
*/
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
let data = base64Data;
let contentType = 'image/jpeg';
// Extract content type from data URI if present
if (data.includes(',')) {
const matches = data.match(/^data:(.+);base64,/);
if (matches) {
contentType = matches[1];
data = data.split(',')[1];
}
}
const buffer = Buffer.from(data, 'base64');
return this.upload(buffer, contentType, folder);
}
/**
* Delete a file from R2 storage
* @param key - File key/path in the bucket
*/
async delete(key: string): Promise<void> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
this.logger.log(`Deleting file from R2: ${key}`);
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
}),
);
}
/**
* Get a signed URL for temporary access to a file
* @param key - File key/path in the bucket
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
* @returns Signed URL
*/
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
if (!this.isConfigured()) {
throw new Error('R2 storage is not configured');
}
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return getSignedUrl(this.s3Client, command, { expiresIn });
}
private getExtensionFromContentType(contentType: string): string {
const mapping: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/heic': '.heic',
'image/heif': '.heif',
};
return mapping[contentType] || '.jpg';
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
}
}

View 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()
]
});

View 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"
}
}

View 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">
&copy; {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
</p>
<p class="text-text-muted text-sm">
Made with 💚 in Germany
</p>
</div>
</div>
</footer>

View 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>

View 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>

View 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>

View 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;
}

View 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')
]
};

View file

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View 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"
}
}
}
}

View 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'} />}
</>
);
}

View 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;
}
}`;

View 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]`,
};

View 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>
);
}

View 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'} />}
</>
);
}

View 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>
);
}

View 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'} />
</>
);
}

View 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>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
let plugins = [];
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View 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"
}
}

View 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',
};

View 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',
};

View 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`,
};

View 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,
},
});

View 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`,
};

View 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,
},
});

View 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}
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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}>
&ldquo;{meal.user_notes}&rdquo;
</Text>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
};

View 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)} />
</>
);
};

View 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}>
&ldquo;{meal.user_notes}&rdquo;
</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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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} />;
};

View 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": {}
}
}

View 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',
},
},
]);

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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,
};
}

View 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,
};
}

View 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',
};
};

View 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' });

View 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.

View 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
}

View 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'],
};

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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'
);
}
}

View 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);
}
}

View 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,
};
}
}
}

View 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,
},
}),
}));

View 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 }),
}));

View file

@ -0,0 +1,3 @@
// Re-export main stores for convenience
export { useMealStore } from './MealStore';
export { useAppStore } from './AppStore';

View 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: [],
};

View 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"]
}

View 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;
}

View 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[];
}

View 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,
},
});

View 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

View 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:*"
}
}

View file

@ -0,0 +1 @@
@import 'tailwindcss';

21
apps/nutriphi/apps/web/src/app.d.ts vendored Normal file
View 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 {};

View 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>

View 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);
};

Some files were not shown because too many files have changed in this diff Show more