style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

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

View file

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

View file

@ -8,17 +8,17 @@ import { MealsModule } from './meals/meals.module';
import { SyncModule } from './sync/sync.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
StorageModule,
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
})
export class AppModule {}

View file

@ -1,15 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);

View file

@ -1,66 +1,60 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -6,23 +6,23 @@ export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return createClient(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_TOKEN],
providers: [
{
provide: DATABASE_TOKEN,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return createClient(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeDb();
}
async onModuleDestroy() {
await closeDb();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,8 @@ import { MealsService } from './meals.service';
import { GeminiModule } from '../gemini/gemini.module';
@Module({
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
imports: [GeminiModule],
controllers: [MealsController],
providers: [MealsService],
})
export class MealsModule {}

View file

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

View file

@ -3,7 +3,7 @@ import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

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

View file

@ -1,146 +1,153 @@
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean } from 'class-validator';
import {
IsString,
IsOptional,
IsArray,
ValidateNested,
IsNumber,
IsBoolean,
} from 'class-validator';
import { Type } from 'class-transformer';
/**
* Local meal data from mobile app
*/
export class LocalMealDto {
@IsNumber()
localId: number;
@IsNumber()
localId: number;
@IsOptional()
@IsString()
cloudId?: string;
@IsOptional()
@IsString()
cloudId?: string;
@IsString()
foodName: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
calories?: number;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
healthScore?: number;
@IsOptional()
healthScore?: number;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
userRating?: number;
@IsOptional()
userRating?: number;
@IsOptional()
foodItems?: any[];
@IsOptional()
foodItems?: any[];
@IsNumber()
version: number;
@IsNumber()
version: number;
@IsString()
createdAt: string;
@IsString()
createdAt: string;
@IsString()
updatedAt: string;
@IsString()
updatedAt: string;
}
/**
* Push request - local changes to server
*/
export class SyncPushDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsOptional()
@IsString()
lastSyncAt?: string;
@IsOptional()
@IsString()
lastSyncAt?: string;
}
/**
* Push response
*/
export interface SyncPushResponse {
created: { localId: number; cloudId: string }[];
updated: string[];
conflicts: ConflictInfo[];
serverTime: string;
created: { localId: number; cloudId: string }[];
updated: string[];
conflicts: ConflictInfo[];
serverTime: string;
}
/**
* Conflict information
*/
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
/**
* Pull query parameters
*/
export class SyncPullQueryDto {
@IsOptional()
@IsString()
since?: string;
@IsOptional()
@IsString()
since?: string;
}
/**
* Pull response
*/
export interface SyncPullResponse {
meals: any[];
deletedIds: string[];
serverTime: string;
meals: any[];
deletedIds: string[];
serverTime: string;
}
/**
* Sync status response
*/
export interface SyncStatusResponse {
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
}

View file

@ -1,11 +1,11 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { SyncService } from './sync.service';
import {
SyncPushDto,
SyncPushResponse,
SyncPullQueryDto,
SyncPullResponse,
SyncStatusResponse,
SyncPushDto,
SyncPushResponse,
SyncPullQueryDto,
SyncPullResponse,
SyncStatusResponse,
} from './dto/sync.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@ -13,38 +13,38 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
@Controller('sync')
@UseGuards(JwtAuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
constructor(private readonly syncService: SyncService) {}
/**
* Push local changes to server
* POST /api/sync/push
*/
@Post('push')
async pushChanges(
@Body() dto: SyncPushDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPushResponse> {
return this.syncService.pushChanges(user.userId, dto);
}
/**
* Push local changes to server
* POST /api/sync/push
*/
@Post('push')
async pushChanges(
@Body() dto: SyncPushDto,
@CurrentUser() user: CurrentUserData
): Promise<SyncPushResponse> {
return this.syncService.pushChanges(user.userId, dto);
}
/**
* Pull changes from server
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
*/
@Get('pull')
async pullChanges(
@Query() query: SyncPullQueryDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPullResponse> {
return this.syncService.pullChanges(user.userId, query.since);
}
/**
* Pull changes from server
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
*/
@Get('pull')
async pullChanges(
@Query() query: SyncPullQueryDto,
@CurrentUser() user: CurrentUserData
): Promise<SyncPullResponse> {
return this.syncService.pullChanges(user.userId, query.since);
}
/**
* Get sync status
* GET /api/sync/status
*/
@Get('status')
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
return this.syncService.getStatus(user.userId);
}
/**
* Get sync status
* GET /api/sync/status
*/
@Get('status')
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
return this.syncService.getStatus(user.userId);
}
}

View file

@ -3,8 +3,8 @@ import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
@Module({
controllers: [SyncController],
providers: [SyncService],
exports: [SyncService],
controllers: [SyncController],
providers: [SyncService],
exports: [SyncService],
})
export class SyncModule {}

View file

@ -1,251 +1,246 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gt,
type Meal as DbMeal,
type Database,
meals,
eq,
and,
gt,
type Meal as DbMeal,
} from '@manacore/nutriphi-database';
import { DATABASE_TOKEN } from '../database/database.module';
import {
LocalMealDto,
SyncPushDto,
SyncPushResponse,
SyncPullResponse,
SyncStatusResponse,
ConflictInfo,
LocalMealDto,
SyncPushDto,
SyncPushResponse,
SyncPullResponse,
SyncStatusResponse,
ConflictInfo,
} from './dto/sync.dto';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private readonly logger = new Logger(SyncService.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
/**
* Push local changes to server
*/
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
/**
* Push local changes to server
*/
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
const created: { localId: number; cloudId: string }[] = [];
const updated: string[] = [];
const conflicts: ConflictInfo[] = [];
const serverTime = new Date().toISOString();
const created: { localId: number; cloudId: string }[] = [];
const updated: string[] = [];
const conflicts: ConflictInfo[] = [];
const serverTime = new Date().toISOString();
// Process each meal
for (const localMeal of dto.meals) {
try {
if (localMeal.cloudId) {
// Update existing meal
const result = await this.updateExistingMeal(userId, localMeal);
if (result.conflict) {
conflicts.push(result.conflict);
} else if (result.updated) {
updated.push(localMeal.cloudId);
}
} else {
// Create new meal
const cloudId = await this.createNewMeal(userId, localMeal);
created.push({ localId: localMeal.localId, cloudId });
}
} catch (error) {
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
}
}
// Process each meal
for (const localMeal of dto.meals) {
try {
if (localMeal.cloudId) {
// Update existing meal
const result = await this.updateExistingMeal(userId, localMeal);
if (result.conflict) {
conflicts.push(result.conflict);
} else if (result.updated) {
updated.push(localMeal.cloudId);
}
} else {
// Create new meal
const cloudId = await this.createNewMeal(userId, localMeal);
created.push({ localId: localMeal.localId, cloudId });
}
} catch (error) {
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
}
}
// Process deletions
for (const cloudId of dto.deletedIds) {
try {
await this.db
.delete(meals)
.where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
this.logger.log(`Deleted meal: ${cloudId}`);
} catch (error) {
this.logger.error(`Error deleting meal ${cloudId}:`, error);
}
}
// Process deletions
for (const cloudId of dto.deletedIds) {
try {
await this.db.delete(meals).where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
this.logger.log(`Deleted meal: ${cloudId}`);
} catch (error) {
this.logger.error(`Error deleting meal ${cloudId}:`, error);
}
}
return { created, updated, conflicts, serverTime };
}
return { created, updated, conflicts, serverTime };
}
/**
* Pull changes from server since given timestamp
*/
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
/**
* Pull changes from server since given timestamp
*/
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
const serverTime = new Date().toISOString();
const serverTime = new Date().toISOString();
let query;
if (since) {
const sinceDate = new Date(since);
query = this.db
.select()
.from(meals)
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
} else {
// Full sync - get all meals
query = this.db.select().from(meals).where(eq(meals.userId, userId));
}
let query;
if (since) {
const sinceDate = new Date(since);
query = this.db
.select()
.from(meals)
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
} else {
// Full sync - get all meals
query = this.db.select().from(meals).where(eq(meals.userId, userId));
}
const results = await query;
const results = await query;
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
return {
meals: mappedMeals,
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
serverTime,
};
}
return {
meals: mappedMeals,
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
serverTime,
};
}
/**
* Get sync status
*/
async getStatus(userId: string): Promise<SyncStatusResponse> {
const serverTime = new Date().toISOString();
/**
* Get sync status
*/
async getStatus(userId: string): Promise<SyncStatusResponse> {
const serverTime = new Date().toISOString();
// Count user's meals
const result = await this.db
.select()
.from(meals)
.where(eq(meals.userId, userId));
// Count user's meals
const result = await this.db.select().from(meals).where(eq(meals.userId, userId));
return {
lastSyncAt: null, // Could be stored in a user preferences table
pendingChanges: 0,
serverTime,
};
}
return {
lastSyncAt: null, // Could be stored in a user preferences table
pendingChanges: 0,
serverTime,
};
}
/**
* Create a new meal from local data
*/
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
const [result] = await this.db
.insert(meals)
.values({
userId,
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus ?? 'completed',
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
createdAt: new Date(localMeal.createdAt),
updatedAt: new Date(localMeal.updatedAt),
})
.returning();
/**
* Create a new meal from local data
*/
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
const [result] = await this.db
.insert(meals)
.values({
userId,
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus ?? 'completed',
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
createdAt: new Date(localMeal.createdAt),
updatedAt: new Date(localMeal.updatedAt),
})
.returning();
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
return result.id;
}
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
return result.id;
}
/**
* Update existing meal, checking for conflicts
*/
private async updateExistingMeal(
userId: string,
localMeal: LocalMealDto,
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
// Get current server version
const [serverMeal] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
/**
* Update existing meal, checking for conflicts
*/
private async updateExistingMeal(
userId: string,
localMeal: LocalMealDto
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
// Get current server version
const [serverMeal] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
if (!serverMeal) {
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
return { updated: false };
}
if (!serverMeal) {
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
return { updated: false };
}
// Simple last-write-wins strategy
// In production, you might want more sophisticated conflict resolution
const localUpdateTime = new Date(localMeal.updatedAt);
const serverUpdateTime = serverMeal.updatedAt;
// Simple last-write-wins strategy
// In production, you might want more sophisticated conflict resolution
const localUpdateTime = new Date(localMeal.updatedAt);
const serverUpdateTime = serverMeal.updatedAt;
// If local is newer, update server
if (localUpdateTime >= serverUpdateTime) {
await this.db
.update(meals)
.set({
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus,
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
updatedAt: new Date(),
})
.where(eq(meals.id, localMeal.cloudId!));
// If local is newer, update server
if (localUpdateTime >= serverUpdateTime) {
await this.db
.update(meals)
.set({
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus,
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
updatedAt: new Date(),
})
.where(eq(meals.id, localMeal.cloudId!));
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
return { updated: true };
}
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
return { updated: true };
}
// Server is newer - report conflict
return {
updated: false,
conflict: {
cloudId: localMeal.cloudId!,
localVersion: localMeal.version,
serverVersion: 1, // Would need version tracking in DB
serverData: this.mapDbMealToSync(serverMeal),
message: 'Server has newer data',
},
};
}
// Server is newer - report conflict
return {
updated: false,
conflict: {
cloudId: localMeal.cloudId!,
localVersion: localMeal.version,
serverVersion: 1, // Would need version tracking in DB
serverData: this.mapDbMealToSync(serverMeal),
message: 'Server has newer data',
},
};
}
/**
* Map database meal to sync format
*/
private mapDbMealToSync(meal: DbMeal): any {
return {
cloudId: meal.id,
userId: meal.userId,
foodName: meal.foodName,
imageUrl: meal.imageUrl,
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
sodium: meal.sodium,
servingSize: meal.servingSize,
mealType: meal.mealType,
analysisStatus: meal.analysisStatus,
healthScore: meal.healthScore,
healthCategory: meal.healthCategory,
notes: meal.notes,
userRating: meal.userRating,
foodItems: meal.foodItems,
createdAt: meal.createdAt.toISOString(),
updatedAt: meal.updatedAt.toISOString(),
};
}
/**
* Map database meal to sync format
*/
private mapDbMealToSync(meal: DbMeal): any {
return {
cloudId: meal.id,
userId: meal.userId,
foodName: meal.foodName,
imageUrl: meal.imageUrl,
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
sodium: meal.sodium,
servingSize: meal.servingSize,
mealType: meal.mealType,
analysisStatus: meal.analysisStatus,
healthScore: meal.healthScore,
healthCategory: meal.healthCategory,
notes: meal.notes,
userRating: meal.userRating,
foodItems: meal.foodItems,
createdAt: meal.createdAt.toISOString(),
updatedAt: meal.updatedAt.toISOString(),
};
}
}

View file

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

View file

@ -1,26 +1,26 @@
{
"name": "@nutriphi/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
}
"name": "@nutriphi/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
}
}

View file

@ -1,78 +1,82 @@
---
const footerLinks = {
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' }
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ href: '/imprint', label: 'Impressum' }
]
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ href: '/imprint', label: 'Impressum' },
],
};
const currentYear = new Date().getFullYear();
---
<footer class="bg-background-card border-t border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="/" class="flex items-center gap-2 mb-4">
<span class="text-2xl">🥗</span>
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten
und erhalte sofort detaillierte Nährwertinformationen.
</p>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="/" class="flex items-center gap-2 mb-4">
<span class="text-2xl">🥗</span>
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten und erhalte sofort
detaillierte Nährwertinformationen.
</p>
</div>
<!-- Product Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
<ul class="space-y-2">
{footerLinks.product.map(link => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
<!-- Product Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
<ul class="space-y-2">
{
footerLinks.product.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Legal Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
<ul class="space-y-2">
{footerLinks.legal.map(link => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</div>
<!-- Legal Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
<ul class="space-y-2">
{
footerLinks.legal.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom -->
<div class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
<p class="text-text-muted text-sm">
&copy; {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
</p>
<p class="text-text-muted text-sm">
Made with 💚 in Germany
</p>
</div>
</div>
<!-- Bottom -->
<div
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
>
<p class="text-text-muted text-sm">
&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

@ -1,84 +1,89 @@
---
const navLinks = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: 'So funktioniert\'s' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' }
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: "So funktioniert's" },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
];
---
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<span class="text-2xl">🥗</span>
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
</a>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<span class="text-2xl">🥗</span>
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{navLinks.map(link => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))}
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))
}
</div>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a
href="#download"
class="btn-primary text-sm px-4 py-2"
>
App herunterladen
</a>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{navLinks.map(link => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))}
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{
navLinks.map((link) => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))
}
</div>
</div>
</nav>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -2,46 +2,49 @@
import '../styles/global.css';
interface Props {
title: string;
description?: string;
title: string;
description?: string;
}
const {
title,
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse'
title,
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse',
} = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -14,274 +14,300 @@ import Card from '@manacore/shared-landing-ui/atoms/Card.astro';
// Feature data
const features = [
{
icon: '📸',
title: 'Foto-Analyse',
description: 'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.'
},
{
icon: '🤖',
title: 'Google Gemini KI',
description: 'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.'
},
{
icon: '📊',
title: 'Tagesbilanz',
description: 'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.'
},
{
icon: '🎯',
title: 'Persönliche Ziele',
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.'
},
{
icon: '📱',
title: 'Plattformübergreifend',
description: 'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
},
{
icon: '💡',
title: 'Gesundheitstipps',
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.'
}
{
icon: '📸',
title: 'Foto-Analyse',
description:
'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.',
},
{
icon: '🤖',
title: 'Google Gemini KI',
description:
'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.',
},
{
icon: '📊',
title: 'Tagesbilanz',
description:
'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.',
},
{
icon: '🎯',
title: 'Persönliche Ziele',
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.',
},
{
icon: '📱',
title: 'Plattformübergreifend',
description:
'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.',
},
{
icon: '💡',
title: 'Gesundheitstipps',
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.',
},
];
// Steps data
const steps = [
{
number: '1',
title: 'Mahlzeit fotografieren',
description: 'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
image: '/screenshots/photo.png'
},
{
number: '2',
title: 'KI analysiert',
description: 'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
image: '/screenshots/analyze.png'
},
{
number: '3',
title: 'Fortschritt verfolgen',
description: 'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
image: '/screenshots/track.png'
}
{
number: '1',
title: 'Mahlzeit fotografieren',
description:
'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
image: '/screenshots/photo.png',
},
{
number: '2',
title: 'KI analysiert',
description:
'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
image: '/screenshots/analyze.png',
},
{
number: '3',
title: 'Fortschritt verfolgen',
description:
'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
image: '/screenshots/track.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '10 Foto-Analysen/Tag', included: true },
{ text: 'Basis-Nährwertdaten', included: true },
{ text: 'Tagesübersicht', included: true },
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
{ text: 'Unbegrenzte Analysen', included: false },
{ text: 'Erweiterte Statistiken', included: false }
],
cta: {
text: 'Kostenlos starten',
href: '#download'
}
},
{
name: 'Pro',
price: '6,99',
period: '/Monat',
description: 'Für ernsthafte Tracker',
features: [
{ text: 'Unbegrenzte Foto-Analysen', included: true },
{ text: 'Detaillierte Mikronährstoffe', included: true },
{ text: 'Wochen- & Monatsstatistiken', included: true },
{ text: 'Unbegrenzte Historie', included: true },
{ text: 'Export als CSV/PDF', included: true },
{ text: 'Prioritäts-Analyse', included: true }
],
cta: {
text: 'Pro werden',
href: '#download'
},
highlighted: true,
badge: 'Beliebt'
},
{
name: 'Family',
price: '12,99',
period: '/Monat',
description: 'Für die ganze Familie',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Bis zu 5 Profile', included: true },
{ text: 'Familien-Dashboard', included: true },
{ text: 'Gemeinsame Mahlzeiten', included: true },
{ text: 'Kinder-Modus', included: true },
{ text: 'Premium-Support', included: true }
],
cta: {
text: 'Family starten',
href: '#download'
}
}
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '10 Foto-Analysen/Tag', included: true },
{ text: 'Basis-Nährwertdaten', included: true },
{ text: 'Tagesübersicht', included: true },
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
{ text: 'Unbegrenzte Analysen', included: false },
{ text: 'Erweiterte Statistiken', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#download',
},
},
{
name: 'Pro',
price: '6,99',
period: '/Monat',
description: 'Für ernsthafte Tracker',
features: [
{ text: 'Unbegrenzte Foto-Analysen', included: true },
{ text: 'Detaillierte Mikronährstoffe', included: true },
{ text: 'Wochen- & Monatsstatistiken', included: true },
{ text: 'Unbegrenzte Historie', included: true },
{ text: 'Export als CSV/PDF', included: true },
{ text: 'Prioritäts-Analyse', included: true },
],
cta: {
text: 'Pro werden',
href: '#download',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Family',
price: '12,99',
period: '/Monat',
description: 'Für die ganze Familie',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Bis zu 5 Profile', included: true },
{ text: 'Familien-Dashboard', included: true },
{ text: 'Gemeinsame Mahlzeiten', included: true },
{ text: 'Kinder-Modus', included: true },
{ text: 'Premium-Support', included: true },
],
cta: {
text: 'Family starten',
href: '#download',
},
},
];
// FAQ data
const faqs = [
{
question: 'Wie genau ist die KI-Analyse?',
answer: 'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.'
},
{
question: 'Welche Nährwerte werden analysiert?',
answer: 'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.'
},
{
question: 'Funktioniert die App auch offline?',
answer: 'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.'
},
{
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
answer: 'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.'
},
{
question: 'Wie werden meine Daten geschützt?',
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.'
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer: 'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.'
}
{
question: 'Wie genau ist die KI-Analyse?',
answer:
'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.',
},
{
question: 'Welche Nährwerte werden analysiert?',
answer:
'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.',
},
{
question: 'Funktioniert die App auch offline?',
answer:
'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.',
},
{
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
answer:
'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.',
},
{
question: 'Wie werden meine Daten geschützt?',
answer:
'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.',
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer:
'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.',
},
];
---
<Layout title="Nutriphi - KI-gestützter Ernährungs-Tracker">
<Navigation />
<Navigation />
<main class="pt-16">
<HeroSection
title="Ernährung tracken war nie einfacher"
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
variant="default"
primaryCta={{
text: 'Jetzt kostenlos starten',
href: '#download'
}}
secondaryCta={{
text: 'Features entdecken',
href: '#features',
variant: 'secondary'
}}
trustBadges={[
{ icon: '📸', text: 'Foto-Analyse' },
{ icon: '🔒', text: 'DSGVO-konform' },
{ icon: '📱', text: 'iOS, Android & Web' }
]}
/>
<main class="pt-16">
<HeroSection
title="Ernährung tracken war nie einfacher"
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
variant="default"
primaryCta={{
text: 'Jetzt kostenlos starten',
href: '#download',
}}
secondaryCta={{
text: 'Features entdecken',
href: '#features',
variant: 'secondary',
}}
trustBadges={[
{ icon: '📸', text: 'Foto-Analyse' },
{ icon: '🔒', text: 'DSGVO-konform' },
{ icon: '📱', text: 'iOS, Android & Web' },
]}
/>
<FeatureSection
id="features"
title="Alles für dein Ernährungstracking"
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
features={features}
columns={3}
variant="cards"
class="bg-[var(--color-background-card)]"
>
<!-- AI Technology Highlight -->
<div class="mt-12 md:mt-16 px-4" slot="highlight">
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
<div class="text-5xl sm:text-6xl">🧠</div>
<div class="flex-1 text-center md:text-left">
<h3 class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3">
Powered by Google Gemini
</h3>
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu analysieren.
Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte mit hoher Genauigkeit.
</p>
</div>
<div class="flex items-center gap-2">
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center">
<span class="text-white font-bold text-sm sm:text-base">AI</span>
</div>
<div class="text-left">
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">85-95%</div>
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
</div>
</div>
</div>
</Card>
</div>
</FeatureSection>
<FeatureSection
id="features"
title="Alles für dein Ernährungstracking"
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
features={features}
columns={3}
variant="cards"
class="bg-[var(--color-background-card)]"
>
<!-- AI Technology Highlight -->
<div class="mt-12 md:mt-16 px-4" slot="highlight">
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
<div class="text-5xl sm:text-6xl">🧠</div>
<div class="flex-1 text-center md:text-left">
<h3
class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3"
>
Powered by Google Gemini
</h3>
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu
analysieren. Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte
mit hoher Genauigkeit.
</p>
</div>
<div class="flex items-center gap-2">
<div
class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center"
>
<span class="text-white font-bold text-sm sm:text-base">AI</span>
</div>
<div class="text-left">
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">
85-95%
</div>
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
</div>
</div>
</div>
</Card>
</div>
</FeatureSection>
<StepsSection
id="how-it-works"
title="In 3 Schritten zum Ziel"
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
steps={steps}
showImages={false}
alternateLayout={true}
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten zum Ziel"
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
steps={steps}
showImages={false}
alternateLayout={true}
/>
<PricingSection
id="pricing"
title="Wähle deinen Plan"
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
plans={pricingPlans}
class="bg-[var(--color-background-card)]"
/>
<PricingSection
id="pricing"
title="Wähle deinen Plan"
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
plans={pricingPlans}
class="bg-[var(--color-background-card)]"
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über Nutriphi wissen musst"
faqs={faqs}
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über Nutriphi wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Starte deine gesunde Ernährungsreise"
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
primaryCta={{ text: 'App herunterladen', href: '#' }}
variant="highlighted"
>
<!-- App Store Buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
</a>
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
</a>
</div>
<CTASection
id="download"
title="Starte deine gesunde Ernährungsreise"
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
primaryCta={{ text: 'App herunterladen', href: '#' }}
variant="highlighted"
>
<!-- App Store Buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
</a>
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
</a>
</div>
<!-- Trust Indicators -->
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
</div>
</div>
</CTASection>
</main>
<!-- Trust Indicators -->
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
</div>
</div>
</CTASection>
</main>
<Footer />
<Footer />
</Layout>

View file

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

View file

@ -1,79 +1,74 @@
{
"expo": {
"name": "nutriphi",
"slug": "nutriphi",
"version": "1.0.0",
"scheme": "nutriphi",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-dev-launcher",
{
"launchMode": "most-recent"
}
],
[
"expo-camera",
{
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tilljs.nutriphi",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.nutriphi",
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
]
},
"extra": {
"router": {},
"eas": {
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
}
}
}
"expo": {
"name": "nutriphi",
"slug": "nutriphi",
"version": "1.0.0",
"scheme": "nutriphi",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-dev-launcher",
{
"launchMode": "most-recent"
}
],
[
"expo-camera",
{
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tilljs.nutriphi",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.nutriphi",
"permissions": ["android.permission.CAMERA", "android.permission.RECORD_AUDIO"]
},
"extra": {
"router": {},
"eas": {
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
}
}
}
}

View file

@ -5,41 +5,42 @@ import { useAppStore } from '../../store/AppStore';
import { useTheme } from '../../hooks/useTheme';
export default function TabLayout() {
const { showCameraModal, cameraMode } = useAppStore();
const { isDark } = useTheme();
const { showCameraModal, cameraMode } = useAppStore();
const { isDark } = useTheme();
return (
<>
<Tabs
screenOptions={{
tabBarActiveTintColor: '#6366f1',
tabBarStyle: {
backgroundColor: isDark ? '#1f2937' : 'white',
borderTopWidth: 1,
borderTopColor: isDark ? '#374151' : '#e5e7eb',
},
}}>
<Tabs.Screen
name="index"
options={{
title: 'Meals',
tabBarIcon: ({ color }) => (
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Stats',
tabBarIcon: ({ color }) => (
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
),
}}
/>
</Tabs>
return (
<>
<Tabs
screenOptions={{
tabBarActiveTintColor: '#6366f1',
tabBarStyle: {
backgroundColor: isDark ? '#1f2937' : 'white',
borderTopWidth: 1,
borderTopColor: isDark ? '#374151' : '#e5e7eb',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Meals',
tabBarIcon: ({ color }) => (
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Stats',
tabBarIcon: ({ color }) => (
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
),
}}
/>
</Tabs>
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
</>
);
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
</>
);
}

View file

@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `

View file

@ -3,22 +3,22 @@ import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className={styles.container}>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className={styles.container}>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = {
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -8,56 +8,56 @@ import { useEffect } from 'react';
import { PhotoService } from '../services/storage/PhotoService';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: 'index',
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: 'index',
};
export default function RootLayout() {
const { isReady, error } = useDatabase();
const { isReady, error } = useDatabase();
// Initialize theme on app start
useTheme();
// Initialize theme on app start
useTheme();
// Clean up temporary photos when app comes to foreground
useEffect(() => {
const handleAppStateChange = async (nextAppState: string) => {
if (nextAppState === 'active') {
try {
const photoService = PhotoService.getInstance();
await photoService.cleanupTempPhotos();
console.log('Temporary photos cleaned up on app foreground');
} catch (error) {
console.warn('Failed to cleanup temp photos on foreground:', error);
}
}
};
// Clean up temporary photos when app comes to foreground
useEffect(() => {
const handleAppStateChange = async (nextAppState: string) => {
if (nextAppState === 'active') {
try {
const photoService = PhotoService.getInstance();
await photoService.cleanupTempPhotos();
console.log('Temporary photos cleaned up on app foreground');
} catch (error) {
console.warn('Failed to cleanup temp photos on foreground:', error);
}
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, []);
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, []);
if (!isReady) {
return (
<View className="flex-1 items-center justify-center bg-white">
{error ? (
<View className="items-center space-y-4">
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
<Text className="px-4 text-center text-gray-600">{error}</Text>
</View>
) : (
<View className="items-center space-y-4">
<ActivityIndicator size="large" color="#6366f1" />
<Text className="text-gray-600">Initializing Nutriphi...</Text>
</View>
)}
</View>
);
}
if (!isReady) {
return (
<View className="flex-1 items-center justify-center bg-white">
{error ? (
<View className="items-center space-y-4">
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
<Text className="px-4 text-center text-gray-600">{error}</Text>
</View>
) : (
<View className="items-center space-y-4">
<ActivityIndicator size="large" color="#6366f1" />
<Text className="text-gray-600">Initializing Nutriphi...</Text>
</View>
)}
</View>
);
}
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
);
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
);
}

View file

@ -8,45 +8,45 @@ import { MealWithItems } from '../types/Database';
import { useAppStore } from '../store/AppStore';
export default function Home() {
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
const handleMealPress = (meal: MealWithItems) => {
router.push(`/meal/${meal.id}`);
};
const handleMealPress = (meal: MealWithItems) => {
router.push(`/meal/${meal.id}`);
};
const handleCameraPress = () => {
toggleCameraModal(true, 'camera');
};
const handleCameraPress = () => {
toggleCameraModal(true, 'camera');
};
const handleGalleryPress = () => {
toggleCameraModal(true, 'gallery');
};
const handleGalleryPress = () => {
toggleCameraModal(true, 'gallery');
};
return (
<>
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
<MealList onMealPress={handleMealPress} />
return (
<>
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
<MealList onMealPress={handleMealPress} />
{/* Camera Button (larger, centered) */}
<FloatingActionButton
onPress={handleCameraPress}
sfSymbol="camera"
fallbackIcon="camera"
size="large"
position="center"
/>
{/* Camera Button (larger, centered) */}
<FloatingActionButton
onPress={handleCameraPress}
sfSymbol="camera"
fallbackIcon="camera"
size="large"
position="center"
/>
{/* Gallery Button (smaller, right) */}
<FloatingActionButton
onPress={handleGalleryPress}
sfSymbol="photo"
fallbackIcon="image"
size="normal"
position="right"
/>
</SafeAreaView>
{/* Gallery Button (smaller, right) */}
<FloatingActionButton
onPress={handleGalleryPress}
sfSymbol="photo"
fallbackIcon="image"
size="normal"
position="right"
/>
</SafeAreaView>
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
</>
);
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
</>
);
}

View file

@ -9,245 +9,246 @@ import { FoodItemList } from '@/components/meals/FoodItemList';
import { AnalysisStatusIndicator } from '@/components/meals/AnalysisStatusIndicator';
export default function MealDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { selectedMeal, loadMealById, isLoading } = useMealStore();
const [imageError, setImageError] = useState(false);
const { id } = useLocalSearchParams<{ id: string }>();
const { selectedMeal, loadMealById, isLoading } = useMealStore();
const [imageError, setImageError] = useState(false);
useEffect(() => {
if (id) {
loadMealById(parseInt(id));
setImageError(false); // Reset image error state when loading new meal
}
}, [id, loadMealById]);
useEffect(() => {
if (id) {
loadMealById(parseInt(id));
setImageError(false); // Reset image error state when loading new meal
}
}, [id, loadMealById]);
// Poll for updates if analysis is pending
useEffect(() => {
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
return;
}
// Poll for updates if analysis is pending
useEffect(() => {
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
return;
}
// Poll every 2 seconds
const interval = setInterval(() => {
loadMealById(selectedMeal.id);
}, 2000);
// Poll every 2 seconds
const interval = setInterval(() => {
loadMealById(selectedMeal.id);
}, 2000);
return () => clearInterval(interval);
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
return () => clearInterval(interval);
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
// Add debug logging when component renders
useEffect(() => {
console.log(
'Meal detail component rendered with selectedMeal:',
selectedMeal?.id,
'photo_path:',
selectedMeal?.photo_path
);
}, [selectedMeal]);
// Add debug logging when component renders
useEffect(() => {
console.log(
'Meal detail component rendered with selectedMeal:',
selectedMeal?.id,
'photo_path:',
selectedMeal?.photo_path
);
}, [selectedMeal]);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-white">
<LoadingSpinner />
</View>
);
}
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-white">
<LoadingSpinner />
</View>
);
}
if (!selectedMeal) {
return (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
</View>
);
}
if (!selectedMeal) {
return (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
</View>
);
}
const generateMealTitle = (meal: any): string => {
if (meal.food_items && meal.food_items.length > 0) {
const foodNames = meal.food_items.map((item: any) => item.name);
const generateMealTitle = (meal: any): string => {
if (meal.food_items && meal.food_items.length > 0) {
const foodNames = meal.food_items.map((item: any) => item.name);
if (foodNames.length === 1) {
return foodNames[0];
} else if (foodNames.length === 2) {
return `${foodNames[0]} & ${foodNames[1]}`;
} else if (foodNames.length > 2) {
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
}
}
if (foodNames.length === 1) {
return foodNames[0];
} else if (foodNames.length === 2) {
return `${foodNames[0]} & ${foodNames[1]}`;
} else if (foodNames.length > 2) {
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
}
}
// Fallback to meal type if no food items
return getMealTypeLabel(meal.meal_type);
};
// Fallback to meal type if no food items
return getMealTypeLabel(meal.meal_type);
};
const formatDate = (timestamp: string) => {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDate = (timestamp: string) => {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return 'sunny-outline';
case 'lunch':
return 'restaurant-outline';
case 'dinner':
return 'moon-outline';
case 'snack':
return 'cafe-outline';
default:
return 'restaurant-outline';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return 'sunny-outline';
case 'lunch':
return 'restaurant-outline';
case 'dinner':
return 'moon-outline';
case 'snack':
return 'cafe-outline';
default:
return 'restaurant-outline';
}
};
const getMealTypeLabel = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return 'Frühstück';
case 'lunch':
return 'Mittagessen';
case 'dinner':
return 'Abendessen';
case 'snack':
return 'Snack';
default:
return 'Mahlzeit';
}
};
const getMealTypeLabel = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return 'Frühstück';
case 'lunch':
return 'Mittagessen';
case 'dinner':
return 'Abendessen';
case 'snack':
return 'Snack';
default:
return 'Mahlzeit';
}
};
const renderStars = (rating?: number) => {
if (!rating) return null;
const renderStars = (rating?: number) => {
if (!rating) return null;
return (
<View className="flex-row">
{[1, 2, 3, 4, 5].map((star) => (
<Ionicons
key={star}
name={star <= rating ? 'star' : 'star-outline'}
size={20}
color={star <= rating ? '#fbbf24' : '#d1d5db'}
/>
))}
</View>
);
};
return (
<View className="flex-row">
{[1, 2, 3, 4, 5].map((star) => (
<Ionicons
key={star}
name={star <= rating ? 'star' : 'star-outline'}
size={20}
color={star <= rating ? '#fbbf24' : '#d1d5db'}
/>
))}
</View>
);
};
return (
<ScrollView className="flex-1 bg-white">
{/* Header */}
<View className="relative">
<TouchableOpacity
onPress={() => router.back()}
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2">
<Ionicons name="arrow-back" size={24} color="white" />
</TouchableOpacity>
return (
<ScrollView className="flex-1 bg-white">
{/* Header */}
<View className="relative">
<TouchableOpacity
onPress={() => router.back()}
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2"
>
<Ionicons name="arrow-back" size={24} color="white" />
</TouchableOpacity>
{/* Photo */}
<View className="h-80 bg-gray-200">
{selectedMeal.photo_path && !imageError ? (
<Image
source={{ uri: selectedMeal.photo_path }}
className="h-full w-full"
resizeMode="cover"
onError={(error) => {
console.error('Detail page image loading error:', error);
console.log('Detail page photo_path:', selectedMeal.photo_path);
setImageError(true);
}}
onLoad={() => {
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
}}
/>
) : (
<View className="flex-1 items-center justify-center">
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
<Text className="mt-2 text-sm text-gray-500">
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
</Text>
</View>
)}
</View>
</View>
{/* Photo */}
<View className="h-80 bg-gray-200">
{selectedMeal.photo_path && !imageError ? (
<Image
source={{ uri: selectedMeal.photo_path }}
className="h-full w-full"
resizeMode="cover"
onError={(error) => {
console.error('Detail page image loading error:', error);
console.log('Detail page photo_path:', selectedMeal.photo_path);
setImageError(true);
}}
onLoad={() => {
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
}}
/>
) : (
<View className="flex-1 items-center justify-center">
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
<Text className="mt-2 text-sm text-gray-500">
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
</Text>
</View>
)}
</View>
</View>
{/* Content */}
<View className="p-4">
{/* Meal Title and Rating */}
<View className="mb-2 flex-row items-start justify-between">
<View className="flex-1">
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
{generateMealTitle(selectedMeal)}
</Text>
</View>
{selectedMeal.user_rating && (
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
)}
</View>
{/* Content */}
<View className="p-4">
{/* Meal Title and Rating */}
<View className="mb-2 flex-row items-start justify-between">
<View className="flex-1">
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
{generateMealTitle(selectedMeal)}
</Text>
</View>
{selectedMeal.user_rating && (
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
)}
</View>
{/* Meal Type and Date */}
<View className="mb-6 flex-row items-center">
<Ionicons
name={getMealTypeIcon(selectedMeal.meal_type)}
size={20}
color="#6b7280"
style={{ marginRight: 6 }}
/>
<Text className="text-base text-gray-600">
{getMealTypeLabel(selectedMeal.meal_type)}
</Text>
<Text className="mx-2 text-gray-400"></Text>
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
</View>
{/* Meal Type and Date */}
<View className="mb-6 flex-row items-center">
<Ionicons
name={getMealTypeIcon(selectedMeal.meal_type)}
size={20}
color="#6b7280"
style={{ marginRight: 6 }}
/>
<Text className="text-base text-gray-600">
{getMealTypeLabel(selectedMeal.meal_type)}
</Text>
<Text className="mx-2 text-gray-400"></Text>
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
</View>
{/* Location */}
{selectedMeal.location && (
<View className="mb-6 flex-row items-center">
<Ionicons name="location-outline" size={20} color="#6b7280" />
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
</View>
)}
{/* Location */}
{selectedMeal.location && (
<View className="mb-6 flex-row items-center">
<Ionicons name="location-outline" size={20} color="#6b7280" />
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
</View>
)}
{/* Nutrition Overview */}
{selectedMeal.analysis_status === 'completed' && (
<View className="mb-6">
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
<NutritionBar meal={selectedMeal} showDetailed={true} />
</View>
)}
{/* Nutrition Overview */}
{selectedMeal.analysis_status === 'completed' && (
<View className="mb-6">
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
<NutritionBar meal={selectedMeal} showDetailed={true} />
</View>
)}
{/* Analysis Status */}
<View className="mb-6">
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
</View>
{/* Analysis Status */}
<View className="mb-6">
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
</View>
{/* Food Items */}
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
<View className="mb-6">
<FoodItemList foodItems={selectedMeal.food_items} />
</View>
)}
{/* Food Items */}
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
<View className="mb-6">
<FoodItemList foodItems={selectedMeal.food_items} />
</View>
)}
{/* User Notes */}
{selectedMeal.user_notes && (
<View className="mb-6">
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
<View className="rounded-lg bg-blue-50 p-3">
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
</View>
</View>
)}
{/* User Notes */}
{selectedMeal.user_notes && (
<View className="mb-6">
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
<View className="rounded-lg bg-blue-50 p-3">
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
</View>
</View>
)}
{/* Analysis Confidence */}
{selectedMeal.analysis_confidence && (
<View className="mb-6">
<Text className="text-sm text-gray-600">
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
</Text>
</View>
)}
</View>
</ScrollView>
);
{/* Analysis Confidence */}
{selectedMeal.analysis_confidence && (
<View className="mb-6">
<Text className="text-sm text-gray-600">
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
</Text>
</View>
)}
</View>
</ScrollView>
);
}

View file

@ -4,10 +4,10 @@ import { Platform } from 'react-native';
import { ScreenContent } from '~/components/ScreenContent';
export default function Modal() {
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
}

View file

@ -9,288 +9,294 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
import LoadingOverlay from '../components/ui/LoadingOverlay';
export default function Settings() {
const { theme, updateTheme } = useTheme();
const [isClearing, setIsClearing] = useState(false);
const [locationEnabled, setLocationEnabled] = useState(true);
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
const { theme, updateTheme } = useTheme();
const [isClearing, setIsClearing] = useState(false);
const [locationEnabled, setLocationEnabled] = useState(true);
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
const themeOptions = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '📱' },
];
const themeOptions = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '📱' },
];
useEffect(() => {
loadPreferences();
}, []);
useEffect(() => {
loadPreferences();
}, []);
const loadPreferences = async () => {
try {
const prefsService = UserPreferencesService.getInstance();
const prefs = await prefsService.getPreferences();
setLocationEnabled(prefs.locationEnabled);
} catch (error) {
console.error('Failed to load preferences:', error);
} finally {
setIsLoadingPrefs(false);
}
};
const loadPreferences = async () => {
try {
const prefsService = UserPreferencesService.getInstance();
const prefs = await prefsService.getPreferences();
setLocationEnabled(prefs.locationEnabled);
} catch (error) {
console.error('Failed to load preferences:', error);
} finally {
setIsLoadingPrefs(false);
}
};
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
updateTheme(selectedTheme);
};
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
updateTheme(selectedTheme);
};
const handleLocationToggle = async (value: boolean) => {
setLocationEnabled(value);
try {
const prefsService = UserPreferencesService.getInstance();
await prefsService.setLocationEnabled(value);
} catch (error) {
console.error('Failed to update location preference:', error);
// Revert on error
setLocationEnabled(!value);
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
}
};
const handleLocationToggle = async (value: boolean) => {
setLocationEnabled(value);
try {
const prefsService = UserPreferencesService.getInstance();
await prefsService.setLocationEnabled(value);
} catch (error) {
console.error('Failed to update location preference:', error);
// Revert on error
setLocationEnabled(!value);
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
}
};
const openAppSettings = () => {
Linking.openSettings();
};
const openAppSettings = () => {
Linking.openSettings();
};
const handleDeleteAllData = () => {
Alert.alert(
'Alle Daten löschen',
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
[
{
text: 'Abbrechen',
style: 'cancel',
},
{
text: 'Alles löschen',
style: 'destructive',
onPress: confirmDeleteAllData,
},
]
);
};
const handleDeleteAllData = () => {
Alert.alert(
'Alle Daten löschen',
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
[
{
text: 'Abbrechen',
style: 'cancel',
},
{
text: 'Alles löschen',
style: 'destructive',
onPress: confirmDeleteAllData,
},
]
);
};
const confirmDeleteAllData = async () => {
setIsClearing(true);
const confirmDeleteAllData = async () => {
setIsClearing(true);
try {
const dataClearingService = DataClearingService.getInstance();
const result = await dataClearingService.clearAllData();
try {
const dataClearingService = DataClearingService.getInstance();
const result = await dataClearingService.clearAllData();
if (result.success) {
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
{
text: 'OK',
onPress: () => router.replace('/(tabs)'),
},
]);
} else {
Alert.alert(
'Teilweise erfolgreich',
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
[{ text: 'OK' }]
);
}
} catch (error) {
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
{ text: 'OK' },
]);
} finally {
setIsClearing(false);
}
};
if (result.success) {
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
{
text: 'OK',
onPress: () => router.replace('/(tabs)'),
},
]);
} else {
Alert.alert(
'Teilweise erfolgreich',
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
[{ text: 'OK' }]
);
}
} catch (error) {
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
{ text: 'OK' },
]);
} finally {
setIsClearing(false);
}
};
return (
<>
<Stack.Screen
options={{
title: 'Settings',
headerShown: true,
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()} className="p-2">
<Text className="text-lg"></Text>
</TouchableOpacity>
),
}}
/>
return (
<>
<Stack.Screen
options={{
title: 'Settings',
headerShown: true,
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()} className="p-2">
<Text className="text-lg"></Text>
</TouchableOpacity>
),
}}
/>
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
<ScrollView className="flex-1">
{/* App Info Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
App Info
</Text>
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
<ScrollView className="flex-1">
{/* App Info Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
App Info
</Text>
<View className="space-y-3">
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
</View>
<View className="space-y-3">
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
</View>
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
<Text className="text-sm text-gray-600 dark:text-gray-300">
Track your nutrition with AI-powered meal analysis
</Text>
</View>
</View>
</View>
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
<Text className="text-sm text-gray-600 dark:text-gray-300">
Track your nutrition with AI-powered meal analysis
</Text>
</View>
</View>
</View>
{/* Theme Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Appearance
</Text>
{/* Theme Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Appearance
</Text>
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
<View className="space-y-2">
{themeOptions.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
theme === option.value
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
}`}>
<View className="flex-row items-center">
<Text className="mr-3 text-lg">{option.icon}</Text>
<Text
className={`font-medium ${
theme === option.value
? 'text-indigo-700 dark:text-indigo-300'
: 'text-gray-900 dark:text-white'
}`}>
{option.label}
</Text>
</View>
<View className="space-y-2">
{themeOptions.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
theme === option.value
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
}`}
>
<View className="flex-row items-center">
<Text className="mr-3 text-lg">{option.icon}</Text>
<Text
className={`font-medium ${
theme === option.value
? 'text-indigo-700 dark:text-indigo-300'
: 'text-gray-900 dark:text-white'
}`}
>
{option.label}
</Text>
</View>
{theme === option.value && (
<Text className="text-lg text-indigo-500 dark:text-indigo-400"></Text>
)}
</TouchableOpacity>
))}
</View>
</View>
{theme === option.value && (
<Text className="text-lg text-indigo-500 dark:text-indigo-400"></Text>
)}
</TouchableOpacity>
))}
</View>
</View>
{/* Privacy & Location Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Privatsphäre & Standort
</Text>
{/* Privacy & Location Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Privatsphäre & Standort
</Text>
{/* Location Toggle */}
<View className="mb-4 flex-row items-center justify-between">
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons name="location-outline" size={20} color="#6b7280" />
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
Standort speichern
</Text>
</View>
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
</Text>
</View>
<Switch
value={locationEnabled}
onValueChange={handleLocationToggle}
disabled={isLoadingPrefs}
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
ios_backgroundColor="#d1d5db"
/>
</View>
{/* Location Toggle */}
<View className="mb-4 flex-row items-center justify-between">
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons name="location-outline" size={20} color="#6b7280" />
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
Standort speichern
</Text>
</View>
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
</Text>
</View>
<Switch
value={locationEnabled}
onValueChange={handleLocationToggle}
disabled={isLoadingPrefs}
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
ios_backgroundColor="#d1d5db"
/>
</View>
{/* App Settings Link */}
<TouchableOpacity
onPress={openAppSettings}
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600">
<View className="flex-1">
<Text className="font-medium text-gray-900 dark:text-white">
App-Berechtigungen
</Text>
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
Verwalte Kamera- und Standortberechtigungen
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{/* App Settings Link */}
<TouchableOpacity
onPress={openAppSettings}
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600"
>
<View className="flex-1">
<Text className="font-medium text-gray-900 dark:text-white">
App-Berechtigungen
</Text>
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
Verwalte Kamera- und Standortberechtigungen
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{/* Data Management Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Datenverwaltung
</Text>
{/* Data Management Section */}
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
Datenverwaltung
</Text>
<TouchableOpacity
onPress={handleDeleteAllData}
disabled={isClearing}
className={`rounded-lg p-4 ${
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
}`}>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text
className={`font-medium ${
isClearing
? 'text-gray-400 dark:text-gray-500'
: 'text-red-700 dark:text-red-300'
}`}>
Alle Daten löschen
</Text>
<Text
className={`mt-1 text-sm ${
isClearing
? 'text-gray-400 dark:text-gray-500'
: 'text-red-600 dark:text-red-400'
}`}>
Löscht alle Mahlzeiten, Fotos und Einstellungen
</Text>
</View>
<TouchableOpacity
onPress={handleDeleteAllData}
disabled={isClearing}
className={`rounded-lg p-4 ${
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text
className={`font-medium ${
isClearing
? 'text-gray-400 dark:text-gray-500'
: 'text-red-700 dark:text-red-300'
}`}
>
Alle Daten löschen
</Text>
<Text
className={`mt-1 text-sm ${
isClearing
? 'text-gray-400 dark:text-gray-500'
: 'text-red-600 dark:text-red-400'
}`}
>
Löscht alle Mahlzeiten, Fotos und Einstellungen
</Text>
</View>
{isClearing ? (
<Text className="ml-3 text-gray-400 dark:text-gray-500"></Text>
) : (
<Text className="ml-3 text-red-500 dark:text-red-400">🗑</Text>
)}
</View>
</TouchableOpacity>
{isClearing ? (
<Text className="ml-3 text-gray-400 dark:text-gray-500"></Text>
) : (
<Text className="ml-3 text-red-500 dark:text-red-400">🗑</Text>
)}
</View>
</TouchableOpacity>
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
Diese Aktion kann nicht rückgängig gemacht werden
</Text>
</View>
</View>
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
Diese Aktion kann nicht rückgängig gemacht werden
</Text>
</View>
</View>
{/* Footer */}
<View className="mx-4 mb-4 mt-8">
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
Made with for better nutrition tracking
</Text>
</View>
</ScrollView>
{/* Footer */}
<View className="mx-4 mb-4 mt-8">
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
Made with for better nutrition tracking
</Text>
</View>
</ScrollView>
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
</SafeAreaView>
</>
);
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
</SafeAreaView>
</>
);
}

View file

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

View file

@ -1,44 +1,44 @@
{
"cesVersion": "2.18.3",
"projectName": "nutriphi",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "tabs"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "zustand",
"type": "state-management"
},
{
"name": "mana-core-auth",
"type": "authentication"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "npm",
"eas": true,
"publish": false
},
"packageManager": {
"type": "npm",
"version": "10.8.2"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.1.0"
}
"cesVersion": "2.18.3",
"projectName": "nutriphi",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "tabs"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "zustand",
"type": "state-management"
},
{
"name": "mana-core-auth",
"type": "authentication"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "npm",
"eas": true,
"publish": false
},
"packageManager": {
"type": "npm",
"version": "10.8.2"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.1.0"
}
}

View file

@ -2,23 +2,24 @@ import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}
>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
Button.displayName = 'Button';
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View file

@ -1,9 +1,9 @@
import { SafeAreaView } from 'react-native';
export const Container = ({ children }: { children: React.ReactNode }) => {
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
};
const styles = {
container: 'flex flex-1 m-6',
container: 'flex flex-1 m-6',
};

View file

@ -1,29 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -3,31 +3,31 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Pressable, StyleSheet } from 'react-native';
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
);
HeaderButton.displayName = 'HeaderButton';
export const styles = StyleSheet.create({
headerRight: {
marginRight: 15,
},
headerRight: {
marginRight: 15,
},
});

View file

@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -2,25 +2,25 @@ import { StyleSheet } from 'react-native';
import { SFSymbol } from './ui/SFSymbol';
interface TabBarIconProps {
sfSymbol: string;
fallbackIcon: string;
color: string;
sfSymbol: string;
fallbackIcon: string;
color: string;
}
export const TabBarIcon = ({ sfSymbol, fallbackIcon, color }: TabBarIconProps) => {
return (
<SFSymbol
name={sfSymbol}
fallbackIcon={fallbackIcon as any}
color={color}
size={24}
style={styles.tabBarIcon}
/>
);
return (
<SFSymbol
name={sfSymbol}
fallbackIcon={fallbackIcon as any}
color={color}
size={24}
style={styles.tabBarIcon}
/>
);
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
tabBarIcon: {
marginBottom: -3,
},
});

View file

@ -17,404 +17,407 @@ import { UserPreferencesService } from '../../services/UserPreferencesService';
import { LocationPermissionModal } from '../location/LocationPermissionModal';
interface CameraModalProps {
mode: 'camera' | 'gallery';
mode: 'camera' | 'gallery';
}
export const CameraModal: React.FC<CameraModalProps> = ({ mode }) => {
const [capturedPhoto, setCapturedPhoto] = useState<{
uri: string;
path: string;
size: number;
dimensions: any;
} | null>(null);
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
const [showLocationPermission, setShowLocationPermission] = useState(false);
const [capturedPhoto, setCapturedPhoto] = useState<{
uri: string;
path: string;
size: number;
dimensions: any;
} | null>(null);
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
const [showLocationPermission, setShowLocationPermission] = useState(false);
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
const {
hasPermission,
canAskPermission,
requestPermission,
isReady,
setIsReady,
isCapturing,
facing,
cameraRef,
toggleCameraFacing,
takePicture,
pickImageFromGallery,
} = useCamera();
const {
hasPermission,
canAskPermission,
requestPermission,
isReady,
setIsReady,
isCapturing,
facing,
cameraRef,
toggleCameraFacing,
takePicture,
pickImageFromGallery,
} = useCamera();
const handleClose = () => {
setCapturedPhoto(null);
setIsGalleryLoading(false);
toggleCameraModal(false);
};
const handleClose = () => {
setCapturedPhoto(null);
setIsGalleryLoading(false);
toggleCameraModal(false);
};
const handleTakePicture = async () => {
try {
const photo = await takePicture();
if (photo) {
setCapturedPhoto(photo);
}
} catch (error) {
console.error('Failed to take picture:', error);
// TODO: Show error toast
}
};
const handleTakePicture = async () => {
try {
const photo = await takePicture();
if (photo) {
setCapturedPhoto(photo);
}
} catch (error) {
console.error('Failed to take picture:', error);
// TODO: Show error toast
}
};
const handleRetake = () => {
setCapturedPhoto(null);
};
const handleRetake = () => {
setCapturedPhoto(null);
};
const handleLocationPermissionAllow = async () => {
const prefsService = UserPreferencesService.getInstance();
const locationService = LocationService.getInstance();
const handleLocationPermissionAllow = async () => {
const prefsService = UserPreferencesService.getInstance();
const locationService = LocationService.getInstance();
// Mark that we've asked
await prefsService.markLocationPermissionAsked();
// Mark that we've asked
await prefsService.markLocationPermissionAsked();
// Request permission
const granted = await locationService.requestPermissions();
// Request permission
const granted = await locationService.requestPermissions();
if (granted) {
await prefsService.setLocationEnabled(true);
} else {
await prefsService.setLocationEnabled(false);
}
if (granted) {
await prefsService.setLocationEnabled(true);
} else {
await prefsService.setLocationEnabled(false);
}
setShowLocationPermission(false);
setShowLocationPermission(false);
// Continue with photo processing
if (capturedPhoto) {
handleUsePhoto();
}
};
// Continue with photo processing
if (capturedPhoto) {
handleUsePhoto();
}
};
const handleLocationPermissionDeny = async () => {
const prefsService = UserPreferencesService.getInstance();
const handleLocationPermissionDeny = async () => {
const prefsService = UserPreferencesService.getInstance();
// Mark that we've asked and user denied
await prefsService.markLocationPermissionAsked();
await prefsService.setLocationEnabled(false);
// Mark that we've asked and user denied
await prefsService.markLocationPermissionAsked();
await prefsService.setLocationEnabled(false);
setShowLocationPermission(false);
setShowLocationPermission(false);
// Continue with photo processing without location
if (capturedPhoto) {
handleUsePhoto();
}
};
// Continue with photo processing without location
if (capturedPhoto) {
handleUsePhoto();
}
};
// Auto-trigger gallery picker when mode is 'gallery'
React.useEffect(() => {
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
const pickFromGallery = async () => {
try {
setIsGalleryLoading(true);
const photo = await pickImageFromGallery();
if (photo) {
setCapturedPhoto(photo);
} else {
// User cancelled, close modal
toggleCameraModal(false);
}
} catch (error) {
console.error('Failed to pick image from gallery:', error);
toggleCameraModal(false);
} finally {
setIsGalleryLoading(false);
}
};
pickFromGallery();
}
}, [
showCameraModal,
mode,
capturedPhoto,
isGalleryLoading,
pickImageFromGallery,
toggleCameraModal,
]);
// Auto-trigger gallery picker when mode is 'gallery'
React.useEffect(() => {
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
const pickFromGallery = async () => {
try {
setIsGalleryLoading(true);
const photo = await pickImageFromGallery();
if (photo) {
setCapturedPhoto(photo);
} else {
// User cancelled, close modal
toggleCameraModal(false);
}
} catch (error) {
console.error('Failed to pick image from gallery:', error);
toggleCameraModal(false);
} finally {
setIsGalleryLoading(false);
}
};
pickFromGallery();
}
}, [
showCameraModal,
mode,
capturedPhoto,
isGalleryLoading,
pickImageFromGallery,
toggleCameraModal,
]);
const handleUsePhoto = async () => {
if (!capturedPhoto) return;
const handleUsePhoto = async () => {
if (!capturedPhoto) return;
try {
setPhotoProcessing(true);
try {
setPhotoProcessing(true);
// Check location preferences and permissions
let locationInfo: any = {};
try {
const prefsService = UserPreferencesService.getInstance();
const locationEnabled = await prefsService.isLocationEnabled();
// Check location preferences and permissions
let locationInfo: any = {};
if (locationEnabled) {
const locationService = LocationService.getInstance();
try {
const prefsService = UserPreferencesService.getInstance();
const locationEnabled = await prefsService.isLocationEnabled();
// Check if we need to ask for permission first time
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
if (!hasAskedBefore) {
const hasPermission = await locationService.checkPermissions();
if (!hasPermission) {
// Show permission modal
setShowLocationPermission(true);
setPhotoProcessing(false);
return; // Wait for user response
}
}
if (locationEnabled) {
const locationService = LocationService.getInstance();
// Get location
try {
const locationData = await locationService.getCurrentLocation();
if (locationData && locationData.latitude && locationData.longitude) {
locationInfo = {
latitude: locationData.latitude,
longitude: locationData.longitude,
location_accuracy: locationData.accuracy,
location: locationData.address
? locationService.formatLocationForDisplay(locationData.address)
: undefined,
};
console.log('Location captured:', locationInfo);
}
} catch (locationError) {
console.warn('Failed to get location:', locationError);
// Continue without location
}
}
} catch (prefsError) {
console.error('Failed to check location preferences:', prefsError);
// Continue without location
}
// Check if we need to ask for permission first time
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
if (!hasAskedBefore) {
const hasPermission = await locationService.checkPermissions();
if (!hasPermission) {
// Show permission modal
setShowLocationPermission(true);
setPhotoProcessing(false);
return; // Wait for user response
}
}
// Create meal record with initial data including location
const mealId = await createMeal({
photo_path: capturedPhoto.path,
photo_size: capturedPhoto.size,
photo_dimensions: capturedPhoto.dimensions,
meal_type: 'lunch', // Default, will be updated by AI
analysis_status: 'pending',
...locationInfo,
});
// Get location
try {
const locationData = await locationService.getCurrentLocation();
if (locationData && locationData.latitude && locationData.longitude) {
locationInfo = {
latitude: locationData.latitude,
longitude: locationData.longitude,
location_accuracy: locationData.accuracy,
location: locationData.address
? locationService.formatLocationForDisplay(locationData.address)
: undefined,
};
console.log('Location captured:', locationInfo);
}
} catch (locationError) {
console.warn('Failed to get location:', locationError);
// Continue without location
}
}
} catch (prefsError) {
console.error('Failed to check location preferences:', prefsError);
// Continue without location
}
console.log('Meal created with ID:', mealId);
// Create meal record with initial data including location
const mealId = await createMeal({
photo_path: capturedPhoto.path,
photo_size: capturedPhoto.size,
photo_dimensions: capturedPhoto.dimensions,
meal_type: 'lunch', // Default, will be updated by AI
analysis_status: 'pending',
...locationInfo,
});
// Convert temporary photo to permanent storage
const photoService = PhotoService.getInstance();
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
console.log('Meal created with ID:', mealId);
// Update meal record with permanent photo path
await updateMeal(mealId, {
photo_path: permanentPhoto.path,
photo_size: permanentPhoto.size,
photo_dimensions: permanentPhoto.dimensions,
});
// Convert temporary photo to permanent storage
const photoService = PhotoService.getInstance();
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
console.log('Photo converted to permanent storage:', permanentPhoto.path);
// Update meal record with permanent photo path
await updateMeal(mealId, {
photo_path: permanentPhoto.path,
photo_size: permanentPhoto.size,
photo_dimensions: permanentPhoto.dimensions,
});
// Close modal immediately, analysis will happen in background
handleClose();
console.log('Photo converted to permanent storage:', permanentPhoto.path);
// Start AI analysis in background
try {
console.log('Starting Gemini analysis...');
const geminiService = GeminiService.getInstance();
// Close modal immediately, analysis will happen in background
handleClose();
// Get current time for meal type context
const hour = new Date().getHours();
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
// Start AI analysis in background
try {
console.log('Starting Gemini analysis...');
const geminiService = GeminiService.getInstance();
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
else mealTypeContext = 'snack';
// Get current time for meal type context
const hour = new Date().getHours();
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
mealType: mealTypeContext,
});
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
else mealTypeContext = 'snack';
console.log('Gemini analysis completed:', analysisResult);
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
mealType: mealTypeContext,
});
// Update meal with AI analysis results
await updateMeal(mealId, {
// Aggregate nutrition data
total_calories: analysisResult.meal_analysis.total_calories,
total_protein: analysisResult.meal_analysis.total_protein,
total_carbs: analysisResult.meal_analysis.total_carbs,
total_fat: analysisResult.meal_analysis.total_fat,
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
console.log('Gemini analysis completed:', analysisResult);
// Health assessment
health_score: analysisResult.meal_analysis.health_score,
health_category: analysisResult.meal_analysis.health_category,
// Update meal with AI analysis results
await updateMeal(mealId, {
// Aggregate nutrition data
total_calories: analysisResult.meal_analysis.total_calories,
total_protein: analysisResult.meal_analysis.total_protein,
total_carbs: analysisResult.meal_analysis.total_carbs,
total_fat: analysisResult.meal_analysis.total_fat,
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
// AI metadata
analysis_result: JSON.stringify(analysisResult),
analysis_confidence: analysisResult.meal_analysis.confidence,
analysis_status: 'completed',
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
// Health assessment
health_score: analysisResult.meal_analysis.health_score,
health_category: analysisResult.meal_analysis.health_category,
// API metadata
api_provider: 'gemini',
processing_time: analysisResult._metadata?.processingTime || 0,
});
// AI metadata
analysis_result: JSON.stringify(analysisResult),
analysis_confidence: analysisResult.meal_analysis.confidence,
analysis_status: 'completed',
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
// Create all food items in a single batch
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
meal_id: mealId,
name: item.name,
category: item.category,
portion_size: item.portion_size,
calories: item.calories,
protein: item.protein,
carbs: item.carbs,
fat: item.fat,
fiber: item.fiber || 0,
sugar: item.sugar || 0,
confidence: item.confidence,
is_organic: item.is_organic ? 1 : 0,
is_processed: item.is_processed ? 1 : 0,
allergens: JSON.stringify(item.allergens || []),
}));
// API metadata
api_provider: 'gemini',
processing_time: analysisResult._metadata?.processingTime || 0,
});
await createFoodItemsBatch(foodItemsToCreate);
// Create all food items in a single batch
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
meal_id: mealId,
name: item.name,
category: item.category,
portion_size: item.portion_size,
calories: item.calories,
protein: item.protein,
carbs: item.carbs,
fat: item.fat,
fiber: item.fiber || 0,
sugar: item.sugar || 0,
confidence: item.confidence,
is_organic: item.is_organic ? 1 : 0,
is_processed: item.is_processed ? 1 : 0,
allergens: JSON.stringify(item.allergens || []),
}));
console.log('Meal analysis completed and saved to database');
} catch (analysisError) {
console.error('AI analysis failed:', analysisError);
await createFoodItemsBatch(foodItemsToCreate);
// Update meal status to failed
await updateMeal(mealId, {
analysis_status: 'failed',
analysis_result: JSON.stringify({
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
timestamp: new Date().toISOString(),
}),
});
}
} catch (error) {
console.error('Failed to save meal:', error);
// TODO: Show error toast
} finally {
setPhotoProcessing(false);
}
};
console.log('Meal analysis completed and saved to database');
} catch (analysisError) {
console.error('AI analysis failed:', analysisError);
const renderPermissionRequest = () => (
<View className="flex-1 items-center justify-center bg-black">
<View className="items-center space-y-6 px-8">
<Text className="text-6xl">📷</Text>
<Text className="text-center text-xl font-semibold text-white">
Camera Permission Required
</Text>
<Text className="text-center text-gray-300">
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
</Text>
// Update meal status to failed
await updateMeal(mealId, {
analysis_status: 'failed',
analysis_result: JSON.stringify({
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
timestamp: new Date().toISOString(),
}),
});
}
} catch (error) {
console.error('Failed to save meal:', error);
// TODO: Show error toast
} finally {
setPhotoProcessing(false);
}
};
{canAskPermission ? (
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
) : (
<View className="items-center space-y-4">
<Text className="text-center text-sm text-gray-300">
Camera permission was denied. Please enable it in your device settings.
</Text>
<Button title="Close" onPress={handleClose} className="px-8" />
</View>
)}
</View>
</View>
);
const renderPermissionRequest = () => (
<View className="flex-1 items-center justify-center bg-black">
<View className="items-center space-y-6 px-8">
<Text className="text-6xl">📷</Text>
<Text className="text-center text-xl font-semibold text-white">
Camera Permission Required
</Text>
<Text className="text-center text-gray-300">
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
</Text>
const renderCamera = () => (
<View className="flex-1">
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing={facing}
onCameraReady={() => setIsReady(true)}>
{/* Header */}
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
<View className="flex-row items-center justify-between px-6 py-4">
<TouchableOpacity
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
<Text className="text-lg text-white"></Text>
</TouchableOpacity>
{canAskPermission ? (
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
) : (
<View className="items-center space-y-4">
<Text className="text-center text-sm text-gray-300">
Camera permission was denied. Please enable it in your device settings.
</Text>
<Button title="Close" onPress={handleClose} className="px-8" />
</View>
)}
</View>
</View>
);
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
const renderCamera = () => (
<View className="flex-1">
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing={facing}
onCameraReady={() => setIsReady(true)}
>
{/* Header */}
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
<View className="flex-row items-center justify-between px-6 py-4">
<TouchableOpacity
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
>
<Text className="text-lg text-white"></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={toggleCameraFacing}
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
<Text className="text-lg text-white">🔄</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
{/* Bottom Controls */}
<View className="absolute bottom-0 left-0 right-0">
<SafeAreaView className="items-center pb-8">
<View className="items-center space-y-4">
<Text className="px-8 text-center text-sm text-white">
Position your food in the frame and tap the capture button
</Text>
<TouchableOpacity
onPress={toggleCameraFacing}
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
>
<Text className="text-lg text-white">🔄</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
<PhotoButton
onPress={handleTakePicture}
disabled={!isReady}
isCapturing={isCapturing}
/>
</View>
</SafeAreaView>
</View>
</CameraView>
</View>
);
{/* Bottom Controls */}
<View className="absolute bottom-0 left-0 right-0">
<SafeAreaView className="items-center pb-8">
<View className="items-center space-y-4">
<Text className="px-8 text-center text-sm text-white">
Position your food in the frame and tap the capture button
</Text>
if (!showCameraModal) return null;
<PhotoButton
onPress={handleTakePicture}
disabled={!isReady}
isCapturing={isCapturing}
/>
</View>
</SafeAreaView>
</View>
</CameraView>
</View>
);
return (
<>
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
<StatusBar barStyle="light-content" backgroundColor="black" />
if (!showCameraModal) return null;
{capturedPhoto ? (
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
) : mode === 'camera' ? (
hasPermission ? (
renderCamera()
) : (
renderPermissionRequest()
)
) : (
// Gallery mode - show loading while picking or error state
<View className="flex-1 items-center justify-center bg-black">
{isGalleryLoading ? (
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
) : (
<View className="items-center space-y-6 px-8">
<Text className="text-6xl">🖼</Text>
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
<Text className="text-center text-gray-300">
Please wait while we access your photo library...
</Text>
<Button title="Cancel" onPress={handleClose} className="px-8" />
</View>
)}
</View>
)}
</Modal>
return (
<>
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
<StatusBar barStyle="light-content" backgroundColor="black" />
<LocationPermissionModal
visible={showLocationPermission}
onAllow={handleLocationPermissionAllow}
onDeny={handleLocationPermissionDeny}
/>
</>
);
{capturedPhoto ? (
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
) : mode === 'camera' ? (
hasPermission ? (
renderCamera()
) : (
renderPermissionRequest()
)
) : (
// Gallery mode - show loading while picking or error state
<View className="flex-1 items-center justify-center bg-black">
{isGalleryLoading ? (
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
) : (
<View className="items-center space-y-6 px-8">
<Text className="text-6xl">🖼</Text>
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
<Text className="text-center text-gray-300">
Please wait while we access your photo library...
</Text>
<Button title="Cancel" onPress={handleClose} className="px-8" />
</View>
)}
</View>
)}
</Modal>
<LocationPermissionModal
visible={showLocationPermission}
onAllow={handleLocationPermissionAllow}
onDeny={handleLocationPermissionDeny}
/>
</>
);
};

View file

@ -1,75 +1,78 @@
import React from 'react';
import { TouchableOpacity, View, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
} from 'react-native-reanimated';
interface PhotoButtonProps {
onPress: () => void;
disabled?: boolean;
isCapturing?: boolean;
onPress: () => void;
disabled?: boolean;
isCapturing?: boolean;
}
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
export const PhotoButton: React.FC<PhotoButtonProps> = ({
onPress,
disabled = false,
isCapturing = false,
onPress,
disabled = false,
isCapturing = false,
}) => {
const pressed = useSharedValue(false);
const pressed = useSharedValue(false);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
return {
transform: [{ scale: withSpring(scale) }],
};
});
return {
transform: [{ scale: withSpring(scale) }],
};
});
const handlePressIn = () => {
if (!disabled) {
pressed.value = true;
}
};
const handlePressIn = () => {
if (!disabled) {
pressed.value = true;
}
};
const handlePressOut = () => {
pressed.value = false;
};
const handlePressOut = () => {
pressed.value = false;
};
return (
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || isCapturing}
activeOpacity={0.8}
style={animatedStyle}
className="items-center justify-center">
{/* Outer Ring */}
<View
className={`
return (
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || isCapturing}
activeOpacity={0.8}
style={animatedStyle}
className="items-center justify-center"
>
{/* Outer Ring */}
<View
className={`
h-20 w-20 items-center justify-center rounded-full border-4
${disabled || isCapturing ? 'border-gray-400' : 'border-white'}
`}>
{/* Inner Circle */}
<View
className={`
`}
>
{/* Inner Circle */}
<View
className={`
h-16 w-16 rounded-full
${disabled || isCapturing ? 'bg-gray-400' : 'bg-white'}
`}>
{isCapturing && (
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
<View className="h-8 w-8 rounded bg-white" />
</View>
)}
</View>
</View>
`}
>
{isCapturing && (
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
<View className="h-8 w-8 rounded bg-white" />
</View>
)}
</View>
</View>
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
</AnimatedTouchableOpacity>
);
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
</AnimatedTouchableOpacity>
);
};

View file

@ -4,60 +4,61 @@ import { Card } from '../ui/Card';
import { Button } from '../Button';
interface PhotoPreviewProps {
uri: string;
onRetake: () => void;
onUse: () => void;
isProcessing?: boolean;
uri: string;
onRetake: () => void;
onUse: () => void;
isProcessing?: boolean;
}
export const PhotoPreview: React.FC<PhotoPreviewProps> = ({
uri,
onRetake,
onUse,
isProcessing = false,
uri,
onRetake,
onUse,
isProcessing = false,
}) => {
return (
<View className="flex-1 bg-black">
{/* Photo */}
<View className="flex-1 justify-center">
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
</View>
return (
<View className="flex-1 bg-black">
{/* Photo */}
<View className="flex-1 justify-center">
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
</View>
{/* Controls */}
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
<Card className="bg-white/90 backdrop-blur">
<View className="space-y-4">
<Text className="text-center text-lg font-semibold text-gray-900">
How does this look?
</Text>
{/* Controls */}
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
<Card className="bg-white/90 backdrop-blur">
<View className="space-y-4">
<Text className="text-center text-lg font-semibold text-gray-900">
How does this look?
</Text>
<Text className="text-center text-sm text-gray-600">
Make sure your food is clearly visible and well-lit for the best analysis results.
</Text>
<Text className="text-center text-sm text-gray-600">
Make sure your food is clearly visible and well-lit for the best analysis results.
</Text>
<View className="flex-row space-x-3">
<TouchableOpacity
onPress={onRetake}
disabled={isProcessing}
className={`
<View className="flex-row space-x-3">
<TouchableOpacity
onPress={onRetake}
disabled={isProcessing}
className={`
flex-1 items-center rounded-lg border-2 px-4 py-3
${isProcessing ? 'border-gray-300 bg-gray-100' : 'border-gray-300 bg-white'}
`}>
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
Retake
</Text>
</TouchableOpacity>
`}
>
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
Retake
</Text>
</TouchableOpacity>
<Button
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
onPress={onUse}
disabled={isProcessing}
className="flex-1"
/>
</View>
</View>
</Card>
</View>
</View>
);
<Button
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
onPress={onUse}
disabled={isProcessing}
className="flex-1"
/>
</View>
</View>
</Card>
</View>
</View>
);
};

View file

@ -4,84 +4,84 @@ import { Ionicons } from '@expo/vector-icons';
import { Button } from '../Button';
interface LocationPermissionModalProps {
visible: boolean;
onAllow: () => void;
onDeny: () => void;
visible: boolean;
onAllow: () => void;
onDeny: () => void;
}
export const LocationPermissionModal: React.FC<LocationPermissionModalProps> = ({
visible,
onAllow,
onDeny,
visible,
onAllow,
onDeny,
}) => {
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
<View className="flex-1 justify-end bg-black/50">
<View className="rounded-t-3xl bg-white p-6 pb-8">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<Ionicons name="location" size={40} color="#3b82f6" />
</View>
</View>
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
<View className="flex-1 justify-end bg-black/50">
<View className="rounded-t-3xl bg-white p-6 pb-8">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<Ionicons name="location" size={40} color="#3b82f6" />
</View>
</View>
{/* Title */}
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
Standort speichern?
</Text>
{/* Title */}
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
Standort speichern?
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
zu geben:
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
zu geben:
</Text>
{/* Benefits */}
<View className="mb-6 space-y-3">
<View className="flex-row items-center">
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Automatische Restaurant-Erkennung
</Text>
</View>
<View className="flex-row items-center">
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Analyse wo du am gesündesten isst
</Text>
</View>
<View className="flex-row items-center">
<Ionicons name="map-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Ernährungstracking auf Reisen
</Text>
</View>
</View>
{/* Benefits */}
<View className="mb-6 space-y-3">
<View className="flex-row items-center">
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Automatische Restaurant-Erkennung
</Text>
</View>
<View className="flex-row items-center">
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Analyse wo du am gesündesten isst
</Text>
</View>
<View className="flex-row items-center">
<Ionicons name="map-outline" size={20} color="#6b7280" />
<Text className="ml-3 flex-1 text-sm text-gray-700">
Ernährungstracking auf Reisen
</Text>
</View>
</View>
{/* Privacy Note */}
<View className="mb-6 rounded-lg bg-gray-50 p-3">
<Text className="text-xs text-gray-600">
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
{'\n'}
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
den Einstellungen deaktiviert werden.
</Text>
</View>
{/* Privacy Note */}
<View className="mb-6 rounded-lg bg-gray-50 p-3">
<Text className="text-xs text-gray-600">
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
{'\n'}
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
den Einstellungen deaktiviert werden.
</Text>
</View>
{/* Buttons */}
<View className="space-y-3">
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
<TouchableOpacity onPress={onDeny} className="py-3">
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
</TouchableOpacity>
</View>
{/* Buttons */}
<View className="space-y-3">
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
<TouchableOpacity onPress={onDeny} className="py-3">
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
</TouchableOpacity>
</View>
{/* Settings hint */}
<Text className="mt-4 text-center text-xs text-gray-500">
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
</Text>
</View>
</View>
</Modal>
);
{/* Settings hint */}
<Text className="mt-4 text-center text-xs text-gray-500">
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
</Text>
</View>
</View>
</Modal>
);
};

View file

@ -4,100 +4,100 @@ import { Ionicons } from '@expo/vector-icons';
import { LoadingSpinner } from '../ui/LoadingSpinner';
interface AnalysisStatusIndicatorProps {
status: 'pending' | 'completed' | 'failed' | 'manual';
mini?: boolean;
status: 'pending' | 'completed' | 'failed' | 'manual';
mini?: boolean;
}
export const AnalysisStatusIndicator: React.FC<AnalysisStatusIndicatorProps> = ({
status,
mini = false,
status,
mini = false,
}) => {
const getStatusConfig = () => {
switch (status) {
case 'pending':
return {
bgColor: 'bg-yellow-100',
textColor: 'text-yellow-800',
icon: null,
text: 'Wird analysiert...',
showSpinner: true,
};
case 'completed':
return {
bgColor: 'bg-green-100',
textColor: 'text-green-800',
icon: 'checkmark-circle' as const,
text: 'Analysiert',
showSpinner: false,
};
case 'failed':
return {
bgColor: 'bg-red-100',
textColor: 'text-red-800',
icon: 'alert-circle' as const,
text: 'Analyse fehlgeschlagen',
showSpinner: false,
};
case 'manual':
return {
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
icon: 'create-outline' as const,
text: 'Manuell',
showSpinner: false,
};
default:
return {
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
icon: 'help-circle-outline' as const,
text: 'Unbekannt',
showSpinner: false,
};
}
};
const getStatusConfig = () => {
switch (status) {
case 'pending':
return {
bgColor: 'bg-yellow-100',
textColor: 'text-yellow-800',
icon: null,
text: 'Wird analysiert...',
showSpinner: true,
};
case 'completed':
return {
bgColor: 'bg-green-100',
textColor: 'text-green-800',
icon: 'checkmark-circle' as const,
text: 'Analysiert',
showSpinner: false,
};
case 'failed':
return {
bgColor: 'bg-red-100',
textColor: 'text-red-800',
icon: 'alert-circle' as const,
text: 'Analyse fehlgeschlagen',
showSpinner: false,
};
case 'manual':
return {
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
icon: 'create-outline' as const,
text: 'Manuell',
showSpinner: false,
};
default:
return {
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
icon: 'help-circle-outline' as const,
text: 'Unbekannt',
showSpinner: false,
};
}
};
const config = getStatusConfig();
const config = getStatusConfig();
if (mini) {
return (
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
<View className="flex-row items-center">
{config.showSpinner ? (
<LoadingSpinner size={12} color="#ca8a04" />
) : (
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
)}
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
</View>
</View>
);
}
if (mini) {
return (
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
<View className="flex-row items-center">
{config.showSpinner ? (
<LoadingSpinner size={12} color="#ca8a04" />
) : (
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
)}
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
</View>
</View>
);
}
return (
<View className={`rounded-lg p-3 ${config.bgColor}`}>
<View className="flex-row items-center">
{config.showSpinner ? (
<LoadingSpinner size={20} color="#ca8a04" />
) : (
config.icon && (
<Ionicons
name={config.icon}
size={20}
color={
config.textColor === 'text-green-800'
? '#166534'
: config.textColor === 'text-red-800'
? '#991b1b'
: config.textColor === 'text-yellow-800'
? '#854d0e'
: '#1f2937'
}
/>
)
)}
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
</View>
</View>
);
return (
<View className={`rounded-lg p-3 ${config.bgColor}`}>
<View className="flex-row items-center">
{config.showSpinner ? (
<LoadingSpinner size={20} color="#ca8a04" />
) : (
config.icon && (
<Ionicons
name={config.icon}
size={20}
color={
config.textColor === 'text-green-800'
? '#166534'
: config.textColor === 'text-red-800'
? '#991b1b'
: config.textColor === 'text-yellow-800'
? '#854d0e'
: '#1f2937'
}
/>
)
)}
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
</View>
</View>
);
};

View file

@ -1,154 +1,157 @@
import React, { useState } from 'react';
import {
Modal,
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
Modal,
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { MealWithItems } from '../../types/Database';
import { useMealStore } from '../../store/MealStore';
interface EditMealModalProps {
meal: MealWithItems | null;
visible: boolean;
onClose: () => void;
meal: MealWithItems | null;
visible: boolean;
onClose: () => void;
}
export const EditMealModal: React.FC<EditMealModalProps> = ({ meal, visible, onClose }) => {
const { updateMeal } = useMealStore();
const [notes, setNotes] = useState(meal?.user_notes || '');
const [rating, setRating] = useState(meal?.user_rating || 0);
const [location, setLocation] = useState(meal?.location || '');
const [isSaving, setIsSaving] = useState(false);
const { updateMeal } = useMealStore();
const [notes, setNotes] = useState(meal?.user_notes || '');
const [rating, setRating] = useState(meal?.user_rating || 0);
const [location, setLocation] = useState(meal?.location || '');
const [isSaving, setIsSaving] = useState(false);
React.useEffect(() => {
if (meal) {
setNotes(meal.user_notes || '');
setRating(meal.user_rating || 0);
setLocation(meal.location || '');
}
}, [meal]);
React.useEffect(() => {
if (meal) {
setNotes(meal.user_notes || '');
setRating(meal.user_rating || 0);
setLocation(meal.location || '');
}
}, [meal]);
const handleSave = async () => {
if (!meal) return;
const handleSave = async () => {
if (!meal) return;
setIsSaving(true);
try {
await updateMeal(meal.id, {
user_notes: notes.trim() || null,
user_rating: rating || null,
location: location.trim() || null,
});
onClose();
} catch (error) {
console.error('Failed to update meal:', error);
} finally {
setIsSaving(false);
}
};
setIsSaving(true);
try {
await updateMeal(meal.id, {
user_notes: notes.trim() || null,
user_rating: rating || null,
location: location.trim() || null,
});
onClose();
} catch (error) {
console.error('Failed to update meal:', error);
} finally {
setIsSaving(false);
}
};
const renderStars = () => {
return (
<View className="flex-row justify-center space-x-2">
{[1, 2, 3, 4, 5].map((star) => (
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
<Ionicons
name={star <= rating ? 'star' : 'star-outline'}
size={32}
color={star <= rating ? '#fbbf24' : '#d1d5db'}
/>
</TouchableOpacity>
))}
</View>
);
};
const renderStars = () => {
return (
<View className="flex-row justify-center space-x-2">
{[1, 2, 3, 4, 5].map((star) => (
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
<Ionicons
name={star <= rating ? 'star' : 'star-outline'}
size={32}
color={star <= rating ? '#fbbf24' : '#d1d5db'}
/>
</TouchableOpacity>
))}
</View>
);
};
if (!meal) return null;
if (!meal) return null;
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white">
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
<TouchableOpacity onPress={onClose} className="p-2">
<Text className="text-base text-blue-600">Abbrechen</Text>
</TouchableOpacity>
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
<Text
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}>
{isSaving ? 'Speichert...' : 'Fertig'}
</Text>
</TouchableOpacity>
</View>
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white"
>
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
<TouchableOpacity onPress={onClose} className="p-2">
<Text className="text-base text-blue-600">Abbrechen</Text>
</TouchableOpacity>
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
<Text
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}
>
{isSaving ? 'Speichert...' : 'Fertig'}
</Text>
</TouchableOpacity>
</View>
{/* Content */}
<ScrollView className="flex-1 p-4">
{/* Rating */}
<View className="mb-6">
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
{renderStars()}
{rating > 0 && (
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
</TouchableOpacity>
)}
</View>
{/* Content */}
<ScrollView className="flex-1 p-4">
{/* Rating */}
<View className="mb-6">
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
{renderStars()}
{rating > 0 && (
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
</TouchableOpacity>
)}
</View>
{/* Location */}
<View className="mb-6">
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
<TextInput
value={location}
onChangeText={setLocation}
placeholder="z.B. Restaurant, Zuhause, Büro..."
placeholderTextColor="#9ca3af"
className="rounded-lg border border-gray-300 p-3 text-base"
returnKeyType="done"
/>
</View>
{/* Location */}
<View className="mb-6">
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
<TextInput
value={location}
onChangeText={setLocation}
placeholder="z.B. Restaurant, Zuhause, Büro..."
placeholderTextColor="#9ca3af"
className="rounded-lg border border-gray-300 p-3 text-base"
returnKeyType="done"
/>
</View>
{/* Notes */}
<View className="mb-6">
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
<TextInput
value={notes}
onChangeText={setNotes}
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
placeholderTextColor="#9ca3af"
multiline
numberOfLines={4}
textAlignVertical="top"
className="rounded-lg border border-gray-300 p-3 text-base"
style={{ minHeight: 100 }}
/>
</View>
{/* Notes */}
<View className="mb-6">
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
<TextInput
value={notes}
onChangeText={setNotes}
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
placeholderTextColor="#9ca3af"
multiline
numberOfLines={4}
textAlignVertical="top"
className="rounded-lg border border-gray-300 p-3 text-base"
style={{ minHeight: 100 }}
/>
</View>
{/* Meal Info */}
<View className="rounded-lg bg-gray-50 p-4">
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
<Text className="text-sm text-gray-600">
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
</Text>
{meal.total_calories && (
<Text className="mt-1 text-sm text-gray-600">
{Math.round(meal.total_calories)} kcal
</Text>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
{/* Meal Info */}
<View className="rounded-lg bg-gray-50 p-4">
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
<Text className="text-sm text-gray-600">
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
</Text>
{meal.total_calories && (
<Text className="mt-1 text-sm text-gray-600">
{Math.round(meal.total_calories)} kcal
</Text>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
};

View file

@ -4,158 +4,159 @@ import { Ionicons } from '@expo/vector-icons';
import { FoodItem } from '@/types/Database';
interface FoodItemCardProps {
foodItem: FoodItem;
categoryColor?: string;
onPress?: () => void;
showDetails?: boolean;
foodItem: FoodItem;
categoryColor?: string;
onPress?: () => void;
showDetails?: boolean;
}
export const FoodItemCard: React.FC<FoodItemCardProps> = (props) => {
const {
foodItem,
categoryColor = 'border-gray-200 bg-gray-50',
onPress,
showDetails = true,
} = props;
const formatValue = (value?: number, unit: string = 'g') => {
if (value === undefined || value === null) return '--';
return `${Math.round(value)}${unit}`;
};
const {
foodItem,
categoryColor = 'border-gray-200 bg-gray-50',
onPress,
showDetails = true,
} = props;
const formatValue = (value?: number, unit: string = 'g') => {
if (value === undefined || value === null) return '--';
return `${Math.round(value)}${unit}`;
};
const getConfidenceColor = (confidence?: number) => {
if (!confidence) return 'text-gray-400';
if (confidence >= 0.8) return 'text-green-600';
if (confidence >= 0.6) return 'text-yellow-600';
return 'text-red-600';
};
const getConfidenceColor = (confidence?: number) => {
if (!confidence) return 'text-gray-400';
if (confidence >= 0.8) return 'text-green-600';
if (confidence >= 0.6) return 'text-yellow-600';
return 'text-red-600';
};
const getConfidenceIcon = (confidence?: number) => {
if (!confidence) return 'help-outline';
if (confidence >= 0.8) return 'checkmark-circle-outline';
if (confidence >= 0.6) return 'warning-outline';
return 'alert-circle-outline';
};
const getConfidenceIcon = (confidence?: number) => {
if (!confidence) return 'help-outline';
if (confidence >= 0.8) return 'checkmark-circle-outline';
if (confidence >= 0.6) return 'warning-outline';
return 'alert-circle-outline';
};
const renderNutritionValue = (
label: string,
value?: number,
unit: string = 'g',
color: string = 'text-gray-700'
) => {
if (value === undefined || value === null) return null;
const renderNutritionValue = (
label: string,
value?: number,
unit: string = 'g',
color: string = 'text-gray-700'
) => {
if (value === undefined || value === null) return null;
return (
<View className="items-center">
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
</View>
);
};
return (
<View className="items-center">
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
</View>
);
};
const CardComponent = onPress ? TouchableOpacity : View;
const CardComponent = onPress ? TouchableOpacity : View;
return (
<CardComponent
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
className={`rounded-lg border p-4 ${categoryColor}`}>
{/* Header */}
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
{foodItem.portion_size && (
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
)}
</View>
return (
<CardComponent
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
className={`rounded-lg border p-4 ${categoryColor}`}
>
{/* Header */}
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
{foodItem.portion_size && (
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
)}
</View>
{/* Confidence Indicator */}
{foodItem.confidence && (
<View className="flex-row items-center">
<Ionicons
name={getConfidenceIcon(foodItem.confidence)}
size={16}
color={
getConfidenceColor(foodItem.confidence) === 'text-green-600'
? '#16a34a'
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
? '#ca8a04'
: '#dc2626'
}
/>
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
{Math.round(foodItem.confidence * 100)}%
</Text>
</View>
)}
</View>
{/* Confidence Indicator */}
{foodItem.confidence && (
<View className="flex-row items-center">
<Ionicons
name={getConfidenceIcon(foodItem.confidence)}
size={16}
color={
getConfidenceColor(foodItem.confidence) === 'text-green-600'
? '#16a34a'
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
? '#ca8a04'
: '#dc2626'
}
/>
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
{Math.round(foodItem.confidence * 100)}%
</Text>
</View>
)}
</View>
{/* Nutrition Information */}
{showDetails && (
<View className="space-y-3">
{/* Main Calories */}
{foodItem.calories && (
<View className="rounded-lg border border-gray-100 bg-white p-3">
<Text className="text-center text-lg font-bold text-gray-900">
{formatValue(foodItem.calories, ' kcal')}
</Text>
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
Kalorien
</Text>
</View>
)}
{/* Nutrition Information */}
{showDetails && (
<View className="space-y-3">
{/* Main Calories */}
{foodItem.calories && (
<View className="rounded-lg border border-gray-100 bg-white p-3">
<Text className="text-center text-lg font-bold text-gray-900">
{formatValue(foodItem.calories, ' kcal')}
</Text>
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
Kalorien
</Text>
</View>
)}
{/* Macronutrients */}
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
<View className="flex-row justify-between">
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
</View>
)}
{/* Macronutrients */}
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
<View className="flex-row justify-between">
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
</View>
)}
{/* Additional nutrients */}
{(foodItem.fiber || foodItem.sugar) && (
<View className="flex-row justify-between">
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
<View /> {/* Spacer for alignment */}
</View>
)}
{/* Additional nutrients */}
{(foodItem.fiber || foodItem.sugar) && (
<View className="flex-row justify-between">
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
<View /> {/* Spacer for alignment */}
</View>
)}
{/* Food Properties */}
<View className="flex-row flex-wrap gap-2">
{Boolean(foodItem.is_organic) && (
<View className="rounded-full bg-green-100 px-2 py-1">
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
</View>
)}
{Boolean(foodItem.is_processed) && (
<View className="rounded-full bg-orange-100 px-2 py-1">
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
</View>
)}
</View>
{/* Food Properties */}
<View className="flex-row flex-wrap gap-2">
{Boolean(foodItem.is_organic) && (
<View className="rounded-full bg-green-100 px-2 py-1">
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
</View>
)}
{Boolean(foodItem.is_processed) && (
<View className="rounded-full bg-orange-100 px-2 py-1">
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
</View>
)}
</View>
{/* Allergens */}
{foodItem.allergens && (
<View>
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
<Text className="text-xs text-red-600">
{(() => {
try {
const allergens = JSON.parse(foodItem.allergens);
return Array.isArray(allergens) && allergens.length > 0
? allergens.join(', ')
: 'Keine';
} catch {
return 'Keine';
}
})()}
</Text>
</View>
)}
</View>
)}
</CardComponent>
);
{/* Allergens */}
{foodItem.allergens && (
<View>
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
<Text className="text-xs text-red-600">
{(() => {
try {
const allergens = JSON.parse(foodItem.allergens);
return Array.isArray(allergens) && allergens.length > 0
? allergens.join(', ')
: 'Keine';
} catch {
return 'Keine';
}
})()}
</Text>
</View>
)}
</View>
)}
</CardComponent>
);
};

View file

@ -4,159 +4,159 @@ import { FoodItem } from '@/types/Database';
import { FoodItemCard } from './FoodItemCard';
interface FoodItemListProps {
foodItems: FoodItem[];
title?: string;
showTitle?: boolean;
foodItems: FoodItem[];
title?: string;
showTitle?: boolean;
}
export const FoodItemList: React.FC<FoodItemListProps> = ({
foodItems,
title = 'Erkannte Lebensmittel',
showTitle = true,
foodItems,
title = 'Erkannte Lebensmittel',
showTitle = true,
}) => {
if (!foodItems || foodItems.length === 0) {
return (
<View className="py-4">
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
<View className="items-center rounded-lg bg-gray-50 p-6">
<Text className="mb-2 text-4xl">🍽</Text>
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
</View>
</View>
);
}
if (!foodItems || foodItems.length === 0) {
return (
<View className="py-4">
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
<View className="items-center rounded-lg bg-gray-50 p-6">
<Text className="mb-2 text-4xl">🍽</Text>
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
</View>
</View>
);
}
const getCategoryIcon = (category: string) => {
switch (category) {
case 'protein':
return '🥩';
case 'vegetable':
return '🥕';
case 'grain':
return '🌾';
case 'fruit':
return '🍎';
case 'dairy':
return '🥛';
case 'fat':
return '🥑';
case 'processed':
return '📦';
case 'beverage':
return '🥤';
default:
return '🍽️';
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'protein':
return '🥩';
case 'vegetable':
return '🥕';
case 'grain':
return '🌾';
case 'fruit':
return '🍎';
case 'dairy':
return '🥛';
case 'fat':
return '🥑';
case 'processed':
return '📦';
case 'beverage':
return '🥤';
default:
return '🍽️';
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'protein':
return 'border-red-200 bg-red-50';
case 'vegetable':
return 'border-green-200 bg-green-50';
case 'grain':
return 'border-yellow-200 bg-yellow-50';
case 'fruit':
return 'border-orange-200 bg-orange-50';
case 'dairy':
return 'border-blue-200 bg-blue-50';
case 'fat':
return 'border-purple-200 bg-purple-50';
case 'processed':
return 'border-gray-200 bg-gray-50';
case 'beverage':
return 'border-cyan-200 bg-cyan-50';
default:
return 'border-gray-200 bg-gray-50';
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'protein':
return 'border-red-200 bg-red-50';
case 'vegetable':
return 'border-green-200 bg-green-50';
case 'grain':
return 'border-yellow-200 bg-yellow-50';
case 'fruit':
return 'border-orange-200 bg-orange-50';
case 'dairy':
return 'border-blue-200 bg-blue-50';
case 'fat':
return 'border-purple-200 bg-purple-50';
case 'processed':
return 'border-gray-200 bg-gray-50';
case 'beverage':
return 'border-cyan-200 bg-cyan-50';
default:
return 'border-gray-200 bg-gray-50';
}
};
// Group food items by category
const groupedByCategory = foodItems.reduce(
(acc, item) => {
const category = item.category || 'other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(item);
return acc;
},
{} as Record<string, FoodItem[]>
);
// Group food items by category
const groupedByCategory = foodItems.reduce(
(acc, item) => {
const category = item.category || 'other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(item);
return acc;
},
{} as Record<string, FoodItem[]>
);
const categoryOrder = [
'protein',
'vegetable',
'grain',
'fruit',
'dairy',
'fat',
'beverage',
'processed',
'other',
];
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
const otherCategories = Object.keys(groupedByCategory).filter(
(category) => !categoryOrder.includes(category)
);
const allCategories = [...sortedCategories, ...otherCategories];
const categoryOrder = [
'protein',
'vegetable',
'grain',
'fruit',
'dairy',
'fat',
'beverage',
'processed',
'other',
];
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
const otherCategories = Object.keys(groupedByCategory).filter(
(category) => !categoryOrder.includes(category)
);
const allCategories = [...sortedCategories, ...otherCategories];
const getCategoryName = (category: string) => {
switch (category) {
case 'protein':
return 'Proteine';
case 'vegetable':
return 'Gemüse';
case 'grain':
return 'Getreide';
case 'fruit':
return 'Obst';
case 'dairy':
return 'Milchprodukte';
case 'fat':
return 'Fette';
case 'processed':
return 'Verarbeitete Lebensmittel';
case 'beverage':
return 'Getränke';
default:
return 'Sonstige';
}
};
const getCategoryName = (category: string) => {
switch (category) {
case 'protein':
return 'Proteine';
case 'vegetable':
return 'Gemüse';
case 'grain':
return 'Getreide';
case 'fruit':
return 'Obst';
case 'dairy':
return 'Milchprodukte';
case 'fat':
return 'Fette';
case 'processed':
return 'Verarbeitete Lebensmittel';
case 'beverage':
return 'Getränke';
default:
return 'Sonstige';
}
};
return (
<View className="py-4">
{showTitle && (
<Text className="mb-4 text-lg font-semibold text-gray-900">
{title} ({foodItems.length})
</Text>
)}
return (
<View className="py-4">
{showTitle && (
<Text className="mb-4 text-lg font-semibold text-gray-900">
{title} ({foodItems.length})
</Text>
)}
<ScrollView showsVerticalScrollIndicator={false}>
{allCategories.map((category) => (
<View key={category} className="mb-6">
{/* Category Header */}
<View className="mb-3 flex-row items-center">
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
<Text className="text-base font-medium text-gray-800">
{getCategoryName(category)} ({groupedByCategory[category].length})
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{allCategories.map((category) => (
<View key={category} className="mb-6">
{/* Category Header */}
<View className="mb-3 flex-row items-center">
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
<Text className="text-base font-medium text-gray-800">
{getCategoryName(category)} ({groupedByCategory[category].length})
</Text>
</View>
{/* Category Items */}
<View className="space-y-2">
{groupedByCategory[category].map((item, index) => (
<FoodItemCard
key={item.id || `${category}-${index}`}
foodItem={item}
categoryColor={getCategoryColor(category)}
/>
))}
</View>
</View>
))}
</ScrollView>
</View>
);
{/* Category Items */}
<View className="space-y-2">
{groupedByCategory[category].map((item, index) => (
<FoodItemCard
key={item.id || `${category}-${index}`}
foodItem={item}
categoryColor={getCategoryColor(category)}
/>
))}
</View>
</View>
))}
</ScrollView>
</View>
);
};

View file

@ -5,186 +5,186 @@ import { MealWithItems } from '../../types/Database';
import { AnalysisStatusIndicator } from './AnalysisStatusIndicator';
interface MealCardProps {
meal: MealWithItems;
onPress: () => void;
meal: MealWithItems;
onPress: () => void;
}
export const MealCard: React.FC<MealCardProps> = ({ meal, onPress }) => {
const [imageError, setImageError] = useState(false);
const generateMealTitle = (meal: MealWithItems): string => {
if (meal.food_items && meal.food_items.length > 0) {
const foodNames = meal.food_items.map((item) => item.name);
const [imageError, setImageError] = useState(false);
const generateMealTitle = (meal: MealWithItems): string => {
if (meal.food_items && meal.food_items.length > 0) {
const foodNames = meal.food_items.map((item) => item.name);
if (foodNames.length === 1) {
return foodNames[0];
} else if (foodNames.length === 2) {
return `${foodNames[0]} & ${foodNames[1]}`;
} else if (foodNames.length > 2) {
return `${foodNames[0]} & ${foodNames.length - 1} more`;
}
}
if (foodNames.length === 1) {
return foodNames[0];
} else if (foodNames.length === 2) {
return `${foodNames[0]} & ${foodNames[1]}`;
} else if (foodNames.length > 2) {
return `${foodNames[0]} & ${foodNames.length - 1} more`;
}
}
// Fallback to meal type if no food items
const mealTypeLabels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
// Fallback to meal type if no food items
const mealTypeLabels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
};
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
};
const getMealTypeLabel = (mealType?: string): string => {
const labels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
return labels[mealType as keyof typeof labels] || 'Meal';
};
const getMealTypeLabel = (mealType?: string): string => {
const labels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
return labels[mealType as keyof typeof labels] || 'Meal';
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else {
return 'Now';
}
};
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else {
return 'Now';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return '🥐';
case 'lunch':
return '🥗';
case 'dinner':
return '🍽️';
case 'snack':
return '🍎';
default:
return '🍽️';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return '🥐';
case 'lunch':
return '🥗';
case 'dinner':
return '🍽️';
case 'snack':
return '🍎';
default:
return '🍽️';
}
};
const getHealthScoreColor = (score?: number) => {
if (!score) return 'text-gray-400';
if (score >= 80) return 'text-green-400';
if (score >= 60) return 'text-yellow-400';
return 'text-red-400';
};
const getHealthScoreColor = (score?: number) => {
if (!score) return 'text-gray-400';
if (score >= 80) return 'text-green-400';
if (score >= 60) return 'text-yellow-400';
return 'text-red-400';
};
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
{/* Background Image */}
{meal.photo_path && !imageError ? (
<Image
source={{ uri: meal.photo_path }}
className="h-full w-full"
resizeMode="cover"
onError={(error) => {
console.error('MealCard image loading error:', error);
console.log('MealCard photo_path:', meal.photo_path);
setImageError(true);
}}
onLoad={() => {
console.log('MealCard image loaded successfully:', meal.photo_path);
setImageError(false);
}}
/>
) : (
<View className="h-full w-full items-center justify-center bg-gray-300">
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
</View>
)}
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
{/* Background Image */}
{meal.photo_path && !imageError ? (
<Image
source={{ uri: meal.photo_path }}
className="h-full w-full"
resizeMode="cover"
onError={(error) => {
console.error('MealCard image loading error:', error);
console.log('MealCard photo_path:', meal.photo_path);
setImageError(true);
}}
onLoad={() => {
console.log('MealCard image loaded successfully:', meal.photo_path);
setImageError(false);
}}
/>
) : (
<View className="h-full w-full items-center justify-center bg-gray-300">
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
</View>
)}
{/* Blurry Stats Overlay */}
<View className="absolute bottom-0 left-0 right-0">
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
<View className="flex-row items-start justify-between">
{/* Left side - Meal info */}
<View className="flex-1">
<Text className="text-lg font-bold text-white" numberOfLines={1}>
{generateMealTitle(meal)}
</Text>
<View className="flex-row items-center space-x-2">
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
<Text className="text-sm text-gray-400"></Text>
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
</View>
{/* Location if available */}
{meal.location && (
<View className="mt-1 flex-row items-center">
<Ionicons name="location-outline" size={12} color="#d1d5db" />
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
{meal.location}
</Text>
</View>
)}
</View>
{/* Blurry Stats Overlay */}
<View className="absolute bottom-0 left-0 right-0">
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
<View className="flex-row items-start justify-between">
{/* Left side - Meal info */}
<View className="flex-1">
<Text className="text-lg font-bold text-white" numberOfLines={1}>
{generateMealTitle(meal)}
</Text>
<View className="flex-row items-center space-x-2">
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
<Text className="text-sm text-gray-400"></Text>
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
</View>
{/* Location if available */}
{meal.location && (
<View className="mt-1 flex-row items-center">
<Ionicons name="location-outline" size={12} color="#d1d5db" />
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
{meal.location}
</Text>
</View>
)}
</View>
{/* Right side - Stats */}
<View className="items-end">
{meal.analysis_status === 'completed' && (
<View className="flex-row items-center space-x-3">
{/* Calories */}
{meal.total_calories && (
<View className="items-center">
<Text className="text-xs text-gray-300">cal</Text>
<Text className="font-bold text-white">
{Math.round(meal.total_calories)}
</Text>
</View>
)}
{/* Right side - Stats */}
<View className="items-end">
{meal.analysis_status === 'completed' && (
<View className="flex-row items-center space-x-3">
{/* Calories */}
{meal.total_calories && (
<View className="items-center">
<Text className="text-xs text-gray-300">cal</Text>
<Text className="font-bold text-white">
{Math.round(meal.total_calories)}
</Text>
</View>
)}
{/* Health Score */}
{meal.health_score && (
<View className="items-center">
<Text className="text-xs text-gray-300">health</Text>
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
{Math.round(meal.health_score)}
</Text>
</View>
)}
{/* Health Score */}
{meal.health_score && (
<View className="items-center">
<Text className="text-xs text-gray-300">health</Text>
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
{Math.round(meal.health_score)}
</Text>
</View>
)}
{/* Rating */}
{meal.user_rating && (
<View className="items-center">
<Text className="text-xs text-gray-300">rating</Text>
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
</View>
)}
</View>
)}
{/* Rating */}
{meal.user_rating && (
<View className="items-center">
<Text className="text-xs text-gray-300">rating</Text>
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
</View>
)}
</View>
)}
{/* Analysis Status for non-completed */}
{meal.analysis_status !== 'completed' && (
<View className="rounded-full bg-black/30 p-1">
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
</View>
)}
</View>
</View>
{/* Analysis Status for non-completed */}
{meal.analysis_status !== 'completed' && (
<View className="rounded-full bg-black/30 p-1">
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
</View>
)}
</View>
</View>
{/* User Notes */}
{meal.user_notes && (
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
&ldquo;{meal.user_notes}&rdquo;
</Text>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
{/* 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

@ -8,39 +8,39 @@ import { EditMealModal } from './EditMealModal';
import { useMealStore } from '../../store/MealStore';
interface MealCardContextMenuProps {
meal: MealWithItems;
onPress: () => void;
meal: MealWithItems;
onPress: () => void;
}
export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal, onPress }) => {
const { deleteMeal, updateMeal } = useMealStore();
const [showEditModal, setShowEditModal] = useState(false);
const { deleteMeal, updateMeal } = useMealStore();
const [showEditModal, setShowEditModal] = useState(false);
const handleDelete = () => {
Alert.alert(
'Mahlzeit löschen',
'Möchtest du diese Mahlzeit wirklich löschen?',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await deleteMeal(meal.id);
} catch {
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
}
},
},
],
{ cancelable: true }
);
};
const handleDelete = () => {
Alert.alert(
'Mahlzeit löschen',
'Möchtest du diese Mahlzeit wirklich löschen?',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await deleteMeal(meal.id);
} catch {
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
}
},
},
],
{ cancelable: true }
);
};
const handleShare = async () => {
try {
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
const handleShare = async () => {
try {
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
📊 Nährwerte:
Kalorien: ${meal.total_calories || '--'} kcal
@ -52,33 +52,33 @@ export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal,
Getrackt mit Nutriphi 🤖`;
await Share.share({
message: nutritionInfo,
title: 'Meine Mahlzeit',
});
} catch (error) {
console.error('Share failed:', error);
}
};
await Share.share({
message: nutritionInfo,
title: 'Meine Mahlzeit',
});
} catch (error) {
console.error('Share failed:', error);
}
};
const handleRating = (rating: number) => {
updateMeal(meal.id, { user_rating: rating });
};
const handleRating = (rating: number) => {
updateMeal(meal.id, { user_rating: rating });
};
const handleReanalyze = () => {
Alert.alert(
'Erneut analysieren',
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
[{ text: 'OK' }]
);
};
const handleReanalyze = () => {
Alert.alert(
'Erneut analysieren',
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
[{ text: 'OK' }]
);
};
const handleEdit = () => {
setShowEditModal(true);
};
const handleEdit = () => {
setShowEditModal(true);
};
const handleCopyNutrition = () => {
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
const handleCopyNutrition = () => {
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
Kalorien: ${meal.total_calories || '--'} kcal
Protein: ${meal.total_protein || '--'}g
Kohlenhydrate: ${meal.total_carbs || '--'}g
@ -87,100 +87,100 @@ Ballaststoffe: ${meal.total_fiber || '--'}g
Zucker: ${meal.total_sugar || '--'}g
Gesundheitsscore: ${meal.health_score ? Math.round(meal.health_score) : '--'}/100`;
Clipboard.setString(nutritionText);
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
};
Clipboard.setString(nutritionText);
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
};
// Build context menu actions
const actions = [
{
title: 'Bearbeiten',
systemIcon: 'pencil',
},
{
title: 'Bewerten',
systemIcon: 'star',
inlineChildren: true,
actions: [
{ title: '⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
],
},
{
title: 'Teilen',
systemIcon: 'square.and.arrow.up',
},
{
title: 'Nährwerte kopieren',
systemIcon: 'doc.on.doc',
},
];
// Build context menu actions
const actions = [
{
title: 'Bearbeiten',
systemIcon: 'pencil',
},
{
title: 'Bewerten',
systemIcon: 'star',
inlineChildren: true,
actions: [
{ title: '⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
],
},
{
title: 'Teilen',
systemIcon: 'square.and.arrow.up',
},
{
title: 'Nährwerte kopieren',
systemIcon: 'doc.on.doc',
},
];
// Add conditional actions
if (meal.analysis_status === 'failed') {
actions.push({
title: 'Erneut analysieren',
systemIcon: 'arrow.clockwise',
});
}
// Add conditional actions
if (meal.analysis_status === 'failed') {
actions.push({
title: 'Erneut analysieren',
systemIcon: 'arrow.clockwise',
});
}
// Add destructive action at the end
actions.push({
title: 'Löschen',
systemIcon: 'trash',
destructive: true,
});
// Add destructive action at the end
actions.push({
title: 'Löschen',
systemIcon: 'trash',
destructive: true,
});
const handlePress = (event: any) => {
const { index, name } = event.nativeEvent;
const handlePress = (event: any) => {
const { index, name } = event.nativeEvent;
// Haptic feedback
Vibration.vibrate(10);
// Haptic feedback
Vibration.vibrate(10);
switch (name || actions[index]?.title) {
case 'Bearbeiten':
handleEdit();
break;
case 'Löschen':
handleDelete();
break;
case 'Teilen':
handleShare();
break;
case 'Nährwerte kopieren':
handleCopyNutrition();
break;
case 'Erneut analysieren':
handleReanalyze();
break;
case '⭐':
handleRating(1);
break;
case '⭐⭐':
handleRating(2);
break;
case '⭐⭐⭐':
handleRating(3);
break;
case '⭐⭐⭐⭐':
handleRating(4);
break;
case '⭐⭐⭐⭐⭐':
handleRating(5);
break;
}
};
switch (name || actions[index]?.title) {
case 'Bearbeiten':
handleEdit();
break;
case 'Löschen':
handleDelete();
break;
case 'Teilen':
handleShare();
break;
case 'Nährwerte kopieren':
handleCopyNutrition();
break;
case 'Erneut analysieren':
handleReanalyze();
break;
case '⭐':
handleRating(1);
break;
case '⭐⭐':
handleRating(2);
break;
case '⭐⭐⭐':
handleRating(3);
break;
case '⭐⭐⭐⭐':
handleRating(4);
break;
case '⭐⭐⭐⭐⭐':
handleRating(5);
break;
}
};
return (
<>
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
<MealCard meal={meal} onPress={onPress} />
</ContextMenu>
return (
<>
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
<MealCard meal={meal} onPress={onPress} />
</ContextMenu>
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
</>
);
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
</>
);
};

View file

@ -5,145 +5,146 @@ import { Card } from '../ui/Card';
import { NutritionBar } from './NutritionBar';
interface MealItemProps {
meal: Meal;
onPress: () => void;
meal: Meal;
onPress: () => void;
}
export const MealItem: React.FC<MealItemProps> = ({ meal, onPress }) => {
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else {
return 'Now';
}
};
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else {
return 'Now';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return '🥐';
case 'lunch':
return '🥗';
case 'dinner':
return '🍽️';
case 'snack':
return '🍎';
default:
return '🍽️';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return '🥐';
case 'lunch':
return '🥗';
case 'dinner':
return '🍽️';
case 'snack':
return '🍎';
default:
return '🍽️';
}
};
const getAnalysisStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
case 'manual':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getAnalysisStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
case 'manual':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getAnalysisStatusText = (status: string) => {
switch (status) {
case 'completed':
return 'Analyzed';
case 'pending':
return 'Processing...';
case 'failed':
return 'Failed';
case 'manual':
return 'Manual';
default:
return 'Unknown';
}
};
const getAnalysisStatusText = (status: string) => {
switch (status) {
case 'completed':
return 'Analyzed';
case 'pending':
return 'Processing...';
case 'failed':
return 'Failed';
case 'manual':
return 'Manual';
default:
return 'Unknown';
}
};
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Card variant="elevated" className="mb-4">
<View className="flex-row space-x-4">
{/* Photo */}
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
{meal.photo_path ? (
<Image
source={{ uri: `file://${meal.photo_path}` }}
className="h-full w-full"
resizeMode="cover"
/>
) : (
<View className="h-full w-full items-center justify-center">
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
</View>
)}
</View>
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Card variant="elevated" className="mb-4">
<View className="flex-row space-x-4">
{/* Photo */}
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
{meal.photo_path ? (
<Image
source={{ uri: `file://${meal.photo_path}` }}
className="h-full w-full"
resizeMode="cover"
/>
) : (
<View className="h-full w-full items-center justify-center">
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
</View>
)}
</View>
{/* Content */}
<View className="flex-1 space-y-2">
{/* Header */}
<View className="flex-row items-start justify-between">
<View>
<Text className="text-lg font-semibold capitalize text-gray-900">
{meal.meal_type || 'Meal'}
</Text>
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
</View>
{/* Content */}
<View className="flex-1 space-y-2">
{/* Header */}
<View className="flex-row items-start justify-between">
<View>
<Text className="text-lg font-semibold capitalize text-gray-900">
{meal.meal_type || 'Meal'}
</Text>
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
</View>
<View
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}>
<Text className="text-xs font-medium">
{getAnalysisStatusText(meal.analysis_status)}
</Text>
</View>
</View>
<View
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}
>
<Text className="text-xs font-medium">
{getAnalysisStatusText(meal.analysis_status)}
</Text>
</View>
</View>
{/* Nutrition Summary */}
{meal.analysis_status === 'completed' && (
<NutritionBar
calories={meal.total_calories}
healthScore={meal.health_score}
compact={true}
/>
)}
{/* Nutrition Summary */}
{meal.analysis_status === 'completed' && (
<NutritionBar
calories={meal.total_calories}
healthScore={meal.health_score}
compact={true}
/>
)}
{/* Notes */}
{meal.user_notes && (
<Text className="text-sm italic text-gray-600" numberOfLines={2}>
&ldquo;{meal.user_notes}&rdquo;
</Text>
)}
{/* 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>
{/* Bottom Info */}
<View className="flex-row items-center justify-between">
<View className="flex-row items-center space-x-2">
{meal.location && <Text className="text-xs text-gray-500">📍 {meal.location}</Text>}
</View>
{meal.user_rating && (
<View className="flex-row">
{Array.from({ length: 5 }, (_, i) => (
<Text key={i} className="text-xs">
{i < meal.user_rating! ? '⭐' : '☆'}
</Text>
))}
</View>
)}
</View>
</View>
</View>
</Card>
</TouchableOpacity>
);
{meal.user_rating && (
<View className="flex-row">
{Array.from({ length: 5 }, (_, i) => (
<Text key={i} className="text-xs">
{i < meal.user_rating! ? '⭐' : '☆'}
</Text>
))}
</View>
)}
</View>
</View>
</View>
</Card>
</TouchableOpacity>
);
};

View file

@ -8,93 +8,93 @@ import { Button } from '../Button';
import { Header } from '../ui/Header';
interface MealListProps {
onMealPress: (meal: MealWithItems) => void;
onMealPress: (meal: MealWithItems) => void;
}
export const MealList: React.FC<MealListProps> = ({ onMealPress }) => {
const [refreshing, setRefreshing] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
useEffect(() => {
loadMeals();
}, [loadMeals]);
useEffect(() => {
loadMeals();
}, [loadMeals]);
const handleRefresh = async () => {
setRefreshing(true);
await loadMeals();
setRefreshing(false);
};
const handleRefresh = async () => {
setRefreshing(true);
await loadMeals();
setRefreshing(false);
};
const renderMealItem = ({ item }: { item: MealWithItems }) => (
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
);
const renderMealItem = ({ item }: { item: MealWithItems }) => (
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
);
const renderEmptyState = () => (
<View className="flex-1 items-center justify-center py-20">
<Text className="mb-4 text-6xl">🍽</Text>
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
No meals yet
</Text>
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
Start tracking your nutrition by taking a photo of your first meal using the camera button
below!
</Text>
</View>
);
const renderEmptyState = () => (
<View className="flex-1 items-center justify-center py-20">
<Text className="mb-4 text-6xl">🍽</Text>
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
No meals yet
</Text>
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
Start tracking your nutrition by taking a photo of your first meal using the camera button
below!
</Text>
</View>
);
const renderError = () => (
<View className="flex-1 items-center justify-center py-20">
<Text className="mb-4 text-4xl"></Text>
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
Oops! Something went wrong
</Text>
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
<Button
title="Try Again"
onPress={() => {
clearError();
loadMeals();
}}
className="px-8"
/>
</View>
);
const renderError = () => (
<View className="flex-1 items-center justify-center py-20">
<Text className="mb-4 text-4xl"></Text>
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
Oops! Something went wrong
</Text>
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
<Button
title="Try Again"
onPress={() => {
clearError();
loadMeals();
}}
className="px-8"
/>
</View>
);
if (error && meals.length === 0) {
return renderError();
}
if (error && meals.length === 0) {
return renderError();
}
if (isLoading && meals.length === 0) {
return (
<View className="flex-1 items-center justify-center">
<LoadingSpinner text="Loading your meals..." />
</View>
);
}
if (isLoading && meals.length === 0) {
return (
<View className="flex-1 items-center justify-center">
<LoadingSpinner text="Loading your meals..." />
</View>
);
}
return (
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
{/* Meal List */}
<FlatList
data={meals}
renderItem={renderMealItem}
keyExtractor={(item) => item.id!.toString()}
contentContainerStyle={{
padding: 16,
flexGrow: 1,
}}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
ListEmptyComponent={renderEmptyState}
ListHeaderComponent={<Header title="NutriPhi" />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
}
showsVerticalScrollIndicator={false}
/>
return (
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
{/* Meal List */}
<FlatList
data={meals}
renderItem={renderMealItem}
keyExtractor={(item) => item.id!.toString()}
contentContainerStyle={{
padding: 16,
flexGrow: 1,
}}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
ListEmptyComponent={renderEmptyState}
ListHeaderComponent={<Header title="NutriPhi" />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
}
showsVerticalScrollIndicator={false}
/>
{/* Loading Overlay */}
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
</View>
);
{/* Loading Overlay */}
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
</View>
);
};

View file

@ -3,197 +3,197 @@ import { View, Text } from 'react-native';
import { Meal } from '@/types/Database';
interface NutritionBarProps {
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
healthScore?: number;
compact?: boolean;
meal?: Meal;
showDetailed?: boolean;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
healthScore?: number;
compact?: boolean;
meal?: Meal;
showDetailed?: boolean;
}
export const NutritionBar: React.FC<NutritionBarProps> = ({
calories,
protein,
carbs,
fat,
healthScore,
compact = false,
meal,
showDetailed = false,
calories,
protein,
carbs,
fat,
healthScore,
compact = false,
meal,
showDetailed = false,
}) => {
// Use meal data if provided, otherwise use individual props
const mealCalories = meal?.total_calories || calories;
const mealProtein = meal?.total_protein || protein;
const mealCarbs = meal?.total_carbs || carbs;
const mealFat = meal?.total_fat || fat;
const mealHealthScore = meal?.health_score || healthScore;
const mealFiber = meal?.total_fiber;
const mealSugar = meal?.total_sugar;
const formatValue = (value?: number, unit: string = 'g') => {
if (value === undefined || value === null) return '--';
return `${Math.round(value)}${unit}`;
};
// Use meal data if provided, otherwise use individual props
const mealCalories = meal?.total_calories || calories;
const mealProtein = meal?.total_protein || protein;
const mealCarbs = meal?.total_carbs || carbs;
const mealFat = meal?.total_fat || fat;
const mealHealthScore = meal?.health_score || healthScore;
const mealFiber = meal?.total_fiber;
const mealSugar = meal?.total_sugar;
const formatValue = (value?: number, unit: string = 'g') => {
if (value === undefined || value === null) return '--';
return `${Math.round(value)}${unit}`;
};
const getHealthScoreColor = (score?: number) => {
if (!score) return 'bg-gray-300';
if (score >= 8) return 'bg-green-500';
if (score >= 6) return 'bg-yellow-500';
if (score >= 4) return 'bg-orange-500';
return 'bg-red-500';
};
const getHealthScoreColor = (score?: number) => {
if (!score) return 'bg-gray-300';
if (score >= 8) return 'bg-green-500';
if (score >= 6) return 'bg-yellow-500';
if (score >= 4) return 'bg-orange-500';
return 'bg-red-500';
};
const getHealthScoreText = (score?: number) => {
if (!score) return 'Not analyzed';
if (score >= 8) return 'Very Healthy';
if (score >= 6) return 'Healthy';
if (score >= 4) return 'Moderate';
return 'Unhealthy';
};
const getHealthScoreText = (score?: number) => {
if (!score) return 'Not analyzed';
if (score >= 8) return 'Very Healthy';
if (score >= 6) return 'Healthy';
if (score >= 4) return 'Moderate';
return 'Unhealthy';
};
if (compact) {
return (
<View className="flex-row items-center space-x-4">
<View className="flex-row items-center space-x-1">
<Text className="text-lg font-bold text-gray-900">
{formatValue(mealCalories, ' kcal')}
</Text>
</View>
{mealHealthScore && (
<View className="flex-row items-center space-x-2">
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
</View>
)}
</View>
);
}
if (compact) {
return (
<View className="flex-row items-center space-x-4">
<View className="flex-row items-center space-x-1">
<Text className="text-lg font-bold text-gray-900">
{formatValue(mealCalories, ' kcal')}
</Text>
</View>
{mealHealthScore && (
<View className="flex-row items-center space-x-2">
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
</View>
)}
</View>
);
}
return (
<View className="space-y-3">
{/* Calories Header */}
<View className="flex-row items-center justify-between">
<Text className="text-2xl font-bold text-gray-900">
{formatValue(mealCalories, ' kcal')}
</Text>
{mealHealthScore && (
<View className="flex-row items-center space-x-2">
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
<View className="items-end">
<Text className="text-sm font-medium text-gray-900">
{mealHealthScore.toFixed(1)}/10
</Text>
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
</View>
</View>
)}
</View>
return (
<View className="space-y-3">
{/* Calories Header */}
<View className="flex-row items-center justify-between">
<Text className="text-2xl font-bold text-gray-900">
{formatValue(mealCalories, ' kcal')}
</Text>
{mealHealthScore && (
<View className="flex-row items-center space-x-2">
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
<View className="items-end">
<Text className="text-sm font-medium text-gray-900">
{mealHealthScore.toFixed(1)}/10
</Text>
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
</View>
</View>
)}
</View>
{/* Macronutrients */}
<View className="flex-row justify-between">
<View className="items-center">
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
</View>
{/* Macronutrients */}
<View className="flex-row justify-between">
<View className="items-center">
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
</View>
<View className="items-center">
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
</View>
<View className="items-center">
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
</View>
<View className="items-center">
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
</View>
</View>
<View className="items-center">
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
</View>
</View>
{/* Additional nutrients for detailed view */}
{showDetailed && (mealFiber || mealSugar) && (
<View className="flex-row justify-between">
{mealFiber && (
<View className="items-center">
<Text className="text-lg font-semibold text-purple-600">
{formatValue(mealFiber)}
</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
</View>
)}
{mealSugar && (
<View className="items-center">
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
</View>
)}
<View className="items-center">
<Text className="text-lg font-semibold text-transparent">--</Text>
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
</View>
</View>
)}
{/* Additional nutrients for detailed view */}
{showDetailed && (mealFiber || mealSugar) && (
<View className="flex-row justify-between">
{mealFiber && (
<View className="items-center">
<Text className="text-lg font-semibold text-purple-600">
{formatValue(mealFiber)}
</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
</View>
)}
{mealSugar && (
<View className="items-center">
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
</View>
)}
<View className="items-center">
<Text className="text-lg font-semibold text-transparent">--</Text>
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
</View>
</View>
)}
{/* Visual Progress Bars */}
<View className="space-y-2">
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">PROT</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-blue-500"
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
</View>
{/* Visual Progress Bars */}
<View className="space-y-2">
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">PROT</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-blue-500"
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
</View>
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">CARB</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-green-500"
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
</View>
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">CARB</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-green-500"
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
</View>
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">FAT</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-orange-500"
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
</View>
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">FAT</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-orange-500"
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
</View>
{/* Additional progress bars for detailed view */}
{showDetailed && mealFiber && (
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-purple-500"
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
</View>
)}
{/* Additional progress bars for detailed view */}
{showDetailed && mealFiber && (
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-purple-500"
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
</View>
)}
{showDetailed && mealSugar && (
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-pink-500"
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
</View>
)}
</View>
</View>
);
{showDetailed && mealSugar && (
<View className="flex-row items-center space-x-3">
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
<View className="h-2 flex-1 rounded-full bg-gray-200">
<View
className="h-2 rounded-full bg-pink-500"
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
/>
</View>
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
</View>
)}
</View>
</View>
);
};

View file

@ -2,29 +2,29 @@ import React from 'react';
import { View, ViewProps } from 'react-native';
interface CardProps extends ViewProps {
variant?: 'default' | 'elevated' | 'outline';
children: React.ReactNode;
variant?: 'default' | 'elevated' | 'outline';
children: React.ReactNode;
}
export const Card: React.FC<CardProps> = ({
variant = 'default',
children,
className,
...props
variant = 'default',
children,
className,
...props
}) => {
const baseStyles = 'rounded-xl p-4 bg-white';
const baseStyles = 'rounded-xl p-4 bg-white';
const variantStyles = {
default: 'border border-gray-100',
elevated: 'shadow-lg shadow-gray-200',
outline: 'border-2 border-gray-200',
};
const variantStyles = {
default: 'border border-gray-100',
elevated: 'shadow-lg shadow-gray-200',
outline: 'border-2 border-gray-200',
};
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
return (
<View className={combinedClassName} {...props}>
{children}
</View>
);
return (
<View className={combinedClassName} {...props}>
{children}
</View>
);
};

View file

@ -1,154 +1,156 @@
import React from 'react';
import { TouchableOpacity, Text, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
} from 'react-native-reanimated';
import { SFSymbol } from './SFSymbol';
interface FloatingActionButtonProps {
onPress: () => void;
icon?: string;
sfSymbol?: string;
fallbackIcon?: string;
disabled?: boolean;
size?: 'normal' | 'large';
position?: 'right' | 'center' | 'left';
onPress: () => void;
icon?: string;
sfSymbol?: string;
fallbackIcon?: string;
disabled?: boolean;
size?: 'normal' | 'large';
position?: 'right' | 'center' | 'left';
}
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
onPress,
icon = '+',
sfSymbol,
fallbackIcon,
disabled = false,
size = 'normal',
position = 'right',
onPress,
icon = '+',
sfSymbol,
fallbackIcon,
disabled = false,
size = 'normal',
position = 'right',
}) => {
const pressed = useSharedValue(false);
const pressed = useSharedValue(false);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
return {
transform: [{ scale: withSpring(scale) }],
};
});
return {
transform: [{ scale: withSpring(scale) }],
};
});
const handlePressIn = () => {
pressed.value = true;
};
const handlePressIn = () => {
pressed.value = true;
};
const handlePressOut = () => {
pressed.value = false;
};
const handlePressOut = () => {
pressed.value = false;
};
const getContainerStyle = () => {
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
switch (position) {
case 'center':
return {
...base,
width: '100%',
alignItems: 'center' as const,
};
case 'left':
return { ...base, left: 24 };
case 'right':
default:
return { ...base, right: 24 };
}
};
const getContainerStyle = () => {
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
switch (position) {
case 'center':
return {
...base,
width: '100%',
alignItems: 'center' as const,
};
case 'left':
return { ...base, left: 24 };
case 'right':
default:
return { ...base, right: 24 };
}
};
const getSizeStyle = () => {
switch (size) {
case 'large':
return { width: 80, height: 80 };
case 'normal':
default:
return { width: 64, height: 64 };
}
};
const getSizeStyle = () => {
switch (size) {
case 'large':
return { width: 80, height: 80 };
case 'normal':
default:
return { width: 64, height: 64 };
}
};
const getIconSize = () => {
switch (size) {
case 'large':
return 32;
case 'normal':
default:
return 24;
}
};
const getIconSize = () => {
switch (size) {
case 'large':
return 32;
case 'normal':
default:
return 24;
}
};
const getTextSize = () => {
switch (size) {
case 'large':
return 'text-3xl';
case 'normal':
default:
return 'text-2xl';
}
};
const getTextSize = () => {
switch (size) {
case 'large':
return 'text-3xl';
case 'normal':
default:
return 'text-2xl';
}
};
const renderIcon = () => {
if (sfSymbol && fallbackIcon) {
return (
<SFSymbol
name={sfSymbol}
fallbackIcon={fallbackIcon as any}
size={getIconSize()}
color="white"
/>
);
}
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
};
const renderIcon = () => {
if (sfSymbol && fallbackIcon) {
return (
<SFSymbol
name={sfSymbol}
fallbackIcon={fallbackIcon as any}
size={getIconSize()}
color="white"
/>
);
}
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
};
const combinedStyle = [
animatedStyle,
getSizeStyle(),
position === 'center' ? {} : getContainerStyle(),
];
const combinedStyle = [
animatedStyle,
getSizeStyle(),
position === 'center' ? {} : getContainerStyle(),
];
const containerStyle = position === 'center' ? getContainerStyle() : {};
const containerStyle = position === 'center' ? getContainerStyle() : {};
if (position === 'center') {
return (
<View style={containerStyle}>
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.8}
style={[animatedStyle, getSizeStyle()]}
className={`
if (position === 'center') {
return (
<View style={containerStyle}>
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.8}
style={[animatedStyle, getSizeStyle()]}
className={`
items-center justify-center rounded-full shadow-lg
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
`}>
{renderIcon()}
</AnimatedTouchableOpacity>
</View>
);
}
`}
>
{renderIcon()}
</AnimatedTouchableOpacity>
</View>
);
}
return (
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.8}
style={combinedStyle}
className={`
return (
<AnimatedTouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
activeOpacity={0.8}
style={combinedStyle}
className={`
items-center justify-center rounded-full shadow-lg
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
`}>
{renderIcon()}
</AnimatedTouchableOpacity>
);
`}
>
{renderIcon()}
</AnimatedTouchableOpacity>
);
};

View file

@ -4,29 +4,30 @@ import { router } from 'expo-router';
import { SFSymbol } from './SFSymbol';
interface HeaderProps {
title: string;
onSettingsPress?: () => void;
title: string;
onSettingsPress?: () => void;
}
export const Header: React.FC<HeaderProps> = ({ title, onSettingsPress }) => {
const handleSettingsPress = () => {
if (onSettingsPress) {
onSettingsPress();
} else {
router.push('/settings');
}
};
const handleSettingsPress = () => {
if (onSettingsPress) {
onSettingsPress();
} else {
router.push('/settings');
}
};
return (
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
<TouchableOpacity
onPress={handleSettingsPress}
className="p-2"
accessibilityLabel="Settings"
accessibilityRole="button">
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
</TouchableOpacity>
</View>
);
return (
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
<TouchableOpacity
onPress={handleSettingsPress}
className="p-2"
accessibilityLabel="Settings"
accessibilityRole="button"
>
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
</TouchableOpacity>
</View>
);
};

View file

@ -2,30 +2,30 @@ import React from 'react';
import { View, Text, Modal, ActivityIndicator } from 'react-native';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
backgroundColor?: string;
visible: boolean;
message?: string;
backgroundColor?: string;
}
export default function LoadingOverlay({
visible,
message = 'Wird geladen...',
backgroundColor = 'rgba(0, 0, 0, 0.7)',
visible,
message = 'Wird geladen...',
backgroundColor = 'rgba(0, 0, 0, 0.7)',
}: LoadingOverlayProps) {
if (!visible) return null;
if (!visible) return null;
return (
<Modal transparent visible={visible} animationType="fade">
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
<View className="items-center space-y-4">
<ActivityIndicator size="large" className="text-indigo-500" />
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
{message}
</Text>
</View>
</View>
</View>
</Modal>
);
return (
<Modal transparent visible={visible} animationType="fade">
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
<View className="items-center space-y-4">
<ActivityIndicator size="large" className="text-indigo-500" />
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
{message}
</Text>
</View>
</View>
</View>
</Modal>
);
}

View file

@ -2,31 +2,31 @@ import React from 'react';
import { ActivityIndicator, View, Text } from 'react-native';
interface LoadingSpinnerProps {
size?: 'small' | 'large';
color?: string;
text?: string;
overlay?: boolean;
size?: 'small' | 'large';
color?: string;
text?: string;
overlay?: boolean;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'large',
color = '#6366f1',
text,
overlay = false,
size = 'large',
color = '#6366f1',
text,
overlay = false,
}) => {
const Container = overlay ? View : React.Fragment;
const containerProps = overlay
? {
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
}
: {};
const Container = overlay ? View : React.Fragment;
const containerProps = overlay
? {
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
}
: {};
return (
<Container {...containerProps}>
<View className="items-center space-y-2">
<ActivityIndicator size={size} color={color} />
{text && <Text className="text-sm text-gray-600">{text}</Text>}
</View>
</Container>
);
return (
<Container {...containerProps}>
<View className="items-center space-y-2">
<ActivityIndicator size={size} color={color} />
{text && <Text className="text-sm text-gray-600">{text}</Text>}
</View>
</Container>
);
};

View file

@ -5,50 +5,50 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useColorScheme } from 'nativewind';
interface SFSymbolProps {
name: string;
size?: number;
color?: string;
weight?: SymbolViewProps['weight'];
scale?: SymbolViewProps['scale'];
mode?: SymbolViewProps['mode'];
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
style?: SymbolViewProps['style'];
name: string;
size?: number;
color?: string;
weight?: SymbolViewProps['weight'];
scale?: SymbolViewProps['scale'];
mode?: SymbolViewProps['mode'];
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
style?: SymbolViewProps['style'];
}
export const SFSymbol: React.FC<SFSymbolProps> = ({
name,
size = 24,
color,
weight = 'regular',
scale = 'default',
mode = 'monochrome',
fallbackIcon,
style,
name,
size = 24,
color,
weight = 'regular',
scale = 'default',
mode = 'monochrome',
fallbackIcon,
style,
}) => {
const { colorScheme } = useColorScheme();
const { colorScheme } = useColorScheme();
// Use dynamic color if no color specified
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
// Use SF Symbols on iOS, fallback to FontAwesome on Android
if (Platform.OS === 'ios') {
return (
<SymbolView
name={name}
size={size}
tintColor={dynamicColor}
weight={weight}
scale={scale}
mode={mode}
style={style}
/>
);
}
// Use dynamic color if no color specified
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
// Use SF Symbols on iOS, fallback to FontAwesome on Android
if (Platform.OS === 'ios') {
return (
<SymbolView
name={name}
size={size}
tintColor={dynamicColor}
weight={weight}
scale={scale}
mode={mode}
style={style}
/>
);
}
// Android fallback
if (fallbackIcon) {
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
}
// Android fallback
if (fallbackIcon) {
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
}
// Default fallback if no fallbackIcon provided
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
// Default fallback if no fallbackIcon provided
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
};

View file

@ -1,21 +1,21 @@
{
"cli": {
"version": ">= 16.9.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
"cli": {
"version": ">= 16.9.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View file

@ -3,13 +3,13 @@ const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
{
rules: {
'react/display-name': 'off',
},
},
expoConfig,
{
ignores: ['dist/*'],
},
{
rules: {
'react/display-name': 'off',
},
},
]);

View file

@ -4,102 +4,102 @@ import * as ImagePicker from 'expo-image-picker';
import { PhotoService } from '../services/storage/PhotoService';
export function useCamera() {
const [permission, requestPermission] = useCameraPermissions();
const [isReady, setIsReady] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [facing, setFacing] = useState<CameraType>('back');
const cameraRef = useRef<CameraView>(null);
const [permission, requestPermission] = useCameraPermissions();
const [isReady, setIsReady] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [facing, setFacing] = useState<CameraType>('back');
const cameraRef = useRef<CameraView>(null);
const photoService = PhotoService.getInstance();
const photoService = PhotoService.getInstance();
const toggleCameraFacing = () => {
setFacing((current) => (current === 'back' ? 'front' : 'back'));
};
const toggleCameraFacing = () => {
setFacing((current) => (current === 'back' ? 'front' : 'back'));
};
const takePicture = async () => {
if (!cameraRef.current || isCapturing) return null;
const takePicture = async () => {
if (!cameraRef.current || isCapturing) return null;
try {
setIsCapturing(true);
try {
setIsCapturing(true);
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
exif: false,
});
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
exif: false,
});
if (!photo) return null;
if (!photo) return null;
// Save photo using PhotoService
const savedPhoto = await photoService.savePhoto(photo.uri);
// Save photo using PhotoService
const savedPhoto = await photoService.savePhoto(photo.uri);
return {
uri: photo.uri,
...savedPhoto,
};
} catch (error) {
console.error('Failed to take picture:', error);
throw error;
} finally {
setIsCapturing(false);
}
};
return {
uri: photo.uri,
...savedPhoto,
};
} catch (error) {
console.error('Failed to take picture:', error);
throw error;
} finally {
setIsCapturing(false);
}
};
const pickImageFromGallery = async () => {
try {
// Request permission
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
const pickImageFromGallery = async () => {
try {
// Request permission
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
throw new Error('Permission to access gallery denied');
}
if (!permissionResult.granted) {
throw new Error('Permission to access gallery denied');
}
// Launch image picker
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
// Launch image picker
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (result.canceled) {
return null;
}
if (result.canceled) {
return null;
}
const asset = result.assets[0];
const asset = result.assets[0];
// Save photo using PhotoService
const savedPhoto = await photoService.savePhoto(asset.uri);
// Save photo using PhotoService
const savedPhoto = await photoService.savePhoto(asset.uri);
return {
uri: asset.uri,
...savedPhoto,
};
} catch (error) {
console.error('Failed to pick image from gallery:', error);
throw error;
}
};
return {
uri: asset.uri,
...savedPhoto,
};
} catch (error) {
console.error('Failed to pick image from gallery:', error);
throw error;
}
};
const hasPermission = permission?.granted ?? false;
const canAskPermission = permission?.canAskAgain ?? true;
const hasPermission = permission?.granted ?? false;
const canAskPermission = permission?.canAskAgain ?? true;
return {
// Permission state
hasPermission,
canAskPermission,
requestPermission,
return {
// Permission state
hasPermission,
canAskPermission,
requestPermission,
// Camera state
isReady,
setIsReady,
isCapturing,
facing,
cameraRef,
// Camera state
isReady,
setIsReady,
isCapturing,
facing,
cameraRef,
// Actions
toggleCameraFacing,
takePicture,
pickImageFromGallery,
};
// Actions
toggleCameraFacing,
takePicture,
pickImageFromGallery,
};
}

View file

@ -6,71 +6,71 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
import { useAppStore } from '../store/AppStore';
export function useDatabase() {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const setInitialized = useAppStore((state) => state.setInitialized);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const setInitialized = useAppStore((state) => state.setInitialized);
useEffect(() => {
initializeDatabase();
}, [initializeDatabase]);
useEffect(() => {
initializeDatabase();
}, [initializeDatabase]);
const initializeDatabase = useCallback(async () => {
try {
console.log('Initializing database...');
const initializeDatabase = useCallback(async () => {
try {
console.log('Initializing database...');
// Initialize SQLite service
const dbService = SQLiteService.getInstance();
await dbService.initialize();
// Initialize SQLite service
const dbService = SQLiteService.getInstance();
await dbService.initialize();
// Get database instance for migrations
const db = (dbService as any).db; // Access private db property
if (db) {
const migrationService = MigrationService.getInstance();
migrationService.setDatabase(db);
await migrationService.runMigrations();
}
// Get database instance for migrations
const db = (dbService as any).db; // Access private db property
if (db) {
const migrationService = MigrationService.getInstance();
migrationService.setDatabase(db);
await migrationService.runMigrations();
}
console.log('Database initialized successfully');
console.log('Database initialized successfully');
// Initialize user preferences
const prefsService = UserPreferencesService.getInstance();
await prefsService.initialize();
console.log('User preferences initialized');
// Initialize user preferences
const prefsService = UserPreferencesService.getInstance();
await prefsService.initialize();
console.log('User preferences initialized');
// Clean up temporary photos on app start
const photoService = PhotoService.getInstance();
await photoService.cleanupTempPhotos();
console.log('Temporary photos cleaned up');
// Clean up temporary photos on app start
const photoService = PhotoService.getInstance();
await photoService.cleanupTempPhotos();
console.log('Temporary photos cleaned up');
setIsReady(true);
setInitialized(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
console.error('Database initialization error:', errorMessage);
setError(errorMessage);
}
}, [setInitialized]);
setIsReady(true);
setInitialized(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
console.error('Database initialization error:', errorMessage);
setError(errorMessage);
}
}, [setInitialized]);
const resetDatabase = async () => {
try {
setIsReady(false);
setError(null);
const resetDatabase = async () => {
try {
setIsReady(false);
setError(null);
const dbService = SQLiteService.getInstance();
await dbService.close();
const dbService = SQLiteService.getInstance();
await dbService.close();
// Reinitialize
await initializeDatabase();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
setError(errorMessage);
}
};
// Reinitialize
await initializeDatabase();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
setError(errorMessage);
}
};
return {
isReady,
error,
resetDatabase,
retryInitialization: initializeDatabase,
};
return {
isReady,
error,
resetDatabase,
retryInitialization: initializeDatabase,
};
}

View file

@ -6,57 +6,57 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const THEME_STORAGE_KEY = 'user-theme-preference';
export const useTheme = () => {
const { colorScheme, setColorScheme } = useColorScheme();
const { theme, setTheme } = useAppStore();
const { colorScheme, setColorScheme } = useColorScheme();
const { theme, setTheme } = useAppStore();
// Initialize theme from storage on app start
useEffect(() => {
const initializeTheme = async () => {
try {
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
setTheme(parsedTheme);
// Initialize theme from storage on app start
useEffect(() => {
const initializeTheme = async () => {
try {
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
setTheme(parsedTheme);
// Apply the theme to NativeWind
if (parsedTheme === 'system') {
setColorScheme('system');
} else {
setColorScheme(parsedTheme);
}
}
} catch (error) {
console.error('Error loading theme from storage:', error);
}
};
// Apply the theme to NativeWind
if (parsedTheme === 'system') {
setColorScheme('system');
} else {
setColorScheme(parsedTheme);
}
}
} catch (error) {
console.error('Error loading theme from storage:', error);
}
};
initializeTheme();
}, [setTheme, setColorScheme]);
initializeTheme();
}, [setTheme, setColorScheme]);
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
try {
// Update AppStore
setTheme(newTheme);
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
try {
// Update AppStore
setTheme(newTheme);
// Update NativeWind
if (newTheme === 'system') {
setColorScheme('system');
} else {
setColorScheme(newTheme);
}
// Update NativeWind
if (newTheme === 'system') {
setColorScheme('system');
} else {
setColorScheme(newTheme);
}
// Persist to storage
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
} catch (error) {
console.error('Error saving theme to storage:', error);
}
};
// Persist to storage
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
} catch (error) {
console.error('Error saving theme to storage:', error);
}
};
return {
theme,
colorScheme,
updateTheme,
isDark: colorScheme === 'dark',
isLight: colorScheme === 'light',
};
return {
theme,
colorScheme,
updateTheme,
isDark: colorScheme === 'dark',
isLight: colorScheme === 'light',
};
};

View file

@ -10,8 +10,8 @@ const config = getDefaultConfig(__dirname);
// Add path mapping for @ alias
config.resolver.alias = {
'@': path.resolve(__dirname, './'),
...config.resolver.alias,
'@': path.resolve(__dirname, './'),
...config.resolver.alias,
};
module.exports = withNativeWind(config, { input: './global.css' });

View file

@ -1,70 +1,70 @@
{
"name": "@nutriphi/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@google/generative-ai": "^0.24.1",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-clipboard/clipboard": "^1.16.2",
"@react-navigation/native": "^7.0.3",
"expo": "^53.0.11",
"expo-application": "~6.1.4",
"expo-device": "~7.1.4",
"expo-secure-store": "~14.2.3",
"expo-blur": "^14.1.5",
"expo-camera": "^16.1.8",
"expo-constants": "~17.1.4",
"expo-dev-client": "~5.2.0",
"expo-dev-launcher": "^5.0.17",
"expo-file-system": "^18.1.10",
"expo-image-picker": "^16.1.4",
"expo-linking": "~7.1.4",
"expo-location": "^18.1.5",
"expo-router": "~5.1.0",
"expo-sqlite": "^15.2.12",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.6",
"expo-web-browser": "~14.1.6",
"nativewind": "latest",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.3",
"react-native-context-menu-view": "^1.19.0",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-uuid": "^2.0.3",
"react-native-web": "^0.20.0",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.0.10",
"ajv": "^8.12.0",
"eslint": "^9.25.1",
"eslint-config-expo": "^9.2.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.8.3"
},
"private": true
"name": "@nutriphi/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@google/generative-ai": "^0.24.1",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-clipboard/clipboard": "^1.16.2",
"@react-navigation/native": "^7.0.3",
"expo": "^53.0.11",
"expo-application": "~6.1.4",
"expo-device": "~7.1.4",
"expo-secure-store": "~14.2.3",
"expo-blur": "^14.1.5",
"expo-camera": "^16.1.8",
"expo-constants": "~17.1.4",
"expo-dev-client": "~5.2.0",
"expo-dev-launcher": "^5.0.17",
"expo-file-system": "^18.1.10",
"expo-image-picker": "^16.1.4",
"expo-linking": "~7.1.4",
"expo-location": "^18.1.5",
"expo-router": "~5.1.0",
"expo-sqlite": "^15.2.12",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.6",
"expo-web-browser": "~14.1.6",
"nativewind": "latest",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.3",
"react-native-context-menu-view": "^1.19.0",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-uuid": "^2.0.3",
"react-native-web": "^0.20.0",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.0.10",
"ajv": "^8.12.0",
"eslint": "^9.25.1",
"eslint-config-expo": "^9.2.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.8.3"
},
"private": true
}

View file

@ -1,10 +1,10 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View file

@ -8,143 +8,143 @@ import { useAuthStore } from '../store/AuthStore';
import { tokenManager } from './auth/tokenManager';
export class DataClearingService {
private static instance: DataClearingService;
private static instance: DataClearingService;
public static getInstance(): DataClearingService {
if (!DataClearingService.instance) {
DataClearingService.instance = new DataClearingService();
}
return DataClearingService.instance;
}
public static getInstance(): DataClearingService {
if (!DataClearingService.instance) {
DataClearingService.instance = new DataClearingService();
}
return DataClearingService.instance;
}
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
try {
// 1. Clear SQLite database
await this.clearDatabase();
} catch (error) {
errors.push(`Database clearing failed: ${error}`);
}
try {
// 1. Clear SQLite database
await this.clearDatabase();
} catch (error) {
errors.push(`Database clearing failed: ${error}`);
}
try {
// 2. Clear photo storage
await this.clearPhotoStorage();
} catch (error) {
errors.push(`Photo storage clearing failed: ${error}`);
}
try {
// 2. Clear photo storage
await this.clearPhotoStorage();
} catch (error) {
errors.push(`Photo storage clearing failed: ${error}`);
}
try {
// 3. Reset Zustand stores
this.resetZustandStores();
} catch (error) {
errors.push(`State reset failed: ${error}`);
}
try {
// 3. Reset Zustand stores
this.resetZustandStores();
} catch (error) {
errors.push(`State reset failed: ${error}`);
}
try {
// 4. Clear AsyncStorage
await this.clearAsyncStorage();
} catch (error) {
errors.push(`AsyncStorage clearing failed: ${error}`);
}
try {
// 4. Clear AsyncStorage
await this.clearAsyncStorage();
} catch (error) {
errors.push(`AsyncStorage clearing failed: ${error}`);
}
try {
// 5. Sign out and clear auth tokens
await this.signOutAndClearAuth();
} catch (error) {
errors.push(`Auth clearing failed: ${error}`);
}
try {
// 5. Sign out and clear auth tokens
await this.signOutAndClearAuth();
} catch (error) {
errors.push(`Auth clearing failed: ${error}`);
}
return {
success: errors.length === 0,
errors,
};
}
return {
success: errors.length === 0,
errors,
};
}
private async signOutAndClearAuth(): Promise<void> {
// Sign out from auth store
await useAuthStore.getState().signOut();
// Clear all tokens
await tokenManager.clearTokens();
}
private async signOutAndClearAuth(): Promise<void> {
// Sign out from auth store
await useAuthStore.getState().signOut();
// Clear all tokens
await tokenManager.clearTokens();
}
private async clearDatabase(): Promise<void> {
const db = SQLiteService.getInstance();
private async clearDatabase(): Promise<void> {
const db = SQLiteService.getInstance();
// Clear all main tables while preserving structure
await db.executeRaw('DELETE FROM meals');
await db.executeRaw('DELETE FROM food_items');
await db.executeRaw('DELETE FROM sync_metadata');
// Clear all main tables while preserving structure
await db.executeRaw('DELETE FROM meals');
await db.executeRaw('DELETE FROM food_items');
await db.executeRaw('DELETE FROM sync_metadata');
// Reset user preferences to defaults but keep the table
await db.executeRaw('DELETE FROM user_preferences');
// Reset user preferences to defaults but keep the table
await db.executeRaw('DELETE FROM user_preferences');
// Don't delete schema_migrations to preserve database version
}
// Don't delete schema_migrations to preserve database version
}
private async clearPhotoStorage(): Promise<void> {
const photosDir = `${FileSystem.documentDirectory}photos/`;
private async clearPhotoStorage(): Promise<void> {
const photosDir = `${FileSystem.documentDirectory}photos/`;
// Check if photos directory exists
const dirInfo = await FileSystem.getInfoAsync(photosDir);
if (!dirInfo.exists) return;
// Check if photos directory exists
const dirInfo = await FileSystem.getInfoAsync(photosDir);
if (!dirInfo.exists) return;
// Get all files in photos directory
const files = await FileSystem.readDirectoryAsync(photosDir);
// Get all files in photos directory
const files = await FileSystem.readDirectoryAsync(photosDir);
// Delete all photo files
for (const file of files) {
const filePath = `${photosDir}${file}`;
await FileSystem.deleteAsync(filePath, { idempotent: true });
}
// Delete all photo files
for (const file of files) {
const filePath = `${photosDir}${file}`;
await FileSystem.deleteAsync(filePath, { idempotent: true });
}
// Also cleanup any temp photos
await PhotoService.getInstance().cleanupTempPhotos();
}
// Also cleanup any temp photos
await PhotoService.getInstance().cleanupTempPhotos();
}
private resetZustandStores(): void {
// Reset MealStore
const mealStore = useMealStore.getState();
mealStore.clearAllMeals();
mealStore.setSelectedMeal(null);
private resetZustandStores(): void {
// Reset MealStore
const mealStore = useMealStore.getState();
mealStore.clearAllMeals();
mealStore.setSelectedMeal(null);
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
const appStore = useAppStore.getState();
appStore.resetStats();
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
const appStore = useAppStore.getState();
appStore.resetStats();
// Reset other app store states except theme
const currentTheme = appStore.theme;
appStore.resetToDefaults();
appStore.setTheme(currentTheme); // Preserve current theme
}
// Reset other app store states except theme
const currentTheme = appStore.theme;
appStore.resetToDefaults();
appStore.setTheme(currentTheme); // Preserve current theme
}
private async clearAsyncStorage(): Promise<void> {
// Get all keys
const keys = await AsyncStorage.getAllKeys();
private async clearAsyncStorage(): Promise<void> {
// Get all keys
const keys = await AsyncStorage.getAllKeys();
// Define keys to clear (all except we might want to preserve some)
const keysToRemove = keys.filter(
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
);
// Define keys to clear (all except we might want to preserve some)
const keysToRemove = keys.filter(
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
);
// Clear selected keys
if (keysToRemove.length > 0) {
await AsyncStorage.multiRemove(keysToRemove);
}
}
// Clear selected keys
if (keysToRemove.length > 0) {
await AsyncStorage.multiRemove(keysToRemove);
}
}
// Optional: Clear everything including theme preference
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
const result = await this.clearAllData();
// Optional: Clear everything including theme preference
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
const result = await this.clearAllData();
try {
// Also clear theme preference
await AsyncStorage.removeItem('user-theme-preference');
} catch (error) {
result.errors.push(`Theme preference clearing failed: ${error}`);
result.success = false;
}
try {
// Also clear theme preference
await AsyncStorage.removeItem('user-theme-preference');
} catch (error) {
result.errors.push(`Theme preference clearing failed: ${error}`);
result.success = false;
}
return result;
}
return result;
}
}

View file

@ -1,217 +1,217 @@
import * as Location from 'expo-location';
export interface LocationData {
latitude: number;
longitude: number;
accuracy: number | null;
altitude: number | null;
altitudeAccuracy: number | null;
heading: number | null;
speed: number | null;
timestamp: number;
address?: LocationAddress;
latitude: number;
longitude: number;
accuracy: number | null;
altitude: number | null;
altitudeAccuracy: number | null;
heading: number | null;
speed: number | null;
timestamp: number;
address?: LocationAddress;
}
export interface LocationAddress {
name?: string;
street?: string;
city?: string;
region?: string;
country?: string;
postalCode?: string;
formattedAddress?: string;
name?: string;
street?: string;
city?: string;
region?: string;
country?: string;
postalCode?: string;
formattedAddress?: string;
}
export class LocationService {
private static instance: LocationService;
private hasPermission: boolean = false;
private static instance: LocationService;
private hasPermission: boolean = false;
private constructor() {}
private constructor() {}
public static getInstance(): LocationService {
if (!LocationService.instance) {
LocationService.instance = new LocationService();
}
return LocationService.instance;
}
public static getInstance(): LocationService {
if (!LocationService.instance) {
LocationService.instance = new LocationService();
}
return LocationService.instance;
}
public async checkPermissions(): Promise<boolean> {
try {
const { status } = await Location.getForegroundPermissionsAsync();
this.hasPermission = status === 'granted';
return this.hasPermission;
} catch (error) {
console.error('Failed to check location permissions:', error);
return false;
}
}
public async checkPermissions(): Promise<boolean> {
try {
const { status } = await Location.getForegroundPermissionsAsync();
this.hasPermission = status === 'granted';
return this.hasPermission;
} catch (error) {
console.error('Failed to check location permissions:', error);
return false;
}
}
public async requestPermissions(): Promise<boolean> {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
this.hasPermission = status === 'granted';
return this.hasPermission;
} catch (error) {
console.error('Failed to request location permissions:', error);
return false;
}
}
public async requestPermissions(): Promise<boolean> {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
this.hasPermission = status === 'granted';
return this.hasPermission;
} catch (error) {
console.error('Failed to request location permissions:', error);
return false;
}
}
public async getCurrentLocation(): Promise<LocationData | null> {
try {
// Check permissions first
if (!this.hasPermission) {
const granted = await this.requestPermissions();
if (!granted) {
console.log('Location permission denied');
return null;
}
}
public async getCurrentLocation(): Promise<LocationData | null> {
try {
// Check permissions first
if (!this.hasPermission) {
const granted = await this.requestPermissions();
if (!granted) {
console.log('Location permission denied');
return null;
}
}
// Get current location with high accuracy
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
timeInterval: 5000, // 5 seconds
mayShowUserSettingsDialog: true,
});
// Get current location with high accuracy
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
timeInterval: 5000, // 5 seconds
mayShowUserSettingsDialog: true,
});
const locationData: LocationData = {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
accuracy: location.coords.accuracy,
altitude: location.coords.altitude,
altitudeAccuracy: location.coords.altitudeAccuracy,
heading: location.coords.heading,
speed: location.coords.speed,
timestamp: location.timestamp,
};
const locationData: LocationData = {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
accuracy: location.coords.accuracy,
altitude: location.coords.altitude,
altitudeAccuracy: location.coords.altitudeAccuracy,
heading: location.coords.heading,
speed: location.coords.speed,
timestamp: location.timestamp,
};
// Try to get address
try {
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
locationData.address = address;
} catch (error) {
console.warn('Reverse geocoding failed:', error);
}
// Try to get address
try {
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
locationData.address = address;
} catch (error) {
console.warn('Reverse geocoding failed:', error);
}
return locationData;
} catch (error) {
console.error('Failed to get current location:', error);
return null;
}
}
return locationData;
} catch (error) {
console.error('Failed to get current location:', error);
return null;
}
}
public async reverseGeocode(
latitude: number,
longitude: number
): Promise<LocationAddress | null> {
try {
const results = await Location.reverseGeocodeAsync({
latitude,
longitude,
});
public async reverseGeocode(
latitude: number,
longitude: number
): Promise<LocationAddress | null> {
try {
const results = await Location.reverseGeocodeAsync({
latitude,
longitude,
});
if (results && results.length > 0) {
const result = results[0];
if (results && results.length > 0) {
const result = results[0];
// Build formatted address
const addressParts = [];
// Build formatted address
const addressParts = [];
// Try to detect common places
let placeName = result.name;
if (!placeName && result.street) {
placeName = result.street;
}
// Try to detect common places
let placeName = result.name;
if (!placeName && result.street) {
placeName = result.street;
}
// Build formatted address
if (result.streetNumber) addressParts.push(result.streetNumber);
if (result.street) addressParts.push(result.street);
const streetAddress = addressParts.join(' ');
// Build formatted address
if (result.streetNumber) addressParts.push(result.streetNumber);
if (result.street) addressParts.push(result.street);
const streetAddress = addressParts.join(' ');
const cityParts = [];
if (result.city) cityParts.push(result.city);
if (result.region) cityParts.push(result.region);
if (result.postalCode) cityParts.push(result.postalCode);
const cityAddress = cityParts.join(', ');
const cityParts = [];
if (result.city) cityParts.push(result.city);
if (result.region) cityParts.push(result.region);
if (result.postalCode) cityParts.push(result.postalCode);
const cityAddress = cityParts.join(', ');
const formattedAddress = [streetAddress, cityAddress, result.country]
.filter(Boolean)
.join(', ');
const formattedAddress = [streetAddress, cityAddress, result.country]
.filter(Boolean)
.join(', ');
return {
name: placeName || undefined,
street: streetAddress || undefined,
city: result.city || undefined,
region: result.region || undefined,
country: result.country || undefined,
postalCode: result.postalCode || undefined,
formattedAddress: formattedAddress || undefined,
};
}
return {
name: placeName || undefined,
street: streetAddress || undefined,
city: result.city || undefined,
region: result.region || undefined,
country: result.country || undefined,
postalCode: result.postalCode || undefined,
formattedAddress: formattedAddress || undefined,
};
}
return null;
} catch (error) {
console.error('Reverse geocoding failed:', error);
return null;
}
}
return null;
} catch (error) {
console.error('Reverse geocoding failed:', error);
return null;
}
}
public getReadableLocationName(address: LocationAddress | null): string {
if (!address) return 'Unbekannter Ort';
public getReadableLocationName(address: LocationAddress | null): string {
if (!address) return 'Unbekannter Ort';
// Priority: name > street > city > region > country
if (address.name) return address.name;
if (address.street) return address.street;
if (address.city) return address.city;
if (address.region) return address.region;
if (address.country) return address.country;
// Priority: name > street > city > region > country
if (address.name) return address.name;
if (address.street) return address.street;
if (address.city) return address.city;
if (address.region) return address.region;
if (address.country) return address.country;
return 'Unbekannter Ort';
}
return 'Unbekannter Ort';
}
public formatLocationForDisplay(address: LocationAddress | null): string {
if (!address) return '';
public formatLocationForDisplay(address: LocationAddress | null): string {
if (!address) return '';
// For display in UI, show a concise version
if (address.name && address.city) {
return `${address.name}, ${address.city}`;
}
// For display in UI, show a concise version
if (address.name && address.city) {
return `${address.name}, ${address.city}`;
}
if (address.street && address.city) {
return `${address.street}, ${address.city}`;
}
if (address.street && address.city) {
return `${address.street}, ${address.city}`;
}
if (address.city) {
return address.city;
}
if (address.city) {
return address.city;
}
return address.formattedAddress || 'Unbekannter Ort';
}
return address.formattedAddress || 'Unbekannter Ort';
}
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
// Haversine formula to calculate distance in meters
const R = 6371e3; // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
// Haversine formula to calculate distance in meters
const R = 6371e3; // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
return R * c; // Distance in meters
}
public isNearLocation(
currentLat: number,
currentLon: number,
targetLat: number,
targetLon: number,
thresholdMeters: number = 100
): boolean {
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
return distance <= thresholdMeters;
}
public isNearLocation(
currentLat: number,
currentLon: number,
targetLat: number,
targetLon: number,
thresholdMeters: number = 100
): boolean {
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
return distance <= thresholdMeters;
}
}

View file

@ -1,207 +1,207 @@
import { SQLiteService } from './database/SQLiteService';
export interface UserPreferences {
locationEnabled: boolean;
locationPermissionAsked: boolean;
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
healthGoalCalories?: number;
healthGoalProtein?: number;
healthGoalCarbs?: number;
healthGoalFat?: number;
notificationsEnabled: boolean;
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
theme: 'light' | 'dark' | 'system';
language: 'de' | 'en';
locationEnabled: boolean;
locationPermissionAsked: boolean;
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
healthGoalCalories?: number;
healthGoalProtein?: number;
healthGoalCarbs?: number;
healthGoalFat?: number;
notificationsEnabled: boolean;
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
theme: 'light' | 'dark' | 'system';
language: 'de' | 'en';
}
const DEFAULT_PREFERENCES: UserPreferences = {
locationEnabled: true,
locationPermissionAsked: false,
defaultMealType: 'lunch',
notificationsEnabled: true,
reminderTimes: [],
theme: 'system',
language: 'de',
locationEnabled: true,
locationPermissionAsked: false,
defaultMealType: 'lunch',
notificationsEnabled: true,
reminderTimes: [],
theme: 'system',
language: 'de',
};
export class UserPreferencesService {
private static instance: UserPreferencesService;
private dbService: SQLiteService;
private cachedPreferences: UserPreferences | null = null;
private static instance: UserPreferencesService;
private dbService: SQLiteService;
private cachedPreferences: UserPreferences | null = null;
private constructor() {
this.dbService = SQLiteService.getInstance();
}
private constructor() {
this.dbService = SQLiteService.getInstance();
}
public static getInstance(): UserPreferencesService {
if (!UserPreferencesService.instance) {
UserPreferencesService.instance = new UserPreferencesService();
}
return UserPreferencesService.instance;
}
public static getInstance(): UserPreferencesService {
if (!UserPreferencesService.instance) {
UserPreferencesService.instance = new UserPreferencesService();
}
return UserPreferencesService.instance;
}
public async initialize(): Promise<void> {
// Load preferences into cache
await this.loadPreferences();
}
public async initialize(): Promise<void> {
// Load preferences into cache
await this.loadPreferences();
}
private async loadPreferences(): Promise<UserPreferences> {
try {
const db = await this.dbService.getDatabase();
// Check if table exists first
const tableExists = await db.getFirstAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
);
if (!tableExists) {
console.log('User preferences table does not exist yet, using defaults');
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
return this.cachedPreferences;
}
private async loadPreferences(): Promise<UserPreferences> {
try {
const db = await this.dbService.getDatabase();
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
'SELECT key, value, type FROM user_preferences'
);
// Check if table exists first
const tableExists = await db.getFirstAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
);
const preferences = { ...DEFAULT_PREFERENCES };
if (!tableExists) {
console.log('User preferences table does not exist yet, using defaults');
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
return this.cachedPreferences;
}
for (const row of rows) {
const value = this.parseValue(row.value, row.type);
(preferences as any)[row.key] = value;
}
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
'SELECT key, value, type FROM user_preferences'
);
this.cachedPreferences = preferences;
return preferences;
} catch (error) {
console.error('Failed to load preferences:', error);
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
return this.cachedPreferences;
}
}
const preferences = { ...DEFAULT_PREFERENCES };
private parseValue(value: string, type: string): any {
switch (type) {
case 'boolean':
return value === 'true';
case 'number':
return parseFloat(value);
case 'array':
return JSON.parse(value);
case 'string':
default:
return value;
}
}
for (const row of rows) {
const value = this.parseValue(row.value, row.type);
(preferences as any)[row.key] = value;
}
private getValueType(value: any): string {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (Array.isArray(value)) return 'array';
return 'string';
}
this.cachedPreferences = preferences;
return preferences;
} catch (error) {
console.error('Failed to load preferences:', error);
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
return this.cachedPreferences;
}
}
public async getPreferences(): Promise<UserPreferences> {
if (!this.cachedPreferences) {
await this.loadPreferences();
}
return this.cachedPreferences!;
}
private parseValue(value: string, type: string): any {
switch (type) {
case 'boolean':
return value === 'true';
case 'number':
return parseFloat(value);
case 'array':
return JSON.parse(value);
case 'string':
default:
return value;
}
}
public async updatePreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
): Promise<void> {
try {
// Update cache immediately for responsive UI
if (!this.cachedPreferences) {
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
}
this.cachedPreferences[key] = value;
private getValueType(value: any): string {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (Array.isArray(value)) return 'array';
return 'string';
}
const db = await this.dbService.getDatabase();
// Check if table exists
const tableExists = await db.getFirstAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
);
if (!tableExists) {
console.log('User preferences table does not exist yet, cache updated only');
return;
}
public async getPreferences(): Promise<UserPreferences> {
if (!this.cachedPreferences) {
await this.loadPreferences();
}
return this.cachedPreferences!;
}
const type = this.getValueType(value);
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
public async updatePreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
): Promise<void> {
try {
// Update cache immediately for responsive UI
if (!this.cachedPreferences) {
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
}
this.cachedPreferences[key] = value;
await db.runAsync(
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
const db = await this.dbService.getDatabase();
// Check if table exists
const tableExists = await db.getFirstAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
);
if (!tableExists) {
console.log('User preferences table does not exist yet, cache updated only');
return;
}
const type = this.getValueType(value);
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
await db.runAsync(
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
VALUES (?, ?, ?, datetime('now'))`,
[key, serializedValue, type]
);
} catch (error) {
console.error(`Failed to update preference ${key}:`, error);
// Don't throw - we already updated the cache
}
}
[key, serializedValue, type]
);
} catch (error) {
console.error(`Failed to update preference ${key}:`, error);
// Don't throw - we already updated the cache
}
}
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
const db = await this.dbService.getDatabase();
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
const db = await this.dbService.getDatabase();
try {
await db.execAsync('BEGIN TRANSACTION');
try {
await db.execAsync('BEGIN TRANSACTION');
for (const [key, value] of Object.entries(updates)) {
const type = this.getValueType(value);
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
for (const [key, value] of Object.entries(updates)) {
const type = this.getValueType(value);
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
await db.runAsync(
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
await db.runAsync(
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
VALUES (?, ?, ?, datetime('now'))`,
[key, serializedValue, type]
);
}
[key, serializedValue, type]
);
}
await db.execAsync('COMMIT');
await db.execAsync('COMMIT');
// Update cache
if (this.cachedPreferences) {
Object.assign(this.cachedPreferences, updates);
}
} catch (error) {
await db.execAsync('ROLLBACK');
console.error('Failed to update preferences:', error);
throw error;
}
}
// Update cache
if (this.cachedPreferences) {
Object.assign(this.cachedPreferences, updates);
}
} catch (error) {
await db.execAsync('ROLLBACK');
console.error('Failed to update preferences:', error);
throw error;
}
}
public async resetToDefaults(): Promise<void> {
try {
const db = await this.dbService.getDatabase();
await db.execAsync('DELETE FROM user_preferences');
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
} catch (error) {
console.error('Failed to reset preferences:', error);
throw error;
}
}
public async resetToDefaults(): Promise<void> {
try {
const db = await this.dbService.getDatabase();
await db.execAsync('DELETE FROM user_preferences');
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
} catch (error) {
console.error('Failed to reset preferences:', error);
throw error;
}
}
// Convenience methods
public async isLocationEnabled(): Promise<boolean> {
const prefs = await this.getPreferences();
return prefs.locationEnabled;
}
// Convenience methods
public async isLocationEnabled(): Promise<boolean> {
const prefs = await this.getPreferences();
return prefs.locationEnabled;
}
public async setLocationEnabled(enabled: boolean): Promise<void> {
await this.updatePreference('locationEnabled', enabled);
}
public async setLocationEnabled(enabled: boolean): Promise<void> {
await this.updatePreference('locationEnabled', enabled);
}
public async hasAskedLocationPermission(): Promise<boolean> {
const prefs = await this.getPreferences();
return prefs.locationPermissionAsked;
}
public async hasAskedLocationPermission(): Promise<boolean> {
const prefs = await this.getPreferences();
return prefs.locationPermissionAsked;
}
public async markLocationPermissionAsked(): Promise<void> {
await this.updatePreference('locationPermissionAsked', true);
}
public async markLocationPermissionAsked(): Promise<void> {
await this.updatePreference('locationPermissionAsked', true);
}
}

View file

@ -5,117 +5,117 @@ import Constants from 'expo-constants';
import { GeminiAnalysisResult, GeminiError, PromptContext } from '../../types/API';
interface GeminiConfig {
apiKey: string;
model: string;
temperature: number;
maxOutputTokens: number;
apiKey: string;
model: string;
temperature: number;
maxOutputTokens: number;
}
interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
backoffMultiplier: number;
maxRetries: number;
baseDelay: number;
maxDelay: number;
backoffMultiplier: number;
}
export class GeminiService {
private static instance: GeminiService;
private genAI: GoogleGenerativeAI | null = null;
private model: any = null;
private static instance: GeminiService;
private genAI: GoogleGenerativeAI | null = null;
private model: any = null;
private config: GeminiConfig = {
apiKey:
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
model: 'gemini-1.5-pro-latest',
temperature: 0.1, // Low temperature for consistent analysis
maxOutputTokens: 2048,
};
private config: GeminiConfig = {
apiKey:
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
model: 'gemini-1.5-pro-latest',
temperature: 0.1, // Low temperature for consistent analysis
maxOutputTokens: 2048,
};
private requestTimeout = 60000; // 60 seconds timeout
private requestTimeout = 60000; // 60 seconds timeout
private retryConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
backoffMultiplier: 2,
};
private retryConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
backoffMultiplier: 2,
};
private constructor() {
this.initialize();
}
private constructor() {
this.initialize();
}
public static getInstance(): GeminiService {
if (!GeminiService.instance) {
GeminiService.instance = new GeminiService();
}
return GeminiService.instance;
}
public static getInstance(): GeminiService {
if (!GeminiService.instance) {
GeminiService.instance = new GeminiService();
}
return GeminiService.instance;
}
private initialize() {
console.log('Initializing GeminiService...');
console.log('API Key available:', !!this.config.apiKey);
console.log('API Key length:', this.config.apiKey.length);
private initialize() {
console.log('Initializing GeminiService...');
console.log('API Key available:', !!this.config.apiKey);
console.log('API Key length:', this.config.apiKey.length);
if (!this.config.apiKey) {
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
return;
}
if (!this.config.apiKey) {
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
return;
}
try {
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
this.model = this.genAI.getGenerativeModel({
model: this.config.model,
generationConfig: {
temperature: this.config.temperature,
maxOutputTokens: this.config.maxOutputTokens,
},
});
console.log('GeminiService initialized successfully');
} catch (error) {
console.error('Failed to initialize GeminiService:', error);
}
}
try {
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
this.model = this.genAI.getGenerativeModel({
model: this.config.model,
generationConfig: {
temperature: this.config.temperature,
maxOutputTokens: this.config.maxOutputTokens,
},
});
console.log('GeminiService initialized successfully');
} catch (error) {
console.error('Failed to initialize GeminiService:', error);
}
}
/**
* Converts local image file to base64 for Gemini API
*/
private async imageToBase64(imagePath: string): Promise<string> {
try {
console.log('Converting image to base64:', imagePath);
/**
* Converts local image file to base64 for Gemini API
*/
private async imageToBase64(imagePath: string): Promise<string> {
try {
console.log('Converting image to base64:', imagePath);
// Check if file exists
const fileInfo = await FileSystem.getInfoAsync(imagePath);
if (!fileInfo.exists) {
throw new Error(`Image file not found: ${imagePath}`);
}
// Check if file exists
const fileInfo = await FileSystem.getInfoAsync(imagePath);
if (!fileInfo.exists) {
throw new Error(`Image file not found: ${imagePath}`);
}
// Check file size (limit to 20MB to prevent timeouts)
const maxSize = 20 * 1024 * 1024; // 20MB
if (fileInfo.size && fileInfo.size > maxSize) {
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
}
// Check file size (limit to 20MB to prevent timeouts)
const maxSize = 20 * 1024 * 1024; // 20MB
if (fileInfo.size && fileInfo.size > maxSize) {
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
}
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
const base64 = await FileSystem.readAsStringAsync(imagePath, {
encoding: FileSystem.EncodingType.Base64,
});
const base64 = await FileSystem.readAsStringAsync(imagePath, {
encoding: FileSystem.EncodingType.Base64,
});
console.log('Base64 conversion completed, length:', base64.length);
return base64;
} catch (error) {
console.error('Failed to convert image to base64:', error);
throw new Error(`Failed to convert image to base64: ${error}`);
}
}
console.log('Base64 conversion completed, length:', base64.length);
return base64;
} catch (error) {
console.error('Failed to convert image to base64:', error);
throw new Error(`Failed to convert image to base64: ${error}`);
}
}
/**
* Generates the optimized prompt based on context
*/
private generatePrompt(context?: PromptContext): string {
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
/**
* Generates the optimized prompt based on context
*/
private generatePrompt(context?: PromptContext): string {
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
AUFGABE:
1. Erkenne alle sichtbaren Lebensmittel und schätze realistische Portionsgrößen
@ -194,347 +194,347 @@ WICHTIG:
- Versteckte Fette/Öle nicht vergessen
- Mehrere gleiche Items separat listen`;
return basePrompt;
}
return basePrompt;
}
/**
* Adds contextual information to the prompt
*/
private getContextualPrompt(context?: PromptContext): string {
if (!context) return '';
/**
* Adds contextual information to the prompt
*/
private getContextualPrompt(context?: PromptContext): string {
if (!context) return '';
const contextPrompts = {
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
};
const contextPrompts = {
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
};
const contextStrings: string[] = [];
const contextStrings: string[] = [];
if (context.mealType) {
contextStrings.push(contextPrompts[context.mealType] || '');
}
if (context.mealType) {
contextStrings.push(contextPrompts[context.mealType] || '');
}
if (context.location) {
contextStrings.push(contextPrompts[context.location] || '');
}
if (context.location) {
contextStrings.push(contextPrompts[context.location] || '');
}
if (context.additional) {
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
}
if (context.additional) {
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
}
return contextStrings.filter(Boolean).join('\n');
}
return contextStrings.filter(Boolean).join('\n');
}
/**
* Implements exponential backoff retry logic
*/
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
try {
return await operation();
} catch (error) {
if (attempt >= this.retryConfig.maxRetries) {
throw error;
}
/**
* Implements exponential backoff retry logic
*/
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
try {
return await operation();
} catch (error) {
if (attempt >= this.retryConfig.maxRetries) {
throw error;
}
const delay = Math.min(
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
this.retryConfig.maxDelay
);
const delay = Math.min(
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
this.retryConfig.maxDelay
);
console.log(
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
);
console.log(
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
return this.retry(operation, attempt + 1);
}
}
await new Promise((resolve) => setTimeout(resolve, delay));
return this.retry(operation, attempt + 1);
}
}
/**
* Validates and parses the Gemini API response
*/
private validateResponse(response: string): GeminiAnalysisResult {
try {
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
/**
* Validates and parses the Gemini API response
*/
private validateResponse(response: string): GeminiAnalysisResult {
try {
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
// Clean the response - remove any markdown formatting
const cleanResponse = response
.replace(/```json\n?/g, '')
.replace(/```\n?/g, '')
.trim();
// Clean the response - remove any markdown formatting
const cleanResponse = response
.replace(/```json\n?/g, '')
.replace(/```\n?/g, '')
.trim();
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
const parsed = JSON.parse(cleanResponse);
console.log('Parsed JSON structure:', {
hasMealAnalysis: !!parsed.meal_analysis,
hasFoodItems: !!parsed.food_items,
hasAnalysisNotes: !!parsed.analysis_notes,
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
});
const parsed = JSON.parse(cleanResponse);
console.log('Parsed JSON structure:', {
hasMealAnalysis: !!parsed.meal_analysis,
hasFoodItems: !!parsed.food_items,
hasAnalysisNotes: !!parsed.analysis_notes,
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
});
// Validate required fields
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
throw new Error('Missing required fields in API response');
}
// Validate required fields
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
throw new Error('Missing required fields in API response');
}
// Validate meal_analysis structure with fallbacks
const mealAnalysis = parsed.meal_analysis;
// Validate meal_analysis structure with fallbacks
const mealAnalysis = parsed.meal_analysis;
// Set defaults for missing fields
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
console.warn('health_score missing, setting default value');
mealAnalysis.health_score = 5.0; // Default neutral score
}
// Set defaults for missing fields
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
console.warn('health_score missing, setting default value');
mealAnalysis.health_score = 5.0; // Default neutral score
}
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
console.warn('health_category missing, setting default value');
mealAnalysis.health_category = 'moderate';
}
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
console.warn('health_category missing, setting default value');
mealAnalysis.health_category = 'moderate';
}
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
console.warn('confidence missing, setting default value');
mealAnalysis.confidence = 0.7; // Default medium confidence
}
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
console.warn('confidence missing, setting default value');
mealAnalysis.confidence = 0.7; // Default medium confidence
}
// Ensure required numerical fields exist
const requiredNumericalFields = [
'total_calories',
'total_protein',
'total_carbs',
'total_fat',
];
for (const field of requiredNumericalFields) {
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
console.warn(`${field} missing, setting to 0`);
mealAnalysis[field] = 0;
}
}
// Ensure required numerical fields exist
const requiredNumericalFields = [
'total_calories',
'total_protein',
'total_carbs',
'total_fat',
];
for (const field of requiredNumericalFields) {
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
console.warn(`${field} missing, setting to 0`);
mealAnalysis[field] = 0;
}
}
// Validate health_score range
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
console.warn('Health score out of range, clamping to 1-10');
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
}
// Validate health_score range
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
console.warn('Health score out of range, clamping to 1-10');
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
}
// Validate confidence range
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
console.warn('Confidence out of range, clamping to 0-1');
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
}
// Validate confidence range
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
console.warn('Confidence out of range, clamping to 0-1');
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
}
// Ensure food_items is an array
if (!Array.isArray(parsed.food_items)) {
console.warn('food_items is not an array, creating empty array');
parsed.food_items = [];
}
// Ensure food_items is an array
if (!Array.isArray(parsed.food_items)) {
console.warn('food_items is not an array, creating empty array');
parsed.food_items = [];
}
console.log('Response validation successful');
return parsed as GeminiAnalysisResult;
} catch (error) {
console.error('Full response that failed to parse:', response);
throw new Error(`Failed to parse Gemini response: ${error}`);
}
}
console.log('Response validation successful');
return parsed as GeminiAnalysisResult;
} catch (error) {
console.error('Full response that failed to parse:', response);
throw new Error(`Failed to parse Gemini response: ${error}`);
}
}
/**
* Main method to analyze food image
*/
public async analyzeFoodImage(
imagePath: string,
context?: PromptContext
): Promise<GeminiAnalysisResult> {
if (!this.model) {
console.error('GeminiService not properly initialized');
console.log('Attempting re-initialization...');
this.initialize();
/**
* Main method to analyze food image
*/
public async analyzeFoodImage(
imagePath: string,
context?: PromptContext
): Promise<GeminiAnalysisResult> {
if (!this.model) {
console.error('GeminiService not properly initialized');
console.log('Attempting re-initialization...');
this.initialize();
if (!this.model) {
throw new GeminiError(
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
'INITIALIZATION_ERROR',
'PERMANENT'
);
}
}
if (!this.model) {
throw new GeminiError(
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
'INITIALIZATION_ERROR',
'PERMANENT'
);
}
}
const startTime = Date.now();
const startTime = Date.now();
try {
console.log('Starting Gemini food analysis...');
console.log('Analysis parameters:', {
imagePath,
context,
timeout: this.requestTimeout,
maxRetries: this.retryConfig.maxRetries,
});
try {
console.log('Starting Gemini food analysis...');
console.log('Analysis parameters:', {
imagePath,
context,
timeout: this.requestTimeout,
maxRetries: this.retryConfig.maxRetries,
});
// Convert image to base64
console.log('Step 1: Converting image to base64...');
const base64Image = await this.imageToBase64(imagePath);
console.log('Step 1 completed: Base64 conversion successful');
// Convert image to base64
console.log('Step 1: Converting image to base64...');
const base64Image = await this.imageToBase64(imagePath);
console.log('Step 1 completed: Base64 conversion successful');
// Generate prompt
console.log('Step 2: Generating prompt...');
const prompt = this.generatePrompt(context);
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
// Generate prompt
console.log('Step 2: Generating prompt...');
const prompt = this.generatePrompt(context);
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
// Call Gemini API with retry logic
console.log('Step 3: Making Gemini API request...');
const result = await this.retry(async () => {
console.log('Making Gemini API request with timeout:', this.requestTimeout);
const requestStartTime = Date.now();
// Call Gemini API with retry logic
console.log('Step 3: Making Gemini API request...');
const result = await this.retry(async () => {
console.log('Making Gemini API request with timeout:', this.requestTimeout);
const requestStartTime = Date.now();
// Create timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
}, this.requestTimeout);
});
// Create timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
}, this.requestTimeout);
});
// Race between API call and timeout
const response = await Promise.race([
this.model.generateContent([
prompt,
{
inlineData: {
data: base64Image,
mimeType: 'image/jpeg',
},
},
]),
timeoutPromise,
]);
// Race between API call and timeout
const response = await Promise.race([
this.model.generateContent([
prompt,
{
inlineData: {
data: base64Image,
mimeType: 'image/jpeg',
},
},
]),
timeoutPromise,
]);
const requestDuration = Date.now() - requestStartTime;
console.log('Gemini API request completed in:', requestDuration, 'ms');
const requestDuration = Date.now() - requestStartTime;
console.log('Gemini API request completed in:', requestDuration, 'ms');
if (!response || !response.response) {
throw new Error('Invalid response structure from Gemini API');
}
if (!response || !response.response) {
throw new Error('Invalid response structure from Gemini API');
}
const text = response.response.text();
if (!text) {
throw new Error('Empty response from Gemini API');
}
const text = response.response.text();
if (!text) {
throw new Error('Empty response from Gemini API');
}
console.log('Gemini API response received, length:', text.length);
return text;
});
console.log('Step 3 completed: API request successful');
console.log('Gemini API response received, length:', text.length);
return text;
});
console.log('Step 3 completed: API request successful');
// Validate and parse response
console.log('Step 4: Validating and parsing response...');
const analysisResult = this.validateResponse(result);
console.log('Step 4 completed: Response validation successful');
// Validate and parse response
console.log('Step 4: Validating and parsing response...');
const analysisResult = this.validateResponse(result);
console.log('Step 4 completed: Response validation successful');
const processingTime = Date.now() - startTime;
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
const processingTime = Date.now() - startTime;
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
return {
...analysisResult,
_metadata: {
processingTime,
apiProvider: 'gemini',
model: this.config.model,
timestamp: new Date().toISOString(),
},
};
} catch (error) {
const processingTime = Date.now() - startTime;
console.error('Gemini analysis failed:', error);
return {
...analysisResult,
_metadata: {
processingTime,
apiProvider: 'gemini',
model: this.config.model,
timestamp: new Date().toISOString(),
},
};
} catch (error) {
const processingTime = Date.now() - startTime;
console.error('Gemini analysis failed:', error);
throw new GeminiError(
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
this.categorizeError(error),
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
{
processingTime,
originalError: error instanceof Error ? error.message : String(error),
}
);
}
}
throw new GeminiError(
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
this.categorizeError(error),
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
{
processingTime,
originalError: error instanceof Error ? error.message : String(error),
}
);
}
}
/**
* Categorizes errors for better handling
*/
private categorizeError(error: any): string {
if (!error) return 'UNKNOWN_ERROR';
/**
* Categorizes errors for better handling
*/
private categorizeError(error: any): string {
if (!error) return 'UNKNOWN_ERROR';
const message = error.message || error.toString();
const message = error.message || error.toString();
if (message.includes('API key')) return 'API_KEY_ERROR';
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
return 'TIMEOUT_ERROR';
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
return 'IMAGE_ERROR';
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
if (message.includes('API key')) return 'API_KEY_ERROR';
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
return 'TIMEOUT_ERROR';
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
return 'IMAGE_ERROR';
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
return 'API_ERROR';
}
return 'API_ERROR';
}
/**
* Determines if an error is retryable
*/
private isRetryableError(error: any): boolean {
if (!error) return false;
/**
* Determines if an error is retryable
*/
private isRetryableError(error: any): boolean {
if (!error) return false;
const message = error.message || error.toString();
const message = error.message || error.toString();
// Don't retry these errors
if (message.includes('API key')) return false;
if (message.includes('quota exceeded')) return false;
if (message.includes('base64')) return false;
if (message.includes('file not found')) return false;
if (message.includes('too large')) return false;
if (message.includes('Invalid response structure')) return false;
// Don't retry these errors
if (message.includes('API key')) return false;
if (message.includes('quota exceeded')) return false;
if (message.includes('base64')) return false;
if (message.includes('file not found')) return false;
if (message.includes('too large')) return false;
if (message.includes('Invalid response structure')) return false;
// Retry these errors (but with caution for timeouts)
if (message.includes('network')) return true;
if (message.includes('timeout') || message.includes('aborted')) {
console.log('Timeout detected - will retry with exponential backoff');
return true;
}
if (message.includes('500')) return true;
if (message.includes('502')) return true;
if (message.includes('503')) return true;
// Retry these errors (but with caution for timeouts)
if (message.includes('network')) return true;
if (message.includes('timeout') || message.includes('aborted')) {
console.log('Timeout detected - will retry with exponential backoff');
return true;
}
if (message.includes('500')) return true;
if (message.includes('502')) return true;
if (message.includes('503')) return true;
return false; // Default to non-retryable for unknown errors to prevent infinite loops
}
return false; // Default to non-retryable for unknown errors to prevent infinite loops
}
/**
* Gets service status and configuration
*/
public getStatus() {
return {
initialized: !!this.model,
hasApiKey: !!this.config.apiKey,
model: this.config.model,
retryConfig: this.retryConfig,
};
}
/**
* Gets service status and configuration
*/
public getStatus() {
return {
initialized: !!this.model,
hasApiKey: !!this.config.apiKey,
model: this.config.model,
retryConfig: this.retryConfig,
};
}
/**
* Updates retry configuration
*/
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
this.retryConfig = { ...this.retryConfig, ...newConfig };
}
/**
* Updates retry configuration
*/
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
this.retryConfig = { ...this.retryConfig, ...newConfig };
}
/**
* Updates request timeout
*/
public updateTimeout(timeout: number) {
this.requestTimeout = timeout;
console.log('Updated request timeout to:', timeout);
}
/**
* Updates request timeout
*/
public updateTimeout(timeout: number) {
this.requestTimeout = timeout;
console.log('Updated request timeout to:', timeout);
}
}

View file

@ -14,426 +14,427 @@ const APP_ID = process.env.EXPO_PUBLIC_MIDDLEWARE_APP_ID || 'nutriphi';
* Get device information for authentication
*/
function getDeviceInfo() {
return {
deviceId: Application.getIosIdForVendorAsync ?
Application.androidId || `${Platform.OS}-${Date.now()}` :
`${Platform.OS}-${Date.now()}`,
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
deviceType: Device.isDevice ? 'mobile' : 'simulator',
platform: Platform.OS,
};
return {
deviceId: Application.getIosIdForVendorAsync
? Application.androidId || `${Platform.OS}-${Date.now()}`
: `${Platform.OS}-${Date.now()}`,
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
deviceType: Device.isDevice ? 'mobile' : 'simulator',
platform: Platform.OS,
};
}
/**
* Decode JWT token
*/
function decodeToken(token: string): any | null {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use atob equivalent for React Native
const payload = JSON.parse(
decodeURIComponent(
Array.from(atob(base64))
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
);
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use atob equivalent for React Native
const payload = JSON.parse(
decodeURIComponent(
Array.from(atob(base64))
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
);
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
/**
* Check if token is expired
*/
function isTokenExpired(token: string): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
}
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
}
export interface UserData {
id: string;
email: string;
role: string;
id: string;
email: string;
role: string;
}
/**
* Authentication service
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
const { appToken, refreshToken } = await response.json();
const { appToken, refreshToken } = await response.json();
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
const responseData = await response.json();
const responseData = await response.json();
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
const { appToken, refreshToken } = responseData;
const { appToken, refreshToken } = responseData;
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
/**
* Sign in with Apple ID token
*/
async signInWithApple(idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign in with Apple ID token
*/
async signInWithApple(
idToken: string,
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, user, deviceInfo }),
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, user, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Apple Sign-In failed',
};
}
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Apple Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email || user?.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || '';
}
let email = responseData.email || user?.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Apple:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Apple:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
};
}
},
/**
* Refresh authentication tokens
*/
async refreshTokens(
currentRefreshToken: string
): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
/**
* Refresh authentication tokens
*/
async refreshTokens(currentRefreshToken: string): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
};

View file

@ -1,9 +1,9 @@
import * as SecureStore from 'expo-secure-store';
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
/**
@ -11,110 +11,110 @@ const STORAGE_KEYS = {
* Uses Expo SecureStore for encrypted storage on device
*/
export const tokenManager = {
/**
* Get the app token (JWT)
*/
async getAppToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Get the app token (JWT)
*/
async getAppToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Set the app token
*/
async setAppToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
} catch (error) {
console.error('Error setting app token:', error);
throw error;
}
},
/**
* Set the app token
*/
async setAppToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
} catch (error) {
console.error('Error setting app token:', error);
throw error;
}
},
/**
* Get the refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
} catch (error) {
console.error('Error getting refresh token:', error);
return null;
}
},
/**
* Get the refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
} catch (error) {
console.error('Error getting refresh token:', error);
return null;
}
},
/**
* Set the refresh token
*/
async setRefreshToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
} catch (error) {
console.error('Error setting refresh token:', error);
throw error;
}
},
/**
* Set the refresh token
*/
async setRefreshToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
} catch (error) {
console.error('Error setting refresh token:', error);
throw error;
}
},
/**
* Get the user email
*/
async getUserEmail(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
} catch (error) {
console.error('Error getting user email:', error);
return null;
}
},
/**
* Get the user email
*/
async getUserEmail(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
} catch (error) {
console.error('Error getting user email:', error);
return null;
}
},
/**
* Set the user email
*/
async setUserEmail(email: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
} catch (error) {
console.error('Error setting user email:', error);
throw error;
}
},
/**
* Set the user email
*/
async setUserEmail(email: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
} catch (error) {
console.error('Error setting user email:', error);
throw error;
}
},
/**
* Clear all tokens (logout)
*/
async clearTokens(): Promise<void> {
try {
await Promise.all([
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
]);
} catch (error) {
console.error('Error clearing tokens:', error);
throw error;
}
},
/**
* Clear all tokens (logout)
*/
async clearTokens(): Promise<void> {
try {
await Promise.all([
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
]);
} catch (error) {
console.error('Error clearing tokens:', error);
throw error;
}
},
/**
* Get Authorization header for API requests
*/
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
const token = await this.getAppToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
},
/**
* Get Authorization header for API requests
*/
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
const token = await this.getAppToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
},
/**
* Check if user has tokens stored
*/
async hasTokens(): Promise<boolean> {
const token = await this.getAppToken();
return !!token;
},
/**
* Check if user has tokens stored
*/
async hasTokens(): Promise<boolean> {
const token = await this.getAppToken();
return !!token;
},
};

View file

@ -1,54 +1,54 @@
import * as SQLite from 'expo-sqlite';
export interface Migration {
version: number;
name: string;
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
version: number;
name: string;
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
}
export class MigrationService {
private static instance: MigrationService;
private db: SQLite.SQLiteDatabase | null = null;
private static instance: MigrationService;
private db: SQLite.SQLiteDatabase | null = null;
private constructor() {}
private constructor() {}
public static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
public static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
public setDatabase(db: SQLite.SQLiteDatabase): void {
this.db = db;
}
public setDatabase(db: SQLite.SQLiteDatabase): void {
this.db = db;
}
private migrations: Migration[] = [
{
version: 1,
name: 'Initial Schema',
up: async (db: SQLite.SQLiteDatabase) => {
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
// Hier nur als Referenz für zukünftige Migrationen
},
},
{
version: 2,
name: 'Add indexes for performance',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
private migrations: Migration[] = [
{
version: 1,
name: 'Initial Schema',
up: async (db: SQLite.SQLiteDatabase) => {
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
// Hier nur als Referenz für zukünftige Migrationen
},
},
{
version: 2,
name: 'Add indexes for performance',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_meals_analysis_status ON meals(analysis_status);
CREATE INDEX IF NOT EXISTS idx_meals_health_category ON meals(health_category);
CREATE INDEX IF NOT EXISTS idx_food_items_confidence ON food_items(confidence DESC);
`);
},
},
{
version: 3,
name: 'Add user preferences table',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
},
},
{
version: 3,
name: 'Add user preferences table',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
@ -58,157 +58,157 @@ export class MigrationService {
updated_at TEXT DEFAULT (datetime('now'))
);
`);
},
},
{
version: 4,
name: 'Add GPS location fields to meals',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
},
},
{
version: 4,
name: 'Add GPS location fields to meals',
up: async (db: SQLite.SQLiteDatabase) => {
await db.execAsync(`
ALTER TABLE meals ADD COLUMN latitude REAL;
ALTER TABLE meals ADD COLUMN longitude REAL;
ALTER TABLE meals ADD COLUMN location_accuracy REAL;
`);
// Create index for geo queries
await db.execAsync(`
// Create index for geo queries
await db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_meals_location ON meals(latitude, longitude);
`);
},
},
];
},
},
];
public async initializeMigrationTable(): Promise<void> {
if (!this.db) throw new Error('Database not set');
public async initializeMigrationTable(): Promise<void> {
if (!this.db) throw new Error('Database not set');
await this.db.execAsync(`
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT DEFAULT (datetime('now'))
);
`);
}
}
public async getCurrentVersion(): Promise<number> {
if (!this.db) throw new Error('Database not set');
public async getCurrentVersion(): Promise<number> {
if (!this.db) throw new Error('Database not set');
try {
const result = await this.db.getFirstAsync<{ version: number }>(
'SELECT MAX(version) as version FROM schema_migrations'
);
return result?.version || 0;
} catch {
// Tabelle existiert noch nicht
return 0;
}
}
try {
const result = await this.db.getFirstAsync<{ version: number }>(
'SELECT MAX(version) as version FROM schema_migrations'
);
return result?.version || 0;
} catch {
// Tabelle existiert noch nicht
return 0;
}
}
public async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not set');
public async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not set');
await this.initializeMigrationTable();
const currentVersion = await this.getCurrentVersion();
await this.initializeMigrationTable();
const currentVersion = await this.getCurrentVersion();
console.log(`Current database version: ${currentVersion}`);
console.log(`Current database version: ${currentVersion}`);
const pendingMigrations = this.migrations.filter(
(migration) => migration.version > currentVersion
);
const pendingMigrations = this.migrations.filter(
(migration) => migration.version > currentVersion
);
if (pendingMigrations.length === 0) {
console.log('No pending migrations');
return;
}
if (pendingMigrations.length === 0) {
console.log('No pending migrations');
return;
}
console.log(`Running ${pendingMigrations.length} migrations...`);
console.log(`Running ${pendingMigrations.length} migrations...`);
for (const migration of pendingMigrations) {
try {
console.log(`Applying migration ${migration.version}: ${migration.name}`);
for (const migration of pendingMigrations) {
try {
console.log(`Applying migration ${migration.version}: ${migration.name}`);
await this.db.execAsync('BEGIN TRANSACTION;');
await migration.up(this.db);
await this.db.execAsync('BEGIN TRANSACTION;');
await migration.up(this.db);
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
migration.version,
migration.name,
]);
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
migration.version,
migration.name,
]);
await this.db.execAsync('COMMIT;');
await this.db.execAsync('COMMIT;');
console.log(`Migration ${migration.version} completed successfully`);
} catch (err) {
console.error(`Migration ${migration.version} failed:`, err);
await this.db.execAsync('ROLLBACK;');
throw err;
}
}
console.log(`Migration ${migration.version} completed successfully`);
} catch (err) {
console.error(`Migration ${migration.version} failed:`, err);
await this.db.execAsync('ROLLBACK;');
throw err;
}
}
console.log('All migrations completed successfully');
}
console.log('All migrations completed successfully');
}
public async rollbackToVersion(targetVersion: number): Promise<void> {
if (!this.db) throw new Error('Database not set');
public async rollbackToVersion(targetVersion: number): Promise<void> {
if (!this.db) throw new Error('Database not set');
const currentVersion = await this.getCurrentVersion();
const currentVersion = await this.getCurrentVersion();
if (targetVersion >= currentVersion) {
console.log('Target version is not lower than current version');
return;
}
if (targetVersion >= currentVersion) {
console.log('Target version is not lower than current version');
return;
}
const migrationsToRollback = this.migrations
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
.sort((a, b) => b.version - a.version); // Descending order
const migrationsToRollback = this.migrations
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
.sort((a, b) => b.version - a.version); // Descending order
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
for (const migration of migrationsToRollback) {
if (!migration.down) {
console.warn(`No rollback defined for migration ${migration.version}`);
continue;
}
for (const migration of migrationsToRollback) {
if (!migration.down) {
console.warn(`No rollback defined for migration ${migration.version}`);
continue;
}
try {
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
try {
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
await this.db.execAsync('BEGIN TRANSACTION;');
await migration.down(this.db);
await this.db.execAsync('BEGIN TRANSACTION;');
await migration.down(this.db);
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
migration.version,
]);
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
migration.version,
]);
await this.db.execAsync('COMMIT;');
await this.db.execAsync('COMMIT;');
console.log(`Migration ${migration.version} rolled back successfully`);
} catch (err) {
console.error(`Rollback of migration ${migration.version} failed:`, err);
await this.db.execAsync('ROLLBACK;');
throw err;
}
}
console.log(`Migration ${migration.version} rolled back successfully`);
} catch (err) {
console.error(`Rollback of migration ${migration.version} failed:`, err);
await this.db.execAsync('ROLLBACK;');
throw err;
}
}
console.log(`Rollback to version ${targetVersion} completed`);
}
console.log(`Rollback to version ${targetVersion} completed`);
}
public async addMigration(migration: Migration): Promise<void> {
// Überprüfe, ob die Version bereits existiert
const existingMigration = this.migrations.find((m) => m.version === migration.version);
if (existingMigration) {
throw new Error(`Migration version ${migration.version} already exists`);
}
public async addMigration(migration: Migration): Promise<void> {
// Überprüfe, ob die Version bereits existiert
const existingMigration = this.migrations.find((m) => m.version === migration.version);
if (existingMigration) {
throw new Error(`Migration version ${migration.version} already exists`);
}
this.migrations.push(migration);
this.migrations.sort((a, b) => a.version - b.version);
}
this.migrations.push(migration);
this.migrations.sort((a, b) => a.version - b.version);
}
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
if (!this.db) throw new Error('Database not set');
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
if (!this.db) throw new Error('Database not set');
return this.db.getAllAsync(
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
);
}
return this.db.getAllAsync(
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
);
}
}

View file

@ -2,41 +2,41 @@ import * as SQLite from 'expo-sqlite';
import { Meal, FoodItem, CreateMealInput, MealWithItems } from '../../types/Database';
export class SQLiteService {
private static instance: SQLiteService;
private db: SQLite.SQLiteDatabase | null = null;
private static instance: SQLiteService;
private db: SQLite.SQLiteDatabase | null = null;
private constructor() {}
private constructor() {}
public static getInstance(): SQLiteService {
if (!SQLiteService.instance) {
SQLiteService.instance = new SQLiteService();
}
return SQLiteService.instance;
}
public static getInstance(): SQLiteService {
if (!SQLiteService.instance) {
SQLiteService.instance = new SQLiteService();
}
return SQLiteService.instance;
}
public async initialize(): Promise<void> {
try {
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
await this.createTables();
await this.createIndices();
} catch (error) {
console.error('Database initialization failed:', error);
throw error;
}
}
public async initialize(): Promise<void> {
try {
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
await this.createTables();
await this.createIndices();
} catch (error) {
console.error('Database initialization failed:', error);
throw error;
}
}
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
private async createTables(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
private async createTables(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
// Meals Table
await this.db.execAsync(`
// Meals Table
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cloud_id TEXT UNIQUE,
@ -73,8 +73,8 @@ export class SQLiteService {
);
`);
// Food Items Table
await this.db.execAsync(`
// Food Items Table
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS food_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cloud_id TEXT UNIQUE,
@ -100,8 +100,8 @@ export class SQLiteService {
);
`);
// Sync Metadata Table
await this.db.execAsync(`
// Sync Metadata Table
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS sync_metadata (
table_name TEXT NOT NULL,
record_id INTEGER NOT NULL,
@ -113,8 +113,8 @@ export class SQLiteService {
);
`);
// User Preferences Table
await this.db.execAsync(`
// User Preferences Table
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
@ -124,12 +124,12 @@ export class SQLiteService {
updated_at TEXT DEFAULT (datetime('now'))
);
`);
}
}
private async createIndices(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
private async createIndices(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.execAsync(`
await this.db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_meals_timestamp ON meals(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meals(sync_status);
CREATE INDEX IF NOT EXISTS idx_meals_meal_type ON meals(meal_type);
@ -137,225 +137,225 @@ export class SQLiteService {
CREATE INDEX IF NOT EXISTS idx_food_items_category ON food_items(category);
CREATE INDEX IF NOT EXISTS idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at);
`);
}
}
// CRUD Operations für Meals
public async createMeal(input: CreateMealInput): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
// CRUD Operations für Meals
public async createMeal(input: CreateMealInput): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
const now = new Date().toISOString();
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
const now = new Date().toISOString();
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
const result = await this.db.runAsync(
`
const result = await this.db.runAsync(
`
INSERT INTO meals (
photo_path, photo_size, photo_dimensions, timestamp,
created_at, updated_at, meal_type, location, latitude, longitude, location_accuracy,
user_notes, analysis_status, api_provider
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
input.photo_path,
input.photo_size || null,
dimensions,
now,
now,
now,
input.meal_type || null,
input.location || null,
input.latitude || null,
input.longitude || null,
input.location_accuracy || null,
input.user_notes || null,
input.analysis_status || 'pending',
input.api_provider || 'gemini',
]
);
[
input.photo_path,
input.photo_size || null,
dimensions,
now,
now,
now,
input.meal_type || null,
input.location || null,
input.latitude || null,
input.longitude || null,
input.location_accuracy || null,
input.user_notes || null,
input.analysis_status || 'pending',
input.api_provider || 'gemini',
]
);
return result.lastInsertRowId;
}
return result.lastInsertRowId;
}
public async getMealById(id: number): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
public async getMealById(id: number): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
return result || null;
}
return result || null;
}
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
if (!this.db) throw new Error('Database not initialized');
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
if (!this.db) throw new Error('Database not initialized');
const meal = await this.getMealById(id);
if (!meal) return null;
const meal = await this.getMealById(id);
if (!meal) return null;
const foodItems = await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[id]
);
const foodItems = await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[id]
);
return {
...meal,
food_items: foodItems,
};
}
return {
...meal,
food_items: foodItems,
};
}
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[limit, offset]
);
}
return await this.db.getAllAsync<Meal>(
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
[limit, offset]
);
}
public async getAllMealsWithItems(
limit: number = 50,
offset: number = 0
): Promise<MealWithItems[]> {
if (!this.db) throw new Error('Database not initialized');
public async getAllMealsWithItems(
limit: number = 50,
offset: number = 0
): Promise<MealWithItems[]> {
if (!this.db) throw new Error('Database not initialized');
const meals = await this.getAllMeals(limit, offset);
const mealsWithItems: MealWithItems[] = [];
const meals = await this.getAllMeals(limit, offset);
const mealsWithItems: MealWithItems[] = [];
for (const meal of meals) {
const foodItems = await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[meal.id!]
);
for (const meal of meals) {
const foodItems = await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[meal.id!]
);
mealsWithItems.push({
...meal,
food_items: foodItems,
});
}
mealsWithItems.push({
...meal,
food_items: foodItems,
});
}
return mealsWithItems;
}
return mealsWithItems;
}
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
await this.db.runAsync(
`
await this.db.runAsync(
`
UPDATE meals SET ${setClause}, updated_at = datetime('now') WHERE id = ?
`,
[...updateValues, id]
);
}
[...updateValues, id]
);
}
public async deleteMeal(id: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
public async deleteMeal(id: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
}
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
}
// CRUD Operations für Food Items
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
// CRUD Operations für Food Items
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.runAsync(
`
const result = await this.db.runAsync(
`
INSERT INTO food_items (
meal_id, name, category, portion_size, calories, protein, carbs, fat,
fiber, sugar, confidence, bounding_box, is_organic, is_processed, allergens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
foodItem.meal_id,
foodItem.name,
foodItem.category,
foodItem.portion_size,
foodItem.calories || null,
foodItem.protein || null,
foodItem.carbs || null,
foodItem.fat || null,
foodItem.fiber || null,
foodItem.sugar || null,
foodItem.confidence || null,
foodItem.bounding_box || null,
foodItem.is_organic,
foodItem.is_processed,
foodItem.allergens || null,
]
);
[
foodItem.meal_id,
foodItem.name,
foodItem.category,
foodItem.portion_size,
foodItem.calories || null,
foodItem.protein || null,
foodItem.carbs || null,
foodItem.fat || null,
foodItem.fiber || null,
foodItem.sugar || null,
foodItem.confidence || null,
foodItem.bounding_box || null,
foodItem.is_organic,
foodItem.is_processed,
foodItem.allergens || null,
]
);
return result.lastInsertRowId;
}
return result.lastInsertRowId;
}
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
if (!this.db) throw new Error('Database not initialized');
if (foodItems.length === 0) return [];
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
if (!this.db) throw new Error('Database not initialized');
if (foodItems.length === 0) return [];
const insertedIds: number[] = [];
const insertedIds: number[] = [];
// Use a transaction for better performance
await this.db.execAsync('BEGIN TRANSACTION');
// Use a transaction for better performance
await this.db.execAsync('BEGIN TRANSACTION');
try {
for (const foodItem of foodItems) {
const result = await this.db.runAsync(
`INSERT INTO food_items (
try {
for (const foodItem of foodItems) {
const result = await this.db.runAsync(
`INSERT INTO food_items (
meal_id, name, category, portion_size,
calories, protein, carbs, fat, fiber, sugar,
confidence, bounding_box, is_organic, is_processed, allergens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
foodItem.meal_id,
foodItem.name,
foodItem.category || null,
foodItem.portion_size || null,
foodItem.calories || null,
foodItem.protein || null,
foodItem.carbs || null,
foodItem.fat || null,
foodItem.fiber || null,
foodItem.sugar || null,
foodItem.confidence || null,
foodItem.bounding_box || null,
foodItem.is_organic,
foodItem.is_processed,
foodItem.allergens || null,
]
);
insertedIds.push(result.lastInsertRowId);
}
[
foodItem.meal_id,
foodItem.name,
foodItem.category || null,
foodItem.portion_size || null,
foodItem.calories || null,
foodItem.protein || null,
foodItem.carbs || null,
foodItem.fat || null,
foodItem.fiber || null,
foodItem.sugar || null,
foodItem.confidence || null,
foodItem.bounding_box || null,
foodItem.is_organic,
foodItem.is_processed,
foodItem.allergens || null,
]
);
insertedIds.push(result.lastInsertRowId);
}
await this.db.execAsync('COMMIT');
return insertedIds;
} catch (error) {
await this.db.execAsync('ROLLBACK');
throw error;
}
}
await this.db.execAsync('COMMIT');
return insertedIds;
} catch (error) {
await this.db.execAsync('ROLLBACK');
throw error;
}
}
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
if (!this.db) throw new Error('Database not initialized');
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[mealId]
);
}
return await this.db.getAllAsync<FoodItem>(
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
[mealId]
);
}
// Statistiken und Aggregationen
public async getMealStats(days: number = 7): Promise<{
totalMeals: number;
avgCalories: number;
avgHealthScore: number;
}> {
if (!this.db) throw new Error('Database not initialized');
// Statistiken und Aggregationen
public async getMealStats(days: number = 7): Promise<{
totalMeals: number;
avgCalories: number;
avgHealthScore: number;
}> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.getFirstAsync<{
count: number;
avg_calories: number;
avg_health_score: number;
}>(`
const result = await this.db.getFirstAsync<{
count: number;
avg_calories: number;
avg_health_score: number;
}>(`
SELECT
COUNT(*) as count,
AVG(total_calories) as avg_calories,
@ -365,18 +365,18 @@ export class SQLiteService {
AND analysis_status = 'completed'
`);
return {
totalMeals: result?.count || 0,
avgCalories: Math.round(result?.avg_calories || 0),
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
};
}
return {
totalMeals: result?.count || 0,
avgCalories: Math.round(result?.avg_calories || 0),
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
};
}
public async searchMeals(query: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
public async searchMeals(query: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`
return await this.db.getAllAsync<Meal>(
`
SELECT DISTINCT m.* FROM meals m
LEFT JOIN food_items fi ON m.id = fi.meal_id
WHERE m.user_notes LIKE ?
@ -384,147 +384,146 @@ export class SQLiteService {
OR fi.name LIKE ?
ORDER BY m.timestamp DESC
`,
[`%${query}%`, `%${query}%`, `%${query}%`]
);
}
[`%${query}%`, `%${query}%`, `%${query}%`]
);
}
// Hilfsmethoden
public async close(): Promise<void> {
if (this.db) {
await this.db.closeAsync();
this.db = null;
}
}
// Hilfsmethoden
public async close(): Promise<void> {
if (this.db) {
await this.db.closeAsync();
this.db = null;
}
}
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.runAsync(sql, params);
}
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.runAsync(sql, params);
}
// ==================== Sync Methods ====================
// ==================== Sync Methods ====================
/**
* Get all unsynced meals (sync_status = 'local' or 'pending')
*/
public async getUnsyncedMeals(): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
/**
* Get all unsynced meals (sync_status = 'local' or 'pending')
*/
public async getUnsyncedMeals(): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
);
}
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
);
}
/**
* Get meal by cloud ID
*/
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
/**
* Get meal by cloud ID
*/
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.getFirstAsync<Meal>(
'SELECT * FROM meals WHERE cloud_id = ?',
[cloudId]
);
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE cloud_id = ?', [
cloudId,
]);
return result || null;
}
return result || null;
}
/**
* Update cloud_id for a local meal
*/
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
/**
* Update cloud_id for a local meal
*/
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
[cloudId, localId]
);
}
await this.db.runAsync(
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
[cloudId, localId]
);
}
/**
* Mark a meal as synced
*/
public async markSynced(localId: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
/**
* Mark a meal as synced
*/
public async markSynced(localId: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
[localId]
);
}
await this.db.runAsync(
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
[localId]
);
}
/**
* Delete a meal by cloud ID
*/
public async deleteByCloudId(cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
/**
* Delete a meal by cloud ID
*/
public async deleteByCloudId(cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
}
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
}
/**
* Create a meal from server data
*/
public async createMealFromServer(serverMeal: any): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
/**
* Create a meal from server data
*/
public async createMealFromServer(serverMeal: any): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
const result = await this.db.runAsync(
`INSERT INTO meals (
const result = await this.db.runAsync(
`INSERT INTO meals (
cloud_id, sync_status, version, last_sync_at,
photo_path, photo_url, timestamp, created_at, updated_at,
meal_type, analysis_result, analysis_status,
total_calories, total_protein, total_carbs, total_fat, total_fiber, total_sugar,
health_score, health_category, user_notes, user_rating
) VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
serverMeal.cloudId,
'synced',
1,
serverMeal.imageUrl || '',
serverMeal.imageUrl || null,
serverMeal.createdAt,
serverMeal.createdAt,
serverMeal.updatedAt,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
]
);
[
serverMeal.cloudId,
'synced',
1,
serverMeal.imageUrl || '',
serverMeal.imageUrl || null,
serverMeal.createdAt,
serverMeal.createdAt,
serverMeal.updatedAt,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
]
);
return result.lastInsertRowId;
}
return result.lastInsertRowId;
}
/**
* Update a local meal from server data
*/
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
/**
* Update a local meal from server data
*/
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
await this.db.runAsync(
`UPDATE meals SET
await this.db.runAsync(
`UPDATE meals SET
sync_status = 'synced',
last_sync_at = datetime('now'),
photo_url = ?,
@ -543,36 +542,36 @@ export class SQLiteService {
user_rating = ?,
updated_at = ?
WHERE id = ?`,
[
serverMeal.imageUrl || null,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
serverMeal.updatedAt,
localId,
]
);
}
[
serverMeal.imageUrl || null,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
serverMeal.updatedAt,
localId,
]
);
}
/**
* Get meals modified since a given timestamp
*/
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
/**
* Get meals modified since a given timestamp
*/
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
[since]
);
}
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
[since]
);
}
}

View file

@ -2,200 +2,200 @@ import * as FileSystem from 'expo-file-system';
import { PhotoDimensions } from '../../types/Database';
export class PhotoService {
private static instance: PhotoService;
private photosDirectory: string;
private static instance: PhotoService;
private photosDirectory: string;
private constructor() {
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
}
private constructor() {
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
}
public static getInstance(): PhotoService {
if (!PhotoService.instance) {
PhotoService.instance = new PhotoService();
}
return PhotoService.instance;
}
public static getInstance(): PhotoService {
if (!PhotoService.instance) {
PhotoService.instance = new PhotoService();
}
return PhotoService.instance;
}
public async initialize(): Promise<void> {
// Create photos directory if it doesn't exist
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
}
}
public async initialize(): Promise<void> {
// Create photos directory if it doesn't exist
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
}
}
public async savePhoto(
uri: string,
mealId?: number
): Promise<{
path: string;
size: number;
dimensions: PhotoDimensions;
}> {
await this.initialize();
public async savePhoto(
uri: string,
mealId?: number
): Promise<{
path: string;
size: number;
dimensions: PhotoDimensions;
}> {
await this.initialize();
// Generate unique filename
const timestamp = Date.now();
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
// Generate unique filename
const timestamp = Date.now();
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
const destPath = `${this.photosDirectory}${filename}`;
const destPath = `${this.photosDirectory}${filename}`;
// Copy file to app directory
await FileSystem.copyAsync({
from: uri,
to: destPath,
});
// Copy file to app directory
await FileSystem.copyAsync({
from: uri,
to: destPath,
});
// Get file info
const fileInfo = await FileSystem.getInfoAsync(destPath);
// Get file info
const fileInfo = await FileSystem.getInfoAsync(destPath);
// Get image dimensions (basic implementation)
const dimensions = await this.getImageDimensions(destPath);
// Get image dimensions (basic implementation)
const dimensions = await this.getImageDimensions(destPath);
return {
path: destPath,
size: fileInfo.size || 0,
dimensions,
};
}
return {
path: destPath,
size: fileInfo.size || 0,
dimensions,
};
}
public async makePhotoPermanent(
tempPath: string,
mealId: number
): Promise<{
path: string;
size: number;
dimensions: PhotoDimensions;
}> {
await this.initialize();
public async makePhotoPermanent(
tempPath: string,
mealId: number
): Promise<{
path: string;
size: number;
dimensions: PhotoDimensions;
}> {
await this.initialize();
// Generate permanent filename
const timestamp = Date.now();
const filename = `meal_${mealId}_${timestamp}.jpg`;
const destPath = `${this.photosDirectory}${filename}`;
// Generate permanent filename
const timestamp = Date.now();
const filename = `meal_${mealId}_${timestamp}.jpg`;
const destPath = `${this.photosDirectory}${filename}`;
// Copy temp file to permanent location
await FileSystem.copyAsync({
from: tempPath,
to: destPath,
});
// Copy temp file to permanent location
await FileSystem.copyAsync({
from: tempPath,
to: destPath,
});
// Get file info
const fileInfo = await FileSystem.getInfoAsync(destPath);
// Get file info
const fileInfo = await FileSystem.getInfoAsync(destPath);
// Get image dimensions
const dimensions = await this.getImageDimensions(destPath);
// Get image dimensions
const dimensions = await this.getImageDimensions(destPath);
// Delete the temporary file
await this.deletePhoto(tempPath);
// Delete the temporary file
await this.deletePhoto(tempPath);
return {
path: destPath,
size: fileInfo.size || 0,
dimensions,
};
}
return {
path: destPath,
size: fileInfo.size || 0,
dimensions,
};
}
public async deletePhoto(path: string): Promise<void> {
try {
const fileInfo = await FileSystem.getInfoAsync(path);
if (fileInfo.exists) {
await FileSystem.deleteAsync(path);
}
} catch (error) {
console.warn('Failed to delete photo:', error);
}
}
public async deletePhoto(path: string): Promise<void> {
try {
const fileInfo = await FileSystem.getInfoAsync(path);
if (fileInfo.exists) {
await FileSystem.deleteAsync(path);
}
} catch (error) {
console.warn('Failed to delete photo:', error);
}
}
public async getPhotoAsBase64(path: string): Promise<string> {
try {
const base64 = await FileSystem.readAsStringAsync(path, {
encoding: FileSystem.EncodingType.Base64,
});
return base64;
} catch (error) {
console.error('Failed to read photo as base64:', error);
throw new Error('Failed to process image');
}
}
public async getPhotoAsBase64(path: string): Promise<string> {
try {
const base64 = await FileSystem.readAsStringAsync(path, {
encoding: FileSystem.EncodingType.Base64,
});
return base64;
} catch (error) {
console.error('Failed to read photo as base64:', error);
throw new Error('Failed to process image');
}
}
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
// This is a simplified implementation
// In a real app, you might use expo-image-manipulator or similar
// to get actual image dimensions
return {
width: 1920,
height: 1080,
};
}
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
// This is a simplified implementation
// In a real app, you might use expo-image-manipulator or similar
// to get actual image dimensions
return {
width: 1920,
height: 1080,
};
}
public async cleanupTempPhotos(): Promise<void> {
try {
await this.initialize();
public async cleanupTempPhotos(): Promise<void> {
try {
await this.initialize();
// Check if directory exists before trying to read it
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
if (!dirInfo.exists) {
console.log('Photos directory does not exist yet, skipping cleanup');
return;
}
// Check if directory exists before trying to read it
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
if (!dirInfo.exists) {
console.log('Photos directory does not exist yet, skipping cleanup');
return;
}
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
const tempFiles = files.filter((file) => file.startsWith('temp_'));
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
const tempFiles = files.filter((file) => file.startsWith('temp_'));
// Delete temp files older than 1 hour
const oneHourAgo = Date.now() - 60 * 60 * 1000;
// Delete temp files older than 1 hour
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const file of tempFiles) {
const filePath = `${this.photosDirectory}${file}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
for (const file of tempFiles) {
const filePath = `${this.photosDirectory}${file}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
if (
fileInfo.exists &&
fileInfo.modificationTime &&
fileInfo.modificationTime < oneHourAgo
) {
await FileSystem.deleteAsync(filePath);
}
}
if (
fileInfo.exists &&
fileInfo.modificationTime &&
fileInfo.modificationTime < oneHourAgo
) {
await FileSystem.deleteAsync(filePath);
}
}
if (tempFiles.length > 0) {
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
}
} catch (error) {
console.warn('Failed to cleanup temp photos:', error);
}
}
if (tempFiles.length > 0) {
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
}
} catch (error) {
console.warn('Failed to cleanup temp photos:', error);
}
}
public async getStorageStats(): Promise<{
totalPhotos: number;
totalSize: number;
averageSize: number;
}> {
try {
await this.initialize();
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
public async getStorageStats(): Promise<{
totalPhotos: number;
totalSize: number;
averageSize: number;
}> {
try {
await this.initialize();
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
let totalSize = 0;
for (const file of photoFiles) {
const filePath = `${this.photosDirectory}${file}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
totalSize += fileInfo.size || 0;
}
let totalSize = 0;
for (const file of photoFiles) {
const filePath = `${this.photosDirectory}${file}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
totalSize += fileInfo.size || 0;
}
return {
totalPhotos: photoFiles.length,
totalSize,
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
};
} catch (error) {
console.error('Failed to get storage stats:', error);
return {
totalPhotos: 0,
totalSize: 0,
averageSize: 0,
};
}
}
return {
totalPhotos: photoFiles.length,
totalSize,
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
};
} catch (error) {
console.error('Failed to get storage stats:', error);
return {
totalPhotos: 0,
totalSize: 0,
averageSize: 0,
};
}
}
}

View file

@ -5,343 +5,341 @@ import type { Meal } from '../../types/Database';
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3002';
export interface SyncResult {
success: boolean;
created: number;
updated: number;
deleted: number;
conflicts: ConflictInfo[];
error?: string;
success: boolean;
created: number;
updated: number;
deleted: number;
conflicts: ConflictInfo[];
error?: string;
}
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
export interface LocalMealForSync {
localId: number;
cloudId?: string;
foodName: string;
imageUrl?: string;
calories?: number;
protein?: number;
carbohydrates?: number;
fat?: number;
fiber?: number;
sugar?: number;
sodium?: number;
servingSize?: string;
mealType?: string;
analysisStatus?: string;
healthScore?: number;
healthCategory?: string;
notes?: string;
userRating?: number;
foodItems?: any[];
version: number;
createdAt: string;
updatedAt: string;
localId: number;
cloudId?: string;
foodName: string;
imageUrl?: string;
calories?: number;
protein?: number;
carbohydrates?: number;
fat?: number;
fiber?: number;
sugar?: number;
sodium?: number;
servingSize?: string;
mealType?: string;
analysisStatus?: string;
healthScore?: number;
healthCategory?: string;
notes?: string;
userRating?: number;
foodItems?: any[];
version: number;
createdAt: string;
updatedAt: string;
}
/**
* Sync Service for synchronizing local SQLite data with the backend
*/
export class SyncService {
private static instance: SyncService;
private isSyncing = false;
private lastSyncAt: string | null = null;
private static instance: SyncService;
private isSyncing = false;
private lastSyncAt: string | null = null;
private constructor() {}
private constructor() {}
public static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService();
}
return SyncService.instance;
}
public static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService();
}
return SyncService.instance;
}
/**
* Check if sync is currently in progress
*/
public isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Check if sync is currently in progress
*/
public isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Get last sync timestamp
*/
public getLastSyncAt(): string | null {
return this.lastSyncAt;
}
/**
* Get last sync timestamp
*/
public getLastSyncAt(): string | null {
return this.lastSyncAt;
}
/**
* Perform a full sync (push + pull)
*/
public async fullSync(): Promise<SyncResult> {
if (this.isSyncing) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Sync already in progress',
};
}
/**
* Perform a full sync (push + pull)
*/
public async fullSync(): Promise<SyncResult> {
if (this.isSyncing) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Sync already in progress',
};
}
this.isSyncing = true;
this.isSyncing = true;
try {
// First push local changes
const pushResult = await this.pushChanges();
if (!pushResult.success) {
return pushResult;
}
try {
// First push local changes
const pushResult = await this.pushChanges();
if (!pushResult.success) {
return pushResult;
}
// Then pull server changes
const pullResult = await this.pullChanges();
// Then pull server changes
const pullResult = await this.pullChanges();
return {
success: pullResult.success,
created: pushResult.created + pullResult.created,
updated: pushResult.updated + pullResult.updated,
deleted: pullResult.deleted,
conflicts: pushResult.conflicts,
error: pullResult.error,
};
} finally {
this.isSyncing = false;
}
}
return {
success: pullResult.success,
created: pushResult.created + pullResult.created,
updated: pushResult.updated + pullResult.updated,
deleted: pullResult.deleted,
conflicts: pushResult.conflicts,
error: pullResult.error,
};
} finally {
this.isSyncing = false;
}
}
/**
* Push local changes to server
*/
public async pushChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
/**
* Push local changes to server
*/
public async pushChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const db = SQLiteService.getInstance();
const db = SQLiteService.getInstance();
// Get unsynced meals
const unsyncedMeals = await db.getUnsyncedMeals();
// Get unsynced meals
const unsyncedMeals = await db.getUnsyncedMeals();
if (unsyncedMeals.length === 0) {
return {
success: true,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
};
}
if (unsyncedMeals.length === 0) {
return {
success: true,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
};
}
// Map to sync format
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
this.mapMealToSyncFormat(meal)
);
// Map to sync format
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
this.mapMealToSyncFormat(meal)
);
// Get deleted meals (meals marked for deletion)
const deletedIds: string[] = []; // TODO: Implement delete tracking
// Get deleted meals (meals marked for deletion)
const deletedIds: string[] = []; // TODO: Implement delete tracking
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
body: JSON.stringify({
meals: mealsForSync,
deletedIds,
lastSyncAt: this.lastSyncAt,
}),
});
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
body: JSON.stringify({
meals: mealsForSync,
deletedIds,
lastSyncAt: this.lastSyncAt,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Push failed',
};
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Push failed',
};
}
const result = await response.json();
const result = await response.json();
// Update local records with cloud IDs
for (const created of result.created) {
await db.updateCloudId(created.localId, created.cloudId);
await db.markSynced(created.localId);
}
// Update local records with cloud IDs
for (const created of result.created) {
await db.updateCloudId(created.localId, created.cloudId);
await db.markSynced(created.localId);
}
// Mark updated records as synced
for (const cloudId of result.updated) {
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
if (meal && meal.id) {
await db.markSynced(meal.id);
}
}
// Mark updated records as synced
for (const cloudId of result.updated) {
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
if (meal && meal.id) {
await db.markSynced(meal.id);
}
}
this.lastSyncAt = result.serverTime;
this.lastSyncAt = result.serverTime;
return {
success: true,
created: result.created.length,
updated: result.updated.length,
deleted: 0,
conflicts: result.conflicts || [],
};
} catch (error) {
console.error('Push sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Push failed',
};
}
}
return {
success: true,
created: result.created.length,
updated: result.updated.length,
deleted: 0,
conflicts: result.conflicts || [],
};
} catch (error) {
console.error('Push sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Push failed',
};
}
}
/**
* Pull changes from server
*/
public async pullChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
/**
* Pull changes from server
*/
public async pullChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
if (this.lastSyncAt) {
url.searchParams.set('since', this.lastSyncAt);
}
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
if (this.lastSyncAt) {
url.searchParams.set('since', this.lastSyncAt);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
});
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Pull failed',
};
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Pull failed',
};
}
const result = await response.json();
const db = SQLiteService.getInstance();
const result = await response.json();
const db = SQLiteService.getInstance();
let created = 0;
let updated = 0;
let deleted = 0;
let created = 0;
let updated = 0;
let deleted = 0;
// Process server meals
for (const serverMeal of result.meals) {
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
// Process server meals
for (const serverMeal of result.meals) {
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
if (existingMeal) {
// Update existing local meal
await db.updateMealFromServer(existingMeal.id!, serverMeal);
updated++;
} else {
// Create new local meal
await db.createMealFromServer(serverMeal);
created++;
}
}
if (existingMeal) {
// Update existing local meal
await db.updateMealFromServer(existingMeal.id!, serverMeal);
updated++;
} else {
// Create new local meal
await db.createMealFromServer(serverMeal);
created++;
}
}
// Process deletions
for (const cloudId of result.deletedIds) {
await db.deleteByCloudId(cloudId);
deleted++;
}
// Process deletions
for (const cloudId of result.deletedIds) {
await db.deleteByCloudId(cloudId);
deleted++;
}
this.lastSyncAt = result.serverTime;
this.lastSyncAt = result.serverTime;
return {
success: true,
created,
updated,
deleted,
conflicts: [],
};
} catch (error) {
console.error('Pull sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Pull failed',
};
}
}
return {
success: true,
created,
updated,
deleted,
conflicts: [],
};
} catch (error) {
console.error('Pull sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Pull failed',
};
}
}
/**
* Map local meal to sync format
*/
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
return {
localId: meal.id!,
cloudId: meal.cloud_id || undefined,
foodName: meal.analysis_result
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
: 'Unbekanntes Gericht',
imageUrl: meal.photo_url || undefined,
calories: meal.total_calories || undefined,
protein: meal.total_protein || undefined,
carbohydrates: meal.total_carbs || undefined,
fat: meal.total_fat || undefined,
fiber: meal.total_fiber || undefined,
sugar: meal.total_sugar || undefined,
servingSize: undefined,
mealType: meal.meal_type || undefined,
analysisStatus: meal.analysis_status || 'completed',
healthScore: meal.health_score || undefined,
healthCategory: meal.health_category || undefined,
notes: meal.user_notes || undefined,
userRating: meal.user_rating || undefined,
foodItems: meal.analysis_result
? JSON.parse(meal.analysis_result).foodItems
: [],
version: meal.version || 1,
createdAt: meal.created_at || new Date().toISOString(),
updatedAt: meal.updated_at || new Date().toISOString(),
};
}
/**
* Map local meal to sync format
*/
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
return {
localId: meal.id!,
cloudId: meal.cloud_id || undefined,
foodName: meal.analysis_result
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
: 'Unbekanntes Gericht',
imageUrl: meal.photo_url || undefined,
calories: meal.total_calories || undefined,
protein: meal.total_protein || undefined,
carbohydrates: meal.total_carbs || undefined,
fat: meal.total_fat || undefined,
fiber: meal.total_fiber || undefined,
sugar: meal.total_sugar || undefined,
servingSize: undefined,
mealType: meal.meal_type || undefined,
analysisStatus: meal.analysis_status || 'completed',
healthScore: meal.health_score || undefined,
healthCategory: meal.health_category || undefined,
notes: meal.user_notes || undefined,
userRating: meal.user_rating || undefined,
foodItems: meal.analysis_result ? JSON.parse(meal.analysis_result).foodItems : [],
version: meal.version || 1,
createdAt: meal.created_at || new Date().toISOString(),
updatedAt: meal.updated_at || new Date().toISOString(),
};
}
}

View file

@ -1,118 +1,118 @@
import { create } from 'zustand';
interface AppState {
isInitialized: boolean;
isOnline: boolean;
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
isInitialized: boolean;
isOnline: boolean;
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
// UI States
showCameraModal: boolean;
cameraMode: 'camera' | 'gallery' | null;
isPhotoProcessing: boolean;
// UI States
showCameraModal: boolean;
cameraMode: 'camera' | 'gallery' | null;
isPhotoProcessing: boolean;
// User Preferences
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
enableNotifications: boolean;
preferredUnits: 'metric' | 'imperial';
theme: 'light' | 'dark' | 'system';
// User Preferences
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
enableNotifications: boolean;
preferredUnits: 'metric' | 'imperial';
theme: 'light' | 'dark' | 'system';
// Stats Cache
statsCache: {
totalMeals: number;
avgCalories: number;
avgHealthScore: number;
lastUpdated: string | null;
};
// Stats Cache
statsCache: {
totalMeals: number;
avgCalories: number;
avgHealthScore: number;
lastUpdated: string | null;
};
// Actions
setInitialized: (initialized: boolean) => void;
setOnlineStatus: (online: boolean) => void;
setCurrentScreen: (screen: AppState['currentScreen']) => void;
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
setPhotoProcessing: (processing: boolean) => void;
updateUserPreferences: (
prefs: Partial<
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
>
) => void;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
resetStats: () => void;
resetToDefaults: () => void;
// Actions
setInitialized: (initialized: boolean) => void;
setOnlineStatus: (online: boolean) => void;
setCurrentScreen: (screen: AppState['currentScreen']) => void;
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
setPhotoProcessing: (processing: boolean) => void;
updateUserPreferences: (
prefs: Partial<
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
>
) => void;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
resetStats: () => void;
resetToDefaults: () => void;
}
export const useAppStore = create<AppState>((set, get) => ({
isInitialized: false,
isOnline: true,
currentScreen: 'home',
showCameraModal: false,
cameraMode: null,
isPhotoProcessing: false,
defaultMealType: null,
enableNotifications: true,
preferredUnits: 'metric',
theme: 'system',
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
isInitialized: false,
isOnline: true,
currentScreen: 'home',
showCameraModal: false,
cameraMode: null,
isPhotoProcessing: false,
defaultMealType: null,
enableNotifications: true,
preferredUnits: 'metric',
theme: 'system',
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
const currentShow = get().showCameraModal;
const newShow = show !== undefined ? show : !currentShow;
set({
showCameraModal: newShow,
cameraMode: newShow ? mode || 'camera' : null,
});
},
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
const currentShow = get().showCameraModal;
const newShow = show !== undefined ? show : !currentShow;
set({
showCameraModal: newShow,
cameraMode: newShow ? mode || 'camera' : null,
});
},
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
updateUserPreferences: (prefs) => set(prefs),
updateUserPreferences: (prefs) => set(prefs),
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
updateStatsCache: (stats) =>
set({
statsCache: {
...stats,
lastUpdated: new Date().toISOString(),
},
}),
updateStatsCache: (stats) =>
set({
statsCache: {
...stats,
lastUpdated: new Date().toISOString(),
},
}),
resetStats: () =>
set({
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
}),
resetStats: () =>
set({
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
}),
resetToDefaults: () =>
set({
isInitialized: false,
currentScreen: 'home',
showCameraModal: false,
cameraMode: null,
isPhotoProcessing: false,
defaultMealType: null,
enableNotifications: true,
preferredUnits: 'metric',
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
}),
resetToDefaults: () =>
set({
isInitialized: false,
currentScreen: 'home',
showCameraModal: false,
cameraMode: null,
isPhotoProcessing: false,
defaultMealType: null,
enableNotifications: true,
preferredUnits: 'metric',
statsCache: {
totalMeals: 0,
avgCalories: 0,
avgHealthScore: 0,
lastUpdated: null,
},
}),
}));

View file

@ -3,298 +3,307 @@ import { authService, type UserData, type AuthResult } from '../services/auth/au
import { tokenManager } from '../services/auth/tokenManager';
interface AuthState {
// State
user: UserData | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// State
user: UserData | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Actions
initialize: () => Promise<void>;
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
signInWithApple: (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => Promise<{ success: boolean; error?: string }>;
signOut: () => Promise<void>;
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
refreshAuth: () => Promise<boolean>;
clearError: () => void;
// Actions
initialize: () => Promise<void>;
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
signUp: (
email: string,
password: string
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
signInWithApple: (
idToken: string,
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
) => Promise<{ success: boolean; error?: string }>;
signOut: () => Promise<void>;
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
refreshAuth: () => Promise<boolean>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: false,
error: null,
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: false,
error: null,
/**
* Initialize auth state from stored tokens
*/
initialize: async () => {
if (get().isInitialized) return;
/**
* Initialize auth state from stored tokens
*/
initialize: async () => {
if (get().isInitialized) return;
set({ isLoading: true });
set({ isLoading: true });
try {
const token = await tokenManager.getAppToken();
try {
const token = await tokenManager.getAppToken();
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
return;
}
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
return;
}
// Check if token is still valid
if (authService.isTokenValidLocally(token)) {
const userData = authService.getUserFromToken(token);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
// Check if token is still valid
if (authService.isTokenValidLocally(token)) {
const userData = authService.getUserFromToken(token);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
// Try to refresh token
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
try {
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
// Try to refresh token
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
try {
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
} catch (error) {
console.error('Failed to refresh token on init:', error);
}
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
} catch (error) {
console.error('Failed to refresh token on init:', error);
}
}
// Clear invalid tokens
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
} catch (error) {
console.error('Error initializing auth:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: true,
error: 'Failed to initialize authentication',
});
}
},
// Clear invalid tokens
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
} catch (error) {
console.error('Error initializing auth:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: true,
error: 'Failed to initialize authentication',
});
}
},
/**
* Sign in with email and password
*/
signIn: async (email: string, password: string) => {
set({ isLoading: true, error: null });
/**
* Sign in with email and password
*/
signIn: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signIn(email, password);
try {
const result = await authService.signIn(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign in failed' });
return { success: false, error: result.error };
}
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign in failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
signUp: async (email: string, password: string) => {
set({ isLoading: true, error: null });
/**
* Sign up with email and password
*/
signUp: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signUp(email, password);
try {
const result = await authService.signUp(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign up failed' });
return { success: false, error: result.error };
}
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign up failed' });
return { success: false, error: result.error };
}
if (result.needsVerification) {
set({ isLoading: false, error: null });
return { success: true, needsVerification: true };
}
if (result.needsVerification) {
set({ isLoading: false, error: null });
return { success: true, needsVerification: true };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Google
*/
signInWithGoogle: async (idToken: string) => {
set({ isLoading: true, error: null });
/**
* Sign in with Google
*/
signInWithGoogle: async (idToken: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithGoogle(idToken);
try {
const result = await authService.signInWithGoogle(idToken);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
return { success: false, error: result.error };
}
if (!result.success) {
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Apple
*/
signInWithApple: async (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => {
set({ isLoading: true, error: null });
/**
* Sign in with Apple
*/
signInWithApple: async (
idToken: string,
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithApple(idToken, user);
try {
const result = await authService.signInWithApple(idToken, user);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
return { success: false, error: result.error };
}
if (!result.success) {
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign out
*/
signOut: async () => {
set({ isLoading: true });
/**
* Sign out
*/
signOut: async () => {
set({ isLoading: true });
try {
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
await authService.signOut(refreshToken);
}
} catch (error) {
console.error('Error during sign out:', error);
} finally {
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
}
},
try {
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
await authService.signOut(refreshToken);
}
} catch (error) {
console.error('Error during sign out:', error);
} finally {
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
}
},
/**
* Forgot password
*/
forgotPassword: async (email: string) => {
return authService.forgotPassword(email);
},
/**
* Forgot password
*/
forgotPassword: async (email: string) => {
return authService.forgotPassword(email);
},
/**
* Refresh authentication tokens
*/
refreshAuth: async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) {
return false;
}
/**
* Refresh authentication tokens
*/
refreshAuth: async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) {
return false;
}
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.userData) {
set({ user: result.userData });
}
return true;
}
return false;
} catch (error) {
console.error('Error refreshing auth:', error);
return false;
}
},
if (result.userData) {
set({ user: result.userData });
}
return true;
}
return false;
} catch (error) {
console.error('Error refreshing auth:', error);
return false;
}
},
/**
* Clear error state
*/
clearError: () => set({ error: null }),
/**
* Clear error state
*/
clearError: () => set({ error: null }),
}));

View file

@ -3,188 +3,188 @@ import { Meal, MealWithItems, CreateMealInput, CreateFoodItemInput } from '../ty
import { SQLiteService } from '../services/database/SQLiteService';
interface MealState {
meals: MealWithItems[];
isLoading: boolean;
error: string | null;
selectedMeal: MealWithItems | null;
meals: MealWithItems[];
isLoading: boolean;
error: string | null;
selectedMeal: MealWithItems | null;
// Actions
loadMeals: () => Promise<void>;
loadMealById: (id: number) => Promise<void>;
createMeal: (input: CreateMealInput) => Promise<number>;
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
deleteMeal: (id: number) => Promise<void>;
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
searchMeals: (query: string) => Promise<void>;
clearError: () => void;
setSelectedMeal: (meal: MealWithItems | null) => void;
clearAllMeals: () => void;
// Actions
loadMeals: () => Promise<void>;
loadMealById: (id: number) => Promise<void>;
createMeal: (input: CreateMealInput) => Promise<number>;
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
deleteMeal: (id: number) => Promise<void>;
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
searchMeals: (query: string) => Promise<void>;
clearError: () => void;
setSelectedMeal: (meal: MealWithItems | null) => void;
clearAllMeals: () => void;
}
export const useMealStore = create<MealState>((set, get) => ({
meals: [],
isLoading: false,
error: null,
selectedMeal: null,
meals: [],
isLoading: false,
error: null,
selectedMeal: null,
loadMeals: async () => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meals = await dbService.getAllMealsWithItems(50, 0);
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load meals',
isLoading: false,
});
}
},
loadMeals: async () => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meals = await dbService.getAllMealsWithItems(50, 0);
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load meals',
isLoading: false,
});
}
},
loadMealById: async (id: number) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meal = await dbService.getMealWithItems(id);
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
set({ selectedMeal: meal, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load meal',
isLoading: false,
});
}
},
loadMealById: async (id: number) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meal = await dbService.getMealWithItems(id);
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
set({ selectedMeal: meal, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load meal',
isLoading: false,
});
}
},
createMeal: async (input: CreateMealInput) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const mealId = await dbService.createMeal(input);
createMeal: async (input: CreateMealInput) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const mealId = await dbService.createMeal(input);
// Reload meals to update the list
await get().loadMeals();
// Reload meals to update the list
await get().loadMeals();
set({ isLoading: false });
return mealId;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create meal',
isLoading: false,
});
throw error;
}
},
set({ isLoading: false });
return mealId;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create meal',
isLoading: false,
});
throw error;
}
},
updateMeal: async (id: number, updates: Partial<Meal>) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
await dbService.updateMeal(id, updates);
updateMeal: async (id: number, updates: Partial<Meal>) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
await dbService.updateMeal(id, updates);
// If this is a completed analysis, reload the meal with all food items
if (updates.analysis_status === 'completed') {
const updatedMealWithItems = await dbService.getMealWithItems(id);
// If this is a completed analysis, reload the meal with all food items
if (updates.analysis_status === 'completed') {
const updatedMealWithItems = await dbService.getMealWithItems(id);
// Update meals in store with the full meal data
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
// Update meals in store with the full meal data
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
// Update selected meal if it's the one being updated
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: updatedMealWithItems });
}
// Update selected meal if it's the one being updated
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: updatedMealWithItems });
}
set({ meals, isLoading: false });
} else {
// For other updates, just update the fields we have
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
set({ meals, isLoading: false });
} else {
// For other updates, just update the fields we have
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
// Update selected meal if it's the one being updated
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: { ...selectedMeal, ...updates } });
}
// Update selected meal if it's the one being updated
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: { ...selectedMeal, ...updates } });
}
set({ meals, isLoading: false });
}
set({ meals, isLoading: false });
}
console.log(`Meal ${id} updated with:`, updates);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to update meal',
isLoading: false,
});
}
},
console.log(`Meal ${id} updated with:`, updates);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to update meal',
isLoading: false,
});
}
},
deleteMeal: async (id: number) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
await dbService.deleteMeal(id);
deleteMeal: async (id: number) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
await dbService.deleteMeal(id);
// Remove from meals array
const meals = get().meals.filter((meal) => meal.id !== id);
// Remove from meals array
const meals = get().meals.filter((meal) => meal.id !== id);
// Clear selected meal if it was deleted
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: null });
}
// Clear selected meal if it was deleted
const selectedMeal = get().selectedMeal;
if (selectedMeal && selectedMeal.id === id) {
set({ selectedMeal: null });
}
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete meal',
isLoading: false,
});
}
},
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete meal',
isLoading: false,
});
}
},
searchMeals: async (query: string) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meals =
query.trim() === ''
? await dbService.getAllMeals(50, 0)
: await dbService.searchMeals(query);
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to search meals',
isLoading: false,
});
}
},
searchMeals: async (query: string) => {
set({ isLoading: true, error: null });
try {
const dbService = SQLiteService.getInstance();
const meals =
query.trim() === ''
? await dbService.getAllMeals(50, 0)
: await dbService.searchMeals(query);
set({ meals, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to search meals',
isLoading: false,
});
}
},
createFoodItem: async (input: CreateFoodItemInput) => {
try {
const dbService = SQLiteService.getInstance();
const foodItemId = await dbService.createFoodItem(input);
return foodItemId;
} catch (error) {
console.error('Failed to create food item:', error);
throw error;
}
},
createFoodItem: async (input: CreateFoodItemInput) => {
try {
const dbService = SQLiteService.getInstance();
const foodItemId = await dbService.createFoodItem(input);
return foodItemId;
} catch (error) {
console.error('Failed to create food item:', error);
throw error;
}
},
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
try {
const dbService = SQLiteService.getInstance();
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
return foodItemIds;
} catch (error) {
console.error('Failed to create food items batch:', error);
throw error;
}
},
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
try {
const dbService = SQLiteService.getInstance();
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
return foodItemIds;
} catch (error) {
console.error('Failed to create food items batch:', error);
throw error;
}
},
clearError: () => set({ error: null }),
clearError: () => set({ error: null }),
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
}));

View file

@ -1,10 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -1,13 +1,13 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~/*": ["*"],
"@/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~/*": ["*"],
"@/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
}

View file

@ -1,97 +1,97 @@
// Gemini API Response Typen
export interface GeminiAnalysisResult {
meal_analysis: {
total_calories: number;
total_protein: number;
total_carbs: number;
total_fat: number;
total_fiber?: number;
total_sugar?: number;
health_score: number; // 1.0-10.0
health_category: 'healthy' | 'moderate' | 'unhealthy';
confidence: number; // 0.0-1.0
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
};
food_items: GeminiFoodItem[];
analysis_notes: {
health_reasoning: string;
improvement_suggestions: string[];
cooking_method: string;
estimated_freshness: string;
hidden_ingredients: string[];
portion_accuracy: 'low' | 'medium' | 'high';
};
_metadata?: {
processingTime: number;
apiProvider: string;
model: string;
timestamp: string;
};
meal_analysis: {
total_calories: number;
total_protein: number;
total_carbs: number;
total_fat: number;
total_fiber?: number;
total_sugar?: number;
health_score: number; // 1.0-10.0
health_category: 'healthy' | 'moderate' | 'unhealthy';
confidence: number; // 0.0-1.0
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
};
food_items: GeminiFoodItem[];
analysis_notes: {
health_reasoning: string;
improvement_suggestions: string[];
cooking_method: string;
estimated_freshness: string;
hidden_ingredients: string[];
portion_accuracy: 'low' | 'medium' | 'high';
};
_metadata?: {
processingTime: number;
apiProvider: string;
model: string;
timestamp: string;
};
}
export interface GeminiFoodItem {
name: string;
category:
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
portion_size: string;
calories: number;
protein: number;
carbs: number;
fat: number;
fiber?: number;
sugar?: number;
confidence: number;
is_organic: boolean;
is_processed: boolean;
allergens: string[];
name: string;
category:
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
portion_size: string;
calories: number;
protein: number;
carbs: number;
fat: number;
fiber?: number;
sugar?: number;
confidence: number;
is_organic: boolean;
is_processed: boolean;
allergens: string[];
}
// API Error Types
export interface APIError {
code: string;
message: string;
details?: any;
code: string;
message: string;
details?: any;
}
// Prompt Context Types
export interface PromptContext {
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
location?: 'restaurant' | 'homemade' | 'fastfood';
additional?: string;
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
location?: 'restaurant' | 'homemade' | 'fastfood';
additional?: string;
}
// Gemini Error Class
export class GeminiError extends Error {
public readonly code: string;
public readonly type: 'TEMPORARY' | 'PERMANENT';
public readonly metadata?: any;
public readonly code: string;
public readonly type: 'TEMPORARY' | 'PERMANENT';
public readonly metadata?: any;
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
super(message);
this.name = 'GeminiError';
this.code = code;
this.type = type;
this.metadata = metadata;
}
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
super(message);
this.name = 'GeminiError';
this.code = code;
this.type = type;
this.metadata = metadata;
}
}
export interface AnalysisRequest {
imageBase64: string;
context?: PromptContext;
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
imageBase64: string;
context?: PromptContext;
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
}
export interface AnalysisResponse {
success: boolean;
data?: GeminiAnalysisResult;
error?: APIError;
processingTime: number;
cost?: number;
success: boolean;
data?: GeminiAnalysisResult;
error?: APIError;
processingTime: number;
cost?: number;
}

View file

@ -1,116 +1,116 @@
export interface Meal {
id?: number;
cloud_id?: string;
user_id?: string;
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
version: number;
last_sync_at?: string;
photo_path: string;
photo_url?: string;
photo_size?: number;
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
timestamp: string;
created_at: string;
updated_at: string;
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
location?: string;
latitude?: number;
longitude?: number;
location_accuracy?: number;
analysis_result?: string; // JSON der Gemini-Antwort
analysis_confidence?: number;
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
total_calories?: number;
total_protein?: number;
total_carbs?: number;
total_fat?: number;
total_fiber?: number;
total_sugar?: number;
health_score?: number; // 1.0 - 10.0
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
user_notes?: string;
user_modified: number; // Boolean als Integer
user_rating?: number; // 1-5 Sterne
api_provider: string;
api_cost?: number; // Kosten in Cent
processing_time?: number; // Millisekunden
id?: number;
cloud_id?: string;
user_id?: string;
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
version: number;
last_sync_at?: string;
photo_path: string;
photo_url?: string;
photo_size?: number;
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
timestamp: string;
created_at: string;
updated_at: string;
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
location?: string;
latitude?: number;
longitude?: number;
location_accuracy?: number;
analysis_result?: string; // JSON der Gemini-Antwort
analysis_confidence?: number;
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
total_calories?: number;
total_protein?: number;
total_carbs?: number;
total_fat?: number;
total_fiber?: number;
total_sugar?: number;
health_score?: number; // 1.0 - 10.0
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
user_notes?: string;
user_modified: number; // Boolean als Integer
user_rating?: number; // 1-5 Sterne
api_provider: string;
api_cost?: number; // Kosten in Cent
processing_time?: number; // Millisekunden
}
export interface FoodItem {
id?: number;
cloud_id?: string;
meal_id: number;
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
version: number;
name: string;
category:
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
portion_size: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number; // 0.0 - 1.0
bounding_box?: string; // JSON: Position im Bild
is_organic: number; // Boolean als Integer
is_processed: number; // Boolean als Integer
allergens?: string; // JSON Array
created_at: string;
id?: number;
cloud_id?: string;
meal_id: number;
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
version: number;
name: string;
category:
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
portion_size: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number; // 0.0 - 1.0
bounding_box?: string; // JSON: Position im Bild
is_organic: number; // Boolean als Integer
is_processed: number; // Boolean als Integer
allergens?: string; // JSON Array
created_at: string;
}
export interface SyncMetadata {
table_name: string;
record_id: number;
cloud_id?: string;
last_sync_at?: string;
conflict_data?: string; // JSON für Konfliktlösung
retry_count: number;
table_name: string;
record_id: number;
cloud_id?: string;
last_sync_at?: string;
conflict_data?: string; // JSON für Konfliktlösung
retry_count: number;
}
export interface PhotoDimensions {
width: number;
height: number;
width: number;
height: number;
}
// Eingabe für neue Mahlzeiten
export interface CreateMealInput {
photo_path: string;
photo_size?: number;
photo_dimensions?: PhotoDimensions;
meal_type?: Meal['meal_type'];
location?: string;
user_notes?: string;
analysis_status?: Meal['analysis_status'];
photo_path: string;
photo_size?: number;
photo_dimensions?: PhotoDimensions;
meal_type?: Meal['meal_type'];
location?: string;
user_notes?: string;
analysis_status?: Meal['analysis_status'];
}
// Eingabe für neue Lebensmittel
export interface CreateFoodItemInput {
meal_id: number;
name: string;
category?: FoodItem['category'];
portion_size?: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
is_organic?: number;
is_processed?: number;
allergens?: string;
meal_id: number;
name: string;
category?: FoodItem['category'];
portion_size?: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
is_organic?: number;
is_processed?: number;
allergens?: string;
}
// Vollständige Mahlzeit mit FoodItems
export interface MealWithItems extends Meal {
food_items: FoodItem[];
food_items: FoodItem[];
}

View file

@ -1,40 +1,40 @@
{
"name": "@nutriphi/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
},
"dependencies": {
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*"
}
"name": "@nutriphi/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
},
"dependencies": {
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*"
}
}

View file

@ -2,17 +2,17 @@
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
// Authentication handled via Mana Middleware (client-side)
}
interface PageData {
// Page data types
}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
interface Locals {
// Authentication handled via Mana Middleware (client-side)
}
interface PageData {
// Page data types
}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -5,5 +5,5 @@ import { type Handle } from '@sveltejs/kit';
* Authentication is handled client-side via Mana Middleware
*/
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
return resolve(event);
};

View file

@ -1,67 +1,67 @@
<script lang="ts">
import type { FoodItem } from '$lib/types/meal';
import type { FoodItem } from '$lib/types/meal';
interface Props {
items: FoodItem[];
}
interface Props {
items: FoodItem[];
}
let { items }: Props = $props();
let { items }: Props = $props();
function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
protein: 'Protein',
vegetable: 'Gemüse',
grain: 'Getreide',
fruit: 'Obst',
dairy: 'Milchprodukt',
fat: 'Fett',
processed: 'Verarbeitet',
beverage: 'Getränk'
};
return labels[category] || category;
}
function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
protein: 'Protein',
vegetable: 'Gemüse',
grain: 'Getreide',
fruit: 'Obst',
dairy: 'Milchprodukt',
fat: 'Fett',
processed: 'Verarbeitet',
beverage: 'Getränk',
};
return labels[category] || category;
}
function getCategoryColor(category: string): string {
const colors: Record<string, string> = {
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
};
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
function getCategoryColor(category: string): string {
const colors: Record<string, string> = {
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
};
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
</script>
{#if items.length === 0}
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
{:else}
<div class="space-y-2">
{#each items as item (item.id)}
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
<div class="flex items-center gap-3">
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
{getCategoryLabel(item.category)}
</span>
<div>
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
</div>
</div>
<div class="text-right">
{#if item.calories}
<p class="font-semibold text-gray-900 dark:text-white">
{Math.round(item.calories)} kcal
</p>
{/if}
{#if item.confidence}
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
{/if}
</div>
</div>
{/each}
</div>
<div class="space-y-2">
{#each items as item (item.id)}
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
<div class="flex items-center gap-3">
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
{getCategoryLabel(item.category)}
</span>
<div>
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
</div>
</div>
<div class="text-right">
{#if item.calories}
<p class="font-semibold text-gray-900 dark:text-white">
{Math.round(item.calories)} kcal
</p>
{/if}
{#if item.confidence}
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}

View file

@ -1,104 +1,110 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
import type { Meal } from '$lib/types/meal';
interface Props {
meal: Meal;
onclick?: () => void;
}
interface Props {
meal: Meal;
onclick?: () => void;
}
let { meal, onclick }: Props = $props();
let { meal, onclick }: Props = $props();
function getMealTypeLabel(type: string): string {
const labels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack'
};
return labels[type] || type;
}
function getMealTypeLabel(type: string): string {
const labels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};
return labels[type] || type;
}
const healthColor = $derived(() => {
if (!meal.health_score) return 'text-gray-400';
if (meal.health_score >= 8) return 'text-green-500';
if (meal.health_score >= 6) return 'text-yellow-500';
if (meal.health_score >= 4) return 'text-orange-500';
return 'text-red-500';
});
const healthColor = $derived(() => {
if (!meal.health_score) return 'text-gray-400';
if (meal.health_score >= 8) return 'text-green-500';
if (meal.health_score >= 6) return 'text-yellow-500';
if (meal.health_score >= 4) return 'text-orange-500';
return 'text-red-500';
});
function formatDate(timestamp: string): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short'
});
}
function formatDate(timestamp: string): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<button
{onclick}
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
{onclick}
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
>
{#if meal.photo_url}
<img
src={meal.photo_url}
alt={getMealTypeLabel(meal.meal_type)}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
{/if}
{#if meal.photo_url}
<img
src={meal.photo_url}
alt={getMealTypeLabel(meal.meal_type)}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
{/if}
<!-- Overlay -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4">
<div class="flex items-end justify-between">
<div>
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
<p class="text-sm text-gray-300">
{formatDate(meal.timestamp)}{formatTime(meal.timestamp)}
</p>
</div>
<div class="text-right">
{#if meal.total_calories}
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
<p class="text-xs text-gray-300">kcal</p>
{/if}
</div>
</div>
<!-- Overlay -->
<div
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4"
>
<div class="flex items-end justify-between">
<div>
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
<p class="text-sm text-gray-300">
{formatDate(meal.timestamp)}{formatTime(meal.timestamp)}
</p>
</div>
<div class="text-right">
{#if meal.total_calories}
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
<p class="text-xs text-gray-300">kcal</p>
{/if}
</div>
</div>
{#if meal.health_score}
<div class="mt-2 flex items-center gap-1">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
<div
class="h-full rounded-full {meal.health_score >= 7
? 'bg-green-500'
: meal.health_score >= 5
? 'bg-yellow-500'
: 'bg-red-500'}"
style="width: {meal.health_score * 10}%"
></div>
</div>
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
</div>
{/if}
</div>
{#if meal.health_score}
<div class="mt-2 flex items-center gap-1">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
<div
class="h-full rounded-full {meal.health_score >= 7
? 'bg-green-500'
: meal.health_score >= 5
? 'bg-yellow-500'
: 'bg-red-500'}"
style="width: {meal.health_score * 10}%"
></div>
</div>
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
</div>
{/if}
</div>
<!-- Analysis Status Badge -->
{#if meal.analysis_status === 'pending'}
<div class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white">
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
Analysiert...
</div>
{:else if meal.analysis_status === 'failed'}
<div class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white">
Fehler
</div>
{/if}
<!-- Analysis Status Badge -->
{#if meal.analysis_status === 'pending'}
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white"
>
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
Analysiert...
</div>
{:else if meal.analysis_status === 'failed'}
<div
class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white"
>
Fehler
</div>
{/if}
</button>

View file

@ -1,164 +1,173 @@
<script lang="ts">
import type { Meal, MealType } from '$lib/types/meal';
import { mealsStore } from '$lib/stores/meals.svelte';
import type { Meal, MealType } from '$lib/types/meal';
import { mealsStore } from '$lib/stores/meals.svelte';
interface Props {
meal: Meal;
isOpen: boolean;
onClose: () => void;
}
interface Props {
meal: Meal;
isOpen: boolean;
onClose: () => void;
}
let { meal, isOpen, onClose }: Props = $props();
let { meal, isOpen, onClose }: Props = $props();
// Form state - initialized from meal
let mealType = $state<MealType>(meal.meal_type);
let userNotes = $state(meal.user_notes || '');
let userRating = $state(meal.user_rating || 0);
let isSaving = $state(false);
// Form state - initialized from meal
let mealType = $state<MealType>(meal.meal_type);
let userNotes = $state(meal.user_notes || '');
let userRating = $state(meal.user_rating || 0);
let isSaving = $state(false);
// Reset form when meal changes
$effect(() => {
mealType = meal.meal_type;
userNotes = meal.user_notes || '';
userRating = meal.user_rating || 0;
});
// Reset form when meal changes
$effect(() => {
mealType = meal.meal_type;
userNotes = meal.user_notes || '';
userRating = meal.user_rating || 0;
});
const mealTypes: { value: MealType; label: string }[] = [
{ value: 'breakfast', label: 'Frühstück' },
{ value: 'lunch', label: 'Mittagessen' },
{ value: 'dinner', label: 'Abendessen' },
{ value: 'snack', label: 'Snack' }
];
const mealTypes: { value: MealType; label: string }[] = [
{ value: 'breakfast', label: 'Frühstück' },
{ value: 'lunch', label: 'Mittagessen' },
{ value: 'dinner', label: 'Abendessen' },
{ value: 'snack', label: 'Snack' },
];
async function handleSave() {
isSaving = true;
try {
await mealsStore.updateMeal(meal.id, {
meal_type: mealType,
user_notes: userNotes || undefined,
user_rating: userRating || undefined
});
onClose();
} catch (err) {
console.error('Failed to save meal:', err);
} finally {
isSaving = false;
}
}
async function handleSave() {
isSaving = true;
try {
await mealsStore.updateMeal(meal.id, {
meal_type: mealType,
user_notes: userNotes || undefined,
user_rating: userRating || undefined,
});
onClose();
} catch (err) {
console.error('Failed to save meal:', err);
} finally {
isSaving = false;
}
}
function handleRatingClick(rating: number) {
userRating = userRating === rating ? 0 : rating;
}
function handleRatingClick(rating: number) {
userRating = userRating === rating ? 0 : rating;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal -->
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal -->
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4">
<!-- Meal Type -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Art der Mahlzeit
</label>
<div class="grid grid-cols-2 gap-2">
{#each mealTypes as type}
<button
onclick={() => (mealType = type.value)}
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType === type.value
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{type.label}
</button>
{/each}
</div>
</div>
<div class="space-y-4">
<!-- Meal Type -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Art der Mahlzeit
</label>
<div class="grid grid-cols-2 gap-2">
{#each mealTypes as type}
<button
onclick={() => (mealType = type.value)}
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType ===
type.value
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{type.label}
</button>
{/each}
</div>
</div>
<!-- Rating -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bewertung
</label>
<div class="flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<button
onclick={() => handleRatingClick(star)}
class="text-2xl transition-transform hover:scale-110 {star <= userRating
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'}"
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
>
</button>
{/each}
</div>
</div>
<!-- Rating -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bewertung
</label>
<div class="flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<button
onclick={() => handleRatingClick(star)}
class="text-2xl transition-transform hover:scale-110 {star <= userRating
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'}"
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
>
</button>
{/each}
</div>
</div>
<!-- Notes -->
<div>
<label for="notes" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Notizen
</label>
<textarea
id="notes"
bind:value={userNotes}
rows="3"
placeholder="Notizen zu dieser Mahlzeit..."
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
</div>
</div>
<!-- Notes -->
<div>
<label
for="notes"
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Notizen
</label>
<textarea
id="notes"
bind:value={userNotes}
rows="3"
placeholder="Notizen zu dieser Mahlzeit..."
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex gap-3">
<button
onclick={onClose}
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={isSaving}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex gap-3">
<button
onclick={onClose}
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={isSaving}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -1,27 +1,25 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
import MealCard from './MealCard.svelte';
import type { Meal } from '$lib/types/meal';
import MealCard from './MealCard.svelte';
interface Props {
meals: Meal[];
onMealClick: (meal: Meal) => void;
}
interface Props {
meals: Meal[];
onMealClick: (meal: Meal) => void;
}
let { meals, onMealClick }: Props = $props();
let { meals, onMealClick }: Props = $props();
</script>
{#if meals.length === 0}
<div class="flex h-64 flex-col items-center justify-center text-center">
<div class="mb-4 text-6xl">🥗</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
<p class="text-gray-600 dark:text-gray-400">
Erfasse deine erste Mahlzeit mit einem Foto
</p>
</div>
<div class="flex h-64 flex-col items-center justify-center text-center">
<div class="mb-4 text-6xl">🥗</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
<p class="text-gray-600 dark:text-gray-400">Erfasse deine erste Mahlzeit mit einem Foto</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each meals as meal (meal.id)}
<MealCard {meal} onclick={() => onMealClick(meal)} />
{/each}
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each meals as meal (meal.id)}
<MealCard {meal} onclick={() => onMealClick(meal)} />
{/each}
</div>
{/if}

View file

@ -1,162 +1,162 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
import type { Meal } from '$lib/types/meal';
interface Props {
meal: Meal;
showDetailed?: boolean;
}
interface Props {
meal: Meal;
showDetailed?: boolean;
}
let { meal, showDetailed = false }: Props = $props();
let { meal, showDetailed = false }: Props = $props();
const healthColor = $derived(() => {
if (!meal.health_score) return 'bg-gray-400';
if (meal.health_score >= 8) return 'bg-green-500';
if (meal.health_score >= 6) return 'bg-yellow-500';
if (meal.health_score >= 4) return 'bg-orange-500';
return 'bg-red-500';
});
const healthColor = $derived(() => {
if (!meal.health_score) return 'bg-gray-400';
if (meal.health_score >= 8) return 'bg-green-500';
if (meal.health_score >= 6) return 'bg-yellow-500';
if (meal.health_score >= 4) return 'bg-orange-500';
return 'bg-red-500';
});
const healthLabel = $derived(() => {
if (!meal.health_category) return '';
const labels: Record<string, string> = {
very_healthy: 'Sehr gesund',
healthy: 'Gesund',
moderate: 'Moderat',
unhealthy: 'Ungesund'
};
return labels[meal.health_category] || '';
});
const healthLabel = $derived(() => {
if (!meal.health_category) return '';
const labels: Record<string, string> = {
very_healthy: 'Sehr gesund',
healthy: 'Gesund',
moderate: 'Moderat',
unhealthy: 'Ungesund',
};
return labels[meal.health_category] || '';
});
</script>
<div class="space-y-4">
<!-- Calories Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{meal.total_calories ? Math.round(meal.total_calories) : '—'} kcal
</p>
{#if healthLabel()}
<p class="text-sm text-gray-600 dark:text-gray-400">{healthLabel()}</p>
{/if}
</div>
{#if meal.health_score}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full {healthColor()}"></div>
<span class="text-xl font-semibold text-gray-900 dark:text-white">
{meal.health_score}/10
</span>
</div>
{/if}
</div>
<!-- Calories Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{meal.total_calories ? Math.round(meal.total_calories) : '—'} kcal
</p>
{#if healthLabel()}
<p class="text-sm text-gray-600 dark:text-gray-400">{healthLabel()}</p>
{/if}
</div>
{#if meal.health_score}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full {healthColor()}"></div>
<span class="text-xl font-semibold text-gray-900 dark:text-white">
{meal.health_score}/10
</span>
</div>
{/if}
</div>
<!-- Macro Pills -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-xl bg-blue-50 p-3 text-center dark:bg-blue-900/20">
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Protein</p>
</div>
<div class="rounded-xl bg-green-50 p-3 text-center dark:bg-green-900/20">
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Carbs</p>
</div>
<div class="rounded-xl bg-orange-50 p-3 text-center dark:bg-orange-900/20">
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Fett</p>
</div>
</div>
<!-- Macro Pills -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-xl bg-blue-50 p-3 text-center dark:bg-blue-900/20">
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Protein</p>
</div>
<div class="rounded-xl bg-green-50 p-3 text-center dark:bg-green-900/20">
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Carbs</p>
</div>
<div class="rounded-xl bg-orange-50 p-3 text-center dark:bg-orange-900/20">
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Fett</p>
</div>
</div>
<!-- Detailed Progress Bars -->
{#if showDetailed}
<div class="space-y-3 pt-2">
<!-- Protein -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Protein</span>
<span class="font-medium text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-blue-500 transition-all"
style="width: {Math.min(((meal.total_protein || 0) / 50) * 100, 100)}%"
></div>
</div>
</div>
<!-- Detailed Progress Bars -->
{#if showDetailed}
<div class="space-y-3 pt-2">
<!-- Protein -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Protein</span>
<span class="font-medium text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-blue-500 transition-all"
style="width: {Math.min(((meal.total_protein || 0) / 50) * 100, 100)}%"
></div>
</div>
</div>
<!-- Carbs -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Kohlenhydrate</span>
<span class="font-medium text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-green-500 transition-all"
style="width: {Math.min(((meal.total_carbs || 0) / 100) * 100, 100)}%"
></div>
</div>
</div>
<!-- Carbs -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Kohlenhydrate</span>
<span class="font-medium text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-green-500 transition-all"
style="width: {Math.min(((meal.total_carbs || 0) / 100) * 100, 100)}%"
></div>
</div>
</div>
<!-- Fat -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Fett</span>
<span class="font-medium text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-orange-500 transition-all"
style="width: {Math.min(((meal.total_fat || 0) / 65) * 100, 100)}%"
></div>
</div>
</div>
<!-- Fat -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Fett</span>
<span class="font-medium text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-orange-500 transition-all"
style="width: {Math.min(((meal.total_fat || 0) / 65) * 100, 100)}%"
></div>
</div>
</div>
<!-- Fiber -->
{#if meal.total_fiber !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Ballaststoffe</span>
<span class="font-medium text-purple-600 dark:text-purple-400">
{Math.round(meal.total_fiber)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-purple-500 transition-all"
style="width: {Math.min((meal.total_fiber / 25) * 100, 100)}%"
></div>
</div>
</div>
{/if}
<!-- Fiber -->
{#if meal.total_fiber !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Ballaststoffe</span>
<span class="font-medium text-purple-600 dark:text-purple-400">
{Math.round(meal.total_fiber)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-purple-500 transition-all"
style="width: {Math.min((meal.total_fiber / 25) * 100, 100)}%"
></div>
</div>
</div>
{/if}
<!-- Sugar -->
{#if meal.total_sugar !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Zucker</span>
<span class="font-medium text-pink-600 dark:text-pink-400">
{Math.round(meal.total_sugar)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-pink-500 transition-all"
style="width: {Math.min((meal.total_sugar / 50) * 100, 100)}%"
></div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Sugar -->
{#if meal.total_sugar !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Zucker</span>
<span class="font-medium text-pink-600 dark:text-pink-400">
{Math.round(meal.total_sugar)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-pink-500 transition-all"
style="width: {Math.min((meal.total_sugar / 50) * 100, 100)}%"
></div>
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -6,37 +6,37 @@
import { env as dynamicEnv } from '$env/dynamic/public';
export const env = {
// Middleware APIs
middleware: {
nutriphiUrl: dynamicEnv.PUBLIC_NUTRIPHI_MIDDLEWARE_URL ?? 'https://api.manacore.de',
appId: dynamicEnv.PUBLIC_MIDDLEWARE_APP_ID ?? 'nutriphi'
},
// Middleware APIs
middleware: {
nutriphiUrl: dynamicEnv.PUBLIC_NUTRIPHI_MIDDLEWARE_URL ?? 'https://api.manacore.de',
appId: dynamicEnv.PUBLIC_MIDDLEWARE_APP_ID ?? 'nutriphi',
},
// Backend API
backend: {
url: dynamicEnv.PUBLIC_BACKEND_URL ?? 'http://localhost:3002'
},
// Backend API
backend: {
url: dynamicEnv.PUBLIC_BACKEND_URL ?? 'http://localhost:3002',
},
// OAuth
oauth: {
googleClientId: dynamicEnv.PUBLIC_GOOGLE_CLIENT_ID ?? '',
appleClientId: dynamicEnv.PUBLIC_APPLE_CLIENT_ID ?? '',
appleRedirectUri: dynamicEnv.PUBLIC_APPLE_REDIRECT_URI ?? ''
}
// OAuth
oauth: {
googleClientId: dynamicEnv.PUBLIC_GOOGLE_CLIENT_ID ?? '',
appleClientId: dynamicEnv.PUBLIC_APPLE_CLIENT_ID ?? '',
appleRedirectUri: dynamicEnv.PUBLIC_APPLE_REDIRECT_URI ?? '',
},
} as const;
// Helper to check if optional features are enabled
export const features = {
hasGoogleAuth: !!env.oauth.googleClientId,
hasAppleAuth: !!env.oauth.appleClientId && !!env.oauth.appleRedirectUri
hasGoogleAuth: !!env.oauth.googleClientId,
hasAppleAuth: !!env.oauth.appleClientId && !!env.oauth.appleRedirectUri,
} as const;
// Log environment configuration on startup (useful for debugging deployment issues)
if (typeof window !== 'undefined') {
console.log('Nutriphi Environment Configuration:', {
middleware: !!env.middleware.nutriphiUrl ? 'Configured' : 'Missing',
backend: !!env.backend.url ? 'Configured' : 'Missing',
googleOAuth: features.hasGoogleAuth ? 'Enabled' : 'Disabled',
appleOAuth: features.hasAppleAuth ? 'Enabled' : 'Disabled'
});
console.log('Nutriphi Environment Configuration:', {
middleware: !!env.middleware.nutriphiUrl ? 'Configured' : 'Missing',
backend: !!env.backend.url ? 'Configured' : 'Missing',
googleOAuth: features.hasGoogleAuth ? 'Enabled' : 'Disabled',
appleOAuth: features.hasAppleAuth ? 'Enabled' : 'Disabled',
});
}

View file

@ -3,114 +3,114 @@ import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
export interface NutritionAnalysis {
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
servingSize: string;
confidence: number;
ingredients?: string[];
healthTips?: string[];
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
servingSize: string;
confidence: number;
ingredients?: string[];
healthTips?: string[];
}
export interface Meal {
id: string;
user_id: string;
food_name: string;
image_url?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
serving_size: string;
meal_type?: string;
notes?: string;
created_at: string;
updated_at: string;
id: string;
user_id: string;
food_name: string;
image_url?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
serving_size: string;
meal_type?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface DailySummary {
date: string;
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
totalSodium: number;
mealCount: number;
date: string;
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
totalSodium: number;
mealCount: number;
}
class ApiService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
return response.json();
}
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/image', {
method: 'POST',
body: JSON.stringify({ imageBase64 }),
});
}
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/image', {
method: 'POST',
body: JSON.stringify({ imageBase64 }),
});
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/text', {
method: 'POST',
body: JSON.stringify({ description }),
});
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/text', {
method: 'POST',
body: JSON.stringify({ description }),
});
}
async getMeals(userId: string, date?: string): Promise<Meal[]> {
const params = date ? `?date=${date}` : '';
return this.request(`/meals/user/${userId}${params}`);
}
async getMeals(userId: string, date?: string): Promise<Meal[]> {
const params = date ? `?date=${date}` : '';
return this.request(`/meals/user/${userId}${params}`);
}
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
return this.request(`/meals/user/${userId}/summary?date=${date}`);
}
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
return this.request(`/meals/user/${userId}/summary?date=${date}`);
}
async createMeal(meal: Partial<Meal> & { userId: string }): Promise<Meal> {
return this.request('/meals', {
method: 'POST',
body: JSON.stringify(meal),
});
}
async createMeal(meal: Partial<Meal> & { userId: string }): Promise<Meal> {
return this.request('/meals', {
method: 'POST',
body: JSON.stringify(meal),
});
}
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
return this.request(`/meals/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
return this.request(`/meals/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async deleteMeal(id: string): Promise<void> {
await this.request(`/meals/${id}`, {
method: 'DELETE',
});
}
async deleteMeal(id: string): Promise<void> {
await this.request(`/meals/${id}`, {
method: 'DELETE',
});
}
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
return this.request('/health');
}
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
return this.request('/health');
}
}
export const api = new ApiService();

View file

@ -10,404 +10,402 @@ const APP_ID = env.middleware.appId;
// Storage keys for tokens
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email'
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
/**
* Get device information for authentication
*/
function getDeviceInfo() {
return {
deviceId: getBrowserFingerprint(),
deviceName: getBrowserName(),
deviceType: 'web',
platform: 'web'
};
return {
deviceId: getBrowserFingerprint(),
deviceName: getBrowserName(),
deviceType: 'web',
platform: 'web',
};
}
/**
* Generate a browser fingerprint for device identification
*/
function getBrowserFingerprint(): string {
const ua = navigator.userAgent;
const screen = `${window.screen.width}x${window.screen.height}`;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const lang = navigator.language;
const ua = navigator.userAgent;
const screen = `${window.screen.width}x${window.screen.height}`;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const lang = navigator.language;
const data = `${ua}|${screen}|${timezone}|${lang}`;
return btoa(data).slice(0, 32);
const data = `${ua}|${screen}|${timezone}|${lang}`;
return btoa(data).slice(0, 32);
}
/**
* Get browser name
*/
function getBrowserName(): string {
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Edge')) return 'Edge';
return 'Unknown Browser';
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Edge')) return 'Edge';
return 'Unknown Browser';
}
/**
* Decode JWT token
*/
function decodeToken(token: string) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
/**
* Check if token is expired
*/
function isTokenExpired(token: string): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
}
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
}
export interface UserData {
id: string;
email: string;
role: string;
id: string;
email: string;
role: string;
}
/**
* Authentication service
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password, deviceInfo })
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED'
};
}
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED'
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS'
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed'
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
const { appToken, refreshToken } = await response.json();
const { appToken, refreshToken } = await response.json();
return {
success: true,
appToken,
refreshToken,
email
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in'
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password, deviceInfo })
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use'
};
}
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
return {
success: false,
error: errorData.message || 'Registration failed'
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
const responseData = await response.json();
const responseData = await response.json();
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true
};
}
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
const { appToken, refreshToken } = responseData;
const { appToken, refreshToken } = responseData;
return {
success: true,
appToken,
refreshToken,
email
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration'
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: idToken, deviceInfo })
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed'
};
}
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In'
};
}
},
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
/**
* Refresh authentication tokens
*/
async refreshTokens(
currentRefreshToken: string
): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
/**
* Refresh authentication tokens
*/
async refreshTokens(currentRefreshToken: string): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo })
});
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user'
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.'
};
}
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed'
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset'
};
}
},
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user'
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
}
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
};

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